import { randomBytes } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { lstat, link, mkdir, open, realpath, unlink } from "node:fs/promises"; import { dirname, join, relative, resolve, sep } from "node:path"; import { FAST_BROWSER_ARTIFACT_LIMITS } from "./contracts.ts"; import type { PrivateArtifactKind, PrivateArtifactMetadata, PrivateArtifactOwner, PrivateArtifactStore, PrivateArtifactWriter, } from "./contracts.ts"; const MAX_OWNER_FIELD = 200; const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; const ROOT_MODE = 0o700; const FILE_MODE = 0o600; export interface PrivateArtifactStoreOptions { /** A dedicated local directory. It is never selected by a caller per artifact. */ root: string; /** Maximum bytes per artifact; defaults to eight MiB. */ maxArtifactBytes?: number; } function safeOwnerField(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0 || value.length > MAX_OWNER_FIELD || value.includes("\0")) { throw new Error(`${label} is invalid`); } return value; } function validateOwner(owner: PrivateArtifactOwner): PrivateArtifactOwner { if (!owner || typeof owner !== "object") throw new Error("artifact owner is invalid"); return Object.freeze({ sessionId: safeOwnerField(owner.sessionId, "artifact sessionId"), runId: safeOwnerField(owner.runId, "artifact runId"), operationId: safeOwnerField(owner.operationId, "artifact operationId"), }); } function validateKind(kind: PrivateArtifactKind): PrivateArtifactKind { if (kind !== "screenshot" && kind !== "diagnostics") throw new Error("artifact kind is invalid"); return kind; } function encodedComponent(value: string): string { // Owner identifiers are deliberately not included verbatim in filesystem // paths. This both prevents traversal and keeps credentials/tokens out of // local artifact metadata and directory names. return Buffer.from(value, "utf8").toString("base64url").slice(0, 96); } function artifactId(): string { return `a-${randomBytes(18).toString("hex")}`; } async function assertRegularDirectory(path: string): Promise { const stat = await lstat(path); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("artifact path is not a private directory"); if (stat.mode & 0o077) throw new Error("artifact directory permissions are too broad"); const uid = typeof process.getuid === "function" ? process.getuid() : undefined; if (uid !== undefined && stat.uid !== uid) throw new Error("artifact directory is not owned by this process"); } async function assertNoSymlinkComponents(path: string): Promise { const absolute = resolve(path); const parts = absolute.split(sep); let current = parts[0] || sep; for (const part of parts.slice(1)) { if (!part) continue; current = current === sep ? join(current, part) : join(current, part); const stat = await lstat(current).catch(() => undefined); if (stat?.isSymbolicLink()) throw new Error("artifact path may not contain symlinks"); } } async function ensurePrivateDirectory(path: string, create = true, tighten = true): Promise { await assertNoSymlinkComponents(path); if (create) { try { await mkdir(path, { recursive: true, mode: ROOT_MODE }); } catch (error) { throw new Error("could not create private artifact directory", { cause: error }); } } const stat = await lstat(path).catch(() => undefined); if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) { throw new Error("artifact path is not a private directory"); } // Never chmod a caller-owned parent or weaken a pre-existing directory's // permissions. Store roots must already be private; only mkdir-created // children are accepted with the requested mode. if (tighten) await assertRegularDirectory(path); } function contentType(kind: PrivateArtifactKind): PrivateArtifactMetadata["contentType"] { return kind === "screenshot" ? "image/png" : "application/json"; } function isWithin(root: string, candidate: string): boolean { const rel = relative(root, candidate); return rel !== "" && rel !== ".." && !rel.startsWith(`..${resolve("/")}`) && !rel.startsWith("../") && !rel.startsWith("..\\") && !rel.includes("\0"); } class FileArtifactWriter implements PrivateArtifactWriter { readonly owner: PrivateArtifactOwner; readonly kind: PrivateArtifactKind; private handle: Awaited> | undefined; private bytesWritten = 0; private state: "open" | "committed" | "aborted" = "open"; private readonly tempPath: string; private readonly finalPath: string; private readonly id: string; private readonly maxBytes: number; private readonly onDone: () => void; constructor( owner: PrivateArtifactOwner, kind: PrivateArtifactKind, handle: Awaited>, tempPath: string, finalPath: string, id: string, maxBytes: number, onDone: () => void, ) { this.owner = owner; this.kind = kind; this.handle = handle; this.tempPath = tempPath; this.finalPath = finalPath; this.id = id; this.maxBytes = maxBytes; this.onDone = onDone; } async write(chunk: Uint8Array): Promise { if (this.state !== "open" || !this.handle) throw new Error("artifact writer is closed"); if (!(chunk instanceof Uint8Array)) throw new Error("artifact chunk is invalid"); if (chunk.byteLength > this.maxBytes - this.bytesWritten) { await this.abort(); throw new Error("artifact exceeds the private size limit"); } if (chunk.byteLength === 0) return; try { await this.handle.write(chunk); this.bytesWritten += chunk.byteLength; } catch (error) { await this.abort(); throw new Error("could not write private artifact", { cause: error }); } } async commit(): Promise { if (this.state !== "open" || !this.handle) throw new Error("artifact writer is closed"); try { await this.handle.sync(); const stat = await this.handle.stat(); if (!stat.isFile() || stat.size !== this.bytesWritten || stat.mode & 0o077) { throw new Error("private artifact validation failed"); } await this.handle.chmod(FILE_MODE); await this.handle.close(); this.handle = undefined; // link() makes the completed file visible atomically without replacing a // pre-existing symlink or foreign artifact. The temporary inode is then // removed; no caller-selected path is ever opened for writing. await link(this.tempPath, this.finalPath); await unlink(this.tempPath); this.state = "committed"; this.onDone(); return Object.freeze({ artifactId: this.id, kind: this.kind, contentType: contentType(this.kind), path: this.finalPath, bytes: this.bytesWritten, owner: this.owner, permissions: "0600", localOnly: true, modelForwarded: false, }); } catch (error) { await this.abort(); throw error instanceof Error && error.message.startsWith("private artifact") ? error : new Error("could not commit private artifact", { cause: error }); } } async abort(): Promise { if (this.state !== "open") return; this.state = "aborted"; const handle = this.handle; this.handle = undefined; try { await handle?.close(); } catch { /* best effort cleanup */ } try { await unlink(this.tempPath); } catch { /* already absent */ } this.onDone(); } } /** Filesystem-backed implementation of the frozen PrivateArtifactStore API. */ export class FilePrivateArtifactStore implements PrivateArtifactStore { readonly root: string; private readonly maxBytes: number; private readonly active = new Set(); private closed = false; private ready: Promise; constructor(options: PrivateArtifactStoreOptions | string) { const root = typeof options === "string" ? options : options.root; const maxBytes = typeof options === "string" ? DEFAULT_MAX_BYTES : options.maxArtifactBytes ?? DEFAULT_MAX_BYTES; if (typeof root !== "string" || root.length === 0 || root.includes("\0")) throw new Error("artifact root is invalid"); if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > DEFAULT_MAX_BYTES) throw new Error("artifact size limit is invalid"); this.root = resolve(root); this.maxBytes = maxBytes; this.ready = this.initialize(); } private async initialize(): Promise { const parent = dirname(this.root); await ensurePrivateDirectory(parent, true, false); await assertNoSymlinkComponents(this.root); const existing = await lstat(this.root).catch(() => undefined); if (existing?.isSymbolicLink()) throw new Error("artifact root may not be a symlink"); await ensurePrivateDirectory(this.root); await assertNoSymlinkComponents(this.root); const canonical = await realpath(this.root); if (canonical !== this.root) throw new Error("artifact root may not resolve through a symlink"); } async open(owner: PrivateArtifactOwner, kind: PrivateArtifactKind): Promise { if (this.closed) throw new Error("artifact store is closed"); const checkedOwner = validateOwner(owner); const checkedKind = validateKind(kind); await this.ready; if (this.closed) throw new Error("artifact store is closed"); const sessionDir = join(this.root, encodedComponent(checkedOwner.sessionId)); const runDir = join(sessionDir, encodedComponent(checkedOwner.runId)); await ensurePrivateDirectory(sessionDir); await ensurePrivateDirectory(runDir); const id = artifactId(); const extension = checkedKind === "screenshot" ? "png" : "json"; const finalPath = join(runDir, `${id}.${extension}`); const tempPath = join(runDir, `.${id}.tmp`); if (!isWithin(this.root, finalPath) || !isWithin(this.root, tempPath)) throw new Error("artifact path escaped private root"); // wx + O_NOFOLLOW ensures a malicious replacement cannot redirect writes. const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0); const handle = await open(tempPath, flags, FILE_MODE).catch((error) => { throw new Error("could not create private artifact", { cause: error }); }); await handle.chmod(FILE_MODE); const kindLimit = checkedKind === "screenshot" ? this.maxBytes : Math.min(this.maxBytes, FAST_BROWSER_ARTIFACT_LIMITS.diagnosticsBytes); const writer = new FileArtifactWriter(checkedOwner, checkedKind, handle, tempPath, finalPath, id, kindLimit, () => this.active.delete(writer)); this.active.add(writer); return writer; } async close(): Promise { if (this.closed) return; this.closed = true; await this.ready.catch(() => undefined); await Promise.all([...this.active].map((writer) => writer.abort())); } } export function createPrivateArtifactStore(options: PrivateArtifactStoreOptions | string): PrivateArtifactStore { return new FilePrivateArtifactStore(options); } // A descriptive alias keeps the concrete implementation discoverable without // widening the frozen contract surface. export const createArtifactStore = createPrivateArtifactStore;