mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
## Thinking Path > - Paperclip manages agents and their work. > - Native Runner providers need verified executables before they can run. > - Google Antigravity ships a native ACP server and a helper executable. > - An archive hash alone does not verify the installed runtime or its child process cleanup. > - This pull request adds a pinned installer and a small, verified compatibility patch. > - Later pull requests add Runner execution, account setup, and the recorded user journey eval. ## Linked Issues or Issue Description Refs: #12503, #14938. Those pull requests use the legacy CLI adapter. This stack adds native Runner ACP support. **Agent or provider** Google Antigravity ACP 1.4.3. **Why this adapter is useful** Users need a reproducible runtime before they can select Antigravity for a Paperclip agent. **How the agent is invoked** The installer prepares the native ACP server and helper. This pull request does not enable task execution. Default execution remains closed pending qualification. ## What Changed - Pin the complete macOS ARM64 runtime closure. Keep other platforms as inventory only. - Verify archive entries, installed bytes, ownership, and cache layout. - Patch exact upstream code anchors for model admission, instruction loading, native process ownership, and per-call file tool completion. - Package a self-contained installer and expose `paperclipai runtime setup antigravity`. - Record candidate limitations and historical acceptance evidence. ## Verification - Runtime provisioning, packaging, metadata, and patch support tests: 62 passed. The separate exact pinned native patch run passed all 5 tests with no skips and no Google authentication. - CLI runtime setup tests: 5 passed. - Map contract check passed. Staged diff whitespace check passed. - The first test command ran before dependency installation and failed on missing esbuild. The same command passed after installation. - Current master has a stale lockfile. Dependencies were resolved locally with scripts disabled. The generated lockfile is excluded from this pull request. - Final head: required `ci / verify` and `ci / e2e` passed, and Greptile is 5/5 with both review threads resolved. The optional external Paperclip Review remains queued. Two original timing failures are retained; one failed-job rerun passed without source or deadline changes. ## Risks - The patch depends on exact upstream native code. A new upstream release must get a new verified closure. - Only macOS ARM64 has a complete verified runtime. Installation does not qualify authentication, task execution, or other platforms. - The installer downloads official Google assets when the verified cache is absent. ## Model Used OpenAI Codex (GPT-6). The host did not expose the exact model revision or context limit. The model used tools for code edits, test execution, and review. ## 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>
325 lines
18 KiB
JavaScript
325 lines
18 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { chmod, copyFile, link, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { test } from "node:test";
|
|
import { pathToFileURL } from "node:url";
|
|
import { antigravityArchiveEntries, antigravityDistribution } from "./materialize-antigravity-distribution.mjs";
|
|
|
|
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
const files = [
|
|
{ path: "agy_acp_server.par", content: Buffer.from("native ACP server fixture\n") },
|
|
{ path: "localharness_external", content: Buffer.from("owned native child fixture\n") },
|
|
];
|
|
|
|
// A minimal stored ZIP fixture exercises the real inspector and filesystem.
|
|
// Integrity comes from the checked-in archive and complete-file SHA-256 pins.
|
|
function zip(entries = files) {
|
|
const locals = [];
|
|
const centrals = [];
|
|
let offset = 0;
|
|
for (const entry of entries) {
|
|
const name = Buffer.from(entry.path);
|
|
const content = entry.content ?? Buffer.from("fixture");
|
|
const local = Buffer.alloc(30);
|
|
local.writeUInt32LE(0x04034b50);
|
|
local.writeUInt16LE(20, 4);
|
|
local.writeUInt32LE(content.length, 18);
|
|
local.writeUInt32LE(content.length, 22);
|
|
local.writeUInt16LE(name.length, 26);
|
|
const central = Buffer.alloc(46);
|
|
central.writeUInt32LE(0x02014b50);
|
|
central.writeUInt16LE(0x314, 4);
|
|
central.writeUInt16LE(20, 6);
|
|
central.writeUInt32LE(content.length, 20);
|
|
central.writeUInt32LE(content.length, 24);
|
|
central.writeUInt16LE(name.length, 28);
|
|
central.writeUInt32LE(((entry.mode ?? 0o100755) << 16) >>> 0, 38);
|
|
central.writeUInt32LE(offset, 42);
|
|
locals.push(local, name, content);
|
|
centrals.push(central, name);
|
|
offset += local.length + name.length + content.length;
|
|
}
|
|
const central = Buffer.concat(centrals);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(central.length, 12);
|
|
end.writeUInt32LE(offset, 16);
|
|
return Buffer.concat([...locals, central, end]);
|
|
}
|
|
|
|
async function fixture(t, { expectedFiles = files, expectedVendorFiles = files, archive = zip(), patchSuffix = "", baseDirectory = tmpdir(), changeParentDuringPatch = false } = {}) {
|
|
const root = await realpath(await mkdtemp(join(baseDirectory, "paperclip-antigravity-provision-test-")));
|
|
t.after(() => rm(root, { recursive: true, force: true }));
|
|
const scripts = join(root, "scripts");
|
|
await mkdir(scripts);
|
|
for (const name of ["materialize-antigravity-distribution.mjs", "antigravity-provisioner-layout.mjs", "provision-antigravity.mjs"]) await copyFile(new URL(name, import.meta.url), join(scripts, name));
|
|
const admissionSource = "# trusted test admission source\n";
|
|
const admissionSourceSha256 = hash(admissionSource);
|
|
await writeFile(join(scripts, "patch-antigravity-runtime.admission.py"), admissionSource);
|
|
await writeFile(join(scripts, "patch-antigravity-runtime.mjs"), `
|
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
export const ANTIGRAVITY_PATCH_VERSION='1.4.3-paperclip.1';
|
|
export async function patchAntigravityRuntime({source,destination}) {
|
|
await writeFile(${JSON.stringify(join(root, "patch.called"))},'called');
|
|
await mkdir(destination,{mode:0o700});
|
|
for(const name of ['agy_acp_server.par','localharness_external']) {
|
|
const content=await readFile(join(source,name));
|
|
await writeFile(join(destination,name),name==='agy_acp_server.par'?Buffer.concat([content,Buffer.from(${JSON.stringify(patchSuffix)})]):content,{mode:0o500});
|
|
}
|
|
${changeParentDuringPatch ? `await chmod(${JSON.stringify(join(root, "home/.paperclip/runtimes/antigravity/darwin-arm64"))},0o777);` : ""}
|
|
return {version:ANTIGRAVITY_PATCH_VERSION,admissionSourceSha256:${JSON.stringify(admissionSourceSha256)}};
|
|
}
|
|
`);
|
|
const entries = expectedFiles.map(entry => ({ path: entry.path, sha256: hash(entry.content), size: entry.content.length, executable: true }));
|
|
const closureSha256 = hash(JSON.stringify(entries));
|
|
const vendorEntries = expectedVendorFiles.map(entry => ({ path: entry.path, sha256: hash(entry.content), size: entry.content.length, executable: true }));
|
|
// The code is copied into an isolated package so its normal fixed manifest
|
|
// read uses a tiny trusted fixture. Production has no manifest-path override.
|
|
await writeFile(join(root, "antigravity-distributions.json"), JSON.stringify({
|
|
schema: "paperclip.antigravity_distribution.v1", version: "1.4.3",
|
|
platforms: { "darwin-arm64": {
|
|
url: "https://dl.google.com/agy-extensions/releases/macos/fixture.zip",
|
|
archiveSha256: hash(archive), closureSha256, vendorEntries, vendorClosureSha256: hash(JSON.stringify(vendorEntries)), patchVersion: "1.4.3-paperclip.1", admissionSourceSha256, provisioning: "verified-closure",
|
|
executable: "agy_acp_server.par", args: [], entries,
|
|
} },
|
|
}));
|
|
const archivePath = join(root, "fixture.zip");
|
|
await writeFile(archivePath, archive);
|
|
const api = await import(pathToFileURL(join(scripts, "materialize-antigravity-distribution.mjs")));
|
|
const provisioner = await import(pathToFileURL(join(scripts, "provision-antigravity.mjs")));
|
|
const home = join(root, "home");
|
|
const destination = join(home, ".paperclip/runtimes/antigravity/darwin-arm64", closureSha256);
|
|
const options = { platform: "darwin", architecture: "arm64", archivePath, destination };
|
|
const provision = () => provisioner.provisionAntigravityRuntime({ ...options, home, moduleUrl: "file:///consumer/node_modules/@paperclipai/paperclip-runner/dist/cli/provision-antigravity.cjs" });
|
|
return { root, destination, options, api, provision, distribution: await api.antigravityDistribution("darwin", "arm64") };
|
|
}
|
|
|
|
test("the inspected native mac ARM closure is pinned; other platforms remain inventory-only", async () => {
|
|
const pin = await antigravityDistribution("darwin", "arm64");
|
|
assert.equal(pin.version, "1.4.3");
|
|
assert.deepEqual(pin.entries.map(entry => entry.path), files.map(entry => entry.path));
|
|
assert.equal(hash(JSON.stringify(pin.entries)), pin.closureSha256);
|
|
for (const [platform, architecture] of [["darwin", "x64"], ["linux", "x64"], ["linux", "arm64"]]) await assert.rejects(antigravityDistribution(platform, architecture), /inventory-only/);
|
|
await assert.rejects(antigravityDistribution("win32", "x64"), /no pinned distribution/);
|
|
});
|
|
|
|
test("ZIP inspection rejects traversal, absolute names, duplicates, links and partial closure", () => {
|
|
assert.deepEqual(antigravityArchiveEntries(zip()).map(entry => entry.path), files.map(entry => entry.path));
|
|
for (const entries of [
|
|
[{ ...files[0], path: "../agy_acp_server.par" }, files[1]],
|
|
[{ ...files[0], path: "/agy_acp_server.par" }, files[1]],
|
|
[{ ...files[0], path: "dir\\agy_acp_server.par" }, files[1]],
|
|
[files[0], files[0]],
|
|
[{ ...files[0], mode: 0o120755 }, files[1]],
|
|
[{ ...files[0], mode: 0o060755 }, files[1]],
|
|
[files[0]],
|
|
]) assert.throws(() => antigravityArchiveEntries(zip(entries)), /unsafe or unsupported ZIP/);
|
|
});
|
|
|
|
test("ZIP inspection rejects disagreeing headers and overlapping payloads", () => {
|
|
const disagreeing = zip();
|
|
disagreeing.writeUInt16LE(8, 8);
|
|
assert.throws(() => antigravityArchiveEntries(disagreeing), /unsafe or unsupported ZIP/);
|
|
const overlapping = zip();
|
|
const end = overlapping.length - 22;
|
|
const central = overlapping.readUInt32LE(end + 16);
|
|
const secondCentral = central + 46 + Buffer.byteLength(files[0].path);
|
|
overlapping.writeUInt32LE(0, secondCentral + 42);
|
|
assert.throws(() => antigravityArchiveEntries(overlapping), /unsafe or unsupported ZIP/);
|
|
});
|
|
|
|
test("a corrupted archive never installs and its temporary directory is removed", async t => {
|
|
const { api, options, destination } = await fixture(t);
|
|
await writeFile(options.archivePath, "corrupt archive");
|
|
await assert.rejects(api.materializePinnedAntigravityDistribution(options), /archive digest mismatch/);
|
|
await assert.rejects(lstat(destination), { code: "ENOENT" });
|
|
assert.deepEqual(await readdir(join(destination, "..")), []);
|
|
});
|
|
|
|
test("installation preserves and verifies the complete two-binary closure", async t => {
|
|
const { api, options, destination, distribution } = await fixture(t);
|
|
const result = await api.materializePinnedAntigravityDistribution(options);
|
|
assert.equal(result.executable, join(destination, files[0].path));
|
|
assert.equal(result.closureSha256, distribution.closureSha256);
|
|
for (const file of files) {
|
|
assert.deepEqual(await readFile(join(destination, file.path)), file.content);
|
|
assert.equal((await lstat(join(destination, file.path))).mode & 0o777, 0o500);
|
|
}
|
|
assert.equal((await api.verifyAntigravityDistribution(destination, distribution)).sha256, distribution.closureSha256);
|
|
assert.deepEqual(await readdir(join(destination, "..")), [distribution.closureSha256]);
|
|
await assert.rejects(api.materializePinnedAntigravityDistribution(options), /already exists/);
|
|
});
|
|
|
|
test("a failed full-closure check leaves no partial installation", async t => {
|
|
const expectedFiles = files.map(file => ({ ...file, content: Buffer.from(`wrong expected ${file.path}`) }));
|
|
const { api, options, destination } = await fixture(t, { expectedFiles });
|
|
await assert.rejects(api.materializePinnedAntigravityDistribution(options), /full execution closure digest mismatch/);
|
|
await assert.rejects(lstat(destination), { code: "ENOENT" });
|
|
assert.deepEqual(await readdir(join(destination, "..")), []);
|
|
});
|
|
|
|
test("verified vendor bytes are patched and only the separately pinned final closure is installed", async t => {
|
|
const patchSuffix = "\nowned native admission patch\n";
|
|
const expectedFiles = files.map((file, index) => ({ ...file, content: index === 0 ? Buffer.concat([file.content, Buffer.from(patchSuffix)]) : file.content }));
|
|
const { api, options, destination, root, distribution } = await fixture(t, { expectedFiles, patchSuffix });
|
|
await api.materializePinnedAntigravityDistribution(options);
|
|
assert.equal(await readFile(join(root, "patch.called"), "utf8"), "called");
|
|
assert.notEqual(distribution.vendorClosureSha256, distribution.closureSha256);
|
|
assert.deepEqual(await readFile(join(destination, files[0].path)), expectedFiles[0].content);
|
|
assert.deepEqual(await readFile(join(destination, files[1].path)), files[1].content);
|
|
await api.verifyAntigravityDistribution(destination, distribution);
|
|
});
|
|
|
|
test("vendor closure mismatch stops before the owned patch executes", async t => {
|
|
const expectedVendorFiles = files.map(file => ({ ...file, content: Buffer.from(`wrong vendor ${file.path}`) }));
|
|
const { api, options, root, destination } = await fixture(t, { expectedVendorFiles });
|
|
await assert.rejects(api.materializePinnedAntigravityDistribution(options), /vendor execution closure digest mismatch/);
|
|
await assert.rejects(readFile(join(root, "patch.called")), { code: "ENOENT" });
|
|
await assert.rejects(lstat(destination), { code: "ENOENT" });
|
|
assert.deepEqual(await readdir(join(destination, "..")), []);
|
|
});
|
|
|
|
test("changed admission patch source is rejected before extraction or native execution", async t => {
|
|
const { api, options, root, destination } = await fixture(t);
|
|
await writeFile(join(root, "scripts/patch-antigravity-runtime.admission.py"), "# changed admission source\n");
|
|
await assert.rejects(api.materializePinnedAntigravityDistribution(options), /patch source digest mismatch/);
|
|
await assert.rejects(lstat(destination), { code: "ENOENT" });
|
|
await assert.rejects(readFile(join(root, "patch.called")), { code: "ENOENT" });
|
|
});
|
|
|
|
test("repeated setup verifies installed bytes and refuses corruption without download or replacement", async t => {
|
|
const { provision, destination, options } = await fixture(t);
|
|
await provision();
|
|
await rm(options.archivePath);
|
|
await provision();
|
|
const binary = join(destination, files[1].path);
|
|
await chmod(binary, 0o700);
|
|
await writeFile(binary, "corrupted native child");
|
|
await assert.rejects(provision(), /full execution closure digest mismatch/);
|
|
assert.equal(await readFile(binary, "utf8"), "corrupted native child");
|
|
});
|
|
|
|
test("rewriting the cached record or passing altered pins cannot authorize changed bytes", async t => {
|
|
const { provision, api, destination, distribution } = await fixture(t);
|
|
await provision();
|
|
const recordPath = join(destination, api.ANTIGRAVITY_TREE_MANIFEST);
|
|
await chmod(recordPath, 0o600);
|
|
await writeFile(recordPath, JSON.stringify({ ...distribution, closureSha256: "a".repeat(64) }));
|
|
await assert.rejects(provision(), /cached closure record/);
|
|
await assert.rejects(api.verifyAntigravityDistribution(destination, { ...distribution, closureSha256: "a".repeat(64) }), /checked-in distribution pins/);
|
|
});
|
|
|
|
test("cached links, hardlinks, extra files and missing children fail closed", async t => {
|
|
for (const kind of ["symlink", "hardlink", "extra", "missing", "record-link"]) {
|
|
const current = await fixture(t);
|
|
await current.provision();
|
|
const child = join(current.destination, files[1].path);
|
|
if (kind === "extra") await writeFile(join(current.destination, "unexpected-native-child"), "unexpected");
|
|
else if (kind === "record-link") {
|
|
const record = join(current.destination, current.api.ANTIGRAVITY_TREE_MANIFEST);
|
|
const target = join(current.root, "record-copy.json");
|
|
await copyFile(record, target);
|
|
await rm(record);
|
|
await symlink(target, record);
|
|
} else {
|
|
const target = join(current.root, "native-child-copy");
|
|
await copyFile(child, target);
|
|
await rm(child);
|
|
if (kind === "symlink") await symlink(target, child);
|
|
if (kind === "hardlink") await link(target, child);
|
|
}
|
|
await assert.rejects(current.provision(), /both native binaries|links or special files/);
|
|
}
|
|
});
|
|
|
|
test("cached assets with shared write access and linked runtime roots fail closed", async t => {
|
|
const { provision, api, destination, distribution, root } = await fixture(t);
|
|
await provision();
|
|
const binary = join(destination, files[1].path);
|
|
await chmod(binary, 0o777);
|
|
await assert.rejects(provision(), /unsafe file permissions/);
|
|
await chmod(binary, 0o500);
|
|
const linked = join(root, "linked-runtime");
|
|
await symlink(destination, linked);
|
|
await assert.rejects(api.verifyAntigravityDistribution(linked, distribution), /real directory/);
|
|
await chmod(destination, 0o777);
|
|
await assert.rejects(provision(), /private write access/);
|
|
await chmod(destination, 0o700);
|
|
});
|
|
|
|
test("a private cached closure is rejected when another user could replace its parent entry", async t => {
|
|
const { provision, destination } = await fixture(t);
|
|
await provision();
|
|
const parent = join(destination, "..");
|
|
await chmod(parent, 0o777);
|
|
try { await assert.rejects(provision(), /ancestor.*write|unsafe.*ancestor/); }
|
|
finally { await chmod(parent, 0o700); }
|
|
});
|
|
|
|
test("publication refuses a replaceable cache parent before patching or creating a temporary installation", async t => {
|
|
const { api, options, destination, root } = await fixture(t);
|
|
const parent = join(destination, "..");
|
|
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
await chmod(parent, 0o777);
|
|
try {
|
|
await assert.rejects(api.materializePinnedAntigravityDistribution(options), /ancestor.*write|unsafe.*ancestor/);
|
|
await assert.rejects(readFile(join(root, "patch.called")), { code: "ENOENT" });
|
|
assert.deepEqual(await readdir(parent), []);
|
|
} finally { await chmod(parent, 0o700); }
|
|
});
|
|
|
|
test("package pin sources beneath a replaceable ancestor cannot authorize installation", async t => {
|
|
const { api, options, root } = await fixture(t);
|
|
await chmod(root, 0o777);
|
|
try { await assert.rejects(api.materializePinnedAntigravityDistribution(options), /ancestor.*write|unsafe.*ancestor/); }
|
|
finally { await chmod(root, 0o700); }
|
|
});
|
|
|
|
test("canonical parent aliases keep a private owned installation valid", async t => {
|
|
const { api, options, distribution, root } = await fixture(t);
|
|
const home = join(root, "private-home");
|
|
await mkdir(home, { mode: 0o700 });
|
|
const alias = join(root, "home-alias");
|
|
await symlink(home, alias);
|
|
const destination = join(alias, "cache", distribution.closureSha256);
|
|
await api.materializePinnedAntigravityDistribution({ ...options, destination });
|
|
assert.equal((await api.verifyAntigravityDistribution(destination, distribution)).sha256, distribution.closureSha256);
|
|
});
|
|
|
|
test("a root-owned sticky temporary ancestor protects the private installation", async t => {
|
|
const temporary = await realpath("/tmp");
|
|
const stat = await lstat(temporary);
|
|
if (stat.uid !== 0 || !(stat.mode & 0o1000)) return t.skip("requires the host's root-owned sticky temporary directory");
|
|
const { provision, api, destination, distribution } = await fixture(t, { baseDirectory: "/tmp" });
|
|
await provision();
|
|
assert.equal((await api.verifyAntigravityDistribution(destination, distribution)).sha256, distribution.closureSha256);
|
|
});
|
|
|
|
test("source and cached ownership are checked for the current execution principal", async t => {
|
|
const { api, provision, destination } = await fixture(t);
|
|
await provision();
|
|
const getuid = process.getuid;
|
|
if (!getuid || getuid() === 0) return t.skip("requires a non-root principal to model foreign ownership without changing filesystem privileges");
|
|
// Keep the real kernel metadata; model a different verifier principal rather
|
|
// than chowning files or running another user's process during the test.
|
|
process.getuid = () => getuid() + 1;
|
|
try {
|
|
await assert.rejects(api.antigravityDistributionClosure(destination), /untrusted filesystem owner/);
|
|
await assert.rejects(api.antigravityDistribution("darwin", "arm64"), /untrusted filesystem owner/);
|
|
} finally { process.getuid = getuid; }
|
|
});
|
|
|
|
test("a parent made replaceable while patching cannot receive a published runtime", async t => {
|
|
const { api, options, destination } = await fixture(t, { changeParentDuringPatch: true });
|
|
try {
|
|
await assert.rejects(api.materializePinnedAntigravityDistribution(options), /ancestor.*write|unsafe.*ancestor/);
|
|
await assert.rejects(lstat(destination), { code: "ENOENT" });
|
|
assert.deepEqual(await readdir(join(destination, "..")), []);
|
|
} finally { await chmod(join(destination, ".."), 0o700); }
|
|
});
|