吸收数字员工产物访问治理
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 ?? []);
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 : {}) });
|
||||
},
|
||||
|
||||
@@ -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` 读取只读审批动作时间线,审批动作时间线已开始闭环。
|
||||
|
||||
Reference in New Issue
Block a user