mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +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>
278 lines
18 KiB
JavaScript
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; });
|
|
}
|