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

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

@@ -2171,10 +2171,18 @@
.de-workday-result-summary { margin: 0; color: #173550; font-size: 13px; line-height: 1.55; }
.de-artifact-pills { display: flex; flex-wrap: wrap; gap: 8px; }
.de-artifact-source-list { display: grid; gap: 8px; max-height: 320px; overflow-y: auto; padding-right: 4px; }
.de-artifact-source-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(88px, 140px); gap: 4px 10px; align-items: center; padding: 9px 10px; border-radius: 8px; border: 1px solid rgba(79,172,254,0.12); background: rgba(244,250,255,0.62); }
.de-artifact-source-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(88px, 140px) auto; gap: 4px 10px; align-items: center; padding: 9px 10px; border-radius: 8px; border: 1px solid rgba(79,172,254,0.12); background: rgba(244,250,255,0.62); }
.de-artifact-source-row strong { min-width: 0; color: var(--de-text-primary); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.de-artifact-source-row span { color: var(--de-text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.de-artifact-source-row code, .de-artifact-source-row em { grid-column: 1 / -1; min-width: 0; color: var(--de-text-body); font-size: 12px; font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.de-artifact-source-actions { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; }
.de-icon-btn { width: 28px; height: 28px; display: inline-grid; place-items: center; border: 1px solid rgba(79,172,254,0.18); border-radius: 6px; color: #275a82; background: rgba(255,255,255,0.78); cursor: pointer; }
.de-icon-btn:hover:not(:disabled) { border-color: rgba(79,172,254,0.45); background: #fff; }
.de-icon-btn:disabled { cursor: not-allowed; opacity: 0.42; }
.de-inline-notice { margin: 8px 0 0; color: var(--de-text-muted); font-size: 12px; }
.de-artifact-preview { margin-top: 10px; border: 1px solid rgba(79,172,254,0.14); border-radius: 8px; background: rgba(255,255,255,0.78); overflow: hidden; }
.de-artifact-preview > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-bottom: 1px solid rgba(79,172,254,0.1); }
.de-artifact-preview pre { max-height: 220px; margin: 0; padding: 10px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: var(--de-text-body); font-size: 12px; line-height: 1.55; }
.de-decision-card { background: rgba(247,251,255,0.96); }
.de-decision-chain-note {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;

View File

@@ -723,6 +723,22 @@ export function getArtifacts(): Promise<any[]> {
.then((data) => data.artifacts ?? []);
}
export function accessArtifact(artifactId: string, action: 'preview' | 'download' | 'open_location'): Promise<any> {
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}/access`, {
method: 'POST',
body: JSON.stringify({ action }),
});
}
export function getArtifactAccessEvents(params: Record<string, string | number | undefined> = {}): Promise<any> {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== '') search.set(key, String(value));
}
const suffix = search.toString() ? `?${search.toString()}` : '';
return apiFetch(`/api/digital-employee/artifact-access-events${suffix}`);
}
export function getDebugConversations(): Promise<any[]> {
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
.then((data) => data.conversations ?? []);

View File

@@ -63,6 +63,7 @@ declare global {
recordRouteDecision?: (input: QimingclawRouteDecisionInput) => Promise<RouteDecisionRecord>;
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>;
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
};
@@ -237,6 +238,17 @@ interface QimingclawDailyReportRecordResult {
eventId: string;
}
interface QimingclawArtifactAccessResponse {
ok: boolean;
artifact_id: string;
action: 'preview' | 'download' | 'open_location';
status: 'allowed' | 'rejected';
error?: string;
reason_codes?: string[];
preview?: Record<string, unknown>;
event_id?: string | null;
}
interface QimingclawManagedServiceActionResponse {
ok: boolean;
service_id: string;
@@ -659,6 +671,15 @@ export async function handleQimingclawDigitalApi<T>(
const rows = filterSkillCallAuditRows(skillCallAuditRows(await readQimingclawSnapshot()), url.searchParams);
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
}
if (method === 'GET' && pathname === '/api/digital-employee/artifact-access-events') {
const rows = filterArtifactAccessRows(artifactAccessRows(await readQimingclawSnapshot()), url.searchParams);
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
}
const artifactAccessMatch = pathname.match(/^\/api\/(?:digital-employee\/artifacts|artifact)\/([^/]+)\/access$/);
if (method === 'POST' && artifactAccessMatch) {
const artifactId = decodeURIComponent(artifactAccessMatch[1] || '');
return await handleArtifactAccess(artifactId, parseOptionalJsonBody(options.body)) as T;
}
const businessPlanDetailMatch = pathname.match(/^\/api\/digital-employee\/plans\/([^/]+)$/);
if (method === 'GET' && businessPlanDetailMatch) {
const planId = decodeURIComponent(businessPlanDetailMatch[1] || '');
@@ -1351,6 +1372,44 @@ async function handleManagedServiceRestart(
}
}
async function handleArtifactAccess(
artifactId: string,
body: Record<string, unknown>,
): Promise<QimingclawArtifactAccessResponse> {
const bridge = window.QimingClawBridge?.digital;
const action = normalizeArtifactAccessAction(stringValue(body.action));
if (!bridge?.accessArtifact) {
return {
ok: false,
artifact_id: artifactId,
action,
status: 'rejected',
error: 'bridge_unavailable',
reason_codes: ['bridge_unavailable'],
};
}
try {
return await bridge.accessArtifact(artifactId, action, {
actor: stringValue(body.actor) || 'digital_employee_ui',
source: stringValue(body.source) || 'sgrobot-digital-adapter',
});
} catch (error) {
return {
ok: false,
artifact_id: artifactId,
action,
status: 'rejected',
error: error instanceof Error ? error.message : String(error),
reason_codes: ['artifact_access_failed'],
};
}
}
function normalizeArtifactAccessAction(value: string): 'preview' | 'download' | 'open_location' {
if (value === 'download' || value === 'open_location') return value;
return 'preview';
}
function syncLine(snapshot: QimingclawSnapshot | null): string {
const sync = snapshot?.sync;
if (!sync) return '管理端同步:未连接';
@@ -2368,6 +2427,43 @@ function filterSkillCallAuditRows(rows: BusinessViewRow[], searchParams: URLSear
.filter((row) => !decision || stringValue(row.decision) === decision);
}
function artifactAccessRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return runtimeEvents(snapshot)
.filter((event) => ['artifact_access_allowed', 'artifact_access_rejected'].includes(event.kind))
.map((event) => {
const payload = asRecord(event.payload) ?? {};
return {
access_id: event.id,
event_id: event.id,
remote_id: event.remoteId ?? null,
artifact_id: stringValue(payload.artifact_id) || null,
action: stringValue(payload.action) || null,
status: stringValue(payload.status) || event.kind.replace('artifact_access_', ''),
reason_codes: Array.isArray(payload.reason_codes) ? payload.reason_codes : [],
actor: stringValue(payload.actor) || null,
source: stringValue(payload.source) || null,
plan_id: (event.planId ?? stringValue(payload.plan_id)) || null,
task_id: (event.taskId ?? stringValue(payload.task_id)) || null,
run_id: (event.runId ?? stringValue(payload.run_id)) || null,
message: event.message,
occurred_at: event.occurredAt,
sync_status: event.syncStatus ?? null,
payload,
};
})
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
}
function filterArtifactAccessRows(rows: BusinessViewRow[], searchParams: URLSearchParams): BusinessViewRow[] {
const artifactId = stringValue(searchParams.get('artifact_id'));
const action = stringValue(searchParams.get('action'));
const status = stringValue(searchParams.get('status'));
return rows
.filter((row) => !artifactId || stringValue(row.artifact_id) === artifactId)
.filter((row) => !action || stringValue(row.action) === action)
.filter((row) => !status || stringValue(row.status) === status);
}
function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return buildArtifacts(snapshot)
.filter(isDailyReportArtifact)
@@ -2981,7 +3077,26 @@ function buildArtifacts(snapshot: QimingclawSnapshot | null): ArtifactEntry[] {
runId: event.runId,
})),
];
return dedupeArtifacts(artifacts);
return dedupeArtifacts(artifacts).map((artifact) => withArtifactAccessSummary(artifact, snapshot));
}
function withArtifactAccessSummary(artifact: ArtifactEntry, snapshot: QimingclawSnapshot | null): ArtifactEntry {
const artifactId = stringValue(artifact.artifact_id) || stringValue(artifact.subject_id);
const lastAccess = artifactAccessRows(snapshot).find((row) => stringValue(row.artifact_id) === artifactId) ?? null;
const hasLocalUri = isLocalArtifactUri(stringValue(artifact.uri));
const hasDailyText = isDailyReportArtifact(artifact) && Boolean(stringValue(asRecord(artifact.payload)?.text_content));
const formalRuntimeArtifact = runtimeArtifacts(snapshot).some((runtime) => runtime.id === artifactId || runtime.remoteId === artifactId);
const previewable = formalRuntimeArtifact && (hasDailyText || hasLocalUri);
return {
...artifact,
access: {
previewable,
downloadable: formalRuntimeArtifact && hasLocalUri,
openable: formalRuntimeArtifact && hasLocalUri,
access_policy: formalRuntimeArtifact ? 'qimingclaw-local-artifact-policy' : 'runtime-projection-readonly',
last_access_status: stringValue(lastAccess?.status) || null,
},
};
}
function artifactFromRuntimeRecord(artifact: QimingclawArtifactRecord): ArtifactEntry {
@@ -3181,6 +3296,13 @@ function looksLikeUri(value: string): boolean {
return /^(file|https?):\/\//i.test(value) || value.includes('/') || value.includes('\\');
}
function isLocalArtifactUri(value: string): boolean {
if (!value) return false;
if (/^file:\/\//i.test(value)) return true;
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false;
return value.startsWith('/') || /^[a-z]:[\\/]/i.test(value);
}
function artifactName(value: string): string {
if (!value) return '';
const normalized = value.split(/[?#]/)[0] || value;
@@ -3620,6 +3742,7 @@ function reportArtifactSources(artifacts: ArtifactEntry[]) {
task_id: stringValue(artifact.task_id) || null,
run_id: stringValue(artifact.run_id) || null,
created_at: stringValue(artifact.created_at) || null,
access: artifact.access,
}));
}

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { Download } from 'lucide-react';
import { Download, Eye, FolderOpen } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import {
getDigitalEmployeeWorkday,
postDigitalEmployeeWorkdayAction,
accessArtifact,
} from '@/lib/api';
import type {
DigitalEmployeeDailyReport,
@@ -181,20 +182,70 @@ function DetailList({ details }: { details: DigitalEmployeeRunDetail[] }) {
}
function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArtifactSource[] }) {
const [busyKey, setBusyKey] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [preview, setPreview] = useState<{ title: string; text: string } | null>(null);
if (artifacts.length === 0) {
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}></p>;
}
const runAccess = async (artifact: DigitalEmployeeReportArtifactSource, action: 'preview' | 'download' | 'open_location') => {
if (!artifact.id || busyKey) return;
const key = `${artifact.id}:${action}`;
setBusyKey(key);
setNotice(null);
try {
const result = await accessArtifact(artifact.id, action);
if (!result?.ok) {
setNotice(result?.error || '产物访问被拒绝');
return;
}
if (action === 'preview') {
const data = result.preview || {};
setPreview({
title: String(data.name || artifact.name || '产物预览'),
text: String(data.text || `${data.mime || artifact.kind || 'artifact'} · ${data.size ?? 'unknown'}`),
});
} else {
setNotice(action === 'download' ? '已打开产物' : '已打开位置');
}
} catch (error) {
setNotice(error instanceof Error ? error.message : '产物访问失败');
} finally {
setBusyKey(null);
}
};
return (
<div className="de-artifact-source-list" aria-label="日报产物来源">
{artifacts.map((artifact) => (
<div key={artifact.id} className="de-artifact-source-row">
<strong>{artifact.name}</strong>
<span>{artifact.source_label || artifact.source_type || '未知来源'}</span>
{artifact.uri ? <code>{artifact.uri}</code> : <em>{artifact.kind || 'artifact'}</em>}
<>
<div className="de-artifact-source-list" aria-label="日报产物来源">
{artifacts.map((artifact) => {
const previewable = Boolean(artifact.access?.previewable);
const downloadable = Boolean(artifact.access?.downloadable);
const openable = Boolean(artifact.access?.openable);
return (
<div key={artifact.id} className="de-artifact-source-row">
<strong>{artifact.name}</strong>
<span>{artifact.source_label || artifact.source_type || '未知来源'}</span>
<div className="de-artifact-source-actions">
<button type="button" className="de-icon-btn" disabled={!previewable || busyKey !== null} onClick={() => runAccess(artifact, 'preview')} title="预览" aria-label="预览"><Eye size={15} /></button>
<button type="button" className="de-icon-btn" disabled={!downloadable || busyKey !== null} onClick={() => runAccess(artifact, 'download')} title="下载" aria-label="下载"><Download size={15} /></button>
<button type="button" className="de-icon-btn" disabled={!openable || busyKey !== null} onClick={() => runAccess(artifact, 'open_location')} title="位置" aria-label="位置"><FolderOpen size={15} /></button>
</div>
{artifact.uri ? <code>{artifact.uri}</code> : <em>{artifact.kind || 'artifact'}</em>}
</div>
);
})}
</div>
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{preview ? (
<div className="de-artifact-preview">
<div><strong>{preview.title}</strong><button type="button" className="de-btn-secondary" onClick={() => setPreview(null)}></button></div>
<pre>{preview.text}</pre>
</div>
))}
</div>
) : null}
</>
);
}

View File

@@ -475,6 +475,13 @@ export interface ArtifactEntry {
name?: string;
uri?: string;
value?: string;
access?: {
previewable?: boolean;
downloadable?: boolean;
openable?: boolean;
access_policy?: string | null;
last_access_status?: string | null;
};
[key: string]: any;
}
@@ -758,6 +765,7 @@ export interface DigitalEmployeeReportArtifactSource {
task_id?: string | null;
run_id?: string | null;
created_at?: string | null;
access?: ArtifactEntry['access'];
}
export interface DigitalEmployeeDelivery {

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,8 +16,8 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-CdwW2SHB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BarRiSjT.css">
<script type="module" crossorigin src="./assets/index-XYzar59c.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-4_0OumSc.css">
</head>
<body>
<div id="root"></div>

View File

@@ -23,6 +23,7 @@ const requiredDigitalTables = [
const testFiles = [
"src/main/services/digitalEmployee/stateService.test.ts",
"src/main/services/digitalEmployee/artifactAccessService.test.ts",
"src/main/services/digitalEmployee/schedulerService.test.ts",
"src/main/ipc/digitalEmployeeHandlers.test.ts",
"src/main/ipc/eventForwarders.test.ts",

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

View File

@@ -573,9 +573,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端审计消费和远端策略下发,把本地调用审计推进到跨端治理闭环。
7. **产物交付闭环**
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息。
- 缺口:仍缺少产物预览、版本完整治理、保留策略、下载权限和管理端业务产物视图。
- 建议:下一轮围绕 Artifact 预览和下载权限,把 File Server 交付状态从事实归集推进到可操作交付视图
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息Artifact 预览/下载权限已开始闭环,嵌入页通过受控 bridge 访问本地产物,并写入 `artifact_access_allowed` / `artifact_access_rejected` 审计事件
- 缺口:仍缺少版本完整治理、保留策略和管理端业务产物视图的细化交互
- 建议:下一轮围绕 Artifact 版本、保留策略和管理端批量治理,把可操作交付视图推进到完整生命周期管理
8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event并可通过 embedded `/api/approval/:approvalId/timeline` 读取只读审批动作时间线,审批动作时间线已开始闭环。