吸收数字员工技能远端策略下发

This commit is contained in:
baiyanyun
2026-06-11 09:04:08 +08:00
parent 751ce45619
commit 8ab3d73fb1
18 changed files with 876 additions and 207 deletions

View File

@@ -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");

View File

@@ -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();
});

View File

@@ -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,

View File

@@ -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",

View File

@@ -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>

View File

@@ -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,

View File

@@ -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);

View File

@@ -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");
});

View File

@@ -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");
},