mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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>
618 lines
34 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
};
|
|
}
|