import { createHash, randomUUID } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { and, eq } from "drizzle-orm"; import { environments, environmentLeases, executionWorkspaces, type Db } from "@paperclipai/db"; import type { Environment, EnvironmentLease, WorkspaceFileListMode, WorkspaceFileSource } from "@paperclipai/shared"; import { readRemoteWorkspaceAuthority } from "@paperclipai/shared"; import { HttpError, notFound, unprocessable } from "../errors.js"; import type { EnvironmentRuntimeService } from "./environment-runtime.js"; import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js"; import type { OpenSavedWorkspaceArchive, SavedWorkspaceArchive } from "./saved-workspace-archive.js"; const execFileAsync = promisify(execFile); const READ_TIMEOUT_MS = 30_000; const MAX_HELPER_OUTPUT_BYTES = 2 * 1024 * 1024; export interface RemoteWorkspaceFileEntry { kind: "file" | "directory"; relativePath: string; byteSize: number | null; modifiedAt: string | null; text?: boolean; sha256?: string; } export interface RemoteWorkspaceFileListInput { relativePath?: string; mode: WorkspaceFileListMode; query?: string | null; limit: number; offset: number; maxScannedEntries: number; maxDepth: number; } export interface RemoteWorkspaceDownload { stream: Readable; byteSize: number; cleanup(): Promise; } export interface RemoteWorkspaceFileAccess { source: WorkspaceFileSource; stat(relativePath: string): Promise; read(relativePath: string, options: { maxBytes: number }): Promise; list(input: RemoteWorkspaceFileListInput): Promise<{ entries: RemoteWorkspaceFileEntry[]; scannedCount: number; truncated: boolean; }>; download(relativePath: string): Promise; close(): Promise; } export interface SavedWorkspaceFiles { root: string; savedAt: string; cleanup(): Promise; } export interface RemoteWorkspaceFileOptions { environmentRuntime?: Pick; /** Verify the exact company-owned, committed snapshot before exposing files. */ loadSavedWorkspace?(input: { companyId: string; workspaceId: string; snapshotId: string; signal?: AbortSignal; }): Promise; openSavedWorkspaceArchive?: OpenSavedWorkspaceArchive; savedFileDownloadLimits?: Partial; } export interface RemoteWorkspaceFiles { open(input: { companyId: string; workspaceId: string; signal?: AbortSignal }): Promise; } // Run the same confined helper in the execution environment and in a verified, // disposable saved-version directory. No absolute path comes from a file caller. export const REMOTE_WORKSPACE_FILES_SCRIPT = String.raw` const fs = require('node:fs/promises'); const { constants } = require('node:fs'); const path = require('node:path'); const { createHash } = require('node:crypto'); const { execFile } = require('node:child_process'); const { promisify } = require('node:util'); const input = JSON.parse(process.argv[1]); const denied = new Set(['.git','.paperclip','.paperclip-runtime','node_modules','.pnpm-store','.yarn','.cache','.turbo','.next','.vite','.vercel','dist','build','coverage','runtime-services','.runtime']); const fail = code => { const error = new Error(code); error.code = code; throw error; }; const deniedPath = relative => { const parts = relative.toLowerCase().split('/'); const file = parts.at(-1) || ''; return parts.some(part => denied.has(part) || part === '.aws' || part === '.ssh') || file === '.env' || file.startsWith('.env.') || /\.(pem|key|p12|pfx)$/.test(file) || ['id_rsa','id_ed25519','.npmrc','.pypirc','.netrc','kubeconfig'].includes(file) || (parts.at(-2) === '.docker' && file === 'config.json') || (parts.at(-2) === '.kube' && file === 'config'); }; const normalize = (relative, allowRoot = false) => { if (allowRoot && !relative) return ''; if (typeof relative !== 'string' || !relative || relative.length > 4096 || /[\\\u0000-\u001f\u007f]/u.test(relative) || path.isAbsolute(relative) || /^[a-z][a-z0-9+.-]*:/iu.test(relative) || relative.startsWith('~') || relative.split('/').some(part => part === '..' || part === '.' || part === '')) fail('invalid_path'); if (deniedPath(relative)) fail('denied_path'); return relative; }; const same = (a,b) => ['dev','ino','size','mtimeNs','ctimeNs'].every(key => a[key] === b[key]); const within = (root,target) => { const relative = path.relative(root,target); return relative === '' || (relative !== '..' && !relative.startsWith('../') && !path.isAbsolute(relative)); }; (async () => { const rootEntry = await fs.lstat(input.root); if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) fail('workspace_unavailable'); const root = await fs.realpath(input.root); const confined = async (relative, allowRoot = false) => { relative = normalize(relative, allowRoot); let current = root; for (const part of relative ? relative.split('/') : []) { current = path.join(current,part); const stat = await fs.lstat(current); if (stat.isSymbolicLink()) fail('symlink_denied'); } const canonical = await fs.realpath(current); if (!within(root,canonical) || canonical !== current) fail('outside_workspace'); return canonical; }; const entry = async (relative, withHash = false) => { const target = await confined(relative); const stat = await fs.lstat(target); if (!stat.isDirectory() && !stat.isFile()) fail('not_regular_file'); const result = {kind:stat.isDirectory()?'directory':'file',relativePath:relative,byteSize:stat.isFile()?stat.size:null,modifiedAt:stat.mtime.toISOString()}; if (stat.isFile() && input.action === 'stat') { const handle = await fs.open(target,constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const before = await handle.stat({bigint:true}); if (!before.isFile() || before.nlink !== 1n) fail('not_regular_file'); const sample = Buffer.alloc(4096); const {bytesRead} = await handle.read(sample,0,sample.length,0); let controls = 0; let nul = false; for (const byte of sample.subarray(0,bytesRead)) { if (!byte) nul = true; if (byte < 32 && ![9,10,13].includes(byte)) controls++; } result.text = !nul && (!bytesRead || controls / bytesRead < .02); if (withHash) { const hash = createHash('sha256'); for await (const bytes of handle.createReadStream({start:0,autoClose:false})) hash.update(bytes); result.sha256=hash.digest('hex'); } const after = await handle.stat({bigint:true}); const current = await fs.lstat(target,{bigint:true}); if (!same(before,after) || !same(after,current) || await confined(relative) !== target) fail('file_changed'); } finally { await handle.close(); } } return result; }; let result; if (input.action === 'stat') result=await entry(input.relativePath,input.withHash === true); else if (input.action === 'list') { const requested = input.options; const start = normalize(requested.relativePath || '',true); const startPath = await confined(start,true); if (!(await fs.stat(startPath)).isDirectory()) fail('not_directory'); const maxScanned = Math.min(5000,Math.max(1,requested.maxScannedEntries)); const maxDepth = Math.min(20,Math.max(0,requested.maxDepth)); const limit = Math.min(100,Math.max(1,requested.limit)); const offset = Math.min(10000,Math.max(0,requested.offset)); const query = (requested.query || '').toLowerCase(); let scannedCount=0,truncated=false; const entries=[]; const matches = relative => !query || relative.toLowerCase().includes(query); const add = async relative => { try { const value=await entry(relative); if(matches(relative)) entries.push(value); } catch(error) { if(!['ENOENT','ENOTDIR','denied_path','symlink_denied','not_regular_file'].includes(error.code)) throw error; } }; if (requested.mode === 'changed') { let stdout; try { ({stdout}=await promisify(execFile)('git',['-c','core.fsmonitor=false','-c','core.hooksPath=/dev/null','-C',root,'status','--porcelain=v1','-z','--untracked-files=all'],{encoding:'utf8',timeout:10000,maxBuffer:1048576,env:{...process.env,GIT_OPTIONAL_LOCKS:'0',GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null'}})); } catch { fail('changed_unavailable'); } const tokens=stdout.split('\0').filter(Boolean); for(let i=0;imaxScanned) {truncated=true;break;} const token=tokens[i]; const status=token.slice(0,2),relative=token.slice(3); if(status.includes('R')||status.includes('C')) i++; if(relative && !deniedPath(relative)) await add(relative); } } else { const immediate=requested.mode==='all'&&!query; const queue=[{relative:start,depth:0}]; scan: while(queue.length) { const current=queue.shift(); const directory=await confined(current.relative,true); const children=await fs.readdir(directory,{withFileTypes:true}); children.sort((a,b)=>immediate&&a.isDirectory()!==b.isDirectory()?(a.isDirectory()?-1:1):a.name.localeCompare(b.name)); for(const child of children) { if(++scannedCount>maxScanned) {truncated=true;break scan;} const relative=current.relative?current.relative+'/'+child.name:child.name; if(deniedPath(relative)||child.isSymbolicLink()||/[\\\u0000-\u001f\u007f]/u.test(relative)) continue; if(child.isDirectory()) { if(immediate) await add(relative); else if(current.depthoffset+limit) {truncated=true;break scan;} } } } if(requested.mode==='recent') entries.sort((a,b)=>(b.modifiedAt||'').localeCompare(a.modifiedAt||'')||a.relativePath.localeCompare(b.relativePath)); else if(requested.mode==='changed') entries.sort((a,b)=>a.relativePath.localeCompare(b.relativePath)); result={entries:entries.slice(offset,offset+limit),scannedCount:Math.min(scannedCount,maxScanned),truncated:truncated||entries.length>offset+limit}; } else fail('invalid_operation'); if(await fs.realpath(input.root)!==root) fail('workspace_unavailable'); process.stdout.write(JSON.stringify(result)); })().catch(error => { const allowed=new Set(['invalid_path','denied_path','symlink_denied','outside_workspace','workspace_unavailable','not_regular_file','not_directory','file_changed','changed_unavailable','ENOENT','ENOTDIR']); process.stderr.write(allowed.has(error.code)?error.code:'remote_read_failed'); process.exitCode=1; }); `; function fileFailure(code: string): HttpError { if (["ENOENT", "ENOTDIR"].includes(code)) return notFound("Workspace file not found", { code: "not_found" }); if (["denied_path", "symlink_denied", "outside_workspace", "invalid_path"].includes(code)) { return new HttpError(403, "Workspace file path is denied", { code }); } const safe = new Set(["workspace_unavailable", "workspace_changed", "not_regular_file", "not_directory", "file_changed", "changed_unavailable", "too_large"]); return unprocessable("Workspace files are unavailable", { code: safe.has(code) ? code : "workspace_unavailable" }); } function parseFileEntry(value: unknown): RemoteWorkspaceFileEntry { const entry = value as Partial | null; if (!entry || !["file", "directory"].includes(entry.kind ?? "") || typeof entry.relativePath !== "string" || !entry.relativePath || entry.relativePath.length > 4096 || /[\\\u0000-\u001f\u007f]/u.test(entry.relativePath) || path.posix.isAbsolute(entry.relativePath) || entry.relativePath.split("/").some(part => !part || part === "." || part === "..") || (entry.kind === "file" ? !Number.isSafeInteger(entry.byteSize) || entry.byteSize! < 0 : entry.byteSize !== null) || (entry.modifiedAt !== null && (typeof entry.modifiedAt !== "string" || !Number.isFinite(Date.parse(entry.modifiedAt)))) || (entry.text !== undefined && typeof entry.text !== "boolean") || (entry.sha256 !== undefined && !/^[a-f0-9]{64}$/.test(entry.sha256))) throw fileFailure("workspace_unavailable"); return entry as RemoteWorkspaceFileEntry; } function parseHelperResult(action: "stat" | "list", value: unknown, withHash: boolean): unknown { if (action === "stat") { const entry = parseFileEntry(value); if (withHash && entry.kind === "file" && !entry.sha256) throw fileFailure("workspace_unavailable"); return entry; } const list = value as { entries?: unknown; scannedCount?: unknown; truncated?: unknown } | null; if (!list || !Array.isArray(list.entries) || list.entries.length > 100 || !Number.isSafeInteger(list.scannedCount) || Number(list.scannedCount) < 0 || Number(list.scannedCount) > 5000 || typeof list.truncated !== "boolean") throw fileFailure("workspace_unavailable"); return { entries: list.entries.map(parseFileEntry), scannedCount: list.scannedCount, truncated: list.truncated }; } async function verifyDownload(file: string, expected: RemoteWorkspaceFileEntry): Promise { const handle = await fs.open(file, "r"); try { const stat = await handle.stat(); if (!stat.isFile() || stat.size !== expected.byteSize) throw fileFailure("file_changed"); const hash = createHash("sha256"); for await (const bytes of handle.createReadStream({ autoClose: false })) hash.update(bytes); if (expected.sha256 && hash.digest("hex") !== expected.sha256) throw fileFailure("file_changed"); return stat.size; } finally { await handle.close(); } } class ArchiveReadFailure extends Error { constructor(readonly code: string) { super(code); } } const ARCHIVE_DENIED_NAMES = new Set([".git", ".paperclip", ".paperclip-runtime", "node_modules", ".pnpm-store", ".yarn", ".cache", ".turbo", ".next", ".vite", ".vercel", "dist", "build", "coverage", "runtime-services", ".runtime"]); const ARCHIVE_LIST_SKIPPED = new Set(["ENOENT", "ENOTDIR", "denied_path", "symlink_denied", "not_regular_file"]); const archiveFail = (code: string): never => { throw new ArchiveReadFailure(code); }; function archiveDeniedPath(relative: string) { const parts = relative.toLowerCase().split("/"); const file = parts.at(-1) || ""; return parts.some(part => ARCHIVE_DENIED_NAMES.has(part) || part === ".aws" || part === ".ssh") || file === ".env" || file.startsWith(".env.") || /\.(pem|key|p12|pfx)$/.test(file) || ["id_rsa", "id_ed25519", ".npmrc", ".pypirc", ".netrc", "kubeconfig"].includes(file) || (parts.at(-2) === ".docker" && file === "config.json") || (parts.at(-2) === ".kube" && file === "config"); } function archiveNormalize(relative: unknown, allowRoot = false): string { if (allowRoot && !relative) return ""; if (typeof relative !== "string" || !relative || relative.length > 4096 || /[\\\u0000-\u001f\u007f]/u.test(relative) || path.isAbsolute(relative) || /^[a-z][a-z0-9+.-]*:/iu.test(relative) || relative.startsWith("~") || relative.split("/").some(part => part === ".." || part === "." || part === "")) archiveFail("invalid_path"); if (archiveDeniedPath(relative as string)) archiveFail("denied_path"); return relative as string; } function archiveConfined(archive: SavedWorkspaceArchive, relative: string) { let current = ""; for (const part of relative.split("/")) { if (current && archive.entry(current)?.type !== "directory") archiveFail("ENOTDIR"); current = current ? `${current}/${part}` : part; const entry = archive.entry(current); if (!entry) archiveFail("ENOENT"); if (entry!.type === "symlink") archiveFail("symlink_denied"); } return archive.entry(relative)!; } function archiveEntryInfo(archive: SavedWorkspaceArchive, relativePath: unknown) { const relative = archiveNormalize(relativePath); const entry = archiveConfined(archive, relative); if (entry.type !== "directory" && entry.type !== "file") archiveFail("not_regular_file"); return { entry, result: { kind: entry.type === "directory" ? "directory" : "file", relativePath: relative, byteSize: entry.type === "file" ? entry.size : null, modifiedAt: archive.savedAt } as RemoteWorkspaceFileEntry }; } async function archiveEntry(archive: SavedWorkspaceArchive, relativePath: unknown, action: "stat" | "list", withHash: boolean, signal?: AbortSignal): Promise { const { entry, result } = archiveEntryInfo(archive, relativePath); if (entry.type === "file" && action === "stat") { const chunks: Buffer[] = []; for await (const chunk of await archive.read(entry, 0, Math.min(4096, entry.size), signal)) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const sample = Buffer.concat(chunks); let controls = 0, nul = false; for (const byte of sample) { if (!byte) nul = true; if (byte < 32 && ![9, 10, 13].includes(byte)) controls++; } result.text = !nul && (!sample.length || controls / sample.length < 0.02); if (withHash) result.sha256 = entry.sha256!; } return result; } function archiveList(archive: SavedWorkspaceArchive, requested: RemoteWorkspaceFileListInput) { const start = archiveNormalize(requested.relativePath || "", true); if (start && archiveConfined(archive, start).type !== "directory") archiveFail("not_directory"); const maxScanned = Math.min(5000, Math.max(1, requested.maxScannedEntries)); const maxDepth = Math.min(20, Math.max(0, requested.maxDepth)); const limit = Math.min(100, Math.max(1, requested.limit)); const offset = Math.min(10000, Math.max(0, requested.offset)); const query = (requested.query || "").toLowerCase(); let scannedCount = 0, truncated = false; const entries: RemoteWorkspaceFileEntry[] = []; const add = (relative: string) => { try { const { result } = archiveEntryInfo(archive, relative); if (!query || relative.toLowerCase().includes(query)) entries.push(result); } catch (error) { if (!(error instanceof ArchiveReadFailure) || !ARCHIVE_LIST_SKIPPED.has(error.code)) throw error; } }; const immediate = requested.mode === "all" && !query; const queue = [{ relative: start, depth: 0 }]; scan: while (queue.length) { const current = queue.shift()!; const sorted = archive.children(current.relative); const passes = immediate ? [(entry: { type: string }) => entry.type === "directory", (entry: { type: string }) => entry.type !== "directory"] : [() => true]; for (const include of passes) for (const child of sorted) { if (!include(child)) continue; if (++scannedCount > maxScanned) { truncated = true; break scan; } const relative = current.relative ? `${current.relative}/${child.name}` : child.name; if (archiveDeniedPath(relative) || child.type === "symlink" || /[\\\u0000-\u001f\u007f]/u.test(relative)) continue; if (child.type === "directory") { if (immediate) add(relative); else if (current.depth < maxDepth) queue.push({ relative, depth: current.depth + 1 }); else truncated = true; } else if (child.type === "file") add(relative); if (immediate && entries.length > offset + limit) { truncated = true; break scan; } } } if (requested.mode === "recent") entries.sort((a, b) => (b.modifiedAt || "").localeCompare(a.modifiedAt || "") || a.relativePath.localeCompare(b.relativePath)); return { entries: entries.slice(offset, offset + limit), scannedCount: Math.min(scannedCount, maxScanned), truncated: truncated || entries.length > offset + limit }; } export type SavedFileDownloadLimits = { maxReservedBytes: number; freeSpaceMarginBytes: number; availableBytes: () => Promise }; const SAVED_FILE_DOWNLOAD_LIMITS: SavedFileDownloadLimits = { maxReservedBytes: 4 * 1024 * 1024 * 1024, freeSpaceMarginBytes: 256 * 1024 * 1024, availableBytes: async () => { const disk = await fs.statfs(os.tmpdir(), { bigint: true }); return disk.bavail * disk.bsize; }, }; let savedFileDownloadReservedBytes = 0; async function archiveRead(archive: SavedWorkspaceArchive, relativePath: string, expected: RemoteWorkspaceFileEntry, maxBytes: number, signal?: AbortSignal): Promise { const entry = archive.entry(relativePath); if (!entry || entry.type !== "file" || entry.size !== expected.byteSize) throw fileFailure("file_changed"); if (entry.size > maxBytes) throw fileFailure("too_large"); const chunks: Buffer[] = [], hash = createHash("sha256"); let size = 0; for await (const chunk of await archive.read(entry, 0, entry.size, signal)) { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); size += bytes.length; if (size > entry.size) throw fileFailure("file_changed"); hash.update(bytes); chunks.push(bytes); } if (size !== entry.size || hash.digest("hex") !== entry.sha256) throw fileFailure("file_changed"); return Buffer.concat(chunks); } async function archiveDownload(archive: SavedWorkspaceArchive, relativePath: string, expected: RemoteWorkspaceFileEntry, checkCurrent: () => Promise, limits: SavedFileDownloadLimits, signal?: AbortSignal): Promise { const entry = archive.entry(relativePath); if (!entry || entry.type !== "file" || entry.size !== expected.byteSize) throw fileFailure("file_changed"); if (entry.size > limits.maxReservedBytes) throw fileFailure("too_large"); if (savedFileDownloadReservedBytes + entry.size > limits.maxReservedBytes) throw fileFailure("workspace_unavailable"); savedFileDownloadReservedBytes += entry.size; let released = false; const release = () => { if (!released) { released = true; savedFileDownloadReservedBytes -= entry.size; } }; try { if (await limits.availableBytes() < BigInt(savedFileDownloadReservedBytes + limits.freeSpaceMarginBytes)) { throw fileFailure("workspace_unavailable"); } } catch (error) { release(); throw error; } let directory: string | undefined; const cleanup = async () => { try { if (directory) await fs.rm(directory, { recursive: true, force: true }); } finally { release(); } }; try { directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-saved-file-")); const file = path.join(directory, "file"); await pipeline(await archive.read(entry, 0, entry.size, signal), createWriteStream(file, { flags: "wx", mode: 0o600 }), ...(signal ? [{ signal }] : [])); await verifyDownload(file, expected); await checkCurrent(); return { stream: createReadStream(file), byteSize: entry.size, cleanup }; } catch (error) { await cleanup(); throw error; } } export function createRemoteWorkspaceFiles(db: Db, options: RemoteWorkspaceFileOptions = {}): RemoteWorkspaceFiles { return { async open(input) { input.signal?.throwIfAborted(); const [workspace] = await db.select().from(executionWorkspaces).where(and( eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.workspaceId), )).limit(1); if (!workspace) throw notFound("Workspace not found"); const authority = readRemoteWorkspaceAuthority(workspace.metadata); if (!authority) return null; const initialAuthority = JSON.stringify(authority); let saved: SavedWorkspaceFiles | undefined; let archive: SavedWorkspaceArchive | undefined; let savedLocationReleased = false; const checkCurrent = async () => { input.signal?.throwIfAborted(); const [current] = await db.select({ metadata: executionWorkspaces.metadata }).from(executionWorkspaces).where(and( eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.workspaceId), )).limit(1); const currentAuthority = current ? readRemoteWorkspaceAuthority(current.metadata) : null; // An extracted committed save no longer depends on the allocation. // Allow only its monotonic location removal; every logical field stays // pinned, and a foreign or reintroduced location remains a conflict. if ((saved || archive) && authority.location && currentAuthority?.location === null && JSON.stringify({ ...currentAuthority, location: authority.location }) === initialAuthority) { savedLocationReleased = true; return; } if (!currentAuthority || JSON.stringify(currentAuthority) !== initialAuthority || savedLocationReleased && currentAuthority.location !== null) { throw fileFailure("workspace_changed"); } }; let environment: Environment | undefined; let lease: EnvironmentLease | undefined; let source: WorkspaceFileSource; let root: string; const savedReady = Boolean(options.loadSavedWorkspace || options.openSavedWorkspaceArchive); const loadSaved = async () => { if (!authority.snapshotId || authority.dirty || authority.writerRunId || !savedReady) { throw fileFailure("workspace_unavailable"); } const scope = { companyId: input.companyId, workspaceId: input.workspaceId, snapshotId: authority.snapshotId, ...(input.signal ? { signal: input.signal } : {}) }; if (options.openSavedWorkspaceArchive) { try { archive = await options.openSavedWorkspaceArchive(scope); } catch (error) { if (error instanceof HttpError) throw error; if (error instanceof Error && error.message === "remote_workspace_archive_entry_limit") throw fileFailure("too_large"); if (error instanceof Error && /^remote_workspace_(snapshot|archive)_/.test(error.message)) throw fileFailure("workspace_unavailable"); throw error; } return { root: "", source: { kind: "saved", savedAt: archive.savedAt, snapshotId: authority.snapshotId } as WorkspaceFileSource }; } saved = await options.loadSavedWorkspace!(scope); return { root: saved.root, source: { kind: "saved", savedAt: saved.savedAt, snapshotId: authority.snapshotId } as WorkspaceFileSource }; }; const extractedRoot = async () => { if (saved) return saved.root; if (!options.loadSavedWorkspace || !authority.snapshotId) throw fileFailure("changed_unavailable"); try { saved = await options.loadSavedWorkspace({ companyId: input.companyId, workspaceId: input.workspaceId, snapshotId: authority.snapshotId, ...(input.signal ? { signal: input.signal } : {}) }); } catch (error) { if (error instanceof HttpError && error.status === 422) throw fileFailure("changed_unavailable"); throw error; } return saved.root; }; // A final save clears dirty state and writer authority. Its accepted // bytes do not depend on an allocation that can already be closing. if (authority.snapshotId && !authority.dirty && !authority.writerRunId && savedReady) { ({ root, source } = await loadSaved()); } else if (authority.location) { const [leaseRow] = await db.select().from(environmentLeases).where(and( eq(environmentLeases.companyId, input.companyId), eq(environmentLeases.id, authority.location.leaseId), )).limit(1); const [environmentRow] = await db.select().from(environments).where(eq(environments.id, authority.location.environmentId)).limit(1); if (!leaseRow || !environmentRow || leaseRow.environmentId !== environmentRow.id || leaseRow.providerLeaseId !== authority.location.providerLeaseId || leaseRow.metadata?.remoteCwd !== authority.location.remoteCwd) throw fileFailure("workspace_unavailable"); if (hasRemoteTerminationReceipt(leaseRow) && (leaseRow.metadata?.remoteExecutionTermination as { state?: string } | undefined)?.state === "stopped") { ({ root, source } = await loadSaved()); } else { if (!options.environmentRuntime || !["active", "retained", "pending_cleanup"].includes(leaseRow.status)) { throw fileFailure("workspace_unavailable"); } lease = leaseRow as EnvironmentLease; environment = environmentRow as Environment; root = authority.location.remoteCwd; source = { kind: "live", savedAt: null, snapshotId: null }; } } else { ({ root, source } = await loadSaved()); } let closed = false; const close = async () => { if (closed) return; closed = true; archive?.close(); await saved?.cleanup(); }; const execute = async (action: "stat" | "list", args: Record): Promise => { if (closed) throw fileFailure("workspace_unavailable"); await checkCurrent(); const changedList = action === "list" && (args.options as RemoteWorkspaceFileListInput | undefined)?.mode === "changed"; if (archive && !changedList) { let value: unknown; try { value = action === "stat" ? await archiveEntry(archive, args.relativePath, "stat", args.withHash === true, input.signal) : archiveList(archive, args.options as RemoteWorkspaceFileListInput); } catch (error) { throw fileFailure(error instanceof ArchiveReadFailure ? error.code : "workspace_unavailable"); } await checkCurrent(); try { return parseHelperResult(action, value, args.withHash === true) as T; } catch { throw fileFailure("workspace_unavailable"); } } const helperRoot = archive ? await extractedRoot() : root; const payload = JSON.stringify({ root: helperRoot, action, ...args }); let stdout: string; if (saved) { try { ({ stdout } = await execFileAsync(process.execPath, ["--input-type=commonjs", "-e", REMOTE_WORKSPACE_FILES_SCRIPT, payload], { cwd: helperRoot, timeout: READ_TIMEOUT_MS, maxBuffer: MAX_HELPER_OUTPUT_BYTES, signal: input.signal, env: { ...process.env, NODE_OPTIONS: "", NODE_PATH: "" }, })); } catch (error) { throw fileFailure(String((error as { stderr?: string }).stderr ?? "workspace_unavailable").trim()); } } else { const result = await options.environmentRuntime!.execute({ environment: environment!, lease: lease!, command: "node", args: ["--input-type=commonjs", "-e", REMOTE_WORKSPACE_FILES_SCRIPT, payload], cwd: root, timeoutMs: READ_TIMEOUT_MS, bypassSession: true, env: { NODE_OPTIONS: "", NODE_PATH: "" }, }).catch(() => { throw fileFailure("workspace_unavailable"); }); if (result.timedOut || result.exitCode !== 0) throw fileFailure(result.timedOut ? "workspace_unavailable" : result.stderr.trim()); stdout = result.stdout; } await checkCurrent(); try { return parseHelperResult(action, JSON.parse(stdout), args.withHash === true) as T; } catch { throw fileFailure("workspace_unavailable"); } }; const stat = (relativePath: string) => execute("stat", { relativePath }); const download = async (relativePath: string): Promise => { const expected = await execute("stat", { relativePath, withHash: true }); if (expected.kind !== "file") throw fileFailure("not_regular_file"); if (archive) return archiveDownload(archive, relativePath, expected, checkCurrent, { ...SAVED_FILE_DOWNLOAD_LIMITS, ...options.savedFileDownloadLimits }, input.signal); if (saved) { await checkCurrent(); const file = path.join(root, ...relativePath.split("/")); return { stream: createReadStream(file), byteSize: expected.byteSize!, cleanup: async () => {} }; } const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-file-")); const cleanup = async () => { await fs.rm(directory, { recursive: true, force: true }); }; try { await options.environmentRuntime!.syncOut({ environment: environment!, lease: lease!, operations: [{ operationId: `workspace-file:${randomUUID()}`, files: [{ sourcePath: path.posix.join(root, relativePath), targetPath: path.join(directory, "file"), kind: "file", mode: 0o600 }], }] }).catch(() => { throw fileFailure("workspace_unavailable"); }); await verifyDownload(path.join(directory, "file"), expected); await checkCurrent(); return { stream: createReadStream(path.join(directory, "file")), byteSize: expected.byteSize!, cleanup }; } catch (error) { await cleanup(); throw error; } }; return { source, stat, close, list: listInput => execute("list", { options: listInput }), async read(relativePath, readOptions) { const expected = await stat(relativePath); if (expected.kind !== "file") throw fileFailure("not_regular_file"); if (expected.byteSize! > readOptions.maxBytes) throw fileFailure("too_large"); if (archive) { const bytes = await archiveRead(archive, relativePath, expected, readOptions.maxBytes, input.signal); await checkCurrent(); return bytes; } const transfer = await download(relativePath); try { const chunks: Buffer[] = []; let size = 0; for await (const chunk of transfer.stream) { input.signal?.throwIfAborted(); const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); size += bytes.length; if (size > readOptions.maxBytes) throw fileFailure("too_large"); chunks.push(bytes); } await checkCurrent(); return Buffer.concat(chunks); } finally { transfer.stream.destroy(); await transfer.cleanup(); } }, download, }; }, }; }