吸收数字员工产物访问治理
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user