Files
PaperClipAI/packages/paperclip-runner/scripts/materialize-antigravity-distribution.mjs
T
DottaandPaperclip 09c05c5505 feat(runner): provision pinned Google Antigravity native runtime (#18187)
## 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>
2026-10-29 13:25:54 -05:00

278 lines
18 KiB
JavaScript

import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { chmod, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { isDeepStrictEqual } from "node:util";
import { inflateRawSync } from "node:zlib";
import { ANTIGRAVITY_PATCH_VERSION, patchAntigravityRuntime } from "./patch-antigravity-runtime.mjs";
const MANIFEST_PATH = fileURLToPath(new URL("../antigravity-distributions.json", import.meta.url));
const ADMISSION_SOURCE_PATH = fileURLToPath(new URL("./patch-antigravity-runtime.admission.py", import.meta.url));
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024;
const MAX_TREE_BYTES = 1024 * 1024 * 1024;
export const ANTIGRAVITY_TREE_MANIFEST = ".paperclip-antigravity-closure.json";
const RUNTIME_FILES = ["agy_acp_server.par", "localharness_external"];
const SHA256 = /^[a-f0-9]{64}$/;
const digest = bytes => createHash("sha256").update(bytes).digest("hex");
function assertTrustedOwner(stat) {
const uid = process.getuid?.();
if (!Number.isInteger(uid) || !Number.isInteger(stat.uid) || (stat.uid !== uid && stat.uid !== 0)) {
throw new Error("Antigravity assets have an untrusted filesystem owner");
}
}
/** Resolve parent aliases once, then use only the checked canonical path.
* A root-owned sticky temporary directory protects each owned child entry. */
async function ownedDirectory(directory, { privateRoot = false, parentAlias = false } = {}) {
const original = await lstat(directory);
if ((!original.isDirectory() || original.isSymbolicLink()) && !(parentAlias && original.isSymbolicLink())) {
throw new Error("Antigravity closure must be a real directory with private write access");
}
const canonical = await realpath(directory);
let current = canonical;
let root;
while (true) {
const stat = await lstat(current);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Antigravity assets have an unsafe ancestor directory");
assertTrustedOwner(stat);
if (!root) root = stat;
const trustedSticky = stat.uid === 0 && (stat.mode & 0o1000) !== 0;
if ((stat.mode & 0o022) && !(trustedSticky && !(privateRoot && current === canonical))) {
throw new Error("Antigravity assets have an unsafe ancestor with shared write access; closures require private write access");
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
if (!original.isSymbolicLink() && (root.dev !== original.dev || root.ino !== original.ino)) {
throw new Error("Antigravity asset directory changed during ownership verification");
}
return { directory: canonical, stat: root };
}
function assertOwnedFile(stat, maximum) {
if (!stat.isFile() || stat.nlink !== 1) throw new Error("Antigravity closure contains links or special files");
assertTrustedOwner(stat);
if (stat.mode & 0o7022) throw new Error("Antigravity closure contains unsafe file permissions");
if (stat.size > maximum) throw new Error("Antigravity asset exceeds its file size limit");
}
async function openOwnedFile(path, maximum) {
const parent = await ownedDirectory(dirname(path), { parentAlias: true });
const canonical = join(parent.directory, basename(path));
const before = await lstat(canonical);
assertOwnedFile(before, maximum);
const file = await open(canonical, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = await file.stat();
assertOwnedFile(stat, maximum);
if (stat.dev !== before.dev || stat.ino !== before.ino) throw new Error("Antigravity asset changed while opening");
return { file, path: canonical, before: stat };
} catch (error) { await file.close(); throw error; }
}
async function readOwnedFile(path, maximum) {
const { file, before } = await openOwnedFile(path, maximum);
try {
const bytes = await file.readFile();
const after = await file.stat();
assertOwnedFile(after, maximum);
if (bytes.length > maximum || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
throw new Error("Antigravity asset changed while reading");
}
return bytes;
} finally { await file.close(); }
}
/** Reject an unsafe existing ancestor before creating even a staging directory. */
async function ownedDestination(destination) {
const missing = [];
let ancestor = dirname(destination);
while (true) {
try { await lstat(ancestor); break; }
catch (error) {
if (error.code !== "ENOENT") throw error;
missing.push(basename(ancestor));
ancestor = dirname(ancestor);
}
}
let { directory } = await ownedDirectory(ancestor, { parentAlias: true });
for (const name of missing.reverse()) {
directory = join(directory, name);
try { await mkdir(directory, { mode: 0o700 }); }
catch (error) { if (error.code !== "EEXIST") throw error; }
directory = (await ownedDirectory(directory)).directory;
}
return join(directory, basename(destination));
}
/** Only the checked-in declaration selects downloads and runtime contents. */
export async function antigravityDistribution(platform = process.platform, architecture = process.arch) {
const manifest = JSON.parse((await readOwnedFile(MANIFEST_PATH, 64 * 1024)).toString("utf8"));
const item = manifest.platforms?.[`${platform}-${architecture}`];
if (manifest.schema !== "paperclip.antigravity_distribution.v1" || !/^\d+\.\d+\.\d+$/.test(manifest.version)) throw new Error("Invalid Antigravity distribution declaration");
if (!item) throw new Error(`Antigravity has no pinned distribution for ${platform}/${architecture}`);
if (item.provisioning !== "verified-closure") throw new Error(`Antigravity ${platform}/${architecture} is inventory-only; its complete runtime closure is not verified`);
const url = new URL(item.url);
if (url.protocol !== "https:" || url.hostname !== "dl.google.com" || url.username || url.password || url.search || url.hash || !url.pathname.startsWith("/agy-extensions/releases/")) throw new Error("Antigravity requires an official pinned distribution URL");
if (!SHA256.test(item.archiveSha256) || !SHA256.test(item.closureSha256) || item.executable !== RUNTIME_FILES[0] || !Array.isArray(item.args) || item.args.some(arg => typeof arg !== "string")) throw new Error("Invalid Antigravity distribution pins");
for (const [entries, sha256] of [[item.entries, item.closureSha256], [item.vendorEntries, item.vendorClosureSha256]]) {
if (!Array.isArray(entries) || entries.length !== RUNTIME_FILES.length || entries.some((entry, index) => entry.path !== RUNTIME_FILES[index] || !SHA256.test(entry.sha256) || !Number.isSafeInteger(entry.size) || entry.size < 1 || entry.executable !== true) || digest(JSON.stringify(entries)) !== sha256) throw new Error("Invalid Antigravity complete execution closure pins");
}
if (item.patchVersion !== ANTIGRAVITY_PATCH_VERSION || !SHA256.test(item.admissionSourceSha256)) throw new Error("Antigravity distribution must pin its owned native admission patch");
if (digest(await readOwnedFile(ADMISSION_SOURCE_PATH, 64 * 1024)) !== item.admissionSourceSha256) throw new Error("Antigravity native admission patch source digest mismatch");
return { ...item, version: manifest.version, platform, architecture };
}
/** Hash both native binaries; a cached local manifest never establishes trust. */
export async function antigravityDistributionClosure(directory) {
const root = await ownedDirectory(directory, { privateRoot: true });
directory = root.directory;
const names = (await readdir(directory)).sort();
const files = names.filter(name => name !== ANTIGRAVITY_TREE_MANIFEST);
if (JSON.stringify(files) !== JSON.stringify(RUNTIME_FILES)) throw new Error("Antigravity closure must contain exactly both native binaries");
let bytes = 0;
const entries = [];
for (const name of names) {
const path = join(directory, name);
const maximum = name === ANTIGRAVITY_TREE_MANIFEST ? 64 * 1024 : MAX_TREE_BYTES;
const { file, before } = await openOwnedFile(path, maximum);
try {
if (name === ANTIGRAVITY_TREE_MANIFEST) continue;
bytes += before.size;
if (bytes > MAX_TREE_BYTES) throw new Error("Antigravity closure exceeds size limit");
const hash = createHash("sha256");
let length = 0;
for await (const chunk of file.createReadStream({ autoClose: false })) { hash.update(chunk); length += chunk.length; }
const after = await file.stat();
const current = await lstat(path);
assertOwnedFile(after, maximum);
if (before.ino !== current.ino || before.dev !== current.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || length !== after.size) throw new Error("Antigravity closure changed while being hashed");
entries.push({ path: name, sha256: hash.digest("hex"), size: length, executable: Boolean(after.mode & 0o111) });
} finally { await file.close(); }
}
const current = await ownedDirectory(directory, { privateRoot: true });
if (current.stat.dev !== root.stat.dev || current.stat.ino !== root.stat.ino) throw new Error("Antigravity closure directory changed while being hashed");
return { entries, sha256: digest(JSON.stringify(entries)) };
}
export async function verifyAntigravityDistribution(directory, distribution) {
directory = (await ownedDirectory(directory, { privateRoot: true })).directory;
const pinned = await antigravityDistribution(distribution?.platform, distribution?.architecture);
if (distribution && !isDeepStrictEqual(distribution, pinned)) throw new Error("Antigravity runtime verification requires the checked-in distribution pins");
const closure = await antigravityDistributionClosure(directory);
if (closure.sha256 !== pinned.closureSha256) throw new Error("Antigravity full execution closure digest mismatch");
const record = JSON.parse((await readOwnedFile(join(directory, ANTIGRAVITY_TREE_MANIFEST), 64 * 1024)).toString("utf8"));
if (!isDeepStrictEqual(record, closureRecord(pinned, closure))) throw new Error("Antigravity cached closure record does not match the checked-in pins");
return closure;
}
function closureRecord(distribution, closure) {
return { schema: "paperclip.antigravity_closure.v1", version: distribution.version, patchVersion: distribution.patchVersion, admissionSourceSha256: distribution.admissionSourceSha256,
archiveSha256: distribution.archiveSha256, vendorClosureSha256: distribution.vendorClosureSha256, closureSha256: distribution.closureSha256, entries: closure.entries };
}
/** Strictly inspect the known two-file ZIP before writing any vendor content. */
export function antigravityArchiveEntries(archive) {
const invalid = () => { throw new Error("Antigravity archive contains unsafe or unsupported ZIP entries"); };
if (!Buffer.isBuffer(archive) || archive.length < 22 || archive.length > MAX_ARCHIVE_BYTES) invalid();
// The pinned vendor archive has no comment, ZIP64, spanning, or data descriptors.
const end = archive.length - 22;
if (archive.readUInt32LE(end) !== 0x06054b50 || archive.readUInt16LE(end + 4) || archive.readUInt16LE(end + 6) || archive.readUInt16LE(end + 20)) invalid();
const count = archive.readUInt16LE(end + 10);
const centralSize = archive.readUInt32LE(end + 12);
const centralOffset = archive.readUInt32LE(end + 16);
if (count !== 2 || archive.readUInt16LE(end + 8) !== count || centralOffset + centralSize !== end) invalid();
let cursor = centralOffset;
const entries = [];
const spans = [];
let bytes = 0;
for (let index = 0; index < count; index++) {
if (cursor + 46 > end || archive.readUInt32LE(cursor) !== 0x02014b50) invalid();
const flags = archive.readUInt16LE(cursor + 8);
const method = archive.readUInt16LE(cursor + 10);
const compressedSize = archive.readUInt32LE(cursor + 20);
const size = archive.readUInt32LE(cursor + 24);
const nameLength = archive.readUInt16LE(cursor + 28);
const extraLength = archive.readUInt16LE(cursor + 30);
const commentLength = archive.readUInt16LE(cursor + 32);
const mode = archive.readUInt32LE(cursor + 38) >>> 16;
const local = archive.readUInt32LE(cursor + 42);
const next = cursor + 46 + nameLength + extraLength + commentLength;
if (next > end || (flags & ~0x800) || ![0, 8].includes(method) || archive.readUInt16LE(cursor + 34) || (mode & 0o170000) !== 0o100000 || !(mode & 0o111)) invalid();
const nameBytes = archive.subarray(cursor + 46, cursor + 46 + nameLength);
const path = nameBytes.toString("utf8");
if (!RUNTIME_FILES.includes(path) || !Buffer.from(path).equals(nameBytes) || entries.some(entry => entry.path === path)) invalid();
if (local + 30 > centralOffset || archive.readUInt32LE(local) !== 0x04034b50 || archive.readUInt16LE(local + 6) !== flags || archive.readUInt16LE(local + 8) !== method || archive.readUInt32LE(local + 14) !== archive.readUInt32LE(cursor + 16) || archive.readUInt32LE(local + 18) !== compressedSize || archive.readUInt32LE(local + 22) !== size) invalid();
const localNameLength = archive.readUInt16LE(local + 26);
const start = local + 30 + localNameLength + archive.readUInt16LE(local + 28);
const finish = start + compressedSize;
if (finish > centralOffset || !archive.subarray(local + 30, local + 30 + localNameLength).equals(nameBytes) || spans.some(([a, b]) => local < b && finish > a)) invalid();
bytes += size;
if (!size || bytes > MAX_TREE_BYTES || (method === 0 && size !== compressedSize)) invalid();
entries.push({ path, method, size, start, finish });
spans.push([local, finish]);
cursor = next;
}
if (cursor !== end || RUNTIME_FILES.some(path => !entries.some(entry => entry.path === path))) invalid();
return entries;
}
/** Explicit setup only: no vendor installer, ambient binary, or npm lifecycle. */
export async function materializePinnedAntigravityDistribution(options = {}) {
const distribution = await antigravityDistribution(options.platform, options.architecture);
if (!options.destination) throw new Error("Antigravity materialization requires an explicit destination");
const destination = await ownedDestination(resolve(options.destination));
const parent = await ownedDirectory(dirname(destination));
try { await lstat(destination); throw new Error("Antigravity destination already exists; refusing to overwrite it"); }
catch (error) { if (error.code !== "ENOENT") throw error; }
const temporary = await mkdtemp(join(dirname(destination), ".antigravity-install-"));
try {
let archive;
if (options.archivePath) {
archive = await readOwnedFile(options.archivePath, MAX_ARCHIVE_BYTES);
} else {
const response = await fetch(distribution.url, { redirect: "error", signal: AbortSignal.timeout(180_000) });
if (!response.ok || !response.body) throw new Error(`Antigravity archive download failed: ${response.status}`);
const chunks = [];
let length = 0;
for await (const chunk of response.body) {
length += chunk.length;
if (length > MAX_ARCHIVE_BYTES) throw new Error("Antigravity archive download exceeds size limit");
chunks.push(chunk);
}
archive = Buffer.concat(chunks);
}
if (digest(archive) !== distribution.archiveSha256) throw new Error("Antigravity archive digest mismatch");
const entries = antigravityArchiveEntries(archive);
const unpacked = join(temporary, "vendor");
await mkdir(unpacked, { mode: 0o700 });
for (const entry of entries) {
const compressed = archive.subarray(entry.start, entry.finish);
const content = entry.method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: entry.size });
if (content.length !== entry.size) throw new Error("Antigravity archive entry size mismatch");
await writeFile(join(unpacked, entry.path), content, { mode: 0o500, flag: "wx" });
}
const vendorClosure = await antigravityDistributionClosure(unpacked);
if (vendorClosure.sha256 !== distribution.vendorClosureSha256) throw new Error("Antigravity vendor execution closure digest mismatch");
const patched = join(temporary, "runtime");
const patch = await patchAntigravityRuntime({ source: unpacked, destination: patched });
if (patch.version !== distribution.patchVersion || patch.admissionSourceSha256 !== distribution.admissionSourceSha256) throw new Error("Antigravity native admission patch identity mismatch");
const closure = await antigravityDistributionClosure(patched);
if (closure.sha256 !== distribution.closureSha256) throw new Error("Antigravity full execution closure digest mismatch");
await writeFile(join(patched, ANTIGRAVITY_TREE_MANIFEST), `${JSON.stringify(closureRecord(distribution, closure))}\n`, { mode: 0o400, flag: "wx" });
for (const entry of closure.entries) await chmod(join(patched, entry.path), 0o500);
const currentParent = await ownedDirectory(dirname(destination));
if (currentParent.stat.dev !== parent.stat.dev || currentParent.stat.ino !== parent.stat.ino) throw new Error("Antigravity cache parent changed before publication");
await rename(patched, destination);
return { ...distribution, destination, executable: join(destination, distribution.executable) };
} finally { await rm(temporary, { recursive: true, force: true }); }
}
if (process.argv[1]?.endsWith("/materialize-antigravity-distribution.mjs") && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
const [destination, platform, architecture, archivePath] = process.argv.slice(2);
materializePinnedAntigravityDistribution({ destination, platform, architecture, archivePath }).then(result => process.stdout.write(`${JSON.stringify(result)}\n`)).catch(error => { console.error(error.message); process.exitCode = 1; });
}