Register agent deliverables as file objects so they appear on the Files page
This commit is contained in:
1 parent
e7fa60d6b6
commit
54ad9d1909
4 files changed
+264
-139
No files matched your search
@@ -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).`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>([`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;
|
||||
}
|
||||
@@ -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<string>([`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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user