mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## 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>
195 lines
10 KiB
TypeScript
195 lines
10 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { constants, createWriteStream } from "node:fs";
|
|
import { lstat, mkdtemp, open, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, posix } from "node:path";
|
|
import { pipeline } from "node:stream/promises";
|
|
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
import type { NativeRunTrace } from "./native-run-trace.js";
|
|
import { providerPackExtractionProbe } from "./provider-pack-extraction.js";
|
|
|
|
type Archive = { sha256: string; bytes: number };
|
|
type FileLayer = Archive & { path: string; mode: number; archive: Archive };
|
|
export type ProviderPackLayers = {
|
|
kind: "layers";
|
|
sourceRoot: string;
|
|
overlay: Archive;
|
|
files: FileLayer[];
|
|
};
|
|
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
const validArchive = (value: unknown): value is Archive => {
|
|
const entry = value as Archive | null;
|
|
return !!entry && /^[a-f0-9]{64}$/.test(entry.sha256)
|
|
&& Number.isSafeInteger(entry.bytes) && entry.bytes > 0;
|
|
};
|
|
|
|
// The run report keeps only the outer message. Name the failed check with a
|
|
// fixed reason so a recurrence is diagnosable without exposing private paths.
|
|
export const invalidArchive = (reason: string, cause?: unknown) =>
|
|
new Error(`runner_remote_provider_archive_invalid: ${reason}`, cause === undefined ? undefined : { cause });
|
|
|
|
export async function verifyArchive(path: string, expected: Archive, signal?: AbortSignal) {
|
|
signal?.throwIfAborted();
|
|
let file;
|
|
try { file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); }
|
|
catch (error) { throw invalidArchive("archive_unreadable", error); }
|
|
try {
|
|
const metadata = await file.stat();
|
|
if (!metadata.isFile()) throw invalidArchive("archive_not_file");
|
|
if (metadata.size !== expected.bytes) throw invalidArchive("size_mismatch");
|
|
const hash = createHash("sha256");
|
|
for await (const chunk of file.createReadStream({ autoClose: false, signal })) hash.update(chunk);
|
|
signal?.throwIfAborted();
|
|
if (hash.digest("hex") !== expected.sha256) throw invalidArchive("sha256_mismatch");
|
|
} finally { await file.close(); }
|
|
}
|
|
|
|
export async function readProviderPackLayers(root: string, expectedDigest: string, value: unknown, signal?: AbortSignal): Promise<ProviderPackLayers> {
|
|
const receipt = value as { encoding: string; providerPackDigest: string; overlay: Archive; files: FileLayer[] };
|
|
if (receipt.providerPackDigest !== expectedDigest) throw invalidArchive("receipt_identity_mismatch");
|
|
if (receipt.encoding !== "tar+zstd"
|
|
|| !validArchive(receipt.overlay) || !Array.isArray(receipt.files) || receipt.files.length > 10_000) {
|
|
throw invalidArchive("receipt_invalid");
|
|
}
|
|
const paths = new Set<string>();
|
|
for (const file of receipt.files) {
|
|
if (!validArchive(file) || !validArchive(file.archive)
|
|
|| typeof file.path !== "string" || !file.path || file.path.length > 4096
|
|
|| file.path.startsWith("/") || file.path.includes("\\") || file.path.includes("\0")
|
|
|| file.path.split("/").some(part => !part || part === "." || part === "..")
|
|
|| !Number.isSafeInteger(file.mode) || file.mode < 0 || file.mode > 0o777 || paths.has(file.path)) {
|
|
throw invalidArchive("layer_invalid");
|
|
}
|
|
paths.add(file.path);
|
|
}
|
|
for (const path of paths) {
|
|
let parent = posix.dirname(path);
|
|
while (parent !== ".") {
|
|
if (paths.has(parent)) throw invalidArchive("layer_invalid");
|
|
parent = posix.dirname(parent);
|
|
}
|
|
}
|
|
const sourceRoot = `${root}.archives`;
|
|
const layers = await lstat(sourceRoot).catch(error => { throw invalidArchive("layers_unreadable", error); });
|
|
if (!layers.isDirectory()) throw invalidArchive("layers_unreadable");
|
|
await verifyArchive(`${sourceRoot}/overlay.tar.zst`, receipt.overlay, signal);
|
|
return { kind: "layers", sourceRoot, overlay: receipt.overlay, files: receipt.files };
|
|
}
|
|
|
|
// Copy first, then hash the private copy: no shared hardlinks, no mutation of
|
|
// the older image, and no time-of-check/time-of-use trust in its contents.
|
|
const reuseScript = `
|
|
(async()=>{
|
|
const fs=require('node:fs'),p=require('node:path'),c=require('node:crypto');
|
|
const spec=JSON.parse(Buffer.from(process.argv[1],'base64').toString('utf8'));
|
|
const f=fs.promises;await f.mkdir(p.dirname(spec.target),{recursive:true,mode:448});
|
|
await f.mkdir(spec.target,{mode:448});
|
|
const missing=[];
|
|
const regularPath=async(root,relative)=>{
|
|
let current=root;if(!(await f.lstat(current)).isDirectory())return false;
|
|
const parts=relative.split('/');
|
|
for(let i=0;i<parts.length;i++){current=p.join(current,parts[i]);const s=await f.lstat(current);
|
|
if(i===parts.length-1?!s.isFile():!s.isDirectory())return false;}
|
|
return true;
|
|
};
|
|
for(const [index,entry] of spec.files.entries()){
|
|
const target=p.join(spec.target,entry.path);let reused=false;
|
|
for(const root of spec.roots){try{
|
|
if(!await regularPath(root,entry.path))continue;
|
|
const source=p.join(root,entry.path);if((await f.lstat(source)).size!==entry.bytes)continue;
|
|
await f.mkdir(p.dirname(target),{recursive:true,mode:448});
|
|
await f.copyFile(source,target,fs.constants.COPYFILE_EXCL|fs.constants.COPYFILE_FICLONE);
|
|
const handle=await f.open(target,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW);
|
|
let matches=false;try{const hash=c.createHash('sha256');for await(const chunk of handle.createReadStream({autoClose:false}))hash.update(chunk);
|
|
matches=(await handle.stat()).size===entry.bytes&&hash.digest('hex')===entry.sha256;
|
|
}finally{await handle.close();}
|
|
if(!matches)throw Error('digest mismatch');
|
|
await f.chmod(target,entry.mode);reused=true;break;
|
|
}catch{await f.unlink(target).catch(()=>{});}}
|
|
if(!reused)missing.push(index);
|
|
}
|
|
console.log(JSON.stringify(missing));
|
|
})().catch(()=>process.exitCode=1);
|
|
`;
|
|
|
|
export function providerPackLayersArgument(archive: ProviderPackLayers, targetPath: string, reuseRoots: readonly string[]) {
|
|
const spec = Buffer.from(JSON.stringify({ target: targetPath, roots: reuseRoots, files: archive.files })).toString("base64");
|
|
return Buffer.byteLength(spec) > 120_000 ? null : spec;
|
|
}
|
|
|
|
export async function stageProviderPackLayers(input: {
|
|
archive: ProviderPackLayers;
|
|
runner: CommandManagedRuntimeRunner;
|
|
targetPath: string;
|
|
reuseRoots: readonly string[];
|
|
trace?: NativeRunTrace;
|
|
}): Promise<boolean> {
|
|
const { archive, runner } = input;
|
|
if (!runner.syncIn) throw Error("runner_remote_provider_archive_transport_unavailable");
|
|
const measure = <T>(name: string, fn: () => Promise<T>) => input.trace ? input.trace.measure(name, fn) : fn();
|
|
const spec = providerPackLayersArgument(archive, input.targetPath, input.reuseRoots);
|
|
// Respect the per-argument Linux limit; unusual packs retain directory staging.
|
|
if (spec === null) return false;
|
|
const capability = await measure("provider_pack.archive.probe", () => runner.execute({
|
|
command: "sh", args: ["-c", `${providerPackExtractionProbe} && node -e 'process.exit(Number(process.versions.node.split(".")[0])>=18?0:1)'`],
|
|
bypassSession: true, timeoutMs: 10_000,
|
|
}));
|
|
if (capability.exitCode !== 0 || capability.timedOut) return false;
|
|
const reused = await measure("provider_pack.archive.reuse", () => runner.execute({
|
|
command: "node", args: ["-e", reuseScript, spec], bypassSession: true, timeoutMs: 60_000,
|
|
}));
|
|
if (reused.exitCode !== 0 || reused.timedOut) throw Error("runner_remote_provider_archive_reuse_failed");
|
|
let missing: number[];
|
|
try { missing = JSON.parse(reused.stdout); } catch { throw Error("runner_remote_provider_archive_reuse_failed"); }
|
|
if (!Array.isArray(missing) || new Set(missing).size !== missing.length
|
|
|| missing.some(index => !Number.isSafeInteger(index) || index < 0 || index >= archive.files.length)) {
|
|
throw Error("runner_remote_provider_archive_reuse_failed");
|
|
}
|
|
const layers = [
|
|
...missing.map(index => ({ name: `${index}.tar.zst`, ...archive.files[index]!.archive })),
|
|
{ name: "overlay.tar.zst", ...archive.overlay },
|
|
];
|
|
const temporary = await mkdtemp(join(tmpdir(), "paperclip-provider-layers-"));
|
|
const bundlePath = join(temporary, "selected.tar.zst");
|
|
const scratch = `${input.targetPath}.upload-${randomUUID()}`;
|
|
const remoteArchive = `${scratch}/selected.tar.zst`;
|
|
try {
|
|
// Concatenate independent zstd frames without recompression. One upload
|
|
// avoids a provider round trip for every small missing layer.
|
|
const hash = createHash("sha256");
|
|
await measure("provider_pack.archive.verify_missing", () => pipeline((async function* () {
|
|
for (const layer of layers) {
|
|
const file = await open(`${archive.sourceRoot}/${layer.name}`, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
.catch(error => { throw invalidArchive("archive_unreadable", error); });
|
|
try {
|
|
const metadata = await file.stat(), layerHash = createHash("sha256");
|
|
if (!metadata.isFile() || metadata.size !== layer.bytes) throw invalidArchive("size_mismatch");
|
|
for await (const chunk of file.createReadStream({ autoClose: false })) {
|
|
layerHash.update(chunk); hash.update(chunk); yield chunk;
|
|
}
|
|
if (layerHash.digest("hex") !== layer.sha256) throw invalidArchive("sha256_mismatch");
|
|
} finally { await file.close(); }
|
|
}
|
|
})(), createWriteStream(bundlePath, { flags: "wx", mode: 0o600 })));
|
|
const sha256 = hash.digest("hex");
|
|
await measure("provider_pack.archive.transfer", () => runner.syncIn!([{
|
|
operationId: `runner-provider-layers-${randomUUID()}`,
|
|
files: [{ kind: "file", sourcePath: bundlePath, targetPath: remoteArchive, mode: 0o600 }],
|
|
}]));
|
|
const result = await measure("provider_pack.archive.extract", () => runner.execute({
|
|
command: "sh", args: ["-c", ["set -eu; umask 077;",
|
|
`test ! -L ${quote(input.targetPath)}; test -d ${quote(input.targetPath)};`,
|
|
`test ! -L ${quote(remoteArchive)}; test "$(sha256sum -- ${quote(remoteArchive)} | cut -d ' ' -f 1)" = ${quote(sha256)};`,
|
|
`tar --zstd --ignore-zeros -xf ${quote(remoteArchive)} --no-same-owner --same-permissions --delay-directory-restore -C ${quote(input.targetPath)};`,
|
|
`chmod 700 ${quote(input.targetPath)};`,
|
|
].join(" ")], bypassSession: true, timeoutMs: 120_000,
|
|
}));
|
|
if (result.exitCode !== 0 || result.timedOut) throw Error("runner_remote_provider_archive_extract_failed");
|
|
return true;
|
|
} finally {
|
|
await rm(temporary, { recursive: true, force: true });
|
|
await runner.execute({ command: "sh", args: ["-c", `rm -rf -- ${quote(scratch)}`],
|
|
bypassSession: true, timeoutMs: 10_000 }).catch(() => undefined);
|
|
}
|
|
}
|