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

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

@@ -619,6 +619,12 @@ export function pullPlanStepDispatchPolicy(): Promise<PlanStepDispatchPolicyPull
});
}
export function pullSkillPolicies(): Promise<{ ok: boolean; skipped?: boolean; error?: string | null }> {
return apiFetch<{ ok: boolean; skipped?: boolean; error?: string | null }>('/api/skill/policies/pull', {
method: 'POST',
});
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {

View File

@@ -78,6 +78,9 @@ export interface SkillSummary {
allowTools?: string[];
denyTools?: string[];
governancePolicy?: 'allow' | 'deny' | 'open' | 'disabled' | null;
policySource?: 'remote' | 'local' | 'open' | null;
remotePolicy?: Record<string, unknown> | null;
localPolicy?: Record<string, unknown> | null;
recentCalls?: Array<{
eventId: string;
message: string;
@@ -496,6 +499,9 @@ export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
allowTools: stringList(s.allow_tools ?? governance?.server_allow_tools ?? governance?.global_allow_tools),
denyTools: stringList(s.deny_tools ?? governance?.server_deny_tools ?? governance?.global_deny_tools),
governancePolicy: governancePolicy(s.governance_policy ?? governance?.effective_policy),
policySource: policySource(s.policy_source),
remotePolicy: obj(s.remote_policy),
localPolicy: obj(s.local_policy),
recentCalls: arr(s.recent_calls).map((call) => {
const c = obj(call) ?? {};
return {
@@ -533,6 +539,12 @@ function governancePolicy(value: unknown): SkillSummary['governancePolicy'] {
: null;
}
function policySource(value: unknown): SkillSummary['policySource'] {
return value === 'remote' || value === 'local' || value === 'open'
? value
: null;
}
export function adaptJournalEntries(storeData: Obj | null): JournalEntry[] {
if (!storeData) return [];
const tables = (storeData.tables ?? {}) as Obj;

View File

@@ -54,6 +54,7 @@ declare global {
getUiState?: () => Promise<AdapterState>;
getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>;
setSkillEnabled?: (skillId: string, enabled: boolean) => Promise<{ ok?: boolean; skill_id?: string; enabled?: boolean; error?: string }>;
pullSkillPolicies?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
listMemories?: (options?: { query?: string; category?: string; limit?: number }) => Promise<QimingclawMemoryRecord[]>;
addMemory?: (input: QimingclawMemoryUpsertInput) => Promise<QimingclawMemoryRecord | null>;
@@ -975,6 +976,9 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && pathname === '/api/skill') {
return { skills: await buildSkills() } as T;
}
if (method === 'POST' && pathname === '/api/skill/policies/pull') {
return await pullBridgeSkillPolicies() as T;
}
if (method === 'POST' && pathname.startsWith('/api/skill/') && pathname.endsWith('/enabled')) {
const skillId = decodeURIComponent(pathname.split('/')[3] || '');
const body = parseJsonBody<{ enabled?: boolean }>(options.body);
@@ -1093,6 +1097,23 @@ async function setBridgeSkillEnabled(
}
}
async function pullBridgeSkillPolicies(): Promise<{ ok: boolean; skipped?: boolean; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.pullSkillPolicies) {
return { ok: false, error: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.pullSkillPolicies();
return {
ok: result?.ok !== false,
...(result?.skipped ? { skipped: true } : {}),
...(result?.error ? { error: result.error } : {}),
};
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
function dedupeSkills(skills: SkillSpec[]): SkillSpec[] {
const seen = new Set<string>();
const result: SkillSpec[] = [];

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
import { getSkills, setSkillEnabled } from '@/lib/api';
import { getSkills, pullSkillPolicies, setSkillEnabled } from '@/lib/api';
import { domSafeId } from './navigation';
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
@@ -81,6 +81,8 @@ function skillMetaBadges(skill: SkillSummary): string[] {
const badges: string[] = [];
if (skill.serverId) badges.push(skill.serverId);
if (skill.running !== null && skill.running !== undefined) badges.push(skill.running ? '运行中' : '未运行');
if (skill.policySource === 'remote') badges.push('远端策略');
if (skill.remotePolicy && skill.remotePolicy.enabled === false) badges.push('管理端禁用');
if (skill.governancePolicy) badges.push(policyLabels[skill.governancePolicy]);
if (typeof skill.toolCount === 'number') badges.push(`${skill.toolCount} 工具`);
if (skill.allowTools?.length) badges.push(`allow ${skill.allowTools.length}`);
@@ -174,7 +176,9 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
const [cadenceFilter, setCadenceFilter] = useState<'all' | TaskCadence>('all');
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(focusSkillId ?? null);
const [updatingSkillId, setUpdatingSkillId] = useState<string | null>(null);
const [pullingPolicies, setPullingPolicies] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const mountedRef = useRef(false);
useEffect(() => {
@@ -217,6 +221,7 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
const nextEnabled = !skill.enabled;
setUpdatingSkillId(skill.id);
setActionError(null);
setActionNotice(null);
try {
const result = await setSkillEnabled(skill.id, nextEnabled);
if (!result.ok) {
@@ -233,6 +238,22 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
}
}, []);
const pullPolicies = useCallback(async () => {
setPullingPolicies(true);
setActionError(null);
setActionNotice(null);
try {
const result = await pullSkillPolicies();
if (!result.ok) throw new Error(result.error || '技能策略拉取失败');
await fetchSkills();
setActionNotice(result.skipped ? '技能策略无需重复拉取。' : '技能策略已更新。');
} catch (err) {
setActionError(err instanceof Error ? err.message : '技能策略拉取失败');
} finally {
setPullingPolicies(false);
}
}, [fetchSkills]);
const filtered = skills
.filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter)
.filter(s => {
@@ -322,6 +343,9 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
<div style={{ padding: '14px 18px' }}>
<GlassCard>
<CardTitleBar title="学习技能">
<button onClick={pullPolicies} disabled={pullingPolicies} className="de-btn-secondary de-btn-sm">
{pullingPolicies ? '拉取中...' : '拉取策略'}
</button>
<button onClick={fetchSkills} className="de-btn-secondary de-btn-sm"></button>
</CardTitleBar>
<div className="de-card-body">
@@ -330,6 +354,11 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
{actionError}
</div>
)}
{actionNotice && (
<div className="de-schedule-result" style={{ marginBottom: 12 }}>
{actionNotice}
</div>
)}
<div className="de-skill-toolbar">
<input type="text" className="de-input" placeholder="搜索业务能力..." value={search} onChange={e => setSearch(e.target.value)} />
<div className="de-filter-row">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-LEtlcmt1.js"></script>
<script type="module" crossorigin src="./assets/index-1hmQ8OoX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4TVkOCsf.css">
</head>
<body>

View File

@@ -409,6 +409,55 @@ function checkDigitalPlanStepGraphDispatchPolicy() {
console.log("[DigitalEmployeeCheck] PlanStep graph/dispatch policy hooks OK");
}
function checkDigitalSkillRemotePolicy() {
console.log("\n[DigitalEmployeeCheck] Verify skill remote policy hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const skillLibraryPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "SkillLibrary.tsx");
const skillExecutionPolicyPath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "skillExecutionPolicy.ts");
const skillCatalogPath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "skillCatalogService.ts");
const syncServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "syncService.ts");
const ipcHandlersPath = path.join(packageRoot, "src", "main", "ipc", "digitalEmployeeHandlers.ts");
const preloadPath = path.join(packageRoot, "src", "preload", "webviewPerfBridge.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const skillLibrary = fs.readFileSync(skillLibraryPath, "utf8");
const skillExecutionPolicy = fs.readFileSync(skillExecutionPolicyPath, "utf8");
const skillCatalog = fs.readFileSync(skillCatalogPath, "utf8");
const syncService = fs.readFileSync(syncServicePath, "utf8");
const ipcHandlers = fs.readFileSync(ipcHandlersPath, "utf8");
const preload = fs.readFileSync(preloadPath, "utf8");
const requiredSnippets = [
[skillExecutionPolicy, "local_skills", "local skill policy map"],
[skillExecutionPolicy, "remote_skills", "remote skill policy map"],
[skillExecutionPolicy, "last_pull_error", "skill policy pull error metadata"],
[skillExecutionPolicy, "effectiveSkillPolicyFor", "effective skill policy merge helper"],
[skillExecutionPolicy, "matched_remote_policy", "skill call remote policy audit snapshot"],
[skillCatalog, "remote_policy", "skill catalog remote policy projection"],
[skillCatalog, "local_policy", "skill catalog local policy projection"],
[skillCatalog, "policy_source", "skill catalog policy source projection"],
[syncService, "pullDigitalEmployeeSkillPolicies", "remote skill policy pull service"],
[syncService, "skillPolicyEndpoint", "remote skill policy endpoint config"],
[syncService, "/api/digital-employee/policies/skills", "remote skill policy default path"],
[syncService, "readRemoteSkillPolicyPayload", "remote skill policy response parser"],
[syncService, "recordSkillPolicyPullFailure", "remote skill policy pull failure recorder"],
[ipcHandlers, "digitalEmployee:pullSkillPolicies", "IPC skill policy pull handler"],
[preload, "pullSkillPolicies", "preload skill policy pull bridge"],
[adapter, "pullSkillPolicies", "adapter skill policy pull bridge"],
[adapter, "/api/skill/policies/pull", "adapter skill policy pull endpoint"],
[api, "pullSkillPolicies", "embedded skill policy pull API"],
[skillLibrary, "拉取策略", "skill library pull policy action"],
[skillLibrary, "远端策略", "skill library remote policy marker"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing skill remote policy hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] skill remote policy hooks OK");
}
function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -455,6 +504,7 @@ function main() {
checkDigitalNotificationPreferences();
checkDigitalApprovalHistorySubscriptions();
checkDigitalPlanStepGraphDispatchPolicy();
checkDigitalSkillRemotePolicy();
checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) {

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