Files
PaperClipAI/server/src/services/saved-workspace-archive.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

197 lines
9.8 KiB
TypeScript

import { createHash } from "node:crypto";
import type { Readable } from "node:stream";
import { and, eq } from "drizzle-orm";
import { executionWorkspaceSnapshots, type Db } from "@paperclipai/db";
import type { StorageService } from "../storage/types.js";
import { getStorageService } from "../storage/index.js";
import { getWorkspaceSnapshot, openWorkspaceSnapshotRanges } from "./remote-workspace-snapshot-storage.js";
const ARCHIVE_MAGIC = Buffer.from("PCWS1\n");
const ARCHIVE_SCHEMA = "paperclip.remote-workspace-archive.v1";
const MAX_MANIFEST_BYTES = 64 * 1024 * 1024;
const MAX_INDEX_ENTRIES = 500_000;
const INDEX_ENTRY_BYTES = 256;
const DEFAULT_INDEX_CACHE_BYTES = 256 * 1024 * 1024;
const MAX_CONCURRENT_INDEX_BUILDS = 2;
let activeIndexBuilds = 0;
const indexBuildWaiters: Array<() => void> = [];
function untilAborted<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return work;
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((resolve, reject) => {
const abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
work.then(value => { signal.removeEventListener("abort", abort); resolve(value); },
error => { signal.removeEventListener("abort", abort); reject(error); });
});
}
async function withIndexBuildSlot<T>(work: () => Promise<T>, signal?: AbortSignal): Promise<T> {
signal?.throwIfAborted();
if (activeIndexBuilds >= MAX_CONCURRENT_INDEX_BUILDS) {
await new Promise<void>((resolve, reject) => {
const granted = () => { signal?.removeEventListener("abort", abort); resolve(); };
const abort = () => {
const waiting = indexBuildWaiters.indexOf(granted);
if (waiting >= 0) indexBuildWaiters.splice(waiting, 1);
reject(signal!.reason);
};
signal?.addEventListener("abort", abort, { once: true });
indexBuildWaiters.push(granted);
});
} else activeIndexBuilds++;
try { return await work(); }
finally {
const next = indexBuildWaiters.shift();
if (next) next(); else activeIndexBuilds--;
}
}
export type SavedArchiveEntry = {
path: string; name: string; type: "directory" | "file" | "symlink"; size: number; sha256: string | null; offset: number;
};
export interface SavedWorkspaceArchive {
snapshotId: string;
savedAt: string;
byteSize: number;
entry(relativePath: string): SavedArchiveEntry | undefined;
children(relativePath: string): readonly SavedArchiveEntry[];
cachedIndexBytes(): number;
read(entry: SavedArchiveEntry, start: number, length: number, signal?: AbortSignal): Promise<Readable>;
close(): void;
}
export type OpenSavedWorkspaceArchive = (input: {
companyId: string; workspaceId: string; snapshotId: string; signal?: AbortSignal;
}) => Promise<SavedWorkspaceArchive>;
type ArchiveIndex = { entries: Map<string, SavedArchiveEntry>; children: Map<string, SavedArchiveEntry[]>; charge: number };
function invalid(reason: string): never { throw new Error(`remote_workspace_archive_${reason}`); }
const unsafePath = (relative: unknown) => typeof relative !== "string" || !relative || relative.length > 4096
|| relative.startsWith("/") || /[\\\u0000-\u001f\u007f]/u.test(relative)
|| relative.split("/").some(part => !part || part === "." || part === "..");
function buildIndex(manifestBytes: Buffer, archiveBytes: number, maxEntries: number): ArchiveIndex {
let manifest: { schema?: unknown; entries?: unknown };
try { manifest = JSON.parse(manifestBytes.toString("utf8")); } catch { invalid("manifest_mismatch"); }
if (manifest.schema !== ARCHIVE_SCHEMA || !Array.isArray(manifest.entries)) invalid("manifest_mismatch");
if (manifest.entries.length > maxEntries) invalid("entry_limit");
const entries = new Map<string, SavedArchiveEntry>(), children = new Map<string, SavedArchiveEntry[]>([["", []]]);
let offset = ARCHIVE_MAGIC.length + 4 + manifestBytes.length;
for (const raw of manifest.entries as Array<Record<string, unknown>>) {
const relative = raw?.path;
if (unsafePath(relative) || entries.has(relative as string)) invalid("invalid_entry");
const entryPath = relative as string, parent = entryPath.includes("/") ? entryPath.slice(0, entryPath.lastIndexOf("/")) : "";
if (parent && entries.get(parent)?.type !== "directory") invalid("invalid_parent");
let entry: SavedArchiveEntry;
if (raw.type === "file") {
if (!Number.isSafeInteger(raw.size) || (raw.size as number) < 0 || typeof raw.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(raw.sha256)) {
invalid("invalid_file");
}
entry = { path: entryPath, name: entryPath.slice(parent ? parent.length + 1 : 0), type: "file", size: raw.size as number,
sha256: raw.sha256 as string, offset };
offset += entry.size;
} else if (raw.type === "directory" || raw.type === "symlink") {
entry = { path: entryPath, name: entryPath.slice(parent ? parent.length + 1 : 0), type: raw.type, size: 0, sha256: null, offset: 0 };
if (raw.type === "directory") children.set(entryPath, []);
} else invalid("invalid_type");
entries.set(entryPath, entry);
children.get(parent)!.push(entry);
}
if (!Number.isSafeInteger(offset) || offset !== archiveBytes) invalid("size_mismatch");
for (const list of children.values()) list.sort((a, b) => a.name.localeCompare(b.name));
return { entries, children, charge: manifestBytes.length + entries.size * INDEX_ENTRY_BYTES };
}
async function readAll(stream: Readable, limit: number): Promise<Buffer> {
const chunks: Buffer[] = [];
let size = 0;
try {
for await (const chunk of stream) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += bytes.length;
if (size > limit) invalid("truncated");
chunks.push(bytes);
}
} finally { stream.destroy(); }
return Buffer.concat(chunks);
}
export function createSavedWorkspaceArchiveOpener(db: Db, storage: StorageService = getStorageService(), options: {
indexCacheBytes?: number; idleTimeoutMs?: number; maxIndexEntries?: number;
} = {}): OpenSavedWorkspaceArchive {
const maxEntries = Math.min(options.maxIndexEntries ?? MAX_INDEX_ENTRIES, MAX_INDEX_ENTRIES);
const cacheBudget = options.indexCacheBytes ?? DEFAULT_INDEX_CACHE_BYTES;
const cache = new Map<string, ArchiveIndex>(), building = new Map<string, Promise<ArchiveIndex>>();
let cachedBytes = 0;
const remember = (key: string, index: ArchiveIndex) => {
const existing = cache.get(key);
if (existing) { cache.delete(key); cachedBytes -= existing.charge; }
if (index.charge > cacheBudget) return;
cache.set(key, index); cachedBytes += index.charge;
for (const [oldest, value] of cache) {
if (cachedBytes <= cacheBudget) break;
cache.delete(oldest); cachedBytes -= value.charge;
}
};
return async input => {
input.signal?.throwIfAborted();
const [row] = await db.select({ updatedAt: executionWorkspaceSnapshots.updatedAt }).from(executionWorkspaceSnapshots).where(and(
eq(executionWorkspaceSnapshots.companyId, input.companyId), eq(executionWorkspaceSnapshots.executionWorkspaceId, input.workspaceId),
eq(executionWorkspaceSnapshots.id, input.snapshotId), eq(executionWorkspaceSnapshots.status, "committed")));
const saved = row ? await getWorkspaceSnapshot(db, input) : null;
if (!row || !saved) throw new Error("remote_workspace_snapshot_unavailable");
const ranges = await openWorkspaceSnapshotRanges({ storage, reference: saved, signal: input.signal,
...(options.idleTimeoutMs ? { idleTimeoutMs: options.idleTimeoutMs } : {}) });
try {
const key = `${saved.companyId}/${saved.workspaceId}/${saved.id}/${saved.manifestDigest}`;
let index: ArchiveIndex | undefined;
for (;;) {
index = cache.get(key);
if (index) { cache.delete(key); cache.set(key, index); break; }
const shared = building.get(key);
if (shared) {
index = await untilAborted(shared.catch(() => undefined), input.signal);
if (index) break;
continue;
}
const build = withIndexBuildSlot(async () => {
const prefixLength = ARCHIVE_MAGIC.length + 4;
if (ranges.plaintextBytes < prefixLength) invalid("truncated");
const prefix = await readAll(await ranges.read(0, prefixLength, input.signal), prefixLength);
if (!prefix.subarray(0, ARCHIVE_MAGIC.length).equals(ARCHIVE_MAGIC)) invalid("invalid_header");
const manifestSize = prefix.readUInt32BE(ARCHIVE_MAGIC.length);
if (manifestSize > MAX_MANIFEST_BYTES || prefixLength + manifestSize > ranges.plaintextBytes) invalid("manifest_too_large");
const manifest = await readAll(await ranges.read(prefixLength, manifestSize, input.signal), manifestSize);
if (manifest.length !== manifestSize || createHash("sha256").update(manifest).digest("hex") !== saved.manifestDigest) {
invalid("manifest_mismatch");
}
return buildIndex(manifest, ranges.plaintextBytes, maxEntries);
}, input.signal);
building.set(key, build);
try { index = await build; remember(key, index); }
finally { if (building.get(key) === build) building.delete(key); }
break;
}
const built = index;
return {
snapshotId: saved.id,
savedAt: row.updatedAt.toISOString(),
byteSize: saved.byteSize,
entry: relativePath => built.entries.get(relativePath),
children: relativePath => built.children.get(relativePath) ?? [],
cachedIndexBytes: () => cachedBytes,
read: (entry, start, length, signal) => {
if (entry.type !== "file" || start < 0 || length < 0 || start + length > entry.size) invalid("range_invalid");
return ranges.read(entry.offset + start, length, signal);
},
close: () => ranges.close(),
};
} catch (error) { ranges.close(); throw error; }
};
}