吸收数字员工技能远端策略下发
This commit is contained in:
@@ -95,6 +95,7 @@ vi.mock("../services/digitalEmployee/syncService", () => ({
|
||||
flushDigitalEmployeeSyncOutbox: vi.fn(),
|
||||
getDigitalEmployeeSyncStatus: vi.fn(),
|
||||
pullDigitalEmployeePlanStepDispatchPolicy: vi.fn(async () => ({ ok: true, policy: { enabled: true, max_per_sweep: 3, auto_dispatch_ready_steps: true, updated_at: null } })),
|
||||
pullDigitalEmployeeSkillPolicies: vi.fn(async () => ({ ok: true, policies: { version: 1, local_skills: {}, remote_skills: {}, skills: {} } })),
|
||||
}));
|
||||
|
||||
vi.mock("../services/digitalEmployee/planTemplateService", () => ({
|
||||
@@ -250,6 +251,23 @@ describe("digital employee managed service actions", () => {
|
||||
expect(syncService.pullDigitalEmployeePlanStepDispatchPolicy).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it("registers skill policy pull through the digital employee IPC boundary", async () => {
|
||||
const electron = await import("electron");
|
||||
const syncService = await import("../services/digitalEmployee/syncService");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
registerDigitalEmployeeHandlers(createCtx());
|
||||
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
|
||||
const policyPullCall = calls.find(([channel]) => channel === "digitalEmployee:pullSkillPolicies");
|
||||
expect(policyPullCall).toBeTruthy();
|
||||
|
||||
const handler = policyPullCall?.[1] as () => Promise<unknown>;
|
||||
const result = await handler();
|
||||
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(syncService.pullDigitalEmployeeSkillPolicies).toHaveBeenCalledWith({ force: true });
|
||||
});
|
||||
|
||||
it("registers approval action history through the digital employee IPC boundary", async () => {
|
||||
const electron = await import("electron");
|
||||
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
flushDigitalEmployeeSyncOutbox,
|
||||
getDigitalEmployeeSyncStatus,
|
||||
pullDigitalEmployeePlanStepDispatchPolicy,
|
||||
pullDigitalEmployeeSkillPolicies,
|
||||
} from "../services/digitalEmployee/syncService";
|
||||
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
||||
import {
|
||||
@@ -193,6 +194,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||
return pullDigitalEmployeePlanStepDispatchPolicy({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:pullSkillPolicies", async () => {
|
||||
return pullDigitalEmployeeSkillPolicies({ force: true });
|
||||
});
|
||||
|
||||
ipcMain.handle("digitalEmployee:getCronSettings", async () => {
|
||||
return readDigitalEmployeeCronSettings();
|
||||
});
|
||||
|
||||
@@ -312,6 +312,111 @@ describe("digital employee skill catalog service", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("normalizes old skills policies as local policies", async () => {
|
||||
mockSettings.values.set("digital_employee_skill_policies_v1", {
|
||||
version: 1,
|
||||
skills: {
|
||||
"qimingclaw-computer-control": {
|
||||
enabled: false,
|
||||
updatedAt: "2026-06-07T08:00:00.000Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { readDigitalEmployeeSkillPolicies, listDigitalEmployeeSkillCapabilities } = await import("./skillCatalogService");
|
||||
|
||||
expect(readDigitalEmployeeSkillPolicies()).toMatchObject({
|
||||
local_skills: {
|
||||
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "local" }),
|
||||
},
|
||||
remote_skills: {},
|
||||
skills: {
|
||||
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "local" }),
|
||||
},
|
||||
});
|
||||
expect(listDigitalEmployeeSkillCapabilities().skills).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
skill_id: "qimingclaw-computer-control",
|
||||
enabled: false,
|
||||
policy_source: "local",
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("applies remote disabled skill policy before local enabled preference", async () => {
|
||||
mockSettings.values.set("digital_employee_skill_policies_v1", {
|
||||
version: 1,
|
||||
local_skills: {
|
||||
"qimingclaw-computer-control": { enabled: true, updatedAt: "2026-06-07T07:00:00.000Z", source: "local" },
|
||||
},
|
||||
remote_skills: {
|
||||
"qimingclaw-computer-control": { enabled: false, updatedAt: "2026-06-07T08:00:00.000Z", source: "remote", reason: "management_disabled" },
|
||||
},
|
||||
});
|
||||
const { listDigitalEmployeeSkillCapabilities, evaluateDigitalEmployeeSkillCall } = await import("./skillCatalogService");
|
||||
|
||||
expect(listDigitalEmployeeSkillCapabilities().skills).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
skill_id: "qimingclaw-computer-control",
|
||||
enabled: false,
|
||||
policy_source: "remote",
|
||||
remote_policy: expect.objectContaining({ reason: "management_disabled" }),
|
||||
}),
|
||||
]));
|
||||
expect(evaluateDigitalEmployeeSkillCall({ skillId: "qimingclaw-computer-control" })).toMatchObject({
|
||||
decision: "rejected",
|
||||
reason_codes: ["skill_disabled"],
|
||||
policy_snapshot: {
|
||||
matched_policy_source: "remote",
|
||||
matched_policy_disabled_by: "remote",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("applies local disabled policy when remote allows or is absent", async () => {
|
||||
mockSettings.values.set("digital_employee_skill_policies_v1", {
|
||||
version: 1,
|
||||
local_skills: {
|
||||
"qimingclaw-computer-control": { enabled: false, updatedAt: "2026-06-07T07:00:00.000Z", source: "local" },
|
||||
},
|
||||
remote_skills: {
|
||||
"qimingclaw-computer-control": { enabled: true, updatedAt: "2026-06-07T08:00:00.000Z", source: "remote" },
|
||||
},
|
||||
});
|
||||
const { listDigitalEmployeeSkillCapabilities } = await import("./skillCatalogService");
|
||||
|
||||
expect(listDigitalEmployeeSkillCapabilities().skills).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
skill_id: "qimingclaw-computer-control",
|
||||
enabled: false,
|
||||
policy_source: "local",
|
||||
local_policy: expect.objectContaining({ enabled: false }),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("preserves remote skills when updating a local skill preference", async () => {
|
||||
mockSettings.values.set("digital_employee_skill_policies_v1", {
|
||||
version: 1,
|
||||
remote_skills: {
|
||||
"qimingclaw-mcp-tool-crm-crm-delete": { enabled: false, updatedAt: "2026-06-07T08:00:00.000Z", source: "remote" },
|
||||
},
|
||||
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
});
|
||||
const { setDigitalEmployeeSkillEnabled, readDigitalEmployeeSkillPolicies } = await import("./skillCatalogService");
|
||||
|
||||
setDigitalEmployeeSkillEnabled("qimingclaw-computer-control", false);
|
||||
|
||||
expect(readDigitalEmployeeSkillPolicies()).toMatchObject({
|
||||
local_skills: {
|
||||
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "local" }),
|
||||
},
|
||||
remote_skills: {
|
||||
"qimingclaw-mcp-tool-crm-crm-delete": expect.objectContaining({ enabled: false, source: "remote" }),
|
||||
},
|
||||
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes disabled MCP server skills from agent execution config", async () => {
|
||||
const {
|
||||
applyDigitalEmployeeMcpExecutionPolicy,
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
normalizeDigitalEmployeeSkillPolicies as normalizeDigitalEmployeeSkillPoliciesImpl,
|
||||
skillIdForMcpServer,
|
||||
skillIdForMcpTool,
|
||||
effectiveSkillPolicyFor,
|
||||
applyDigitalEmployeeMcpExecutionPolicy as applyDigitalEmployeeMcpExecutionPolicyImpl,
|
||||
evaluateDigitalEmployeeSkillCall as evaluateDigitalEmployeeSkillCallImpl,
|
||||
type DigitalEmployeeSkillPolicies,
|
||||
type DigitalEmployeeSkillPolicyRecord,
|
||||
type DigitalEmployeeMcpPolicyConfig,
|
||||
type DigitalEmployeeSkillCallPolicyInput,
|
||||
type DigitalEmployeeSkillCallPolicyResult,
|
||||
@@ -62,6 +64,9 @@ export interface DigitalEmployeeSkillCapability {
|
||||
allow_tools?: string[];
|
||||
deny_tools?: string[];
|
||||
project_ids?: string[];
|
||||
remote_policy?: DigitalEmployeeSkillPolicyRecord | null;
|
||||
local_policy?: DigitalEmployeeSkillPolicyRecord | null;
|
||||
policy_source?: "remote" | "local" | "open";
|
||||
}
|
||||
|
||||
type McpServerEntryWithRuntimeFields = McpServerEntry & {
|
||||
@@ -314,16 +319,26 @@ export function setDigitalEmployeeSkillEnabled(
|
||||
throw new Error("skill_id is required");
|
||||
}
|
||||
const current = readDigitalEmployeeSkillPolicies();
|
||||
const updatedAt = new Date().toISOString();
|
||||
const next: DigitalEmployeeSkillPolicies = {
|
||||
version: 1,
|
||||
skills: {
|
||||
...current.skills,
|
||||
local_skills: {
|
||||
...current.local_skills,
|
||||
[normalizedSkillId]: {
|
||||
enabled: enabled === true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt,
|
||||
source: "local",
|
||||
reason: null,
|
||||
},
|
||||
},
|
||||
remote_skills: current.remote_skills,
|
||||
skills: {},
|
||||
source: current.remote_skills && Object.keys(current.remote_skills).length > 0 ? "mixed" : "local",
|
||||
remote_updated_at: current.remote_updated_at ?? null,
|
||||
last_pulled_at: current.last_pulled_at ?? null,
|
||||
last_pull_error: current.last_pull_error ?? null,
|
||||
};
|
||||
next.skills = normalizeDigitalEmployeeSkillPolicies(next).skills;
|
||||
writeSetting(SKILL_POLICIES_SETTING_KEY, next);
|
||||
return {
|
||||
ok: true,
|
||||
@@ -338,12 +353,14 @@ function applySkillPolicies(
|
||||
policies: DigitalEmployeeSkillPolicies,
|
||||
): DigitalEmployeeSkillCapability[] {
|
||||
return skills.map((skill) => {
|
||||
const policy = policies.skills[skill.skill_id];
|
||||
if (!policy) return skill;
|
||||
const enabled = skill.enabled && policy.enabled !== false;
|
||||
const policy = effectiveSkillPolicyFor(skill.skill_id, policies);
|
||||
const enabled = skill.enabled && policy.enabled;
|
||||
return {
|
||||
...skill,
|
||||
enabled,
|
||||
remote_policy: policy.remote_policy,
|
||||
local_policy: policy.local_policy,
|
||||
policy_source: policy.policy_source,
|
||||
governance: {
|
||||
...skill.governance,
|
||||
effective_policy: enabled ? skill.governance.effective_policy : "disabled",
|
||||
|
||||
@@ -3,11 +3,28 @@ import { readSetting } from "../../db";
|
||||
export interface DigitalEmployeeSkillPolicyRecord {
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
source?: "local" | "remote";
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillPolicies {
|
||||
version: 1;
|
||||
local_skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
|
||||
remote_skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
|
||||
skills: Record<string, DigitalEmployeeSkillPolicyRecord>;
|
||||
source?: "local" | "remote" | "mixed";
|
||||
remote_updated_at?: string | null;
|
||||
last_pulled_at?: string | null;
|
||||
last_pull_error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeEffectiveSkillPolicy {
|
||||
enabled: boolean;
|
||||
skill_id: string;
|
||||
local_policy: DigitalEmployeeSkillPolicyRecord | null;
|
||||
remote_policy: DigitalEmployeeSkillPolicyRecord | null;
|
||||
policy_source: "remote" | "local" | "open";
|
||||
disabled_by: "remote" | "local" | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeMcpServerPolicyEntry {
|
||||
@@ -47,6 +64,14 @@ export interface DigitalEmployeeSkillCallPolicyResult {
|
||||
policy_snapshot: {
|
||||
skill_enabled?: boolean;
|
||||
matched_skill_id?: string | null;
|
||||
matched_policy_source?: "remote" | "local" | "open";
|
||||
matched_policy_disabled_by?: "remote" | "local" | null;
|
||||
matched_remote_policy?: DigitalEmployeeSkillPolicyRecord | null;
|
||||
matched_local_policy?: DigitalEmployeeSkillPolicyRecord | null;
|
||||
server_policy_source?: "remote" | "local" | "open";
|
||||
server_policy_disabled_by?: "remote" | "local" | null;
|
||||
server_remote_policy?: DigitalEmployeeSkillPolicyRecord | null;
|
||||
server_local_policy?: DigitalEmployeeSkillPolicyRecord | null;
|
||||
server_enabled?: boolean;
|
||||
global_allow_tools: string[];
|
||||
global_deny_tools: string[];
|
||||
@@ -73,6 +98,42 @@ export function skillIdForMcpTool(serverId: string, toolName: string): string {
|
||||
return `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`;
|
||||
}
|
||||
|
||||
export function effectiveSkillPolicyFor(
|
||||
skillId: string,
|
||||
policies: DigitalEmployeeSkillPolicies,
|
||||
): DigitalEmployeeEffectiveSkillPolicy {
|
||||
const localPolicy = policies.local_skills[skillId] ?? null;
|
||||
const remotePolicy = policies.remote_skills[skillId] ?? null;
|
||||
if (remotePolicy?.enabled === false) {
|
||||
return {
|
||||
enabled: false,
|
||||
skill_id: skillId,
|
||||
local_policy: localPolicy,
|
||||
remote_policy: remotePolicy,
|
||||
policy_source: "remote",
|
||||
disabled_by: "remote",
|
||||
};
|
||||
}
|
||||
if (localPolicy?.enabled === false) {
|
||||
return {
|
||||
enabled: false,
|
||||
skill_id: skillId,
|
||||
local_policy: localPolicy,
|
||||
remote_policy: remotePolicy,
|
||||
policy_source: "local",
|
||||
disabled_by: "local",
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
skill_id: skillId,
|
||||
local_policy: localPolicy,
|
||||
remote_policy: remotePolicy,
|
||||
policy_source: remotePolicy ? "remote" : localPolicy ? "local" : "open",
|
||||
disabled_by: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDigitalEmployeeMcpExecutionPolicy(
|
||||
config: DigitalEmployeeMcpPolicyConfig,
|
||||
): DigitalEmployeeMcpPolicyConfig {
|
||||
@@ -148,8 +209,8 @@ export function evaluateDigitalEmployeeSkillCall(
|
||||
const effectiveDenyTools = serverDenyTools.length > 0 ? serverDenyTools : globalDenyTools;
|
||||
const reasonCodes: string[] = [];
|
||||
const matchedSkillId = skillId || null;
|
||||
const matchedPolicy = matchedSkillId ? policies.skills[matchedSkillId] : undefined;
|
||||
const serverPolicy = match.serverSkillId ? policies.skills[match.serverSkillId] : undefined;
|
||||
const matchedPolicy = matchedSkillId ? effectiveSkillPolicyFor(matchedSkillId, policies) : null;
|
||||
const serverPolicy = match.serverSkillId ? effectiveSkillPolicyFor(match.serverSkillId, policies) : null;
|
||||
|
||||
if (!matchedSkillId && !match.serverId) {
|
||||
reasonCodes.push("skill_not_matched");
|
||||
@@ -167,6 +228,7 @@ export function evaluateDigitalEmployeeSkillCall(
|
||||
return result("rejected", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, {
|
||||
skill_enabled: false,
|
||||
matched_skill_id: matchedSkillId,
|
||||
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
|
||||
server_enabled: serverEntry?.enabled !== false && serverPolicy?.enabled !== false,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
@@ -180,6 +242,7 @@ export function evaluateDigitalEmployeeSkillCall(
|
||||
return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, {
|
||||
skill_enabled: true,
|
||||
matched_skill_id: matchedSkillId,
|
||||
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
|
||||
server_enabled: true,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
@@ -193,6 +256,7 @@ export function evaluateDigitalEmployeeSkillCall(
|
||||
return result("rejected", reasonCodes, source, serverId || null, toolName, matchedSkillId, {
|
||||
skill_enabled: true,
|
||||
matched_skill_id: matchedSkillId,
|
||||
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
|
||||
server_enabled: true,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
@@ -205,6 +269,7 @@ export function evaluateDigitalEmployeeSkillCall(
|
||||
return result("allowed", reasonCodes, source, serverId || null, toolName || null, matchedSkillId, {
|
||||
skill_enabled: true,
|
||||
matched_skill_id: matchedSkillId,
|
||||
...skillPolicySnapshotFields(matchedPolicy, serverPolicy),
|
||||
server_enabled: true,
|
||||
global_allow_tools: globalAllowTools,
|
||||
global_deny_tools: globalDenyTools,
|
||||
@@ -286,18 +351,67 @@ function result(
|
||||
}
|
||||
|
||||
function isSkillDisabled(policies: DigitalEmployeeSkillPolicies, skillId: string): boolean {
|
||||
return policies.skills[skillId]?.enabled === false;
|
||||
return effectiveSkillPolicyFor(skillId, policies).enabled === false;
|
||||
}
|
||||
|
||||
function skillPolicySnapshotFields(
|
||||
matchedPolicy: DigitalEmployeeEffectiveSkillPolicy | null,
|
||||
serverPolicy: DigitalEmployeeEffectiveSkillPolicy | null,
|
||||
): Partial<DigitalEmployeeSkillCallPolicyResult["policy_snapshot"]> {
|
||||
return {
|
||||
...(matchedPolicy ? {
|
||||
matched_policy_source: matchedPolicy.policy_source,
|
||||
matched_policy_disabled_by: matchedPolicy.disabled_by,
|
||||
matched_remote_policy: matchedPolicy.remote_policy,
|
||||
matched_local_policy: matchedPolicy.local_policy,
|
||||
} : {}),
|
||||
...(serverPolicy ? {
|
||||
server_policy_source: serverPolicy.policy_source,
|
||||
server_policy_disabled_by: serverPolicy.disabled_by,
|
||||
server_remote_policy: serverPolicy.remote_policy,
|
||||
server_local_policy: serverPolicy.local_policy,
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
|
||||
const record = objectRecord(value);
|
||||
const rawSkills = objectRecord(record?.skills) ?? record;
|
||||
const hasStructuredMaps = Boolean(record && (
|
||||
"local_skills" in record
|
||||
|| "localSkills" in record
|
||||
|| "remote_skills" in record
|
||||
|| "remoteSkills" in record
|
||||
|| "skills" in record
|
||||
));
|
||||
const rawLocalSkills = objectRecord(record?.local_skills ?? record?.localSkills)
|
||||
?? objectRecord(record?.skills)
|
||||
?? (hasStructuredMaps ? {} : record);
|
||||
const rawRemoteSkills = objectRecord(record?.remote_skills ?? record?.remoteSkills) ?? {};
|
||||
const localSkills = normalizePolicyMap(rawLocalSkills, "local");
|
||||
const remoteSkills = normalizePolicyMap(rawRemoteSkills, "remote");
|
||||
const skills = mergeEffectiveSkillPolicies(localSkills, remoteSkills);
|
||||
return {
|
||||
version: 1,
|
||||
local_skills: localSkills,
|
||||
remote_skills: remoteSkills,
|
||||
skills,
|
||||
source: normalizePolicySource(record?.source),
|
||||
remote_updated_at: normalizeNullableString(record?.remote_updated_at ?? record?.remoteUpdatedAt),
|
||||
last_pulled_at: normalizeNullableString(record?.last_pulled_at ?? record?.lastPulledAt),
|
||||
last_pull_error: normalizeNullableString(record?.last_pull_error ?? record?.lastPullError),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePolicyMap(
|
||||
rawSkills: Record<string, unknown> | null,
|
||||
defaultSource: "local" | "remote",
|
||||
): Record<string, DigitalEmployeeSkillPolicyRecord> {
|
||||
const skills: Record<string, DigitalEmployeeSkillPolicyRecord> = {};
|
||||
for (const [rawSkillId, rawPolicy] of Object.entries(rawSkills ?? {})) {
|
||||
const skillId = normalizeString(rawSkillId);
|
||||
if (!skillId) continue;
|
||||
if (typeof rawPolicy === "boolean") {
|
||||
skills[skillId] = { enabled: rawPolicy, updatedAt: "" };
|
||||
skills[skillId] = { enabled: rawPolicy, updatedAt: "", source: defaultSource, reason: null };
|
||||
continue;
|
||||
}
|
||||
const policy = objectRecord(rawPolicy);
|
||||
@@ -305,9 +419,38 @@ function normalizeSkillPolicies(value: unknown): DigitalEmployeeSkillPolicies {
|
||||
skills[skillId] = {
|
||||
enabled: policy.enabled,
|
||||
updatedAt: normalizeString(policy.updatedAt ?? policy.updated_at),
|
||||
source: normalizeSkillPolicyRecordSource(policy.source) ?? defaultSource,
|
||||
reason: normalizeNullableString(policy.reason),
|
||||
};
|
||||
}
|
||||
return { version: 1, skills };
|
||||
return skills;
|
||||
}
|
||||
|
||||
function mergeEffectiveSkillPolicies(
|
||||
localSkills: Record<string, DigitalEmployeeSkillPolicyRecord>,
|
||||
remoteSkills: Record<string, DigitalEmployeeSkillPolicyRecord>,
|
||||
): Record<string, DigitalEmployeeSkillPolicyRecord> {
|
||||
const result: Record<string, DigitalEmployeeSkillPolicyRecord> = {};
|
||||
for (const skillId of uniqueStrings([...Object.keys(localSkills), ...Object.keys(remoteSkills)])) {
|
||||
const effective = effectiveSkillPolicyFor(skillId, {
|
||||
version: 1,
|
||||
local_skills: localSkills,
|
||||
remote_skills: remoteSkills,
|
||||
skills: {},
|
||||
});
|
||||
const sourcePolicy = effective.disabled_by === "remote"
|
||||
? effective.remote_policy
|
||||
: effective.disabled_by === "local"
|
||||
? effective.local_policy
|
||||
: effective.remote_policy ?? effective.local_policy;
|
||||
result[skillId] = {
|
||||
enabled: effective.enabled,
|
||||
updatedAt: sourcePolicy?.updatedAt ?? "",
|
||||
source: effective.policy_source === "open" ? undefined : effective.policy_source,
|
||||
reason: sourcePolicy?.reason ?? null,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeToolList(value: unknown): string[] {
|
||||
@@ -325,6 +468,19 @@ function normalizeString(value: unknown): string {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
function normalizeNullableString(value: unknown): string | null {
|
||||
const normalized = normalizeString(value);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizePolicySource(value: unknown): DigitalEmployeeSkillPolicies["source"] {
|
||||
return value === "remote" || value === "mixed" ? value : "local";
|
||||
}
|
||||
|
||||
function normalizeSkillPolicyRecordSource(value: unknown): DigitalEmployeeSkillPolicyRecord["source"] | null {
|
||||
return value === "remote" || value === "local" ? value : null;
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
|
||||
@@ -137,6 +137,106 @@ describe("digital employee sync service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("pulls remote skill policies and preserves local skill preferences", async () => {
|
||||
mockState.settings.set("digital_employee_skill_policies_v1", {
|
||||
version: 1,
|
||||
local_skills: {
|
||||
"qimingclaw-acp-session": { enabled: false, updatedAt: "2026-06-07T07:00:00.000Z", source: "local" },
|
||||
},
|
||||
});
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
skill_policies: {
|
||||
updated_at: "2026-06-07T08:01:00.000Z",
|
||||
skills: {
|
||||
"qimingclaw-mcp-tool-crm-crm-delete": {
|
||||
enabled: false,
|
||||
updated_at: "2026-06-07T08:01:00.000Z",
|
||||
reason: "management_disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
|
||||
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
endpoint: "https://manage.example.com/api/digital-employee/policies/skills",
|
||||
policies: {
|
||||
source: "remote",
|
||||
remote_updated_at: "2026-06-07T08:01:00.000Z",
|
||||
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
last_pull_error: null,
|
||||
local_skills: {
|
||||
"qimingclaw-acp-session": expect.objectContaining({ enabled: false, source: "local" }),
|
||||
},
|
||||
remote_skills: {
|
||||
"qimingclaw-mcp-tool-crm-crm-delete": expect.objectContaining({ enabled: false, source: "remote", reason: "management_disabled" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockState.fetch).toHaveBeenCalledWith(
|
||||
"https://manage.example.com/api/digital-employee/policies/skills",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(mockState.settings.get("digital_employee_skill_policies_v1")).toMatchObject(result.policies);
|
||||
});
|
||||
|
||||
it("keeps current skill policies when remote skill policy pull fails", async () => {
|
||||
mockState.settings.set("digital_employee_skill_policies_v1", {
|
||||
version: 1,
|
||||
remote_skills: {
|
||||
"qimingclaw-computer-control": { enabled: false, updatedAt: "2026-06-07T07:30:00.000Z", source: "remote" },
|
||||
},
|
||||
});
|
||||
mockState.fetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify({ message: "skill policy service unavailable" })),
|
||||
} as unknown as Response);
|
||||
|
||||
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
|
||||
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: "skill policy service unavailable",
|
||||
policies: {
|
||||
remote_skills: {
|
||||
"qimingclaw-computer-control": expect.objectContaining({ enabled: false, source: "remote" }),
|
||||
},
|
||||
last_pulled_at: "2026-06-07T08:00:00.000Z",
|
||||
last_pull_error: "skill policy service unavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["skillPolicies"],
|
||||
["policies"],
|
||||
])("pulls remote skill policies from data.%s", async (fieldName) => {
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
[fieldName]: {
|
||||
"qimingclaw-acp-session": false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { pullDigitalEmployeeSkillPolicies } = await import("./syncService");
|
||||
const result = await pullDigitalEmployeeSkillPolicies({ force: true });
|
||||
|
||||
expect(result.policies.remote_skills["qimingclaw-acp-session"]).toEqual(expect.objectContaining({
|
||||
enabled: false,
|
||||
source: "remote",
|
||||
}));
|
||||
});
|
||||
|
||||
it("acquires a remote plan step dispatch lease through the management endpoint", async () => {
|
||||
mockState.fetch.mockResolvedValue(jsonResponse({
|
||||
ok: true,
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import log from "electron-log";
|
||||
import { getDeviceId } from "../system/deviceId";
|
||||
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||
import { ensureDigitalEmployeeSchema, getDb, readSetting } from "../../db";
|
||||
import { ensureDigitalEmployeeSchema, getDb, readSetting, writeSetting } from "../../db";
|
||||
import {
|
||||
normalizePlanStepDispatchPolicy,
|
||||
readDigitalEmployeeUiState,
|
||||
saveDigitalEmployeeUiState,
|
||||
type DigitalEmployeePlanStepDispatchPolicy,
|
||||
} from "./stateService";
|
||||
import {
|
||||
SKILL_POLICIES_SETTING_KEY,
|
||||
normalizeDigitalEmployeeSkillPolicies,
|
||||
readDigitalEmployeeSkillPolicies,
|
||||
type DigitalEmployeeSkillPolicies,
|
||||
} from "./skillExecutionPolicy";
|
||||
|
||||
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
|
||||
const DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH = "/api/digital-employee/policies/plan-step-dispatch";
|
||||
const DEFAULT_SKILL_POLICY_PATH = "/api/digital-employee/policies/skills";
|
||||
const DEFAULT_PLAN_STEP_DISPATCH_LEASE_PATH = "/api/digital-employee/leases/plan-step-dispatch";
|
||||
const MANAGEMENT_SYNC_RECORD_PATH =
|
||||
"/system/content/content-digital-employee-sync";
|
||||
@@ -29,6 +36,8 @@ let lastSyncAt: string | null = null;
|
||||
let lastSyncError: string | null = null;
|
||||
let policyPulling = false;
|
||||
let lastPolicyPullAttemptMs = 0;
|
||||
let skillPolicyPulling = false;
|
||||
let lastSkillPolicyPullAttemptMs = 0;
|
||||
|
||||
function getDigitalEmployeeDb(): ReturnType<typeof getDb> {
|
||||
if (!ensureDigitalEmployeeSchema()) return null;
|
||||
@@ -39,12 +48,22 @@ interface DigitalEmployeeSyncConfig {
|
||||
enabled: boolean;
|
||||
endpoint: string | null;
|
||||
policyEndpoint: string | null;
|
||||
skillPolicyEndpoint: string | null;
|
||||
leaseEndpoint: string | null;
|
||||
clientKey: string | null;
|
||||
authToken: string | null;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillPolicyPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
endpoint: string | null;
|
||||
pulled_at: string | null;
|
||||
policies: DigitalEmployeeSkillPolicies;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePlanStepDispatchPolicyPullResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
@@ -289,6 +308,66 @@ export async function pullDigitalEmployeePlanStepDispatchPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
export async function pullDigitalEmployeeSkillPolicies(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<DigitalEmployeeSkillPolicyPullResult> {
|
||||
const currentPolicies = readDigitalEmployeeSkillPolicies();
|
||||
if (skillPolicyPulling) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
endpoint: resolveSyncConfig().skillPolicyEndpoint,
|
||||
pulled_at: currentPolicies.last_pulled_at ?? null,
|
||||
policies: currentPolicies,
|
||||
};
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
if (!options.force && nowMs - lastSkillPolicyPullAttemptMs < POLICY_PULL_THROTTLE_MS) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
endpoint: resolveSyncConfig().skillPolicyEndpoint,
|
||||
pulled_at: currentPolicies.last_pulled_at ?? null,
|
||||
policies: currentPolicies,
|
||||
};
|
||||
}
|
||||
lastSkillPolicyPullAttemptMs = nowMs;
|
||||
|
||||
const config = resolveSyncConfig();
|
||||
const missingCredentials = missingSyncCredentialLabels(config);
|
||||
if (!config.skillPolicyEndpoint || missingCredentials.length > 0) {
|
||||
const error = !config.skillPolicyEndpoint ? "management skill policy endpoint unavailable" : `missing credentials: ${missingCredentials.join(", ")}`;
|
||||
return recordSkillPolicyPullFailure(currentPolicies, config.skillPolicyEndpoint, error);
|
||||
}
|
||||
|
||||
skillPolicyPulling = true;
|
||||
try {
|
||||
const response = await fetchJsonWithAuth(config, config.skillPolicyEndpoint);
|
||||
const responseError = readSyncResponseError(response as SyncResponse);
|
||||
if (responseError) throw new Error(responseError);
|
||||
const remotePayload = readRemoteSkillPolicyPayload(response);
|
||||
if (!remotePayload) throw new Error("management policy response missing skill policies");
|
||||
const remoteSkills = readRemoteSkillPolicyRecords(remotePayload);
|
||||
if (!remoteSkills) throw new Error("management policy response missing skill policy records");
|
||||
const pulledAt = new Date().toISOString();
|
||||
const policies = normalizeDigitalEmployeeSkillPolicies({
|
||||
version: 1,
|
||||
local_skills: currentPolicies.local_skills,
|
||||
remote_skills: remoteSkills,
|
||||
source: "remote",
|
||||
remote_updated_at: stringField(remotePayload.updated_at) || stringField(remotePayload.updatedAt) || stringField(remotePayload.remote_updated_at) || null,
|
||||
last_pulled_at: pulledAt,
|
||||
last_pull_error: null,
|
||||
});
|
||||
writeSetting(SKILL_POLICIES_SETTING_KEY, policies);
|
||||
return { ok: true, endpoint: config.skillPolicyEndpoint, pulled_at: pulledAt, policies, error: null };
|
||||
} catch (error) {
|
||||
return recordSkillPolicyPullFailure(currentPolicies, config.skillPolicyEndpoint, normalizeError(error));
|
||||
} finally {
|
||||
skillPolicyPulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireDigitalEmployeePlanStepRemoteLease(
|
||||
input: DigitalEmployeePlanStepRemoteLeaseAcquireInput,
|
||||
): Promise<DigitalEmployeePlanStepRemoteLeaseResult> {
|
||||
@@ -493,6 +572,11 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
? config.policyEndpoint.trim()
|
||||
: null;
|
||||
const policyEndpoint = policyEndpointOverride || buildEndpoint(serverHost, DEFAULT_PLAN_STEP_DISPATCH_POLICY_PATH);
|
||||
const skillPolicyEndpointOverride =
|
||||
typeof config.skillPolicyEndpoint === "string" && config.skillPolicyEndpoint.trim()
|
||||
? config.skillPolicyEndpoint.trim()
|
||||
: null;
|
||||
const skillPolicyEndpoint = skillPolicyEndpointOverride || buildEndpoint(serverHost, DEFAULT_SKILL_POLICY_PATH);
|
||||
const leaseEndpointOverride =
|
||||
typeof config.planStepDispatchLeaseEndpoint === "string" && config.planStepDispatchLeaseEndpoint.trim()
|
||||
? config.planStepDispatchLeaseEndpoint.trim()
|
||||
@@ -508,6 +592,7 @@ function resolveSyncConfig(): DigitalEmployeeSyncConfig {
|
||||
enabled: !disabled,
|
||||
endpoint,
|
||||
policyEndpoint,
|
||||
skillPolicyEndpoint,
|
||||
leaseEndpoint,
|
||||
clientKey: readClientKey(serverHost),
|
||||
authToken: readAuthToken(serverHost),
|
||||
@@ -645,6 +730,22 @@ function readRemotePlanStepDispatchPolicy(response: Record<string, unknown>): Re
|
||||
?? (hasPlanStepDispatchPolicyFields(data) ? data : null);
|
||||
}
|
||||
|
||||
function readRemoteSkillPolicyPayload(response: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const data = objectRecord(response.data) ?? response;
|
||||
return objectRecord(data.skill_policies)
|
||||
?? objectRecord(data.skillPolicies)
|
||||
?? objectRecord(data.policies)
|
||||
?? objectRecord(data.policy)
|
||||
?? (hasSkillPolicyWrapperFields(data) || looksLikeSkillPolicyMap(data) ? data : null);
|
||||
}
|
||||
|
||||
function readRemoteSkillPolicyRecords(payload: Record<string, unknown>): Record<string, unknown> | null {
|
||||
return objectRecord(payload.remote_skills)
|
||||
?? objectRecord(payload.remoteSkills)
|
||||
?? objectRecord(payload.skills)
|
||||
?? (looksLikeSkillPolicyMap(payload) ? payload : null);
|
||||
}
|
||||
|
||||
function readPlanStepLeaseRejectionReason(response: Record<string, unknown>): string | null {
|
||||
if (response.ok === false || response.success === false || response.accepted === false || response.granted === false) {
|
||||
return stringField(response.reason ?? response.error ?? response.message) || "remote_lease_rejected";
|
||||
@@ -703,6 +804,23 @@ function hasPlanStepDispatchPolicyFields(record: Record<string, unknown>): boole
|
||||
return "enabled" in record || "max_per_sweep" in record || "auto_dispatch_ready_steps" in record;
|
||||
}
|
||||
|
||||
function hasSkillPolicyWrapperFields(record: Record<string, unknown>): boolean {
|
||||
return "remote_skills" in record
|
||||
|| "remoteSkills" in record
|
||||
|| "skills" in record
|
||||
|| "skill_policies" in record
|
||||
|| "skillPolicies" in record
|
||||
|| "policies" in record;
|
||||
}
|
||||
|
||||
function looksLikeSkillPolicyMap(record: Record<string, unknown>): boolean {
|
||||
return Object.values(record).some((value) => {
|
||||
if (typeof value === "boolean") return true;
|
||||
const policy = objectRecord(value);
|
||||
return Boolean(policy && typeof policy.enabled === "boolean");
|
||||
});
|
||||
}
|
||||
|
||||
function recordPlanStepDispatchPolicyPullFailure(
|
||||
state: ReturnType<typeof readDigitalEmployeeUiState>,
|
||||
currentPolicy: DigitalEmployeePlanStepDispatchPolicy,
|
||||
@@ -732,6 +850,21 @@ function recordPlanStepDispatchPolicyPullFailure(
|
||||
return { ok: false, endpoint, pulled_at: pulledAt, policy, error };
|
||||
}
|
||||
|
||||
function recordSkillPolicyPullFailure(
|
||||
currentPolicies: DigitalEmployeeSkillPolicies,
|
||||
endpoint: string | null,
|
||||
error: string,
|
||||
): DigitalEmployeeSkillPolicyPullResult {
|
||||
const pulledAt = new Date().toISOString();
|
||||
const policies = normalizeDigitalEmployeeSkillPolicies({
|
||||
...currentPolicies,
|
||||
last_pulled_at: pulledAt,
|
||||
last_pull_error: error,
|
||||
});
|
||||
writeSetting(SKILL_POLICIES_SETTING_KEY, policies);
|
||||
return { ok: false, endpoint, pulled_at: pulledAt, policies, error };
|
||||
}
|
||||
|
||||
function buildSyncItem(row: OutboxRow): Record<string, unknown> {
|
||||
const payload = parsePayload(row.payload);
|
||||
const summary = readEntitySummary(row.entity_type, row.entity_id);
|
||||
|
||||
@@ -525,10 +525,12 @@ describe("AcpEngine.handlePermissionRequest(strict)", () => {
|
||||
};
|
||||
mockDigitalEmployee.settings.set("digital_employee_skill_policies_v1", {
|
||||
version: 1,
|
||||
skills: {
|
||||
remote_skills: {
|
||||
"qimingclaw-mcp-tool-crm-crm-delete": {
|
||||
enabled: false,
|
||||
updatedAt: "2026-06-07T08:00:00.000Z",
|
||||
source: "remote",
|
||||
reason: "management_disabled",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -558,6 +560,14 @@ describe("AcpEngine.handlePermissionRequest(strict)", () => {
|
||||
decision: "rejected",
|
||||
reasonCodes: ["skill_disabled"],
|
||||
sessionId,
|
||||
policySnapshot: expect.objectContaining({
|
||||
matched_policy_source: "remote",
|
||||
matched_policy_disabled_by: "remote",
|
||||
matched_remote_policy: expect.objectContaining({
|
||||
enabled: false,
|
||||
reason: "management_disabled",
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
expect(JSON.stringify(mockDigitalEmployee.recordSkillCallAudit.mock.calls[0][0])).not.toContain("secret-customer");
|
||||
});
|
||||
|
||||
@@ -131,6 +131,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
||||
async pullPlanStepDispatchPolicy() {
|
||||
return ipcRenderer.invoke("digitalEmployee:pullPlanStepDispatchPolicy");
|
||||
},
|
||||
async pullSkillPolicies() {
|
||||
return ipcRenderer.invoke("digitalEmployee:pullSkillPolicies");
|
||||
},
|
||||
async getCronSettings() {
|
||||
return ipcRenderer.invoke("digitalEmployee:getCronSettings");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user