吸收数字员工产物访问治理
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 : {}) });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user