吸收数字员工产物访问治理

This commit is contained in:
baiyanyun
2026-06-10 10:00:34 +08:00
parent cc36d8dcaf
commit 362f397e37
18 changed files with 1016 additions and 166 deletions

View File

@@ -5,6 +5,10 @@ const mockStateService = vi.hoisted(() => ({
recordManagedServiceAction: vi.fn(),
}));
const mockArtifactAccess = vi.hoisted(() => ({
accessDigitalEmployeeArtifact: vi.fn(),
}));
const mockServiceManager = vi.hoisted(() => ({
createServiceManager: vi.fn(),
startFileServer: vi.fn(),
@@ -97,6 +101,10 @@ vi.mock("../services/digitalEmployee/skillCatalogService", () => ({
setDigitalEmployeeSkillEnabled: vi.fn(),
}));
vi.mock("../services/digitalEmployee/artifactAccessService", () => ({
accessDigitalEmployeeArtifact: mockArtifactAccess.accessDigitalEmployeeArtifact,
}));
vi.mock("../services/memory", () => ({
memoryService: {
isInitialized: () => false,
@@ -125,6 +133,7 @@ describe("digital employee managed service actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockStateService.recordManagedServiceAction.mockReturnValue({ eventId: "event-1" });
mockArtifactAccess.accessDigitalEmployeeArtifact.mockResolvedValue({ ok: true, artifact_id: "artifact-1", action: "preview" });
mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" });
mockServiceManager.createServiceManager.mockReturnValue({
startFileServer: mockServiceManager.startFileServer,
@@ -184,4 +193,20 @@ describe("digital employee managed service actions", () => {
result: expect.objectContaining({ error: "port busy" }),
}));
});
it("registers artifact access through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const accessCall = calls.find(([channel]) => channel === "digitalEmployee:accessArtifact");
expect(accessCall).toBeTruthy();
const handler = accessCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>;
const result = await handler({}, { artifactId: "artifact-1", action: "preview" });
expect(result).toMatchObject({ ok: true, artifact_id: "artifact-1", action: "preview" });
expect(mockArtifactAccess.accessDigitalEmployeeArtifact).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "preview" });
});
});

View File

@@ -58,6 +58,10 @@ import {
listDigitalEmployeeSkillCapabilities,
setDigitalEmployeeSkillEnabled,
} from "../services/digitalEmployee/skillCatalogService";
import {
accessDigitalEmployeeArtifact,
type DigitalEmployeeArtifactAccessRequest,
} from "../services/digitalEmployee/artifactAccessService";
import { memoryService } from "../services/memory";
interface RestartManagedServiceRequest {
@@ -221,6 +225,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
},
);
ipcMain.handle(
"digitalEmployee:accessArtifact",
async (_, input: DigitalEmployeeArtifactAccessRequest) => {
return accessDigitalEmployeeArtifact(input ?? {});
},
);
ipcMain.handle(
"digitalEmployee:restartManagedService",
async (_, input: RestartManagedServiceRequest) => {

View File

@@ -0,0 +1,114 @@
import { mkdtemp, rm, writeFile } from "fs/promises";
import os from "os";
import path from "path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockStateService = vi.hoisted(() => ({
records: { artifacts: [] as any[] },
recordDigitalEmployeeArtifactAccess: vi.fn((input: { status: string }) => ({ eventId: `event:${input.status}` })),
}));
const mockShell = vi.hoisted(() => ({
openPath: vi.fn(async () => ""),
showItemInFolder: vi.fn(),
}));
vi.mock("electron", () => ({
shell: mockShell,
}));
vi.mock("./stateService", () => ({
readDigitalEmployeeRuntimeRecords: () => mockStateService.records,
recordDigitalEmployeeArtifactAccess: mockStateService.recordDigitalEmployeeArtifactAccess,
}));
describe("digital employee artifact access service", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockStateService.records = { artifacts: [] };
});
it("returns bounded text preview for a local artifact and audits allowed access", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qiming-artifact-access-"));
try {
const filePath = path.join(tempDir, "report.txt");
await writeFile(filePath, "hello artifact", "utf8");
mockStateService.records = {
artifacts: [{
id: "artifact-1",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
kind: "report",
name: "report.txt",
uri: filePath,
payload: { delivery: { status: "completed", workspace_id: "workspace-1" } },
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
}],
};
const { accessDigitalEmployeeArtifact } = await import("./artifactAccessService");
const result = await accessDigitalEmployeeArtifact({ artifactId: "artifact-1", action: "preview", actor: "operator" });
expect(result).toMatchObject({
ok: true,
artifact_id: "artifact-1",
action: "preview",
status: "allowed",
preview: { name: "report.txt", text: "hello artifact", mime: "text/plain" },
});
expect(mockStateService.recordDigitalEmployeeArtifactAccess).toHaveBeenCalledWith(expect.objectContaining({
artifactId: "artifact-1",
status: "allowed",
uriSummary: expect.objectContaining({ basename: "report.txt", extension: ".txt" }),
deliverySnapshot: expect.objectContaining({ delivery_status: "completed", workspace_id: "workspace-1" }),
}));
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("rejects non-local artifact URIs without invoking shell", async () => {
mockStateService.records = {
artifacts: [{
id: "artifact-remote",
kind: "report",
name: "remote.txt",
uri: "https://example.com/remote.txt",
payload: {},
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
}],
};
const { accessDigitalEmployeeArtifact } = await import("./artifactAccessService");
const result = await accessDigitalEmployeeArtifact({ artifactId: "artifact-remote", action: "download" });
expect(result).toMatchObject({ ok: false, status: "rejected", error: "local_uri_required" });
expect(mockShell.openPath).not.toHaveBeenCalled();
expect(mockShell.showItemInFolder).not.toHaveBeenCalled();
});
it("rejects unknown access actions before opening anything", async () => {
mockStateService.records = {
artifacts: [{
id: "artifact-1",
kind: "report",
name: "report.txt",
uri: "/tmp/report.txt",
payload: {},
syncStatus: "pending",
createdAt: "2026-06-07T08:00:00.000Z",
}],
};
const { accessDigitalEmployeeArtifact } = await import("./artifactAccessService");
const result = await accessDigitalEmployeeArtifact({ artifactId: "artifact-1", action: "delete" });
expect(result).toMatchObject({ ok: false, status: "rejected", error: "dangerous_action" });
expect(mockShell.openPath).not.toHaveBeenCalled();
expect(mockShell.showItemInFolder).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,322 @@
import { shell } from "electron";
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import {
readDigitalEmployeeRuntimeRecords,
recordDigitalEmployeeArtifactAccess,
type DigitalEmployeeArtifactAccessAction,
type DigitalEmployeeArtifactRecord,
} from "./stateService";
const MAX_TEXT_PREVIEW_BYTES = 128 * 1024;
const TEXT_EXTENSIONS = new Set([
".csv",
".json",
".log",
".md",
".markdown",
".txt",
".xml",
".yaml",
".yml",
]);
export interface DigitalEmployeeArtifactAccessRequest {
artifactId?: string | null;
artifact_id?: string | null;
action?: string | null;
actor?: string | null;
source?: string | null;
}
export interface DigitalEmployeeArtifactAccessResponse {
ok: boolean;
artifact_id: string;
action: DigitalEmployeeArtifactAccessAction;
status: "allowed" | "rejected";
error?: string;
reason_codes: string[];
preview?: {
name: string;
kind: string;
mime: string;
size: number | null;
truncated: boolean;
text?: string;
delivery_status?: string | null;
delivery_stage?: string | null;
};
event_id?: string | null;
}
interface ResolvedArtifactAccess {
artifact: DigitalEmployeeArtifactRecord;
path: string | null;
summary: Record<string, unknown>;
delivery: Record<string, unknown>;
}
export async function accessDigitalEmployeeArtifact(
input: DigitalEmployeeArtifactAccessRequest,
): Promise<DigitalEmployeeArtifactAccessResponse> {
const artifactId = String(input.artifactId || input.artifact_id || "").trim();
const actionInput = String(input.action || "preview").trim();
const action = normalizeAction(actionInput);
if (!isAllowedAction(actionInput)) {
return rejectArtifactAccess(artifactId || "unknown-artifact", action, ["dangerous_action"], input);
}
if (!artifactId) {
return rejectArtifactAccess("unknown-artifact", action, ["artifact_id_required"], input);
}
const artifact = findArtifact(artifactId);
if (!artifact) {
return rejectArtifactAccess(artifactId, action, ["artifact_not_found"], input);
}
const resolved = await resolveArtifactAccess(artifact);
if (action === "preview") {
if (isDailyReportTextArtifact(artifact)) {
return allowArtifactAccess(artifactId, action, resolved, input, {
name: artifact.name || "daily-report.txt",
kind: artifact.kind,
mime: "text/plain",
size: textContentForArtifact(artifact).length,
truncated: false,
text: textContentForArtifact(artifact),
delivery_status: deliveryString(resolved.delivery, "delivery_status"),
delivery_stage: deliveryString(resolved.delivery, "delivery_stage"),
});
}
const pathValidation = await validateLocalArtifactPath(resolved.path);
if (!pathValidation.ok) {
return rejectArtifactAccess(artifactId, action, pathValidation.reasonCodes, input, resolved);
}
const preview = await buildFilePreview(artifact, pathValidation.filePath, pathValidation.stats, resolved.delivery);
return allowArtifactAccess(artifactId, action, resolved, input, preview);
}
const pathValidation = await validateLocalArtifactPath(resolved.path);
if (!pathValidation.ok) {
return rejectArtifactAccess(artifactId, action, pathValidation.reasonCodes, input, resolved);
}
if (action === "open_location") {
shell.showItemInFolder(pathValidation.filePath);
} else if (action === "download") {
const openResult = await shell.openPath(pathValidation.filePath);
if (openResult) {
return rejectArtifactAccess(artifactId, action, ["shell_open_failed"], input, resolved);
}
}
return allowArtifactAccess(artifactId, action, resolved, input);
}
function findArtifact(artifactId: string): DigitalEmployeeArtifactRecord | null {
const records = readDigitalEmployeeRuntimeRecords();
return records.artifacts.find((artifact) => artifact.id === artifactId || artifact.remoteId === artifactId) ?? null;
}
async function resolveArtifactAccess(artifact: DigitalEmployeeArtifactRecord): Promise<ResolvedArtifactAccess> {
const payload = objectRecord(artifact.payload) ?? {};
const delivery = normalizeDelivery(payload);
const uri = artifact.uri || stringValue(payload.uri) || stringValue(payload.url) || stringValue(payload.path) || stringValue(delivery.uri);
const localPath = localPathFromUri(uri);
const basename = artifact.name || stringValue(payload.filename) || stringValue(delivery.filename) || (localPath ? path.basename(localPath) : "");
return {
artifact,
path: localPath,
summary: {
scheme: uriScheme(uri),
basename,
extension: basename ? path.extname(basename).toLowerCase() : null,
},
delivery: deliverySnapshot(delivery),
};
}
function normalizeAction(value: string | null | undefined): DigitalEmployeeArtifactAccessAction {
if (value === "download" || value === "open_location") return value;
return "preview";
}
function isAllowedAction(value: string): boolean {
return value === "preview" || value === "download" || value === "open_location";
}
function localPathFromUri(uri: string): string | null {
const value = uri.trim();
if (!value) return null;
if (/^file:\/\//i.test(value)) {
try {
return fileURLToPath(value);
} catch {
return null;
}
}
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
return path.isAbsolute(value) ? value : null;
}
async function validateLocalArtifactPath(filePath: string | null): Promise<
| { ok: true; filePath: string; stats: Awaited<ReturnType<typeof fs.stat>> }
| { ok: false; reasonCodes: string[] }
> {
if (!filePath) return { ok: false, reasonCodes: ["local_uri_required"] };
if (!path.isAbsolute(filePath)) return { ok: false, reasonCodes: ["absolute_path_required"] };
const normalized = path.normalize(filePath);
if (normalized !== filePath || normalized.split(path.sep).includes("..")) {
return { ok: false, reasonCodes: ["unclear_path"] };
}
let stats: Awaited<ReturnType<typeof fs.stat>>;
try {
stats = await fs.stat(normalized);
} catch {
return { ok: false, reasonCodes: ["path_not_found"] };
}
if (!stats.isFile()) return { ok: false, reasonCodes: [stats.isDirectory() ? "directory_not_allowed" : "not_a_file"] };
return { ok: true, filePath: normalized, stats };
}
async function buildFilePreview(
artifact: DigitalEmployeeArtifactRecord,
filePath: string,
stats: Awaited<ReturnType<typeof fs.stat>>,
delivery: Record<string, unknown>,
): Promise<NonNullable<DigitalEmployeeArtifactAccessResponse["preview"]>> {
const name = artifact.name || path.basename(filePath);
const mime = mimeForPath(filePath);
const preview = {
name,
kind: artifact.kind,
mime,
size: Number(stats.size),
truncated: false,
delivery_status: deliveryString(delivery, "delivery_status"),
delivery_stage: deliveryString(delivery, "delivery_stage"),
};
if (!isTextPreviewPath(filePath, mime)) return preview;
const bytes = await fs.readFile(filePath);
const slice = bytes.subarray(0, MAX_TEXT_PREVIEW_BYTES);
return {
...preview,
truncated: bytes.length > MAX_TEXT_PREVIEW_BYTES,
text: slice.toString("utf8"),
};
}
function allowArtifactAccess(
artifactId: string,
action: DigitalEmployeeArtifactAccessAction,
resolved: ResolvedArtifactAccess,
input: DigitalEmployeeArtifactAccessRequest,
preview?: DigitalEmployeeArtifactAccessResponse["preview"],
): DigitalEmployeeArtifactAccessResponse {
const event = recordDigitalEmployeeArtifactAccess({
artifactId,
action,
status: "allowed",
reasonCodes: ["policy_allowed"],
uriSummary: resolved.summary,
deliverySnapshot: resolved.delivery,
actor: input.actor,
source: input.source,
planId: resolved.artifact.planId,
taskId: resolved.artifact.taskId,
runId: resolved.artifact.runId,
});
return { ok: true, artifact_id: artifactId, action, status: "allowed", reason_codes: ["policy_allowed"], preview, event_id: event?.eventId ?? null };
}
function rejectArtifactAccess(
artifactId: string,
action: DigitalEmployeeArtifactAccessAction,
reasonCodes: string[],
input: DigitalEmployeeArtifactAccessRequest,
resolved?: ResolvedArtifactAccess,
): DigitalEmployeeArtifactAccessResponse {
const event = recordDigitalEmployeeArtifactAccess({
artifactId,
action,
status: "rejected",
reasonCodes,
uriSummary: resolved?.summary ?? {},
deliverySnapshot: resolved?.delivery ?? {},
actor: input.actor,
source: input.source,
planId: resolved?.artifact.planId,
taskId: resolved?.artifact.taskId,
runId: resolved?.artifact.runId,
});
return { ok: false, artifact_id: artifactId, action, status: "rejected", error: reasonCodes[0] || "artifact_access_rejected", reason_codes: reasonCodes, event_id: event?.eventId ?? null };
}
function isDailyReportTextArtifact(artifact: DigitalEmployeeArtifactRecord): boolean {
return artifact.kind === "daily_report" && Boolean(textContentForArtifact(artifact));
}
function textContentForArtifact(artifact: DigitalEmployeeArtifactRecord): string {
const payload = objectRecord(artifact.payload) ?? {};
return stringValue(payload.text_content).slice(0, MAX_TEXT_PREVIEW_BYTES);
}
function normalizeDelivery(payload: Record<string, unknown>): Record<string, unknown> {
return objectRecord(payload.delivery) ?? payload;
}
function deliverySnapshot(delivery: Record<string, unknown>): Record<string, unknown> {
return {
delivery_status: stringValue(delivery.status ?? delivery.delivery_status) || null,
delivery_stage: stringValue(delivery.stage ?? delivery.delivery_stage) || null,
workspace_id: stringValue(delivery.workspace_id) || null,
project_id: stringValue(delivery.project_id) || null,
file_id: stringValue(delivery.file_id) || null,
version: stringValue(delivery.version) || null,
file_size: numberValue(delivery.size ?? delivery.file_size),
source_tool: stringValue(delivery.source_tool) || null,
};
}
function uriScheme(uri: string): string | null {
const match = uri.match(/^([a-z][a-z0-9+.-]*):/i);
return match?.[1]?.toLowerCase() ?? (uri ? "path" : null);
}
function mimeForPath(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".csv") return "text/csv";
if (ext === ".json") return "application/json";
if (ext === ".md" || ext === ".markdown") return "text/markdown";
if (ext === ".pdf") return "application/pdf";
if (ext === ".png") return "image/png";
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
if (ext === ".txt" || ext === ".log") return "text/plain";
return "application/octet-stream";
}
function isTextPreviewPath(filePath: string, mime: string): boolean {
return mime.startsWith("text/") || ["application/json"].includes(mime) || TEXT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function deliveryString(delivery: Record<string, unknown>, key: string): string | null {
return stringValue(delivery[key]) || null;
}
function objectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function stringValue(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function numberValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}

View File

@@ -376,6 +376,60 @@ describe("digital employee state service", () => {
});
});
it("records artifact access audit events with sanitized URI and delivery payload", async () => {
const { recordDigitalEmployeeArtifactAccess } = await import("./stateService");
const rejected = recordDigitalEmployeeArtifactAccess({
artifactId: "artifact-1",
action: "download",
status: "rejected",
reasonCodes: ["path_not_found"],
uriSummary: {
scheme: "file",
basename: "report.txt",
extension: ".txt",
raw_path: "/Users/qiming/private/report.txt",
},
deliverySnapshot: {
delivery_status: "completed",
workspace_id: "workspace-1",
secret_token: "should-not-be-persisted",
},
actor: "operator",
source: "test",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
occurredAt: "2026-06-07T08:34:00.000Z",
});
expect(rejected?.eventId).toBe("artifact-access:rejected:artifact-1:download:2026-06-07T08-34-00-000Z");
expect(mockState.db?.events.get(rejected!.eventId)).toMatchObject({
kind: "artifact_access_rejected",
message: "产物访问已拒绝:下载 artifact-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
const payload = JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}");
expect(payload).toMatchObject({
artifact_id: "artifact-1",
action: "download",
status: "rejected",
reason_codes: ["path_not_found"],
uri_summary: { scheme: "file", basename: "report.txt", extension: ".txt" },
delivery_snapshot: { delivery_status: "completed", workspace_id: "workspace-1" },
actor: "operator",
source: "test",
});
expect(JSON.stringify(payload)).not.toContain("/Users/qiming/private/report.txt");
expect(JSON.stringify(payload)).not.toContain("should-not-be-persisted");
expect(mockState.db?.outbox.get(`event:insert:${rejected!.eventId}`)).toMatchObject({
entity_type: "event",
status: "pending",
});
});
it("records and lists route decisions as syncable events", async () => {
const {
listDigitalEmployeeRouteDecisions,

View File

@@ -212,6 +212,32 @@ export interface DigitalEmployeeSkillCallAuditRecord {
payload: Record<string, unknown>;
}
export type DigitalEmployeeArtifactAccessAction = "preview" | "download" | "open_location";
export type DigitalEmployeeArtifactAccessStatus = "allowed" | "rejected";
export interface DigitalEmployeeArtifactAccessAuditInput {
artifactId?: string | null;
action: DigitalEmployeeArtifactAccessAction | string;
status: DigitalEmployeeArtifactAccessStatus;
reasonCodes?: string[] | null;
uriSummary?: Record<string, unknown> | null;
deliverySnapshot?: Record<string, unknown> | null;
actor?: string | null;
source?: string | null;
planId?: string | null;
taskId?: string | null;
runId?: string | null;
occurredAt?: string | null;
message?: string | null;
}
export interface DigitalEmployeeArtifactAccessAuditRecord {
eventId: string;
kind: "artifact_access_allowed" | "artifact_access_rejected";
status: DigitalEmployeeArtifactAccessStatus;
payload: Record<string, unknown>;
}
export interface DigitalEmployeeRouteDecisionRecord {
id: string;
route_kind: string;
@@ -1017,6 +1043,85 @@ export function recordDigitalEmployeeSkillCallAudit(
return { eventId, kind, decision, payload };
}
export function recordDigitalEmployeeArtifactAccess(
input: DigitalEmployeeArtifactAccessAuditInput,
): DigitalEmployeeArtifactAccessAuditRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = input.occurredAt || new Date().toISOString();
const status: DigitalEmployeeArtifactAccessStatus = input.status === "allowed" ? "allowed" : "rejected";
const kind = `artifact_access_${status}` as DigitalEmployeeArtifactAccessAuditRecord["kind"];
const artifactId = stringValue(input.artifactId) || "unknown-artifact";
const action = normalizeArtifactAccessAction(input.action);
const eventId = `artifact-access:${status}:${safeId(artifactId)}:${safeId(action)}:${safeId(now)}`;
const payload: Record<string, unknown> = {
artifact_id: artifactId,
action,
status,
reason_codes: normalizeSkillIds(input.reasonCodes ?? []),
uri_summary: sanitizeArtifactUriSummary(objectRecord(input.uriSummary) ?? {}),
delivery_snapshot: sanitizeArtifactDeliverySnapshot(objectRecord(input.deliverySnapshot) ?? {}),
actor: stringValue(input.actor) || null,
source: stringValue(input.source) || "qimingclaw-artifact-access",
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
run_id: stringValue(input.runId) || null,
};
insertEvent(
{
event_id: eventId,
kind,
occurred_at: now,
message: input.message || artifactAccessAuditMessage(status, action, artifactId),
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
},
stringValue(input.runId) || null,
payload,
);
return { eventId, kind, status, payload };
}
function normalizeArtifactAccessAction(action: string): DigitalEmployeeArtifactAccessAction {
return ["preview", "download", "open_location"].includes(action)
? action as DigitalEmployeeArtifactAccessAction
: "preview";
}
function sanitizeArtifactUriSummary(summary: Record<string, unknown>): Record<string, unknown> {
const allowedKeys = new Set(["scheme", "basename", "extension", "size", "exists", "is_directory", "mime"]);
return Object.fromEntries(
Object.entries(summary).filter(([key]) => allowedKeys.has(key)),
);
}
function sanitizeArtifactDeliverySnapshot(snapshot: Record<string, unknown>): Record<string, unknown> {
const allowedKeys = new Set([
"delivery_status",
"delivery_stage",
"workspace_id",
"project_id",
"file_id",
"version",
"file_size",
"source_tool",
]);
return Object.fromEntries(
Object.entries(snapshot).filter(([key]) => allowedKeys.has(key)),
);
}
function artifactAccessAuditMessage(
status: DigitalEmployeeArtifactAccessStatus,
action: DigitalEmployeeArtifactAccessAction,
artifactId: string,
): string {
const actionLabel = action === "open_location" ? "打开位置" : action === "download" ? "下载" : "预览";
return status === "allowed"
? `产物访问已允许:${actionLabel} ${artifactId}`
: `产物访问已拒绝:${actionLabel} ${artifactId}`;
}
function sanitizeSkillPolicySnapshot(snapshot: Record<string, unknown>): Record<string, unknown> {
const allowedKeys = new Set([
"skill_enabled",

View File

@@ -155,6 +155,13 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async recordDailyReport(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordDailyReport", input);
},
async accessArtifact(artifactId: string, action: "preview" | "download" | "open_location", options?: unknown) {
return ipcRenderer.invoke("digitalEmployee:accessArtifact", {
artifactId,
action,
...(typeof options === "object" && options ? options : {}),
});
},
async restartManagedService(serviceId: string, options?: unknown) {
return ipcRenderer.invoke("digitalEmployee:restartManagedService", { serviceId, ...(typeof options === "object" && options ? options : {}) });
},