mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 11:01:23 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - An ephemeral web server must retain provider history outside its
local disk.
> - Offline enrollment moves verified local harness backups to object
storage.
> - A cold rehearsal reached 699 current backup rows while processing
1,953 sources before its 25-minute deadline.
> - The serial transfer loop prevents this existing dataset from
enrolling.
> - This pull request overlaps at most four transfers within the
existing total scratch limit.
> - The benefit is faster preservation with the same ownership and
publication checks.
## Linked Issues or Issue Description
Refs #19038, #18943, #18932.
**What happened?**
Offline enrollment processed 1,953 local provider backups one at a time.
The normal Cloud image reached 699 current backup rows (including nine
pre-existing rows) before maintenance expired. The serving instance
remained unchanged during the isolated test.
**Expected behavior**
Enrollment must preserve every source within bounded time and disk use.
It must stop all in-flight transfers before it reports failure.
**Steps to reproduce**
1. Prepare a settled instance with multiple verified native harness
backups.
2. Run offline enrollment against shared object storage.
3. Observe that the serial owner waits for each archive, upload,
readback and publication before starting the next source.
## What Changed
- Process groups of up to four verified offline harness backups.
- Reserve space for the archive and encrypted copy before each group
starts. Keep the total scratch limit equal to one maximum-size serial
transfer.
- Derive a smaller archive limit from a closed manifest directory set.
Preserve unlisted files with an exclusive maximum-size reservation.
- Enforce the reservation while streaming. Keep the existing maximum for
ordinary runtime backups.
- Cancel and await peer transfers after failure. Keep completed
publications valid for retry.
- Preserve the original archive-stream failure when the tar writer also
fails after destruction.
- Document the migration bound and add integration regressions for
overlap, restoration, cancellation, retry, extra files and archive
limits.
## Verification
- Serial baseline: the new overlap regression fails with one active
transfer when four are required.
- Candidate: 59 targeted tests pass with real PostgreSQL, S3, filesystem
archives and restored provider bytes. The bundled-entry test is skipped
locally because this source worktree has no runtime bundle. The full
normal image rehearsal remains required.
- Follow-up: 48 archive/storage checks pass after correcting the
PAX-header allowance. The real encoder produces a 972,801,024-byte
full-entry projection, above the old 805,306,368-byte allowance and
below the new 1,024,001,024-byte bound. The existing global object limit
remains in force. The old formula fails the new regression. Exact-size
restoration and one-byte-over-limit rejection pass.
- The previous revision passed full CI after one setup-route timeout
retry. That failure remains recorded; the corrected current revision
f7c2be1289 passed fresh full CI (41937700826) and Greptile 5/5. Its
review thread is resolved.
- Local server typecheck found four test-only Promise library errors;
those are fixed. The remaining 26 diagnostics come from stale linked
dependency declarations outside the changed files. Clean full CI is
required before merge.
- The resumed isolated staging-copy experiment also exceeded its
25-minute deadline. It reached 1,389 current backups. This improves
transfer throughput but does not by itself qualify enrollment; online
preparation is tracked in #19137.
- Full fresh normal-image migration, empty-home restoration and hosted
release handoffs remain deployment acceptance gates.
## Risks
- Parallel work increases concurrent object-storage and database
requests to at most four transfers. The scratch reservation still bounds
archive and ciphertext files together.
- A malformed or changed source can fail its streaming limit. The
migration retains its source and stops before export.
- Partial completed publications survive failure. Retry verifies them
under the existing hash and ownership rules.
- No serving configuration, deployment pin, database contract or
provider lifecycle policy changes in this pull request.
## Model Used
OpenAI GPT-6.1 Codex, exact model ID `gpt-6.1-sol`. Used for reasoning,
code editing, shell tools and test execution. The runtime does not
expose its 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 `#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
- [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>
109 lines
7.1 KiB
TypeScript
109 lines
7.1 KiB
TypeScript
import { mkdtemp, mkdir, open, readFile, readlink, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
import { createWriteStream } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { pipeline } from "node:stream/promises";
|
|
import tar from "tar-stream";
|
|
import { afterEach, expect, it } from "vitest";
|
|
import { captureNativeHarnessArchive, restoreNativeHarnessArchive, MAX_NATIVE_HARNESS_BYTES,
|
|
MAX_NATIVE_HARNESS_ENTRIES, MAX_NATIVE_HARNESS_OBJECT_BYTES, MAX_NATIVE_HARNESS_PATH_BYTES,
|
|
nativeHarnessArchiveSizeLimit } from "./native-harness-backup-archive.js";
|
|
|
|
const roots: string[] = [];
|
|
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });
|
|
async function fixture() { const root = await mkdtemp(join(tmpdir(), "native-backup-archive-test-")); roots.push(root); return root; }
|
|
async function maliciousArchive(root: string, headers: Array<tar.Headers>) {
|
|
const archive = tar.pack(), file = join(root, "malicious.tar");
|
|
const written = pipeline(archive, createWriteStream(file));
|
|
for (const header of headers) archive.entry(header, Buffer.alloc(0));
|
|
archive.finalize(); await written; return file;
|
|
}
|
|
it("reserves the full entry budget using real maximum-path PAX encoding", async () => {
|
|
const root = await fixture(), name = "a/".repeat(MAX_NATIVE_HARNESS_PATH_BYTES / 2 - 1) + "aa";
|
|
const file = await maliciousArchive(root, [{ name, type: "symlink", linkname: "b".repeat(MAX_NATIVE_HARNESS_PATH_BYTES) }]);
|
|
// One real encoded record gives the repeated-entry size without creating a
|
|
// gigabyte fixture. Each hypothetical entry includes its own PAX records.
|
|
const entryBytes = (await stat(file)).size - 1024;
|
|
const denseArchiveBytes = entryBytes * MAX_NATIVE_HARNESS_ENTRIES + 1024;
|
|
const bound = nativeHarnessArchiveSizeLimit(0);
|
|
expect(denseArchiveBytes).toBeGreaterThan(MAX_NATIVE_HARNESS_OBJECT_BYTES - MAX_NATIVE_HARNESS_BYTES);
|
|
expect(denseArchiveBytes).toBeLessThanOrEqual(bound);
|
|
expect(denseArchiveBytes).toBeGreaterThan(bound * 0.9);
|
|
expect(nativeHarnessArchiveSizeLimit(MAX_NATIVE_HARNESS_BYTES)).toBe(MAX_NATIVE_HARNESS_OBJECT_BYTES);
|
|
});
|
|
it("captures at the exact archive byte limit and refuses one byte beyond it", async () => {
|
|
const root = await fixture(), source = join(root, "source"); await mkdir(source);
|
|
await writeFile(join(source, "manifest.json"), "{}"); await writeFile(join(source, "state"), "retained provider bytes");
|
|
const original = join(root, "original"); await captureNativeHarnessArchive(source, original);
|
|
const bytes = (await stat(original)).size, exact = join(root, "exact");
|
|
await captureNativeHarnessArchive(source, exact, undefined, bytes);
|
|
expect((await stat(exact)).size).toBe(bytes);
|
|
await restoreNativeHarnessArchive(exact, join(root, "restored-exact"));
|
|
expect(await readFile(join(root, "restored-exact", "state"), "utf8")).toBe("retained provider bytes");
|
|
const exceeded = join(root, "exceeded");
|
|
await expect(captureNativeHarnessArchive(source, exceeded, undefined, bytes - 1)).rejects.toThrow("native_harness_archive_invalid");
|
|
expect((await stat(exceeded)).size).toBeLessThan(bytes);
|
|
});
|
|
it.each([
|
|
{ name: "../escaped", type: "file" as const },
|
|
{ name: "/absolute", type: "file" as const },
|
|
{ name: "link", type: "symlink" as const, linkname: "../outside" },
|
|
{ name: "device", type: "character-device" as const },
|
|
{ name: "parent/child", type: "file" as const },
|
|
])("rejects unsafe archive entry $name", async header => {
|
|
const root = await fixture(), file = await maliciousArchive(root, [header]);
|
|
await expect(restoreNativeHarnessArchive(file, join(root, "destination"))).rejects.toThrow("native_harness_archive_invalid");
|
|
await expect(stat(join(root, "escaped"))).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
it("rejects duplicate paths instead of overwriting a restored file", async () => {
|
|
const root = await fixture(), file = await maliciousArchive(root, [{ name: "manifest.json", type: "file" }, { name: "manifest.json", type: "file" }]);
|
|
await expect(restoreNativeHarnessArchive(file, join(root, "destination"))).rejects.toThrow("native_harness_archive_invalid");
|
|
});
|
|
it("refuses a source symlink without reading its target", async () => {
|
|
const root = await fixture(), outside = join(root, "outside");
|
|
await writeFile(outside, "private external file");
|
|
const { mkdir } = await import("node:fs/promises"); const source = join(root, "source"); await mkdir(source);
|
|
await symlink(outside, join(source, "linked"));
|
|
await expect(captureNativeHarnessArchive(source, join(root, "archive"))).rejects.toThrow("native_harness_archive_invalid");
|
|
expect(await readFile(outside, "utf8")).toBe("private external file");
|
|
});
|
|
it("honors cancellation before walking the checkpoint tree", async () => {
|
|
const root = await fixture();
|
|
await expect(captureNativeHarnessArchive(root, join(root, "archive"), AbortSignal.abort(new Error("test cancelled"))))
|
|
.rejects.toThrow();
|
|
});
|
|
|
|
it("preserves contained provider links", async () => {
|
|
const root = await fixture(), source = join(root, "source"), target = join(root, "restored");
|
|
await mkdir(join(source, "provider", ".bin"), { recursive: true });
|
|
await writeFile(join(source, "manifest.json"), "{}");
|
|
await writeFile(join(source, "provider", "tool.js"), "provider state");
|
|
await symlink("../tool.js", join(source, "provider", ".bin", "tool"));
|
|
const archive = join(root, "archive");
|
|
await captureNativeHarnessArchive(source, archive);
|
|
await restoreNativeHarnessArchive(archive, target);
|
|
expect(await readlink(join(target, "provider", ".bin", "tool"))).toBe("../tool.js");
|
|
expect(await readFile(join(target, "provider", ".bin", "tool"), "utf8")).toBe("provider state");
|
|
});
|
|
it("rejects writes beneath a symlink even when its target is contained", async () => {
|
|
const root = await fixture(), file = await maliciousArchive(root, [
|
|
{ name: "manifest.json", type: "file" }, { name: "directory", type: "directory" },
|
|
{ name: "link", type: "symlink", linkname: "directory" }, { name: "link/child", type: "file" },
|
|
]);
|
|
await expect(restoreNativeHarnessArchive(file, join(root, "destination"))).rejects.toThrow("native_harness_archive_invalid");
|
|
});
|
|
it("rejects indirect symlink targets instead of allowing chained traversal", async () => {
|
|
const root = await fixture(), file = await maliciousArchive(root, [
|
|
{ name: "manifest.json", type: "file" }, { name: "first", type: "symlink", linkname: "second/child" },
|
|
{ name: "second", type: "symlink", linkname: "elsewhere" },
|
|
]);
|
|
await expect(restoreNativeHarnessArchive(file, join(root, "destination"))).rejects.toThrow("native_harness_archive_invalid");
|
|
});
|
|
it("rejects oversized source files before streaming their payload", async () => {
|
|
const root = await fixture(), source = join(root, "source"); await mkdir(source);
|
|
const handle = await open(join(source, "oversized"), "wx");
|
|
await handle.truncate(MAX_NATIVE_HARNESS_BYTES + 1); await handle.close();
|
|
await expect(captureNativeHarnessArchive(source, join(root, "archive"))).rejects.toThrow("native_harness_archive_invalid");
|
|
expect((await stat(join(root, "archive"))).size).toBeLessThan(1024);
|
|
});
|