From 54ad9d19096731a35c85daba8380d2a381eb9fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 29 May 2026 23:31:53 -0400 Subject: [PATCH] Register agent deliverables as file objects so they appear on the Files page --- .../src/main/services/agent-tools.ts | 12 +- .../src/main/services/file-service.ts | 153 ++---------- .../main/services/files/file-object-store.ts | 231 ++++++++++++++++++ .../tests/integration/autonomous-run.test.ts | 7 + 4 files changed, 264 insertions(+), 139 deletions(-) create mode 100644 AgentCompany-New/src/main/services/files/file-object-store.ts diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts index 229972dd..1c94046e 100644 --- a/AgentCompany-New/src/main/services/agent-tools.ts +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite"; import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, type RiskLevel } from "../../shared/domain"; import type { OrganizationService } from "./organization-service"; import { writeDeliverable } from "./deliverable-writer"; +import { recordWorkspaceFile } from "./files/file-object-store"; import type { JsonRecord } from "./provider-http"; import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; @@ -93,7 +94,7 @@ export function dispatchAgentTool(deps: AgentToolDeps, ctx: AgentToolContext, ca case "update_task_status": return updateTaskStatus(deps, ctx, call.arguments); case "write_deliverable": - return writeDeliverableTool(ctx, call.arguments); + return writeDeliverableTool(deps, ctx, call.arguments); default: return `ERROR: unknown tool "${call.name}".`; } @@ -154,11 +155,18 @@ function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: Json return `Task ${taskId} moved to ${status}.`; } -function writeDeliverableTool(ctx: AgentToolContext, args: JsonRecord): string { +function writeDeliverableTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { if (!ctx.workspace) throw new Error("No workspace is bound to this task; cannot write deliverables."); const path = requireString(args.path, "path"); const content = typeof args.content === "string" ? args.content : ""; const written = writeDeliverable({ workspaceRoot: ctx.workspace.localPath, relativePath: path, content }); + recordWorkspaceFile(deps.db, { + orgId: ctx.orgId, + ownerType: "agent", + ownerId: ctx.agent.id, + workspaceRoot: ctx.workspace.localPath, + relativePath: written, + }); return `Wrote deliverable ${written} (${content.length} chars).`; } diff --git a/AgentCompany-New/src/main/services/file-service.ts b/AgentCompany-New/src/main/services/file-service.ts index c40ae10b..0a3bf62d 100644 --- a/AgentCompany-New/src/main/services/file-service.ts +++ b/AgentCompany-New/src/main/services/file-service.ts @@ -1,45 +1,13 @@ import { createHash } from "node:crypto"; import { copyFileSync, mkdirSync, readFileSync, statSync } from "node:fs"; -import { basename, extname, join } from "node:path"; +import { basename, join } from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { dialog, shell } from "electron"; import type { ImportFileInput, OpenFileInput } from "../../shared/commands"; -import { FILE_SCAN_STATE, type FileScanState } from "../../shared/domain"; +import { FILE_SCAN_STATE } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; - -interface FileScanResult { - state: FileScanState; - labels: string[]; - scannedAt: string; -} - -interface TextScanPattern { - label: string; - pattern: RegExp; - block: boolean; -} - -const TEXT_SCAN_PATTERNS: TextScanPattern[] = [ - { label: "dlp:private-key", pattern: /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PRIVATE )?PRIVATE KEY-----/i, block: true }, - { label: "dlp:aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/, block: true }, - { label: "dlp:github-token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/, block: true }, - { label: "dlp:provider-secret", pattern: /\bsk-[A-Za-z0-9]{32,}\b/, block: true }, - { label: "dlp:credential", pattern: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[A-Za-z0-9_\-./+=]{20,}/i, block: true }, - { label: "dlp:ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/, block: true }, - { label: "dlp:email", pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i, block: false }, -]; - -function fileKind(path: string): string { - const ext = extname(path).toLowerCase().replace(".", ""); - if (!ext) return "file"; - if (["md", "markdown"].includes(ext)) return "md"; - if (["pdf"].includes(ext)) return "pdf"; - if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return "img"; - if (["csv", "xlsx", "xls"].includes(ext)) return "sheet"; - if (["doc", "docx", "txt"].includes(ext)) return "doc"; - return ext; -} +import { fileKind, scanFileContent, upsertFileObject } from "./files/file-object-store"; export class FileService { private readonly audit: AuditService; @@ -66,40 +34,29 @@ export class FileService { const now = new Date().toISOString(); const stats = statSync(sourcePath); if (!stats.isFile()) throw new Error("Selected path is not a file."); + const name = basename(sourcePath); const bytes = readFileSync(sourcePath); const hash = createHash("sha256").update(bytes).digest("hex"); - const scan = scanFile(sourcePath, bytes); + const scan = scanFileContent(name, bytes); const orgDir = join(this.artifactRoot, orgId); mkdirSync(orgDir, { recursive: true }); - const target = join(orgDir, `${id}-${basename(sourcePath)}`); + const target = join(orgDir, `${id}-${name}`); copyFileSync(sourcePath, target); this.db.exec("begin immediate"); try { - this.db.prepare(` - insert into file_objects( - id, org_id, name, kind, size_bytes, sha256, storage_path, - owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at - ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( + upsertFileObject(this.db, { id, orgId, - basename(sourcePath), - fileKind(sourcePath), - stats.size, - hash, - target, - "system", - null, - null, - 0, - scan.state, - JSON.stringify(scan.labels), - scan.scannedAt, + name, + kind: fileKind(name), + sizeBytes: stats.size, + sha256: hash, + storagePath: target, + ownerType: "system", + ownerId: null, + scan, now, - now, - ); - this.grantWorkspaceAccess(orgId, id, now); + }); this.audit.record({ orgId, entityType: "file_object", @@ -143,82 +100,4 @@ export class FileService { }); } - private grantWorkspaceAccess(orgId: string, fileId: string, now: string): void { - this.db.prepare(` - insert into file_access_grants( - id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id, - source_message_id, source_thread_id, access_level, created_at - ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - createId("fgr"), - orgId, - fileId, - "org_group", - `${orgId}:workspace`, - "system", - null, - null, - null, - "manage", - now, - ); - } -} - -function scanFile(sourcePath: string, bytes: Buffer): FileScanResult { - const textSample = sampleText(bytes); - const labels = new Set(scanLabels(sourcePath, bytes.length)); - let blocked = false; - if (bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("latin1").includes("EICAR-STANDARD-ANTIVIRUS-TEST-FILE")) { - labels.add("av:eicar"); - blocked = true; - } - for (const scan of TEXT_SCAN_PATTERNS) { - if (!scan.pattern.test(textSample)) continue; - labels.add(scan.label); - if (scan.block) blocked = true; - } - if (containsPaymentCard(textSample)) { - labels.add("dlp:payment-card"); - blocked = true; - } - labels.add(blocked ? "classification:restricted" : labels.has("dlp:email") ? "classification:confidential" : "classification:internal"); - return { - state: blocked ? FILE_SCAN_STATE.BLOCKED : FILE_SCAN_STATE.APPROVED, - labels: [...labels].sort(), - scannedAt: new Date().toISOString(), - }; -} - -function scanLabels(sourcePath: string, sizeBytes: number): string[] { - const labels = new Set([`kind:${fileKind(sourcePath)}`]); - if (sizeBytes === 0) labels.add("empty"); - if (sizeBytes > 100 * 1024 * 1024) labels.add("large"); - return [...labels].sort(); -} - -function sampleText(bytes: Buffer): string { - return bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("utf8"); -} - -function containsPaymentCard(text: string): boolean { - const matches = text.match(/\b(?:\d[ -]?){13,19}\b/g) ?? []; - return matches.some((match) => luhnValid(match.replace(/[ -]/g, ""))); -} - -function luhnValid(value: string): boolean { - if (!/^\d{13,19}$/.test(value)) return false; - let sum = 0; - let doubleDigit = false; - for (let index = value.length - 1; index >= 0; index -= 1) { - let digit = Number(value[index]); - if (doubleDigit) { - digit *= 2; - if (digit > 9) digit -= 9; - } - sum += digit; - doubleDigit = !doubleDigit; - } - return sum % 10 === 0; } diff --git a/AgentCompany-New/src/main/services/files/file-object-store.ts b/AgentCompany-New/src/main/services/files/file-object-store.ts new file mode 100644 index 00000000..47af2f15 --- /dev/null +++ b/AgentCompany-New/src/main/services/files/file-object-store.ts @@ -0,0 +1,231 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { basename, extname, resolve } from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import { FILE_SCAN_STATE, type FileScanState } from "../../../shared/domain"; +import { createId } from "../../../shared/ids"; + +export interface FileScanResult { + state: FileScanState; + labels: string[]; + scannedAt: string; +} + +interface TextScanPattern { + label: string; + pattern: RegExp; + block: boolean; +} + +const TEXT_SCAN_PATTERNS: TextScanPattern[] = [ + { label: "dlp:private-key", pattern: /-----BEGIN (?:RSA |DSA |EC |OPENSSH |PRIVATE )?PRIVATE KEY-----/i, block: true }, + { label: "dlp:aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/, block: true }, + { label: "dlp:github-token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/, block: true }, + { label: "dlp:provider-secret", pattern: /\bsk-[A-Za-z0-9]{32,}\b/, block: true }, + { label: "dlp:credential", pattern: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[A-Za-z0-9_\-./+=]{20,}/i, block: true }, + { label: "dlp:ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/, block: true }, + { label: "dlp:email", pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i, block: false }, +]; + +export function fileKind(path: string): string { + const ext = extname(path).toLowerCase().replace(".", ""); + if (!ext) return "file"; + if (["md", "markdown"].includes(ext)) return "md"; + if (["pdf"].includes(ext)) return "pdf"; + if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return "img"; + if (["csv", "xlsx", "xls"].includes(ext)) return "sheet"; + if (["doc", "docx", "txt"].includes(ext)) return "doc"; + return ext; +} + +export function scanFileContent(name: string, bytes: Buffer): FileScanResult { + const textSample = sampleText(bytes); + const labels = new Set(scanLabels(name, bytes.length)); + let blocked = false; + if (bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("latin1").includes("EICAR-STANDARD-ANTIVIRUS-TEST-FILE")) { + labels.add("av:eicar"); + blocked = true; + } + for (const scan of TEXT_SCAN_PATTERNS) { + if (!scan.pattern.test(textSample)) continue; + labels.add(scan.label); + if (scan.block) blocked = true; + } + if (containsPaymentCard(textSample)) { + labels.add("dlp:payment-card"); + blocked = true; + } + labels.add(blocked ? "classification:restricted" : labels.has("dlp:email") ? "classification:confidential" : "classification:internal"); + return { + state: blocked ? FILE_SCAN_STATE.BLOCKED : FILE_SCAN_STATE.APPROVED, + labels: [...labels].sort(), + scannedAt: new Date().toISOString(), + }; +} + +export interface FileObjectInput { + /** Optional explicit id; defaults to a generated `fil` id on insert. */ + id?: string; + orgId: string; + name: string; + kind: string; + sizeBytes: number; + sha256: string; + storagePath: string; + ownerType: string; + ownerId: string | null; + scan: FileScanResult; + now: string; +} + +/** + * Inserts (or refreshes, keyed on org_id + storage_path) a file object and, on + * first insert, grants the workspace org-group read/manage access so the file + * surfaces on the Files page and can be attached in chat. Re-writing the same + * path updates the existing row instead of creating a duplicate. + */ +export function upsertFileObject(db: DatabaseSync, input: FileObjectInput): string { + const existing = db.prepare("select id from file_objects where org_id = ? and storage_path = ?") + .get(input.orgId, input.storagePath) as { id: string } | undefined; + const labelsJson = JSON.stringify(input.scan.labels); + if (existing) { + db.prepare(` + update file_objects + set name = ?, kind = ?, size_bytes = ?, sha256 = ?, owner_type = ?, owner_id = ?, + scan_state = ?, scan_labels_json = ?, scanned_at = ?, updated_at = ? + where id = ? + `).run( + input.name, + input.kind, + input.sizeBytes, + input.sha256, + input.ownerType, + input.ownerId, + input.scan.state, + labelsJson, + input.scan.scannedAt, + input.now, + existing.id, + ); + return existing.id; + } + const id = input.id ?? createId("fil"); + db.prepare(` + insert into file_objects( + id, org_id, name, kind, size_bytes, sha256, storage_path, + owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + input.orgId, + input.name, + input.kind, + input.sizeBytes, + input.sha256, + input.storagePath, + input.ownerType, + input.ownerId, + null, + 0, + input.scan.state, + labelsJson, + input.scan.scannedAt, + input.now, + input.now, + ); + grantWorkspaceAccess(db, input.orgId, id, input.now); + return id; +} + +/** + * Records a file an agent wrote into the bound workspace as a first-class file + * object so it appears under that agent's department on the Files page. + */ +export function recordWorkspaceFile(db: DatabaseSync, input: { + orgId: string; + ownerType: string; + ownerId: string | null; + workspaceRoot: string; + relativePath: string; +}): string { + const absolute = resolve(input.workspaceRoot, input.relativePath); + const bytes = readFileSync(absolute); + const name = basename(input.relativePath); + const scan = scanFileContent(name, bytes); + const now = new Date().toISOString(); + db.exec("begin immediate"); + try { + const id = upsertFileObject(db, { + orgId: input.orgId, + name, + kind: fileKind(name), + sizeBytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + storagePath: absolute, + ownerType: input.ownerType, + ownerId: input.ownerId, + scan, + now, + }); + db.exec("commit"); + return id; + } catch (error) { + db.exec("rollback"); + throw error; + } +} + +function grantWorkspaceAccess(db: DatabaseSync, orgId: string, fileId: string, now: string): void { + db.prepare(` + insert into file_access_grants( + id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id, + source_message_id, source_thread_id, access_level, created_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + createId("fgr"), + orgId, + fileId, + "org_group", + `${orgId}:workspace`, + "system", + null, + null, + null, + "manage", + now, + ); +} + +function scanLabels(name: string, sizeBytes: number): string[] { + const labels = new Set([`kind:${fileKind(name)}`]); + if (sizeBytes === 0) labels.add("empty"); + if (sizeBytes > 100 * 1024 * 1024) labels.add("large"); + return [...labels].sort(); +} + +function sampleText(bytes: Buffer): string { + return bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString("utf8"); +} + +function containsPaymentCard(text: string): boolean { + const matches = text.match(/\b(?:\d[ -]?){13,19}\b/g) ?? []; + return matches.some((match) => luhnValid(match.replace(/[ -]/g, ""))); +} + +function luhnValid(value: string): boolean { + if (!/^\d{13,19}$/.test(value)) return false; + let sum = 0; + let doubleDigit = false; + for (let index = value.length - 1; index >= 0; index -= 1) { + let digit = Number(value[index]); + if (doubleDigit) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + doubleDigit = !doubleDigit; + } + return sum % 10 === 0; +} diff --git a/AgentCompany-New/tests/integration/autonomous-run.test.ts b/AgentCompany-New/tests/integration/autonomous-run.test.ts index ec1c06a5..f81996bd 100644 --- a/AgentCompany-New/tests/integration/autonomous-run.test.ts +++ b/AgentCompany-New/tests/integration/autonomous-run.test.ts @@ -94,6 +94,13 @@ describe("autonomous bootstrap run", () => { expect(existsSync(deliverable)).toBe(true); expect(readFileSync(deliverable, "utf8")).toContain("OrbitOps Operating Plan"); + // The deliverable is registered as a file object owned by the writing agent, + // so it appears on the Files page (previously it only existed on disk). + const ceo = state.agents.find((a) => a.orgId === orgId && a.name === "CEO"); + const planFile = state.files.find((f) => f.orgId === orgId && f.name === "operating-plan.md"); + expect(planFile).toBeTruthy(); + expect(planFile).toMatchObject({ ownerType: "agent", ownerId: ceo?.id, kind: "md", scanState: "approved" }); + const run = state.taskRuns.find((r) => r.id === claimed!.id); expect(run?.state).toBe("completed");