Files
PaperClipAI/server/src/services/native-runtime/retained-provider-pack-source.ts
T
DottaandPaperclip 909f4d1a33 fix(native): recover exact historical provider pack code (#19779)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native sessions retain the provider pack that admitted their saved
state.
> - An image update can replace that pack's executable, proxy, or
contract.
> - Older sessions can predate the durable three-file launch artifact
receipt.
> - The current image's archive cannot satisfy a different pinned
manifest.
> - This pull request obtains the exact public code from its recorded
release image.
> - Full source, archive, and installed-file checks still control
provider admission.

## Linked Issues or Issue Description

Refs #19558, #19622, #19755.

A saved OpenCode session can fail with
`runner_remote_provider_archive_invalid` after an image update. Its
original provider state and checkpoint remain valid, but its pinned pack
differs from the controller's pack. The old manifest can name a release
that still has the exact code in GHCR. Matching the OpenCode version
does not prove matching proxy or contract bytes.

## What Changed

- Resolve a pinned historical pack from the recorded source-SHA image in
the public Paperclip GHCR repository. Verify the platform manifest, raw
config, source identity, dedicated code layer, selected pack manifest,
archive contents, paths, and hashes.
- Keep a private temporary cache of verified public code. Rebuild it on
an empty host. Bound each source to 4 GiB and three minutes, with two
active imports or cache verifications, 32 distinct queued sources, and
disk-headroom checks. Share warm-cache verification by manifest while
each caller keeps its own cancellation.
- Select this verified archive through the existing uploader. Preserve
installed-pack and preinstalled-pack checks. Refuse unavailable
historical transport before clearing the target. Keep the current-source
identity guard from #19755. Refuse an oversized layer argument before
any target effect.
- Attach the source wait to the actual per-turn startup. Stop, lease
loss, and startup completion cancel it. Keep existing Runner admission
and turn limits.
- Document the registry retention requirement and the distinction
between public code recovery and encrypted provider-state backup.

## Verification

- Genuine baseline RED: the real uploader rejects the selected old pack
before any remote effect. Treatment installs the exact old code through
that uploader.
- Current head: all 19 source/integrity/cache/deadline controls, all 45
archive controls, and 29 actual-caller and manifest/startup controls
pass (93 total). Four review regressions fail against the prior head and
pass with the fixes. The server and colocated test TypeScript graph
passes. Module-boundary and whitespace checks pass.
- Actual public GHCR cold reconstruction: 1,054,587,008 compressed code
bytes, 115.4 s, about 248 MiB maximum owner RSS. All 114 file layers and
the overlay passed source validation.
- Linux Node 24.22.2 fixture: the real historical pack passes the normal
uploader, full installed-file verifier, and pinned OpenCode version
check. It used one transfer, no application startup, no network, and no
provider calls. Revision12b0d2ba passed in 220.2 s with about 282 MiB
owner RSS. The earlier prototype passed in 108.5 s; both measurements
are retained. The three-minute source limit and the fixture's
four-minute limit were unchanged. The later review fixes add shared
cache verification, hash cancellation, and an early size guard. They do
not change extraction or the accepted Linux fixture input.
- Earlier fixture failures are retained: unsafe hardlink classification
was corrected with confined-link controls; a cached image lacked the
required zstd CLI; Docker Desktop bind storage caused GNU tar
directory-identity errors. The passing fixture uses the official Debian
zstd binary and an owned Linux tmpfs target. Production extraction code
is unchanged.
- Fresh Greptile review is 5/5 on
7b1a249478 with no open findings. All
required CI gates passed on the reviewed head. [CI
run](https://github.com/paperclipai/paperclip/actions/runs/42353011485),
[fresh
review](https://github.com/paperclipai/paperclip/pull/19779#issuecomment-6704065994).
No hosted session was retried by this change.

## Risks

- Cold historical recovery reads about 1 GiB of public code for the
measured release. Registry latency or missing images can block it. The
source deadline remains three minutes.
- GHCR administrators can delete release images. Source-SHA tags and
this temporary cache are not a permanent code-retention guarantee. A
provider-state S3 backup does not replace legacy executable bytes.
- Targets must support the existing qualified archive transport and
selected platform. Failed checks preserve the saved state and prevent
provider dispatch.
- The Linux fixture proves code installation and verification. Hosted
conversation continuation still needs acceptance after normal delivery.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, and local
code execution. The host does not expose the exact deployment ID or
context-window size.

## 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 references)
- [x] My branch name describes the change 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-07 02:06:02 -06:00

309 lines
16 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { constants, createReadStream } from "node:fs";
import { link, lstat, mkdir, mkdtemp, open, readdir, realpath, rm, statfs, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createZstdDecompress } from "node:zlib";
import tar from "tar-stream";
import { canonicalProviderPackJson, type ProviderPackIdentity } from "./remote-provider-pack-verification.js";
import { readProviderPackArchive } from "./provider-pack-archive.js";
import { MAX_RETAINED_PACK_BYTES, receiveRetainedProviderPack, type RetainedPackProvenance } from "./retained-provider-pack-oci.js";
type Source = { root: string; archiveOnly: true };
type ImportJob = { operation: Promise<Source>; controller: AbortController; waiters: number };
const pending = new Map<string, ImportJob>();
const waiting = new Set<() => void>();
let activeImports = 0;
const digest = (value: Buffer | string) => createHash("sha256").update(value).digest("hex");
function fail(): never { throw Error("runner_remote_provider_source_invalid"); }
const maximumMetadata = 1024 * 1024;
type Published = RetainedPackProvenance & { entry: string; receiptSha256: string; verifiedTarPaths: true };
const safeName = (value: string) => value.length > 0 && Buffer.byteLength(value) <= 4096 && !/[\\\u0000-\u001f\u007f]/u.test(value)
&& !value.startsWith("/") && value.split("/").every(part => part !== "" && part !== "." && part !== "..");
async function waitForSource<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let stopped!: () => void;
const aborted = new Promise<never>((_resolve, reject) => {
stopped = () => reject(signal.reason);
signal.addEventListener("abort", stopped, { once: true });
if (signal.aborted) stopped();
});
try { return await Promise.race([operation, aborted]); }
finally { signal.removeEventListener("abort", stopped); }
}
async function importSlot(signal: AbortSignal) {
while (activeImports >= 2) {
let resume!: () => void;
const available = new Promise<void>(done => { resume = done; waiting.add(done); });
try { await waitForSource(available, signal); }
finally { waiting.delete(resume); }
}
signal.throwIfAborted();
activeImports++;
return () => { activeImports--; for (const resume of waiting) resume(); };
}
async function readStable(path: string, maximum = maximumMetadata) {
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const before = await file.stat();
if (!before.isFile() || before.size > maximum) fail();
const bytes = await file.readFile();
const after = await file.stat();
if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) fail();
return bytes;
} finally { await file.close(); }
}
async function regularDigest(path: string, expected: { sha256: string; bytes: number }, signal: AbortSignal) {
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const before = await file.stat();
if (!before.isFile() || before.size !== expected.bytes) fail();
const hash = createHash("sha256");
let bytes = 0;
for await (const chunk of file.createReadStream({ autoClose: false, signal })) { bytes += chunk.length; hash.update(chunk); }
const after = await file.stat();
if (bytes !== before.size || before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || hash.digest("hex") !== expected.sha256) fail();
} finally { await file.close(); }
}
async function selectedArchive(root: string, manifest: ProviderPackIdentity, signal: AbortSignal) {
signal.throwIfAborted();
const value = JSON.parse((await readStable(join(root, "provider-pack.json"))).toString()) as ProviderPackIdentity;
if (canonicalProviderPackJson(value) !== canonicalProviderPackJson(manifest)) fail();
const archive = await readProviderPackArchive(root, manifest.digest, signal);
if (!archive || !("kind" in archive)) fail();
const names = ["overlay.tar.zst", ...archive.files.map((_file, index) => `${index}.tar.zst`)];
if (canonicalProviderPackJson((await readdir(archive.sourceRoot)).sort()) !== canonicalProviderPackJson(names.sort())) fail();
let bytes = archive.overlay.bytes;
for (const [index, file] of archive.files.entries()) {
signal.throwIfAborted();
bytes += file.archive.bytes;
if (bytes > MAX_RETAINED_PACK_BYTES) fail();
await regularDigest(join(archive.sourceRoot, `${index}.tar.zst`), file.archive, signal);
}
signal.throwIfAborted();
return archive;
}
async function extractManifest(root: string, manifest: ProviderPackIdentity, signal: AbortSignal) {
const source = await readProviderPackArchive(root, manifest.digest, signal);
if (!source || !("kind" in source)) fail();
const regular = new Set(source.files.map(file => file.path));
const archive = tar.extract();
const names = new Set<string>(), files = new Set<string>(), links = new Map<string, string>();
let bytes = 0, count = 0;
let found: Buffer | undefined;
archive.on("entry", (header, body, next) => {
body.on("error", () => undefined);
void (async () => {
signal.throwIfAborted();
if (++count > 100_000) fail();
const normalized = header.name.replace(/^\.\//u, "");
const name = header.type === "directory" ? normalized.replace(/\/$/u, "") : normalized;
if (!safeName(name) || names.has(name) || !["file", "directory", "symlink", "link"].includes(header.type ?? "")
|| !Number.isSafeInteger(header.size ?? 0) || (header.size ?? 0) < 0 || ((header.mode ?? 0) & ~0o777) !== 0) fail();
names.add(name);
if (header.type !== "directory") files.add(name);
if (header.type === "link") {
const target = header.linkname?.replace(/^\.\//u, "");
if (!target || !safeName(target) || !regular.has(target)) fail();
regular.add(name);
} else if (header.type === "file") regular.add(name);
if (header.type === "symlink") {
if (!header.linkname || header.linkname.startsWith("/") || /[\\\u0000-\u001f\u007f]/u.test(header.linkname) || Buffer.byteLength(header.linkname) > 4096) fail();
links.set(name, header.linkname);
}
if (name !== "provider-pack.json") { body.resume(); return; }
if (found || header.type !== "file" || !Number.isSafeInteger(header.size) || header.size! > maximumMetadata) fail();
const chunks: Buffer[] = [];
for await (const chunk of body) {
bytes += chunk.length;
if (bytes > maximumMetadata) fail();
chunks.push(Buffer.from(chunk));
}
found = Buffer.concat(chunks);
})().then(() => next(), error => archive.destroy(error));
});
let expanded = 0;
const bound = new Transform({ transform(chunk, _encoding, done) {
expanded += chunk.length;
done(expanded > MAX_RETAINED_PACK_BYTES ? Error("runner_remote_provider_source_size_limit") : null, chunk);
} });
await pipeline(createReadStream(root + ".archives/overlay.tar.zst"), createZstdDecompress(), bound, archive, { signal });
for (const name of names) {
let parent = name;
while (parent.includes("/")) {
parent = parent.slice(0, parent.lastIndexOf("/"));
if (files.has(parent)) fail();
}
}
for (const [name, target] of links) {
let parts = [...name.split("/").slice(0, -1), ...target.split("/")];
for (let followed = 0; ; followed++) {
if (followed > 32) fail();
const resolved: string[] = [];
let changed = false;
for (let index = 0; index < parts.length; index++) {
const part = parts[index]!;
if (part === "" || part === ".") continue;
if (part === "..") { if (!resolved.length) fail(); resolved.pop(); continue; }
resolved.push(part);
const link = links.get(resolved.join("/"));
if (link !== undefined) { parts = [...resolved.slice(0, -1), ...link.split("/"), ...parts.slice(index + 1)]; changed = true; break; }
}
if (!changed) break;
}
}
if (!found || canonicalProviderPackJson(JSON.parse(found.toString())) !== canonicalProviderPackJson(manifest)) fail();
await mkdir(root, { mode: 0o700 });
await writeFile(join(root, "provider-pack.json"), found, { flag: "wx", mode: 0o600 });
return { expanded, files };
}
async function verifyFileContents(archivePath: string, file: { path: string; bytes: number; sha256: string; mode: number }, signal: AbortSignal) {
const archive = tar.extract();
let found = false, count = 0;
archive.on("entry", (header, body, next) => {
body.on("error", () => undefined);
void (async () => {
signal.throwIfAborted();
const normalized = header.name.replace(/^\.\//u, "");
const name = header.type === "directory" ? normalized.replace(/\/$/u, "") : normalized;
if (++count > 128 || !safeName(name)) fail();
if (header.type === "directory" && file.path.startsWith(name + "/")) { body.resume(); return; }
if (found || header.type !== "file" || name !== file.path || header.size !== file.bytes || header.mode !== file.mode) fail();
found = true;
const hash = createHash("sha256");
let bytes = 0;
for await (const chunk of body) {
signal.throwIfAborted();
bytes += chunk.length;
if (bytes > file.bytes) fail();
hash.update(chunk);
}
if (bytes !== file.bytes || hash.digest("hex") !== file.sha256) fail();
})().then(() => next(), error => archive.destroy(error));
});
let expanded = 0;
const bound = new Transform({ transform(chunk, _encoding, done) {
expanded += chunk.length;
done(expanded > file.bytes + 2 * 1024 * 1024 ? Error("runner_remote_provider_source_size_limit") : null, chunk);
} });
await pipeline(createReadStream(archivePath), createZstdDecompress(), bound, archive, { signal });
if (!found) fail();
}
async function privateDirectory(root: string) {
const metadata = await lstat(root);
if (!metadata.isDirectory() || metadata.isSymbolicLink() || metadata.uid !== process.getuid?.() || (metadata.mode & 0o077) !== 0) fail();
}
async function cached(cache: string, pointer: string, manifest: ProviderPackIdentity, signal: AbortSignal) {
let bytes: Buffer;
try { bytes = await readStable(pointer); } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
const publication = JSON.parse(bytes.toString()) as Published;
if (publication.schema !== "paperclip.retained-provider-pack-source.v1" || publication.verifiedTarPaths !== true || publication.providerPackDigest !== manifest.digest
|| publication.sourceRevision !== manifest.payload.runnerSourceRevision || !new RegExp(`^${manifest.digest.slice(7)}-[A-Za-z0-9]{6}$`).test(publication.entry)
|| !/^[a-f0-9]{64}$/.test(publication.receiptSha256)
|| [publication.imageIndexDigest, publication.imageManifestDigest, publication.imageConfigDigest, publication.transportLayerDigest].some(value => !/^sha256:[a-f0-9]{64}$/.test(value))) fail();
const directory = join(cache, publication.entry);
await privateDirectory(directory);
const root = join(directory, "provider-pack");
await privateDirectory(root);
if (digest(await readStable(root + ".archive.json")) !== publication.receiptSha256) fail();
await selectedArchive(root, manifest, signal);
return { root, archiveOnly: true as const };
}
async function importSource(cache: string, pointer: string, manifest: ProviderPackIdentity, signal: AbortSignal) {
const space = await statfs(cache, { bigint: true });
if (space.bavail * space.bsize < BigInt(2 * MAX_RETAINED_PACK_BYTES + 1024 ** 3)) throw Error("runner_remote_provider_source_disk_headroom_required");
const directory = await mkdtemp(join(cache, manifest.digest.slice(7) + "-"));
const temporaryPointer = join(cache, `.${randomUUID()}.pending`);
let published = false;
try {
const provenance = await receiveRetainedProviderPack({ manifest, directory, signal });
const root = join(directory, "provider-pack");
const overlay = await extractManifest(root, manifest, signal);
const archive = await selectedArchive(root, manifest, signal);
let expanded = overlay.expanded;
for (const [index, file] of archive.files.entries()) {
if (!Number.isSafeInteger(file.bytes) || file.bytes < 1 || (expanded += file.bytes) > MAX_RETAINED_PACK_BYTES || overlay.files.has(file.path)) fail();
let parent = file.path;
while (parent.includes("/")) { parent = parent.slice(0, parent.lastIndexOf("/")); if (overlay.files.has(parent)) fail(); }
await verifyFileContents(join(archive.sourceRoot, `${index}.tar.zst`), file, signal);
}
await unlink(join(directory, "transport.tar.gz"));
signal.throwIfAborted();
const publication: Published = { ...provenance, entry: basename(directory), receiptSha256: digest(await readStable(root + ".archive.json")), verifiedTarPaths: true };
await writeFile(temporaryPointer, JSON.stringify(publication), { flag: "wx", mode: 0o600 });
signal.throwIfAborted();
try {
await link(temporaryPointer, pointer);
published = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
const winner = await cached(cache, pointer, manifest, signal);
if (!winner) fail();
return winner;
}
return { root, archiveOnly: true as const };
} finally {
await rm(temporaryPointer, { force: true });
if (!published) await rm(directory, { recursive: true, force: true });
}
}
export async function resolveProviderPackUploadSource(input: {
root: string;
sourceDigest?: string;
manifest: ProviderPackIdentity;
signal?: AbortSignal;
}): Promise<{ root: string; archiveOnly: boolean }> {
const manifest = input.manifest;
if (manifest?.schema !== "paperclip-runner/remote-provider-pack/v1" || manifest.digest !== `sha256:${digest(canonicalProviderPackJson(manifest.payload))}`) fail();
input.signal?.throwIfAborted();
const configured = JSON.parse((await readStable(join(input.root, "provider-pack.json"))).toString()) as ProviderPackIdentity;
if (configured?.schema !== manifest.schema || configured.digest !== `sha256:${digest(canonicalProviderPackJson(configured.payload))}`
|| input.sourceDigest !== undefined && input.sourceDigest !== configured.digest) fail();
if (configured.digest === manifest.digest) return { root: input.root, archiveOnly: false };
const cache = join(await realpath(tmpdir()), `paperclip-provider-pack-sources-${process.getuid?.()}`);
await mkdir(cache, { recursive: true, mode: 0o700 });
await privateDirectory(cache);
const signal = input.signal ? AbortSignal.any([input.signal, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
const pointer = join(cache, manifest.digest.slice(7) + ".json");
const key = `${cache}:${manifest.digest}`;
let job = pending.get(key);
if (!job || job.controller.signal.aborted) {
if (pending.size >= 32) throw Error("runner_remote_provider_source_import_busy");
const controller = new AbortController();
const sharedSignal = AbortSignal.any([controller.signal, AbortSignal.timeout(180_000)]);
let created!: ImportJob;
const operation = (async () => {
const release = await importSlot(sharedSignal);
try { return await cached(cache, pointer, manifest, sharedSignal) ?? await importSource(cache, pointer, manifest, sharedSignal); }
finally { release(); }
})().finally(() => { if (pending.get(key) === created) pending.delete(key); });
job = created = { operation, controller, waiters: 0 };
pending.set(key, job);
}
job.waiters++;
try {
const source = await waitForSource(job.operation, signal);
signal.throwIfAborted();
return source;
} finally {
job.waiters--;
if (job.waiters === 0 && pending.get(key) === job) job.controller.abort(new DOMException("provider source no longer requested", "AbortError"));
}
}