mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +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>
77 lines
4.0 KiB
TypeScript
77 lines
4.0 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
|
import type { NativeRunTrace } from "./native-run-trace.js";
|
|
import { invalidArchive, readProviderPackLayers, stageProviderPackLayers, verifyArchive } from "./provider-pack-layers.js";
|
|
import { providerPackExtractionProbe } from "./provider-pack-extraction.js";
|
|
|
|
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
|
|
export async function readProviderPackArchive(root: string, expectedDigest: string, signal?: AbortSignal) {
|
|
signal?.throwIfAborted();
|
|
let receipt: { schema?: string; encoding?: string; providerPackDigest?: string; sha256?: string; bytes?: number };
|
|
try {
|
|
receipt = JSON.parse(await readFile(`${root}.archive.json`, { encoding: "utf8", signal }));
|
|
} catch (error) {
|
|
signal?.throwIfAborted();
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
throw invalidArchive("receipt_unreadable", error);
|
|
}
|
|
if (receipt.schema === "paperclip-runner/provider-pack-archive/v2") {
|
|
return readProviderPackLayers(root, expectedDigest, receipt, signal);
|
|
}
|
|
if (receipt.schema !== "paperclip-runner/provider-pack-archive/v1"
|
|
|| receipt.encoding !== "tar+zstd"
|
|
|| receipt.providerPackDigest !== expectedDigest
|
|
|| !/^[a-f0-9]{64}$/.test(receipt.sha256 ?? "")
|
|
|| !Number.isSafeInteger(receipt.bytes) || receipt.bytes! <= 0) {
|
|
throw invalidArchive(receipt.providerPackDigest !== expectedDigest ? "receipt_identity_mismatch" : "receipt_invalid");
|
|
}
|
|
const sourcePath = `${root}.tar.zst`;
|
|
await verifyArchive(sourcePath, { sha256: receipt.sha256!, bytes: receipt.bytes! }, signal);
|
|
return { sourcePath, sha256: receipt.sha256!, bytes: receipt.bytes! };
|
|
}
|
|
|
|
export async function stageProviderPackArchive(input: {
|
|
archive: NonNullable<Awaited<ReturnType<typeof readProviderPackArchive>>>;
|
|
runner: CommandManagedRuntimeRunner;
|
|
targetPath: string;
|
|
trace?: NativeRunTrace;
|
|
reuseRoots?: readonly string[];
|
|
}): Promise<boolean> {
|
|
if ("kind" in input.archive) return stageProviderPackLayers({ ...input, archive: input.archive, reuseRoots: input.reuseRoots ?? [] });
|
|
const archive = input.archive;
|
|
if (!input.runner.syncIn) throw new Error("runner_remote_provider_archive_transport_unavailable");
|
|
const archivePath = `${input.targetPath}.upload-${randomUUID()}.tar.zst`;
|
|
const measure = <T>(name: string, fn: () => Promise<T>) => input.trace ? input.trace.measure(name, fn) : fn();
|
|
const capability = await measure("provider_pack.archive.probe", () => input.runner.execute({
|
|
command: "sh", args: ["-c", providerPackExtractionProbe],
|
|
bypassSession: true, timeoutMs: 10_000,
|
|
}));
|
|
if (capability.exitCode !== 0 || capability.timedOut) return false;
|
|
try {
|
|
await measure("provider_pack.archive.transfer", () => input.runner.syncIn!([{
|
|
operationId: `runner-provider-archive-${randomUUID()}`,
|
|
files: [{ kind: "file", sourcePath: archive.sourcePath, targetPath: archivePath, mode: 0o600 }],
|
|
}]));
|
|
const result = await measure("provider_pack.archive.extract", () => input.runner.execute({
|
|
command: "sh",
|
|
args: ["-c", [
|
|
"set -eu; umask 077;",
|
|
`test ! -L ${quote(archivePath)}; test -f ${quote(archivePath)};`,
|
|
`test "$(sha256sum -- ${quote(archivePath)} | cut -d ' ' -f 1)" = ${quote(archive.sha256)};`,
|
|
`test ! -L ${quote(input.targetPath)}; mkdir -p ${quote(input.targetPath)};`,
|
|
`tar --zstd -xf ${quote(archivePath)} --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 new Error("runner_remote_provider_archive_extract_failed");
|
|
return true;
|
|
} finally {
|
|
await input.runner.execute({ command: "sh", args: ["-c", `rm -f -- ${quote(archivePath)}`],
|
|
bypassSession: true, timeoutMs: 10_000 }).catch(() => undefined);
|
|
}
|
|
}
|