Files
PaperClipAI/server/src/services/remote-workspace-files.ts
T
DottaandClaude Opus 5.5 120017e39d fix(workspaces): read saved workspace files in place instead of extracting the whole archive (#20442)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - When a cloud task finishes, its workspace is saved as an encrypted
snapshot and its sandbox is released. The board's Files view then reads
that saved copy
> - Saved Files access downloaded, decrypted and extracted the whole
archive for every request, and refused archives over 512 MiB with
`too_large`
> - A finished task in a Paperclip-sized repository saves a snapshot of
about 730 MiB, so its files could not be listed or opened at all
> - The archive format already records each file's size and SHA-256, and
the snapshot records the manifest digest, so single files can be read
and verified without the rest of the archive
> - This pull request reads the saved archive in place, one verified
file at a time
> - The benefit is that users can open any file of a large finished
workspace quickly, with bounded memory and no whole-archive transfer

## Linked Issues or Issue Description

**What happened?**
In restart-chaos waves on Daytona with a real Paperclip clone, the
committed workspace snapshots were 763,944,535 bytes. After the sandbox
was released, every saved Files read returned 422 `too_large` (506 reads
in total), because `createSavedWorkspaceLoader` refuses archives over
`maxArchiveBytes` (512 MiB). The board Files view uses the same loader,
so it fails the same way.

**Expected behavior**
A user can list and open any single file of a saved workspace of
Paperclip-repository size without the server extracting or buffering the
whole archive. Memory and time stay bounded per request, and integrity
is still verified.

**Steps to reproduce**
1. Run a cloud task whose workspace is a large repository, so the final
snapshot exceeds 512 MiB.
2. Let the task finish and its sandbox be released.
3. Open the task's Files view, or call `GET
/api/issues/:id/file-resources/content?workspace=execution&path=…`: it
returns 422 `too_large`.

**Paperclip version or commit**
`master` at f73ff4ace9.

## What Changed

- `openWorkspaceSnapshotRanges`
(`remote-workspace-snapshot-storage.ts`):
  - range-reads the snapshot object's header;
- decrypts any plaintext byte range with AES-256-CTR, starting from the
AES-GCM counter block for that range;
- checks that the object size matches the wrapped key's plaintext size.
- New `saved-workspace-archive.ts`:
  - range-reads and decrypts the archive header and manifest;
- verifies the manifest's SHA-256 against the snapshot's recorded
`manifestDigest`;
- applies the restore path's manifest checks (safe paths, parent
directories, file sizes and hashes, total size) and computes each file's
offset;
  - keeps parsed manifests of recent snapshots within a 128 MiB budget.
- `remote-workspace-files.ts`: with the new `openSavedWorkspaceArchive`
option, saved stat, list, read and download use the verified manifest,
not the extracted-directory helper.
- Path normalization, denied paths, symlink and missing-path errors,
traversal, paging, truncation and text detection mirror the helper
exactly.
- A read or download decrypts only that file into a private temporary
file and verifies its SHA-256 before serving it.
- The changed-files view needs repository status, so it still uses the
bounded extraction. A saved copy beyond its limits now reports
`changed_unavailable`, a specific code, while its other views stay
available.
- `app.ts` and the support inspection route pass the new opener.
- `doc/SPEC-implementation.md` describes in-place saved reads and the
remaining changed-files limits.

## Verification

- Red before the fix, green after: `reads a saved workspace above the
extraction cap in place, one verified file at a time`. With extraction
only (master's Files service), the capped snapshot returns 422
`too_large`. With the archive reader, preview, list and a 256 KiB
download succeed through the ordinary routes. Every storage read is a
byte-range read, and no extraction directory is created.
- Parity: `answers saved stat and list exactly like the extracted
helper` compares archive-backed results with the extracted-helper path
on the same snapshot.
- Stat covers files, directories, nested and binary files, missing
paths, paths through a file, symlinks, denied names, and invalid paths.
- List covers all mode, a subdirectory, paging, queries, a scan limit, a
non-directory, and a denied directory.
- In recent mode, the entries match. Order is by path, because a saved
copy has no modification times; the extracted copy's mtimes were just
its extraction times.
- Integrity: `refuses a saved file whose stored bytes no longer match
its verified manifest`. Tampered stored bytes return 422 `file_changed`,
and no bytes are served.
- `keeps changed files on bounded extraction and names its limit…`: over
the cap, the changed view returns `changed_unavailable` and the other
views still work.
- The existing location-clearing preview test now checks for range reads
and an empty request temp directory, not for an extraction directory.
- Measured locally, with local-disk storage and a 749,006,840-byte
snapshot (88 × 8 MiB pack file, a 10 MiB file, 2,000 small files):

| path | time |
| --- | --- |
| Old: download, decrypt and extract all, once per request | 2,338 ms,
plus a full 749 MB transfer from S3 |
| New, cold: open (header and manifest range reads) | 7 ms; open and
read a small file: 13 ms |
| New, warm (cached manifest) | open 2 ms; open and read: 6 ms |
| New: list 100 entries and download a 10 MiB file | 23 ms |

- `remote-workspace-file-resources`, `remote-workspaces.integration`,
`remote-workspace-save-concurrency`, and
`remote-workspace-snapshot-retention` pass (212 tests).
- `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm typecheck` in `server` passed.
- Not yet verified: the Daytona R1 wave's file checks after release. The
chaos harness owner will run them on a build with this change.

## Risks

- Range decryption bypasses GCM's whole-object tag. Integrity instead
comes from the manifest's SHA-256 matching the digest recorded in the
database, and from each file's SHA-256 in that manifest. A file is
verified before it is served. Text detection on stat samples the first 4
KiB without verifying the whole file. It only sets a display hint, and
content is always verified before it is shown.
- Saved files carry no modification times, so the recent view of a saved
copy is ordered by path, where it was previously in effectively random
extraction order.
- The changed-files view of a saved copy over 512 MiB still reports
`changed_unavailable`, because it needs a full extraction to run `git
status`.
- No schema or API change.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, file edits, local benchmarks).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 22:33:58 -06:00

618 lines
34 KiB
TypeScript

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<void>;
}
export interface RemoteWorkspaceFileAccess {
source: WorkspaceFileSource;
stat(relativePath: string): Promise<RemoteWorkspaceFileEntry>;
read(relativePath: string, options: { maxBytes: number }): Promise<Buffer>;
list(input: RemoteWorkspaceFileListInput): Promise<{
entries: RemoteWorkspaceFileEntry[];
scannedCount: number;
truncated: boolean;
}>;
download(relativePath: string): Promise<RemoteWorkspaceDownload>;
close(): Promise<void>;
}
export interface SavedWorkspaceFiles {
root: string;
savedAt: string;
cleanup(): Promise<void>;
}
export interface RemoteWorkspaceFileOptions {
environmentRuntime?: Pick<EnvironmentRuntimeService, "execute" | "syncOut">;
/** Verify the exact company-owned, committed snapshot before exposing files. */
loadSavedWorkspace?(input: {
companyId: string;
workspaceId: string;
snapshotId: string;
signal?: AbortSignal;
}): Promise<SavedWorkspaceFiles>;
openSavedWorkspaceArchive?: OpenSavedWorkspaceArchive;
savedFileDownloadLimits?: Partial<SavedFileDownloadLimits>;
}
export interface RemoteWorkspaceFiles {
open(input: { companyId: string; workspaceId: string; signal?: AbortSignal }): Promise<RemoteWorkspaceFileAccess | null>;
}
// 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;i<tokens.length;i++) {
if(++scannedCount>maxScanned) {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.depth<maxDepth) queue.push({relative,depth:current.depth+1});
else truncated=true;
} else if(child.isFile()) await 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));
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<RemoteWorkspaceFileEntry> | 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<number> {
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<RemoteWorkspaceFileEntry> {
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<bigint> };
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<Buffer> {
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<void>, limits: SavedFileDownloadLimits, signal?: AbortSignal): Promise<RemoteWorkspaceDownload> {
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 <T>(action: "stat" | "list", args: Record<string, unknown>): Promise<T> => {
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<RemoteWorkspaceFileEntry>("stat", { relativePath });
const download = async (relativePath: string): Promise<RemoteWorkspaceDownload> => {
const expected = await execute<RemoteWorkspaceFileEntry>("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,
};
},
};
}