diff --git a/cli/src/__tests__/runtime-setup.test.ts b/cli/src/__tests__/runtime-setup.test.ts index 0c588ff6e51..971f5a3f86b 100644 --- a/cli/src/__tests__/runtime-setup.test.ts +++ b/cli/src/__tests__/runtime-setup.test.ts @@ -3,17 +3,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, expect, it } from "vitest"; -import { resolveCursorProvisioner } from "../commands/runtime.js"; +import { resolveAntigravityProvisioner, resolveCursorProvisioner } from "../commands/runtime.js"; const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); }); -async function fixture() { +async function fixture(provider = "cursor") { const root = await realpath(await mkdtemp(join(tmpdir(), "cursor-public-setup-"))); roots.push(root); await mkdir(join(root, "dist/vendor/paperclip-runner/cli"), { recursive: true }); await writeFile(join(root, "package.json"), JSON.stringify({ name: "@paperclipai/server" })); await writeFile(join(root, "dist/index.js"), "throw new Error('must not import server');"); - const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-cursor.cjs"); + const provisioner = join(root, `dist/vendor/paperclip-runner/cli/provision-${provider}.cjs`); await writeFile(provisioner, "fixture"); return { root, provisioner, url: pathToFileURL(join(root, "dist/index.js")).href }; } @@ -36,3 +36,15 @@ it("rejects a provisioner linked outside the installed package", async () => { await rm(f.provisioner); await symlink(other.provisioner, f.provisioner); await expect(resolveCursorProvisioner(f.url)).rejects.toThrow("escapes"); }); + +it("resolves Antigravity setup from the public server without importing it", async () => { + const f = await fixture("antigravity"); + expect(await resolveAntigravityProvisioner(f.url)).toBe(f.provisioner); +}); + +it("rejects an Antigravity provisioner outside the installed server", async () => { + const f = await fixture("antigravity"); const other = await fixture("antigravity"); + await rm(f.provisioner); await symlink(other.provisioner, f.provisioner); + await expect(resolveAntigravityProvisioner(f.url)).rejects.toThrow("escapes"); + await expect(resolveAntigravityProvisioner(`${f.url}?redirect=1`)).rejects.toThrow("installed Paperclip server"); +}); diff --git a/cli/src/__tests__/runtime.test.ts b/cli/src/__tests__/runtime.test.ts index eeed2d26202..0b0d5c35330 100644 --- a/cli/src/__tests__/runtime.test.ts +++ b/cli/src/__tests__/runtime.test.ts @@ -28,7 +28,7 @@ it("rejects foreign package identity and a setup entrypoint outside that package }); it("rejects unsupported runtime setup providers before resolution", async () => { const program = new Command(); registerRuntimeCommands(program); - await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported runtime setup: paperclipai runtime setup pi or cursor"); + await expect(program.parseAsync(["node", "paperclipai", "runtime", "setup", "untrusted"])).rejects.toThrow("Supported runtime setup: paperclipai runtime setup pi, cursor, or antigravity"); }); it("resolves the companion importer only inside the actual public server tar layout", async () => { diff --git a/cli/src/commands/runtime.ts b/cli/src/commands/runtime.ts index ee9b1afdc46..7bc6629ddd2 100644 --- a/cli/src/commands/runtime.ts +++ b/cli/src/commands/runtime.ts @@ -93,6 +93,43 @@ export async function setupCursorRuntime(): Promise { } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); } } +/** Resolve Antigravity setup without importing the server or starting a provider. */ +export async function resolveAntigravityProvisioner(serverUrl: string): Promise { + const url = new URL(serverUrl); + if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Antigravity setup requires an installed Paperclip server"); + const entry = await realpath(fileURLToPath(url)); + if (!entry.endsWith("/dist/index.js")) throw new Error("Antigravity setup requires the published server layout"); + const root = resolve(dirname(entry), ".."); + const manifest = join(root, "package.json"); + const info = await lstat(manifest); + if (!info.isFile() || info.isSymbolicLink() || info.size > 65536 || JSON.parse(await readFile(manifest, "utf8")).name !== "@paperclipai/server") throw new Error("Antigravity setup server package identity is invalid"); + const provisioner = join(root, "dist/vendor/paperclip-runner/cli/provision-antigravity.cjs"); + if (await realpath(provisioner) !== provisioner || !(await lstat(provisioner)).isFile()) throw new Error("Antigravity setup entrypoint escapes its server package"); + return provisioner; +} + +export async function setupAntigravityRuntime(): Promise { + const provisioner = await resolveAntigravityProvisioner(import.meta.resolve("@paperclipai/server")); + const child = spawn(process.execPath, ["--use-env-proxy", provisioner], { + // Setup only downloads public assets. Reuse the existing network allowlist; + // runtime credentials and process-loader settings never reach this child. + stdio: "inherit", env: buildPiSetupEnvironment(), + }); + const cancel = () => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); }; + process.on("SIGINT", cancel); process.on("SIGTERM", cancel); + try { + await new Promise((accept, reject) => { + let spawnError: Error | undefined; + child.on("error", error => { spawnError = error; }); + child.once("close", (code, signal) => { + if (spawnError) reject(spawnError); + else if (code !== 0 || signal) reject(new Error("Antigravity setup did not finish. Review its error; an invalid installation is never replaced automatically.")); + else accept(); + }); + }); + } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); } +} + export function registerRuntimeCommands(program: Command): void { const runtime = program.command("runtime").description("Manage explicitly installed agent runtimes"); @@ -112,10 +149,11 @@ export function registerRuntimeCommands(program: Command): void { } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); } }); runtime.command("setup ") - .description("Install and verify the pinned Pi or Cursor runtime for this host (public downloads; no model calls)") + .description("Install and verify a pinned Pi, Cursor, or Antigravity runtime (public downloads; no model calls)") .action(async (provider: string) => { if (provider === "pi") await setupPiRuntime(); else if (provider === "cursor") await setupCursorRuntime(); - else throw new Error("Supported runtime setup: paperclipai runtime setup pi or cursor"); + else if (provider === "antigravity") await setupAntigravityRuntime(); + else throw new Error("Supported runtime setup: paperclipai runtime setup pi, cursor, or antigravity"); }); } diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index a301752f7a6..adb060704d2 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2130,6 +2130,18 @@ CLI paths that use stdin avoid that separate limit for the wake prompt. ## Adding a native Runner harness +Google Antigravity's pending integration uses the official ACP server 1.4.3 with +a pinned native admission patch. `paperclipai runtime setup antigravity` installs +its verified assets on macOS ARM64 as the OS user running Paperclip. Setup does +not enable global execution: qualification remains pending and admission is +closed by default. An isolated company can use a finite trusted server-startup +candidate authorization bound to the exact model, local platform, profile and +native closure. The candidate's recorded setup and two-turn task reused the same +owner's managed personal-default Google subscription on macOS ARM64 with +`gemini-3.8-flash-low`; fresh consent and API-key live acceptance remain separate +gates. Workspace OAuth is not runtime authentication. See the +[contract, evidence, and remaining gates](plans/2026-10-28-antigravity-integration.md). + To add a harness/provider to the new native Paperclip Runner, use [create-runner-adapter](../.agents/skills/create-runner-adapter/SKILL.md), the [Runner integration playbook](RUNNER-INTEGRATION-PLAYBOOK.md) and its diff --git a/doc/plans/2026-10-28-antigravity-integration.md b/doc/plans/2026-10-28-antigravity-integration.md new file mode 100644 index 00000000000..98d9dd440d2 --- /dev/null +++ b/doc/plans/2026-10-28-antigravity-integration.md @@ -0,0 +1,474 @@ +# Google Antigravity native Runner integration — 2026-10-28 + +Owner: Codex integration owner, with separate provider-research, distribution, +and product-configuration workers. +Source baseline: `dce004259394486f6361d0c0dd5a33a5272acc0a`. +Requested finish line: a reviewable native Runner integration. Publication, +merge, deployment, and migration of existing agents are outside this request. + +## User outcome and release contract + +An operator selects Google Antigravity in native Paperclip Runner, binds an +authorized Google runtime account, selects an exact model, and assigns an ordinary +task. The task produces independently verified files/artifacts, settles through +Paperclip, and can be stopped with physical process cleanup. + +- Transport: Google's official Antigravity ACP server through the existing ACPX + harness driver. The standalone `agy` print protocol is a separate interface. +- First environment: local macOS ARM64. Other registry platforms are an inventory, + not a live qualification claim. No new hosting environment is promised. +- Runtime pin: ACP server **1.4.3**, including its matching + `localharness_external` executable. The CLI's separately versioned release is + not this runtime's version. Use explicit setup and verified immutable assets. +- Authentication: the user authorized an existing Google account connection if + available. Managed Gemini API-key bindings are eligible only through the + native `gemini-api-key` method. Google Drive/MCP OAuth is not runtime auth. + The user subsequently requested a fresh test environment and a named Google + account. Explicit local personal OAuth is now part of the candidate; it needs + its own connection flow and must not borrow a workstation login. +- Model policy: caller-selected exact model, passed unchanged and acknowledged + before a prompt, including on load. Acceptance model remains unset until an + authorized account can advertise/accept one. No product model allowlist. +- Required: exact instruction admission on create/load; scoped semantic and MCP + tools; streamed text/tool fidelity; accepted task result/artifact; permission + denial; distinct native questions and permissions; Stop and bounded shutdown; + provider-loss expiry; honest usage; ordinary save/reopen/Test and clean install. +- Active steering, native planning decisions, child interactions, inbound media, + remote platforms, and enterprise login remain discovery items. Do not expose + unsupported behavior as supported. The required work above remains required. +- Spending: no paid prompts or paid infrastructure in the discovery phase. Do + credential-free/deterministic work first. A paid acceptance campaign needs an + explicit model/account and bounded spending record before it starts. +- Admission: keep the profile pending consistently until its required native + acknowledgments and product acceptance pass. A test override is not delivery. + +## Architecture and ownership + +Provider translation, native metadata, question semantics, auth-home settings, +and executable verification belong under the Antigravity provider boundary. +The existing Runner owns supervision/PRP/replay; Paperclip retains company, +credential, permission, budget, result, and task-state authority. + +- Integration owner: Runner profile/contracts, sidecar/Rust admission, CLI wiring, + integration tests, and final evidence reconciliation. +- Distribution worker: pinned registry manifest, complete two-binary closure, + explicit setup/cache provisioning, integrity and cleanup tests. +- Product worker: shared harness registration, explicit managed Google routing, + server configuration, and gated product presentation. +- Research worker: official documentation and shipped-runtime behavior only. +- Serialize shared profile generation, manifests, schemas and lockfile changes. + Follow the repository lockfile policy; do not commit generated lock changes. + +## Research and capability inventory + +Evidence labels describe the actual boundary, not inferred product support. + +| Capability | Native evidence | Runner/product qualification | Remaining work | +| --- | --- | --- | --- | +| Distribution | Registry-observed 1.4.3, macOS ARM64 archive checksum verified | Unqualified | Complete closure verification and clean setup | +| Initialization | Credential-free observed: ACP v1, `antigravity-acp` 1.4.3 | Unqualified | Actual installed ACPX path | +| Auth | Observed methods: personal OAuth, business OAuth, Gemini API key, Agent Platform | Unqualified | Bound account, settings isolation and revoke | +| Missing auth | Observed `session/new` rejects with authentication-required error | Unqualified | Product diagnostic and routing | +| Instructions | Advertises `_meta.antigravity.systemPrompt.append` | Unverified | Exact create/load content acknowledgment and live behavior | +| Models | Account-dependent; official issue reports client-dependent catalogs | Unverified | Exact model selection/ack on create/load; no identity spoofing | +| Native questions | Upstream ACPX identifies `interaction_` permission callbacks as questions | Unverified | Preserve option identity and human round trip | +| MCP | Advertises HTTP/SSE support | Unverified | Scoped tool discovery/invocation and result acceptance | +| Media | Advertises image/audio/embedded context | Unverified | End-to-end conversion; no UI promise yet | +| Continuity | Advertises load/list/resume | Unverified | Process/session distinctions, fencing and failure recovery | +| Shutdown | Credential-free observed stdin EOF exits 0 after init/auth rejection | Unverified during work | Stop, native-child loss and bounded descendant cleanup | +| Usage/cost | Historical issue reports missing ACP usage | Unverified | Inspect current receipts; absent is unknown, not zero | + +Public references: + +- [Official ACP distribution registry](https://github.com/agentclientprotocol/registry/blob/main/antigravity-acp/agent.json) +- [Google's Zed setup/authentication documentation](https://antigravity.google/docs/ide/extensions/zed/) +- [Upstream ACPX integration](https://github.com/openclaw/acpx/blob/main/agents/Antigravity.md) +- [Client model catalog issue](https://github.com/google-antigravity/antigravity-cli/issues/1318) +- [Instruction metadata and workspace confinement issue](https://github.com/google-antigravity/antigravity-cli/issues/1273) +- [Historical terminal/cancellation issue](https://github.com/google-antigravity/antigravity-cli/issues/1041) + +## Acceptance ledger + +| Gate | Required observation | Current verdict | +| --- | --- | --- | +| Native admission | Installed dependency verifies exact model and instructions before first prompt and after load | Real create passed; loaded session and model behavior still pending | +| Scoped tools | Real semantic/MCP round trip; wrong-company/revoked/stale authority denied | Unattempted | +| Ordinary task | Browser/server/DB/Runner/provider; verified file/artifact, accepted result, durable task state, cleanup | Account/model selected; product execution remains gated pending acceptance | +| Human decisions | Native question/permission identity, correct response, duplicate/stale denial, loss expiry | Unattempted | +| Stop/lifecycle | Startup/work/pending-input Stop, spontaneous child loss, bounded shutdown and no late revival | Unattempted | +| Setup | Connect/select/save/reopen/Test/reconnect/revoke in selected environment | Real personal connect/save/reload/account catalog passed; agent Test/reconnect/revoke pending | +| Distribution | Clean installed package, explicit setup, verified closure and tamper/missing rejection | Final `.4` cold public setup bundle passed on macOS ARM64; other platforms unqualified | +| Compatibility/checks | Focused existing native and legacy regressions; required repo checks | Focused checks passed; full typecheck/build passed; full suite incomplete due host PostgreSQL resources; final impact checks running | + +Credential-free discovery is not a passed Product E2E cell. Original failures and +later attempts retain their own candidate identity. No shipping candidate is +frozen or published yet. Discovery sent no prompts; the later bounded native +slice below sent one prompt through the selected subscription. + +## Implementation and acceptance progress + +The candidate remains pending. One authenticated native prompt passed exact +instruction behavior and scoped file-tool checks; Product E2E remains pending. +The implementation now includes the registered native harness, a two-executable +verified closure and explicit setup, exact-byte/model native admission receipts, +question translation, scoped credential projection, and fail-closed release +checks at catalog, API, TypeScript driver, sidecar, and Rust launch boundaries. + +The operator subsequently requested a fresh test environment and named a Google +account. This adds explicit personal Google runtime sign-in to the first release +contract. The existing local connection-attempt lifecycle owns sign-in, vault +promotion, access, reconnect and expiry. Its setup-only native ACP client sends +only `initialize` and `authenticate`; readiness requires the native result, +physical process retirement, and a verified Google email. A token file appearing +before native onboarding finishes is insufficient. Managed task execution must +silently refresh or fail; it cannot open another browser sign-in. Remote OAuth +execution remains rejected pending separate qualification. + +### Evidence observed so far + +- The official archive and complete vendor closure were verified. The owned + Mach-O/Python patch is signed locally and pinned independently from the vendor + closure. Subsequent patch candidates retain separate hashes and observations. +- Installed ACPX tests with a controlled native peer exercise nonce/session/model/ + instruction receipts, setters, question identity, invalid acknowledgments and + stale connections. They do not prove real model behavior. +- Actual hermetic native Python tests execute Google's protobuf builder, server + configuration builder, native SDK strategy, and a real helper subprocess and + local WebSocket. They reject a cached external executable, exclude browser/ + subagent/workflow/ambient command-hook configuration, and fence unattended OAuth + against browser fallback. Native conversation acknowledgment remains controlled. +- A public-layout CJS setup bundle installed the first patch candidate from the + real vendor archive into an isolated cache, verified its signed closure and + removed it. The final candidate requires a fresh installed-layout result. +- Focused OAuth/credential checks passed 20 tests using real local subprocesses, + private files and lifetime-fence sockets with controlled provider responses. + Shared and UI connection regressions passed 37 tests. Local Google lifecycle + checks passed 8 tests, including early-token rejection and retirement-before- + removal. These tests do not establish Google entitlement or live inference. +- Full `pnpm -r typecheck` and `pnpm build` passed with the OAuth integration. + The subsequent metadata discovery changes passed Runner/server TypeScript; + final impact checks remain required after freeze. +- Fresh ordinary browser onboarding created one disposable company. Personal + Google sign-in completed for the user-selected account, its verified email + was saved on the encrypted runtime grant, all login processes retired, and + the connection remained connected after a browser reload. This proves setup + authentication, not model entitlement or task execution. +- Eight real PostgreSQL connection integration tests passed without DB/service + mocks, covering encrypted user credentials, selected homes, company/member/ + install authority, revoked defaults, and refresh/reconnect compare-and-swap. +- The final metadata candidate is `1.4.3-paperclip.4`, closure + `05ed2a3e39c95fc813abeb664a78a295d1dcbfd4098f28cbc17d30d710a1ea92`. + Its setup-only `_paperclip/models` path invokes the official account catalog + without creating a conversation, native helper, or prompt. Four actual-source + native regressions and 25 auth/catalog process regressions passed. Discovery + retains the credential home and executable lease if process retirement cannot + yet be proved; deletion follows physical retirement. +- The account catalog supplies suggestions. Explicit compatible model IDs remain + eligible even when absent from discovery, for both OAuth and API-key bindings. + The native exact-model receipt decides admission. + +### Preserved failures and limits + +| Observation | Resolution or remaining limit | +| --- | --- | +| Imported patch CLI top-level await broke CJS setup bundling | Removed the imported CLI entry branch; public-layout bundle subsequently installed the real archive successfully | +| New Rust pending-profile regression initially failed formatting | Formatted the assertion and regenerated affected unpublished identities; full typecheck subsequently passed | +| Native embedded PostgreSQL could not create host semaphore/shared-memory resources | Preserved failed launch. Started a disposable PostgreSQL 18 container with an owned volume and loopback-only port; fresh source onboarding, health, bootstrap and browser rendering passed | +| One credential test could not acquire its deterministic TCP quorum | Failed closed before staging. A later fresh-directory run passed all 20 focused cases. Host observation found 1,781 reservations in the relevant ephemeral range; the exact original colliding reservations were not captured, so causality is not claimed | +| Review found SDK cached-helper override, hidden browser/hook setup and interactive refresh fallback | Corrected the actual native boundaries and added actual-source regressions; original candidate observations are not transferred to replacement hashes | +| Missing source vendor-shim exports caused the first new server OAuth tests to fail | Added the explicit exports; focused rerun is required/recorded separately | +| Full test run repeatedly failed native PostgreSQL initialization on the shared host | Full suite did not complete. All 16 affected native-session DB cases subsequently passed using the supported external test-DB seam and a separate disposable PostgreSQL container; later full-suite groups remain unrun | +| One existing route test exceeded its 15-second deadline during concurrent checks | Other 94 tests passed; the unchanged route test subsequently passed alone with its original timeout | +| First authenticated native session returned exact receipts but installed ACPX rejected its grouped model selector | Native create and model setter both acknowledged `gemini-3.8-flash-low` and the exact 112 instruction bytes. ACPX preferred the separate family selector `gemini-3.8-flash`, producing an effective-model mismatch. Zero prompts were sent; native process, command lease, managed-home writeback and deletion all completed. Preserve this failed attempt while fixing the provider projection | + +The local environment is a fresh checkout instance with an isolated Docker +PostgreSQL database, not the seeded `test-drive` CLI (that command requires an +API key and rejects external PostgreSQL). The failed embedded attempt and fresh +Docker-backed attempt have separate evidence directories. No existing company, +workstation CLI login, Google tool grant, or shared database was borrowed. + +Ordinary product task output/result, scoped semantic/MCP tools, human decisions, +loaded sessions, Stop, and native-child loss remain unqualified. The selected +account/model, direct native file-tool slice and final installed setup passed; +typecheck/build passed and the complete test suite remains environment-blocked. + +### Selected account and first native slice + +The fresh instance's saved personal Google subscription returned its account +catalog through the ordinary authorized metadata API: `gemini-3.8-flash-high`, +`gemini-3.8-flash-medium`, and `gemini-3.8-flash-low`. The first slice selects +**`gemini-3.8-flash-low`** unchanged. This is an acceptance choice, not a product +allowlist. The user authorized the named account and fresh local test drive. + +First prove native create/model/instruction admission without a prompt. Then +permit at most two short first-slice prompts, each with a 120-second process +deadline, using the existing subscription and disposable local workspace. +Stop at the first admission, tool, output, or cleanup divergence and diagnose +before another attempt. No subscription change, paid infrastructure, or automatic +paid fallback is authorized by this bounded slice. Per-request provider billing +may remain unavailable; request count and duration do not establish dollar cost. +The broader Product E2E campaign needs its own recorded cells and bound. + +The first authenticated native attempt (2026-10-28 14:44 UTC) failed before a +prompt at the ACPX model-selector projection, after two correct native receipts. +The full wrapper error SHA-256 was +`8b6480a8eac6d058877054700dbd69654955e6061f961d23c8eddbbefc349818`; +the message was independently reproduced from the installed wrapper's source. +Native closure `.4` remains unchanged. The provider projection fix reproduced +that exact error against installed ACPX, then passed 28 admission/dependency +regressions. The unchanged native runtime subsequently passed authenticated +session admission at 14:49 UTC: the exact low model and 112 instruction bytes +were acknowledged, no model setter or prompt was needed, and native process/ +lease plus managed credential writeback and home cleanup completed. The passing +guard JS digest was +`31b9dd005c7452b61e61f3a29c77f74d7f16b20511d1e0f9eff10dfb649dca4b`. +Current slice tally: two native attempts, zero prompt requests, one preserved +failure, one admission pass, physical cleanup passed for both. + +At 15:00 UTC, one bounded native prompt passed on the same `.4` closure and +exact model. The unpredictable proof string appeared only in the admitted +650-byte system append. The real model completed native client write/read +tools; an independent filesystem read matched all 40 expected bytes including +one LF. Native tools, stream, terminal result, physical process retirement, +command lease release and managed credential cleanup passed. The provider +reported context usage but no input/output token or monetary receipt, so cost +remains unknown. This is native dependency evidence, not Product E2E. + +The [sanitized native evidence](2026-10-28-antigravity-native-evidence.json) +retains all three attempts, source/runtime identities, the original failure, +one actual prompt and its independent byte oracle. Total native attempts: three; +prompt requests: one. Production admission remains pending because Product E2E +and the remaining release-qualification cells are not complete. + +### Proposed Product E2E candidate campaign — not implemented + +The existing operator qualification authorization lists only Cursor, Copilot +and Pi; it does not admit Antigravity through the canonical driver, installation, +sidecar and Rust pending gates. Do not mark the profile qualified or inject a +private backend to obtain a pass. A candidate path must bind the exact model and +runtime identity across those boundaries, while default admission stays closed. + +The proposed campaign is local-only, uses this disposable company's saved +personal Google grant, selects `gemini-3.8-flash-low`, and permits at most twelve +provider admissions in thirty minutes, with no automatic story retries. It +stops at the first divergence. Provider billing is unavailable; these are +admission/time bounds rather than a fabricated dollar-cost guarantee. + +Required cells remain ordinary task/file/artifact/result delivery; native +question and permission responses; active and pending-input Stop; spontaneous +native-child loss with durable expiry; and loaded-session instruction/model +continuity. Each needs an independent state/file/cleanup oracle. Reuse the +existing Product E2E catalog and report pipeline with an explicit attached-local +saved-connection fixture. The fresh-connection suite intentionally rejects +usable pre-existing connections, so it cannot be relabeled as this journey. + +Candidate authority should extend the existing trusted startup launch profile: +server operator authorization → Runner launch descriptor → Rust session policy +→ sidecar initialize/open → provider host/install checks. It must bind company, +exact model, profile digest and native closure. Do not place this authority in +agent configuration, provider environment, runtime context, or a saved provider +snapshot. Existing `acpxCandidateProfile` stops at TypeScript transport gates; +it is insufficient on its own. This design is proposed, not implemented or +activated, and does not change any current qualification verdict. + +### October 29 additional-agent user walkthrough + +The user requested a Codex bootstrap with the authorized local OpenAI key, then +Google Antigravity in the ordinary additional-agent picker. The Codex agent and +saved personal OpenAI connection were created through the UI. A subsequent +strict user-control recording found that the pending Antigravity draft lost its +provider when reopened. That 17-step failed recording is preserved. + +The UI now retains a non-secret pending provider choice on the held draft, +labels it Google Antigravity in the roster and overview, and resumes Google +setup through Finish setup. It keeps the physical draft paused and leaves native +qualification and execution configuration unchanged. Change adapter can reuse +the same draft. All 193 affected UI tests, UI typecheck, token gates and map +validation passed. + +A fresh 14-step browser-only recording created Antigravity Assistant from New +agent, found Google Antigravity in the main grid with Advanced collapsed, opened +Google setup, returned to Agents, and reopened the correct provider through +Finish setup. Its report and structured log are in the Codex artifact directory +`add-antigravity-agent-verified-2026-10-29-1793285314053`, with 14 actual viewport +screenshots. The earlier failure is in +`add-antigravity-agent-user-journey-2026-10-29-1793284634382`. + +This proves additional-agent discovery, draft identity, labeling and resumption. +The pending banner still prevents runnable completion. This walkthrough did not +complete Google OAuth, an Antigravity task, a follow-up turn or Stop acceptance. +The Codex overview also exposed taskless scheduled-heartbeat failures, which +remain outside this additional-agent change. The local test drive was moved to +a stable temporary source mirror while retaining the same isolated database and +saved connection. No provider credentials were copied between instances. + +### October 29 finish line: setup and real work + +The user clarified that picker/draft success is insufficient. Continue the +recorded user-test → diagnosis/fix → focused checks → fresh user-test loop until +Google Antigravity connects, finishes setup, and completes a real task. Retain +each failed attempt. Include a follow-up turn and independent output evidence; +the original request expects useful results over multiple turns. + +| Required outcome | Current evidence | Remaining gap | Next action / owner | +| --- | --- | --- | --- | +| Ordinary Google setup and activation | Fresh `.5` agent passed ordinary picker, saved account reuse, native Test, actual readiness and clean task handoff | No gap in the declared local setup path | Preserve the 38-screen walkthrough and exact candidate identity | +| Real native task and follow-up | `.5` first task and follow-up delivered exact 61/119-byte files, retained the native conversation and persisted Done | No gap in the declared local two-turn task path | Preserve independent bytes, decision lineage and native retirement evidence | +| Local release qualification | Exact native model/instruction receipt and physical native cleanup | Product authority, interactions, cancellation and installed path cells | Prove the local contract with exact candidate identity before changing release flags | + +The initial acceptance target remains the local darwin-arm64 test drive and the +selected Google subscription connection. Candidate authorization is bound to +that company, exact model, provider profile, native closure and expiry, with +fresh actual run/session binding. It is supplied only by the server operator; +agent config and provider environment cannot create it. Global qualification +remains pending while gathering real evidence. Diagnose the first divergence +before another paid attempt; provider billing remains unknown rather than zero. + +The next recorded user attempt (`antigravity-connect-and-run-2026-10-29-1793286152790`) +completed the selected Google account sign-in, saved the personal subscription, +and discovered the real account model catalog. Selecting Gemini 3.8 Flash Low +still left Test and Finish disabled, so the full user goal failed. Its 15 +screenshots and original report are retained. No task prompt was sent. + +During implementation the active Codex checkout was externally removed. Snapshot +`7eb51ffb10f4a2fd0937f54060a9d10fd4f8c630` preserved the source; it was recovered +into a stable temporary Git checkout with independent objects and an additional +tracked-source archive. The separate serving mirror, instance database, saved +Google connection and original evidence were unchanged. The remaining native +continuation and taskless timer corrections are being tested there before the +next recorded setup/task attempt. + +The second full setup attempt +(`antigravity-setup-task-iteration-2-1793289638292`) reused the saved Google +subscription, reached Configure with Gemini 3.8 Flash Low, and exposed enabled +Test and Finish controls. The real prompt-free runtime Test failed and disabled +Finish. Its expanded details offered only a generic account/model verification +failure. This remains failed acceptance: no Antigravity task ran. The ten-step +recording and all ten screenshots are retained. Diagnose the original native +error and improve safe recovery details before a fresh browser-only attempt; +do not count an enabled picker or passing unit suite as the user outcome. + +Independent verification after that failure found no serving-source or runtime +drift: the frozen Runner, selected ACPX sidecar and profile/closure manifests +match the final build ledger, and the original PostgreSQL container remains +healthy with its saved volume. The scoped candidate path is implemented across +server, Runner, Rust and sidecar; the earlier proposed-design section records +the state before implementation, not a present claim that authority is absent. + +The next diagnosis reproduced the original `session.open` rejection with zero +model prompts and confirmed physical retirement. Its first failing stage is +installation. The controller selected the JavaScript workspace dependency root +for Antigravity while supplying the native Runner package manifest; the native +asset guard correctly rejected that owner/manifest mismatch. A credential-free +check reproduces the old failure and verifies the real pinned native closure +after selecting the actual Runner package owner. The correction also covers the +vendored source-server layout while preserving existing Copilot behavior. + +The rebuilt candidate's third browser-only attempt completed the real native +Test and Finish setup, then ran ANT-2 in its isolated task workspace. Antigravity +created and read back the requested file, registered its attachment, corrected +an initial completion-citation rejection, and reached Done. Done and the actual +file persisted after Reload. Independent download verification then preserved +the first result: 55 exact UTF-8 bytes, SHA-256 +`5964636e0fd46950f9296593d33bd864e47e35f79997287cc4498735f839e733`. +The 26-step recording includes 26 screenshots. This is the first complete +setup/task success, not a release-qualification verdict. The follow-up native +conversation and completion/cleanup joins remain to be checked. Usability gaps +observed in this run are a stale paused warning in the post-setup task composer +and misleading failed labels on earlier activity rows after a finish error. + +The same task then completed a second ordinary UI turn. Send reopened ANT-2; +Antigravity read the existing file, preserved its first line, appended the new +requested line, registered a second attachment and returned to Done. The updated +file remained readable after Reload. The nine-step follow-up recording has nine +screenshots. Independent download verification preserved all 113 expected bytes +with SHA-256 +`cc8160e66e60e98aa1013668760691ee46379606aeaf1e89ab5ff2b3c604d15a`. +The first accepted native result was joined through committed finalization and +an applied status decision to Done; the follow-up native identity and physical +retirement audit are being completed separately from the strict browser runs. + +Both independent oracles now pass. The follow-up retained every tagged native +conversation identity field and exact requested/effective low model. It used a +fresh run-bound startup/state root and different physical Runner/sidecar PIDs. +Both native results have accepted rows, committed finalizations and applied +assessments/status decisions to Done. All four recorded Runner/sidecar PIDs and +both process groups are absent, both provider states are closed without an +unconfirmed-exit marker, and the exact retained native-home kernel quorum was +acquired and released. This proves native lifetime retirement beyond Runner +exit alone. Setup readiness and activity-label feedback remain under correction +before the final fresh-agent walkthrough; global qualification is unchanged. + +The activity-label diagnosis found two coupled upstream SDK behaviors: local +client file callbacks emitted ACTIVE without a terminal result, and any later +ERROR step failed every open activity. Native patch `.5` closes file callbacks +from their actual `ToolResult` after matching call/step identity and successful +result delivery. It removes the broad error broadcast, retaining the existing +per-call terminal mapping and turn cleanup. No successful outcome is fabricated; +genuine concurrent and scoped errors remain errors. The real hermetic SDK +regression first failed on the missing terminal, then passed actual create/read/ +edit bytes, genuine file failures, concurrent calls and scoped/unscoped errors. +All five native regressions and independent semantic review pass. Admission +Python remains byte-identical. The `.4` binaries, cache and real-task evidence +remain historical; `.5` has a new immutable native closure and needs fresh +candidate authority and live acceptance. + +The post-Finish UI now waits for the actual lifecycle-ready detail before +showing readiness or enabling task assignment, then refreshes the roster entry +used by the composer. Genuine manual/import pauses and approval/Dot flows stay +intact. All 120 setup tests, UI typecheck and token checks pass. The final user +walkthrough will create a fresh agent and exercise this corrected handoff. + +The final `.5` fresh-agent walkthrough passed all 38 recorded steps with 38 +screenshots. Google Antigravity was selected from the ordinary additional-agent +grid with Advanced collapsed. The saved Google subscription passed native Test. +The UI visibly waited in Finishing agent setup before enabling assignment at +actual readiness; the composer had no paused warning. A unique marker was then +saved through the ordinary AGENTS.md instruction editor, preserving the default +sentence. ANT-3 requested that configured marker without including its value. +The delivered file contained that marker, was readable and downloadable, and +Done persisted after Reload. Independent download preserved all 61 expected +bytes with SHA-256 +`e229f07be3a7c6835f91f9bd07f7daf4bd19f839c91f2fdd17161867750d4483`. +Native event evidence confirms completed edit/read activities stayed completed +when the first finish citation failed; only the actual failed finish was marked +failed, and its corrected retry completed. + +A separate nine-step `.5` follow-up also passed with nine screenshots. The same +task reopened through Send, read and updated its existing file, preserved the +instruction marker and appended the requested line. It returned to Done and the +actual updated attachment survived Reload. Independent download preserved all +119 expected bytes with SHA-256 +`b1bca87da838f17535613f48932a40043b9e31a820a43e4c9740d619e3dfac05`. +The second run retained the full native conversation identity and exact low +model under the `.5` profile. Both physical Runner/sidecar process groups and +their four recorded PIDs are absent, provider states are closed with confirmed +exit, and the exact native-home kernel quorum was acquired and released. Both +accepted results have committed finalization and applied Done decision lineage. +The first coordinator's current pointer subsequently changed to a superseding +assessment during the user's follow-up reopening, preserving in-progress work; +its original applied Done decision and captured first-run result remain intact. +The oracle retains the initial strict-current-pointer failure and this explicit +lineage explanation instead of rewriting the original grade. Raw native +instruction wire receipts remain unarchived; +the instruction-only marker provenance and actual bytes establish the observed +behavior without claiming an independent wire archive. + +### Final repository verification and local handoff + +Repository-wide `pnpm -r typecheck` and `pnpm build` pass against the frozen +`.5` source. `pnpm test:run` remains incomplete: its first general-server group +reached the declared 20-minute command bound and reported a 15-second timeout in +the Gmail/Cloud environment-identity case. The exact case passed in isolation +with its unchanged 15-second test budget. The cause of the broad-run timeout is +not established; later full-suite groups were not reached. Original logs and +grades are retained, and owned verification processes were retired. This is not +a claim of a green complete test suite or a production-qualified release. + +The externally removed chat workspace was restored from the independent source +checkout on `codex/google-antigravity`. All 106 changed/new source paths, the +native binary, selected sidecar and Git object connectivity were verified after +restoration. The test drive continues to serve from its separate frozen mirror +with the original isolated database and saved account, and reports ready/healthy. +The final `.5` oracle and both browser journeys establish the requested local +setup, task and follow-up outcome; broader release qualification remains pending. diff --git a/doc/plans/2026-10-28-antigravity-native-evidence.json b/doc/plans/2026-10-28-antigravity-native-evidence.json new file mode 100644 index 00000000000..c607501432f --- /dev/null +++ b/doc/plans/2026-10-28-antigravity-native-evidence.json @@ -0,0 +1,203 @@ +{ + "schema": "paperclip.antigravity.native-evidence.v1", + "date": "2026-10-28", + "sourceBaseline": "dce004259394486f6361d0c0dd5a33a5272acc0a", + "runnerBuildInputsDigest": "c62057227b92", + "platform": "darwin-arm64", + "patchVersion": "1.4.3-paperclip.4", + "acpxVersion": "0.13.1", + "qualification": "pending", + "productE2E": "not_run", + "accountAuthentication": "user-selected saved personal Google runtime connection", + "catalogModels": [ + "gemini-3.8-flash-high", + "gemini-3.8-flash-medium", + "gemini-3.8-flash-low" + ], + "promptRequestsSent": 1, + "providerMonetaryCostUsd": null, + "billingCoverage": "unavailable; context usage is not an input/output token or price receipt", + "attempts": [ + { + "mode": "admission-only", + "selectedModel": "gemini-3.8-flash-low", + "startedAt": "2026-10-28T14:44:03.803Z", + "finishedAt": "2026-10-28T14:44:10.327Z", + "requests": { + "initialize": 1, + "new": 1, + "load": 0, + "modelSetter": 1, + "prompt": 0, + "cancel": 0, + "close": 0 + }, + "instructionBinding": { + "digest": "sha256:c8104d8f48056bab41a5a64aff3c16891538177127c28c1afd2c438c143b27b3", + "byteLength": 112 + }, + "admissionGuardDigest": "sha256:d6f427afae003db83e2942706fc4926c54abcedba1d421f1f41de292564eb557", + "runtimeClosure": "sha256:05ed2a3e39c95fc813abeb664a78a295d1dcbfd4098f28cbc17d30d710a1ea92", + "nativeSessionStarted": false, + "promptSent": false, + "admissionVerified": false, + "physicalRetirementVerified": true, + "leaseReleased": true, + "failure": { + "name": "Error", + "messageDigest": "sha256:8b6480a8eac6d058877054700dbd69654955e6061f961d23c8eddbbefc349818" + }, + "eventCounts": { + "usage_update": 1, + "available_commands_update": 1 + }, + "status": "failed", + "classification": "product_defect_model_selector_projection", + "managedCredentialCleanup": true, + "databaseClientsClosed": true + }, + { + "mode": "admission-only", + "selectedModel": "gemini-3.8-flash-low", + "startedAt": "2026-10-28T14:49:40.091Z", + "finishedAt": "2026-10-28T14:49:47.924Z", + "requests": { + "initialize": 1, + "new": 1, + "load": 0, + "modelSetter": 0, + "prompt": 0, + "cancel": 0, + "close": 0 + }, + "instructionBinding": { + "digest": "sha256:90be316d38e1158284c4e9d88d3624e9d7e3fca30d03c993da7a7bfa9e080704", + "byteLength": 112 + }, + "admissionGuardDigest": "sha256:31b9dd005c7452b61e61f3a29c77f74d7f16b20511d1e0f9eff10dfb649dca4b", + "runtimeClosure": "sha256:05ed2a3e39c95fc813abeb664a78a295d1dcbfd4098f28cbc17d30d710a1ea92", + "nativeSessionStarted": true, + "promptSent": false, + "admissionVerified": true, + "physicalRetirementVerified": true, + "leaseReleased": true, + "eventCounts": { + "usage_update": 1, + "available_commands_update": 1 + }, + "status": "passed", + "classification": "native_admission_only", + "managedCredentialCleanup": true, + "databaseClientsClosed": true + }, + { + "mode": "prompt", + "selectedModel": "gemini-3.8-flash-low", + "startedAt": "2026-10-28T15:00:07.367Z", + "finishedAt": "2026-10-28T15:00:34.155Z", + "requests": { + "initialize": 1, + "new": 1, + "load": 0, + "modelSetter": 0, + "prompt": 1, + "cancel": 0, + "close": 0 + }, + "instructionBinding": { + "digest": "sha256:9cfa6fda4f2135354bf87b2e68a781afa4348ac86a6c59c542345c71081a0794", + "byteLength": 650 + }, + "admissionGuardDigest": "sha256:31b9dd005c7452b61e61f3a29c77f74d7f16b20511d1e0f9eff10dfb649dca4b", + "runtimeClosure": "sha256:05ed2a3e39c95fc813abeb664a78a295d1dcbfd4098f28cbc17d30d710a1ea92", + "nativeSessionStarted": true, + "promptSent": true, + "admissionVerified": true, + "physicalRetirementVerified": true, + "leaseReleased": true, + "eventCounts": { + "usage_update": 4, + "available_commands_update": 1, + "tool_call": 2, + "tool_call_update": 3, + "agent_message_chunk": 5 + }, + "toolEvents": [ + { + "tag": "tool_call", + "idHash": "sha256:6274c2f18aca82ccf069e6905297f7a6ae84aab405642af9536f2ca1c3420cd7", + "status": "pending", + "kind": "edit" + }, + { + "tag": "tool_call_update", + "idHash": "sha256:6274c2f18aca82ccf069e6905297f7a6ae84aab405642af9536f2ca1c3420cd7", + "status": "in_progress", + "kind": "edit" + }, + { + "tag": "tool_call", + "idHash": "sha256:9d9bcb0687bfffd32d7e3c008f6ab55a6ef828d58cc7cb8b6a703054c6182a85", + "status": "in_progress", + "kind": "read" + }, + { + "tag": "tool_call_update", + "idHash": "sha256:6274c2f18aca82ccf069e6905297f7a6ae84aab405642af9536f2ca1c3420cd7", + "status": "completed" + }, + { + "tag": "tool_call_update", + "idHash": "sha256:9d9bcb0687bfffd32d7e3c008f6ab55a6ef828d58cc7cb8b6a703054c6182a85", + "status": "completed" + } + ], + "clientOperations": { + "fs/write_text_file:running": 1, + "fs/write_text_file:completed": 1, + "fs/read_text_file:running": 1, + "fs/read_text_file:completed": 1 + }, + "permissions": { + "allowed": 1, + "denied": 0 + }, + "builtinToolAllowlist": [ + "finish" + ], + "stream": { + "events": 19, + "outputChunks": 5, + "outputBytes": 253, + "thoughtChunks": 0, + "outputSha256": "sha256:f34b143b56dbb6da47c6ca4361e46c21144316cc8f9ff98494c75416ac7e5fde" + }, + "usage": { + "used": 5866, + "size": 1048576 + }, + "turnResult": { + "status": "completed", + "stopReason": "end_turn" + }, + "independentOutput": { + "byteLength": 40, + "sha256": "sha256:79c8d4304612d981c7d8946b523e29af4bbc58b18cca8bc128d284e9a50d651e", + "expectedSha256": "sha256:79c8d4304612d981c7d8946b523e29af4bbc58b18cca8bc128d284e9a50d651e", + "equal": true + }, + "status": "passed", + "classification": "native_file_tools_and_instruction_behavior", + "managedCredentialCleanup": true, + "databaseClientsClosed": true + } + ], + "remainingRequired": [ + "ordinary_product_task_and_artifact", + "scoped_semantic_MCP_tools", + "native_question_and_permission_round_trips", + "stop_and_spontaneous_child_loss", + "loaded_session_instruction_behavior", + "normal_agent_setup_Test_reconnect_revoke" + ] +} diff --git a/doc/plans/2026-10-29-antigravity-candidate-identity-refresh.md b/doc/plans/2026-10-29-antigravity-candidate-identity-refresh.md new file mode 100644 index 00000000000..dcb2a204553 --- /dev/null +++ b/doc/plans/2026-10-29-antigravity-candidate-identity-refresh.md @@ -0,0 +1,45 @@ +# Antigravity candidate identity refresh — 2026-10-29 + +The scoped Antigravity admission changes shared Runner sources. Grok 9, Pi 32, +and Copilot 73 capture that first source snapshot; the later native conversation +continuation correction changes only Copilot's bound inputs, so its final active +identity advances to 74. The subsequent native package authority correction +changes that same bound transport source, advancing the active identity to 75. +All earlier versioned fixtures remain immutable and +decodable, including those interim revisions. + +Grok uses `scripts/grok-profile-identity.mjs`; Copilot uses +`scripts/copilot-profile-identity.mjs`. Both source-bound identities pass their +maintained checks. Pi has no standalone identity generator: Pi 32 derives from +the existing Pi 31 declaration, advances `agentProfileVersion`, rehashes its +declared source fields, and hashes recursively sorted canonical JSON with array +order preserved. All declared source hashes remain unchanged; only its profile +version advances in this coordinated snapshot. Its native wrapper, helper, +extension, platform closure, and other declared contracts remain unchanged. +The installation tests verify those source hashes, +canonical declaration digest, immutable Pi 31 bytes, and historical decoding. + +The Antigravity distribution declaration, command digest, and native patch +`1.4.3-paperclip.4` remain unchanged. This identity refresh supplies build +provenance; it does not transfer prior live qualification to changed sources or +mark the pending Antigravity profile qualified. + +## Subsequent native .5 candidate + +The paragraph above describes the earlier `.4` identity-refresh snapshot and +remains its historical record. The later `1.4.3-paperclip.5` native patch closes +each client file-operation result by its exact call/step identity after delivery. +A genuine finish failure no longer broadcasts failure onto earlier completed +edit/read activities. Its maintained profile command digest is +`sha256:acf5f574b6b9ec4388ff9d081890caa330e66bf1ae3fe3b08e7f0fb0e851b9ce` +and its macOS ARM64 native closure is +`sha256:044792d793e203bc8b4524a418f1816c4d34db67bb41fa4bae5947a050c49b4a`. +Earlier `.4` declarations, artifacts and attempt grades remain immutable. + +The [recorded setup and follow-up evidence](2026-10-28-antigravity-integration.md) +retains the fresh `.5` additional-agent setup, instruction-only file proof and +same-task second turn. Independent actual bytes, exact low model, native +conversation identity, accepted/committed Done lineage and physical retirement +were checked for that local saved-subscription attempt. This evidence does not +transfer prior qualifications across source identities or qualify fresh OAuth, +API-key operation, another platform, remote execution or a global release. diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 93c6bd1e03b..25c132af332 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -214,6 +214,21 @@ are unavailable. Accepting a native plan ends the planning run successfully whil the task waits for the next instruction. See the [Cursor release report](../../doc/plans/2026-10-03-cursor-production-readiness.md). +Google Antigravity is an **unqualified, pending** native ACPX integration. The +explicit `paperclipai runtime setup antigravity` command installs the verified +Antigravity ACP 1.4.3 distribution and its owned admission patch on macOS ARM64. +It verifies both the official vendor closure and the separately pinned patched +closure; npm installation downloads no Antigravity runtime. This setup installs +assets only. Global release qualification remains pending and normal execution +admission stays closed. An isolated operator can admit one company, exact model, +local platform and pinned native closure/profile until a fixed expiry through +trusted server startup authority. The candidate supports a managed saved Google +subscription; the recorded acceptance reused the same owner's personal default +grant and `gemini-3.8-flash-low` on macOS ARM64. This does not qualify fresh Google +consent or the separate API-key route. Google Workspace OAuth does not substitute +for a runtime credential. See the +[integration contract and evidence](../../doc/plans/2026-10-28-antigravity-integration.md). + A release includes all four platform daemons and the Linux provider-pack identity from its matching Daytona image. Run `stage:release-binaries` with a manifest that binds each daemon path and SHA-256, plus `remoteProviderPacks: {"linux-x64": {path, sha256}, "linux-arm64": {path, sha256}}` for diff --git a/packages/paperclip-runner/antigravity-distributions.json b/packages/paperclip-runner/antigravity-distributions.json new file mode 100644 index 00000000000..c6836e59b98 --- /dev/null +++ b/packages/paperclip-runner/antigravity-distributions.json @@ -0,0 +1,70 @@ +{ + "schema": "paperclip.antigravity_distribution.v1", + "version": "1.4.3", + "platforms": { + "darwin-arm64": { + "url": "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.3-darwin-arm64.zip", + "archiveSha256": "c57db0a9b8b712daa9c3c17cdb990a61842ea569f279e8bd79d7aa85a303b20b", + "closureSha256": "044792d793e203bc8b4524a418f1816c4d34db67bb41fa4bae5947a050c49b4a", + "provisioning": "verified-closure", + "executable": "agy_acp_server.par", + "args": [], + "entries": [ + { + "path": "agy_acp_server.par", + "sha256": "c5231c13eeb6a9fd554567e5ce6411d95fe8c2fe952302bbd52d7b81a4e63f15", + "size": 280804016, + "executable": true + }, + { + "path": "localharness_external", + "sha256": "9e8fdc6e20a3262ca0a771850ca4a8414ca839a642cc964ec5c547cc2c33c200", + "size": 122957936, + "executable": true + } + ], + "vendorEntries": [ + { + "path": "agy_acp_server.par", + "sha256": "1251927db3e21f3d693fd042d6890d82f65eb00587e97f1f3d8f0d798e7f0954", + "size": 282446160, + "executable": true + }, + { + "path": "localharness_external", + "sha256": "9e8fdc6e20a3262ca0a771850ca4a8414ca839a642cc964ec5c547cc2c33c200", + "size": 122957936, + "executable": true + } + ], + "vendorClosureSha256": "023881d9f3b460adb22634c625725657358679e08c085156cc6de35e5f87b3c2", + "patchVersion": "1.4.3-paperclip.5", + "admissionSourceSha256": "d660759a424ddc27ac382ce4512e85ca4eb87e5ca446ea111e093d3ab257f7da" + }, + "darwin-x64": { + "url": "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-1.4.3-darwin-x86_64.zip", + "archiveSha256": "9392dbbdad7f0663dbfb5a0bf641297b0cf80d2ee9cd2d49a616bbbc7378731d", + "provisioning": "inventory-only", + "executable": "agy_acp_server.par", + "args": [] + }, + "linux-x64": { + "url": "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.3-linux-x86_64.zip", + "archiveSha256": "0634ce5306ba92c932945455e7a83fda78e2d99c33fa8aa6ee0566206a382714", + "provisioning": "inventory-only", + "executable": "agy_acp_server.par", + "args": [ + "--uid=" + ] + }, + "linux-arm64": { + "url": "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-1.4.3-linux-arm64.zip", + "archiveSha256": "05c153443b0b1175151c760eeddea538950bc9843e317d54786a1f03670182bc", + "provisioning": "inventory-only", + "executable": "agy_acp_server.par", + "args": [ + "--uid=" + ] + } + } +} diff --git a/packages/paperclip-runner/scripts/antigravity-provisioner-layout.mjs b/packages/paperclip-runner/scripts/antigravity-provisioner-layout.mjs new file mode 100644 index 00000000000..5c6ebd838a3 --- /dev/null +++ b/packages/paperclip-runner/scripts/antigravity-provisioner-layout.mjs @@ -0,0 +1,22 @@ +import { homedir } from "node:os"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function antigravityRuntimeCachePath(closureSha256, platform = process.platform, architecture = process.arch, home = homedir()) { + if (!/^[a-f0-9]{64}$/.test(closureSha256) || !["darwin", "linux"].includes(platform) || !["arm64", "x64"].includes(architecture) || !isAbsolute(home)) throw new Error("Invalid Antigravity runtime cache identity"); + return resolve(home, ".paperclip/runtimes/antigravity", `${platform}-${architecture}`, closureSha256); +} + +export function antigravityProvisionerPackageRoot(moduleUrl) { + const url = new URL(moduleUrl); + if (url.protocol !== "file:" || url.search || url.hash) throw new Error("Antigravity setup requires a published provisioner"); + const path = fileURLToPath(url); + if (/\/dist\/vendor\/paperclip-runner\/cli\/provision-antigravity\.(?:cjs|js)$/.test(path)) return resolve(dirname(path), ".."); + if (/\/dist\/cli\/provision-antigravity\.(?:cjs|js)$/.test(path)) return resolve(dirname(path), "../.."); + throw new Error("Antigravity setup requires a published provisioner"); +} + +export function antigravityProvisionerDestination(moduleUrl, distribution, home) { + antigravityProvisionerPackageRoot(moduleUrl); + return antigravityRuntimeCachePath(distribution.closureSha256, distribution.platform, distribution.architecture, home); +} diff --git a/packages/paperclip-runner/scripts/antigravity-provisioner-layout.test.mjs b/packages/paperclip-runner/scripts/antigravity-provisioner-layout.test.mjs new file mode 100644 index 00000000000..3cd93a8c237 --- /dev/null +++ b/packages/paperclip-runner/scripts/antigravity-provisioner-layout.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { antigravityProvisionerDestination, antigravityProvisionerPackageRoot, antigravityRuntimeCachePath } from "./antigravity-provisioner-layout.mjs"; + +test("both published setup layouts share the OS-user cache", () => { + const distribution = { platform: "darwin", architecture: "arm64", closureSha256: "a".repeat(64) }; + for (const root of ["/usr/local/lib/node_modules", "/Users/operator/project/node_modules"]) { + for (const suffix of ["@paperclipai/server/dist/vendor/paperclip-runner/cli/provision-antigravity.cjs", "@paperclipai/paperclip-runner/dist/cli/provision-antigravity.cjs"]) { + const url = `file://${root}/${suffix}`; + assert.equal(antigravityProvisionerDestination(url, distribution, "/Users/operator"), `/Users/operator/.paperclip/runtimes/antigravity/darwin-arm64/${distribution.closureSha256}`); + assert.match(antigravityProvisionerPackageRoot(url), /paperclip-runner$/); + } + } +}); + +test("foreign layouts and cache identities cannot redirect setup", () => { + for (const url of ["file:///repo/scripts/provision-antigravity.mjs", "file:///tmp/provision-antigravity.cjs", "https://example.com/dist/cli/provision-antigravity.js", "file:///consumer/dist/cli/provision-antigravity.js?redirect=1"]) assert.throws(() => antigravityProvisionerPackageRoot(url), /published provisioner/); + for (const values of [["../escape", "darwin", "arm64", "/Users/operator"], ["a".repeat(64), "../escape", "arm64", "/Users/operator"], ["a".repeat(64), "darwin", "arm64", "relative-home"]]) assert.throws(() => antigravityRuntimeCachePath(...values), /cache identity/); +}); diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs index 306a626d305..28f88f3e947 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.mjs @@ -8,6 +8,12 @@ import { build } from "esbuild"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); export const verifiedProviderEntrypoints = Object.freeze([ + Object.freeze({ + name: "provision-antigravity", + source: resolve(packageRoot, "scripts/provision-antigravity.mjs"), + output: resolve(packageRoot, "dist/cli/provision-antigravity.js"), + verifiedOutput: resolve(packageRoot, "dist/cli/provision-antigravity.cjs"), + }), Object.freeze({ name: "provision-cursor", source: resolve(packageRoot, "scripts/provision-cursor.mjs"), @@ -60,6 +66,9 @@ export async function bundleVerifiedProviderEntrypoints({ write = true } = {}) { await mkdir(resolve(packageRoot, "dist/providers"), { recursive: true }); await cp(resolve(packageRoot, "src/providers"), resolve(packageRoot, "dist/providers"), { recursive: true }); await copyFile(resolve(packageRoot, "cursor-distributions.json"), resolve(packageRoot, "dist/cursor-distributions.json")); + await copyFile(resolve(packageRoot, "antigravity-distributions.json"), resolve(packageRoot, "dist/antigravity-distributions.json")); + await mkdir(resolve(packageRoot, "dist/cli"), { recursive: true }); + await copyFile(resolve(packageRoot, "scripts/patch-antigravity-runtime.admission.py"), resolve(packageRoot, "dist/cli/patch-antigravity-runtime.admission.py")); } const results = []; for (const entrypoint of verifiedProviderEntrypoints) { diff --git a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs index fd2c324822f..cca438a9322 100644 --- a/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs +++ b/packages/paperclip-runner/scripts/build-verified-provider-entrypoints.test.mjs @@ -41,3 +41,7 @@ test("written provider entrypoints satisfy qualified launch permissions", async // Package-layout regression runs in the existing verified-entrypoint test lane. import "./provision-pi-package.test.mjs"; +import "./antigravity-provisioner-layout.test.mjs"; +import "./materialize-antigravity-distribution.test.mjs"; +import "./probe-antigravity-metadata.test.mjs"; +import "./patch-antigravity-runtime.test.mjs"; diff --git a/packages/paperclip-runner/scripts/materialize-antigravity-distribution.mjs b/packages/paperclip-runner/scripts/materialize-antigravity-distribution.mjs new file mode 100644 index 00000000000..be9a8cc8e80 --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-antigravity-distribution.mjs @@ -0,0 +1,277 @@ +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; }); +} diff --git a/packages/paperclip-runner/scripts/materialize-antigravity-distribution.test.mjs b/packages/paperclip-runner/scripts/materialize-antigravity-distribution.test.mjs new file mode 100644 index 00000000000..9f922f72810 --- /dev/null +++ b/packages/paperclip-runner/scripts/materialize-antigravity-distribution.test.mjs @@ -0,0 +1,324 @@ +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); } +}); diff --git a/packages/paperclip-runner/scripts/patch-antigravity-runtime.admission.py b/packages/paperclip-runner/scripts/patch-antigravity-runtime.admission.py new file mode 100644 index 00000000000..9b67b239631 --- /dev/null +++ b/packages/paperclip-runner/scripts/patch-antigravity-runtime.admission.py @@ -0,0 +1,442 @@ +"""Paperclip-owned admission for the pinned Antigravity 1.4.3 native runtime. + +Loaded inside Google's hermetic distribution. No credentials or prompt content +are persisted in receipts. Native creation must acknowledge the exact config +before the ACP client receives authority to send a managed prompt. +""" +import asyncio +import contextvars +import ctypes +import dataclasses +import functools +import hashlib +import os +import stat +import re +import uuid + +from acp import exceptions +from google3.cloud.developer_experience.antigravity_extensions.acp_server import request_meta +from google3.cloud.developer_experience.antigravity_extensions.acp_server import system_prompt +from google3.cloud.developer_experience.antigravity_extensions.acp_server import hooks as acp_hooks +from google3.cloud.developer_experience.antigravity_extensions.acp_server import tool_filter +from google3.cloud.developer_experience.antigravity_extensions.acp_server.oauth.credential_manager import OAuthCredentialManager + +SCHEMA = "paperclip.antigravity.admission.v1" +MODELS_SCHEMA = "paperclip.antigravity.models.v1" +_CONTEXT = contextvars.ContextVar("paperclip_antigravity_admission", default=None) + + +def _fail(message): + raise exceptions.RequestError(code=-32602, message="Paperclip admission: " + message) + + +def _read_request(kwargs): + meta = request_meta.antigravity_meta_from_request_kwargs(kwargs) + value = meta.get("paperclipAdmission") + if value is None: + return None + if not isinstance(value, dict) or set(value) != { + "schema", "nonce", "model", "digest", "byteLength" + }: + _fail("invalid request fields") + if value["schema"] != SCHEMA: + _fail("unsupported schema") + for key in ("nonce", "model"): + if not isinstance(value[key], str) or not 1 <= len(value[key]) <= 512: + _fail("invalid " + key) + if not re.fullmatch(r"sha256:[0-9a-f]{64}", str(value["digest"])): + _fail("invalid instruction digest") + prompt = meta.get("systemPrompt") + if not isinstance(prompt, dict) or set(prompt) != {"append"} or not isinstance(prompt["append"], str): + _fail("exact systemPrompt.append is required, including empty content") + content = prompt["append"].encode("utf-8") + if type(value["byteLength"]) is not int or value["byteLength"] != len(content): + _fail("instruction byte length mismatch") + if value["digest"] != "sha256:" + hashlib.sha256(content).hexdigest(): + _fail("instruction digest mismatch") + return {**value, "content": prompt["append"]} + + +def prepare_native_config(config, strategy): + """Inspect the protobuf that will actually be sent to this owned harness.""" + admission = _CONTEXT.get() + if admission is None: + return None + executable_path = ctypes.create_string_buffer(4096) + path_size = ctypes.c_uint32(len(executable_path)) + get_executable_path = ctypes.CDLL("/usr/lib/libSystem.B.dylib")._NSGetExecutablePath + get_executable_path.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)] + get_executable_path.restype = ctypes.c_int + if get_executable_path(executable_path, ctypes.byref(path_size)) != 0: + _fail("cannot resolve the owned native executable path") + helper = os.path.join(os.path.dirname(os.path.realpath(os.fsdecode(executable_path.value))), "localharness_external") + helper_stat = os.lstat(helper) + if not stat.S_ISREG(helper_stat.st_mode) or helper_stat.st_nlink != 1: + _fail("native helper must be the immutable sibling executable") + with open(helper, "rb") as helper_file: + helper_digest = hashlib.file_digest(helper_file, "sha256").hexdigest() + if helper_digest != "9e8fdc6e20a3262ca0a771850ca4a8414ca839a642cc964ec5c547cc2c33c200": + _fail("native sibling helper digest differs") + # The SDK resolves this attribute in its constructor. Updating the global + # environment alone cannot replace that already-cached executable path. + strategy._binary_path = helper + if strategy._env is not None: + strategy._env = {**strategy._env, "ANTIGRAVITY_HARNESS_PATH": helper} + os.environ["ANTIGRAVITY_HARNESS_PATH"] = helper + targets = [model for model in config.models if 1 in model.types] + if len(targets) != 1 or targets[0].name != admission["model"]: + _fail("native text model differs from the exact requested model") + instructions = config.system_instructions + expected = admission["content"] + if expected: + if instructions.WhichOneof("type") != "appended": + _fail("native instruction layering differs") + sections = instructions.appended.appended_sections + if len(sections) != 1 or sections[0].content != expected: + _fail("native instruction content differs") + elif config.HasField("system_instructions"): + # Empty content means no appended section, with the normal native defaults. + if instructions.WhichOneof("type") != "appended" or any(section.content for section in instructions.appended.appended_sections): + _fail("native empty instruction content differs") + if not config.cascade_id: + _fail("native session identity is missing") + if config.harness_side_tools.subagents.enabled or config.harness_side_tools.run_workflow.enabled or config.custom_subagents: + _fail("managed subagents, workflows, and browser agents are unavailable") + if config.harness_side_tools.schedule.enabled: + _fail("managed timers and recurring schedules belong to Paperclip") + return { + **admission, + "sessionId": config.cascade_id, + "connectionId": uuid.uuid4().hex, + "configBytes": config.SerializeToString(deterministic=True), + } + + +def acknowledge_native_config(prepared, event, config): + """A receipt exists only after the same native connection acknowledges init.""" + if prepared is None: + return None + if not event.HasField("initialize_conversation_response"): + _fail("native initialization acknowledgment is missing") + if event.initialize_conversation_response.cascade_id != prepared["sessionId"]: + _fail("native initialization session differs") + if config.SerializeToString(deterministic=True) != prepared["configBytes"]: + _fail("native initialization config changed") + return {key: prepared[key] for key in ( + "schema", "nonce", "sessionId", "connectionId", "model", "digest", "byteLength" + )} + + +def _receipt(adapter, session_id, admission): + session = adapter._sessions.get(session_id) + if session is None: + _fail("native session is missing") + strategy = getattr(session.agent, "_strategy", None) + receipt = getattr(strategy, "_paperclip_native_admission", None) + if not isinstance(receipt, dict) or receipt.get("sessionId") != session_id: + _fail("native content acknowledgment is missing") + for key in ("schema", "nonce", "model", "digest", "byteLength"): + if receipt.get(key) != admission[key]: + _fail("native content acknowledgment differs: " + key) + if session.mode_id != "default" or session.system_prompt != admission["content"]: + _fail("managed mode or instruction state changed") + if session.current_model_id != admission["model"] or session.agent_model_id != admission["model"]: + _fail("selected model differs from initialized model") + return dict(receipt) + + +def _add_receipt(result, receipt): + result.field_meta = {**(result.field_meta or {}), "paperclipAntigravityAdmission": receipt} + return result + + +def install(adapter_type): + if adapter_type.__dict__.get("_paperclip_admission_installed", False): + return + original_initialize = adapter_type.initialize + original_extract = system_prompt.extract_system_prompt + original_list_models = adapter_type._list_available_models + original_create_config = adapter_type._create_agent_config + original_rebuild = adapter_type._rebuild_session_agent + original_replace = adapter_type._maybe_replace_unavailable_model + original_prompt = adapter_type.prompt + original_cleanup = adapter_type.cleanup + original_ext = getattr(adapter_type, "ext_method", None) + original_oauth_mgr = getattr(adapter_type, "_ensure_oauth_mgr", None) + original_hooks_config = acp_hooks.load_hooks_config + original_hooks_config_async = acp_hooks.load_hooks_config_async + original_has_workspace_hooks = acp_hooks.has_workspace_hooks + + # Bind this to the manager, because later token use occurs in background + # threads without the request ContextVar. Explicit standalone authenticate + # uses a separate, unflagged manager and retains its interactive login. + if not getattr(OAuthCredentialManager._interactive_login_and_store, "_paperclip_managed_guard", False): + original_interactive_login = OAuthCredentialManager._interactive_login_and_store + + @functools.wraps(original_interactive_login) + def interactive_login(manager): + if getattr(manager, "_paperclip_noninteractive", False): + raise ValueError("Google sign-in must be renewed through Paperclip before this managed task can continue") + return original_interactive_login(manager) + + interactive_login._paperclip_managed_guard = True + OAuthCredentialManager._interactive_login_and_store = interactive_login + + def ensure_oauth_mgr(self): + manager = original_oauth_mgr(self) + if _CONTEXT.get() is not None or getattr(self, "_paperclip_managed_sessions", {}): + manager._paperclip_noninteractive = True + return manager + + def hooks_config(*args, **kwargs): + return {} if _CONTEXT.get() is not None else original_hooks_config(*args, **kwargs) + + async def hooks_config_async(*args, **kwargs): + return {} if _CONTEXT.get() is not None else await original_hooks_config_async(*args, **kwargs) + + def has_workspace_hooks(*args, **kwargs): + return False if _CONTEXT.get() is not None else original_has_workspace_hooks(*args, **kwargs) + + @functools.wraps(original_initialize) + async def initialize(self, *args, **kwargs): + if getattr(self, "_paperclip_managed_sessions", {}): + _fail("cannot renegotiate an initialized managed connection") + result = await original_initialize(self, *args, **kwargs) + meta = result.agent_capabilities.field_meta or {} + meta.setdefault("antigravity", {})["paperclipAdmission"] = 1 + meta["antigravity"]["paperclipModels"] = 1 + result.agent_capabilities.field_meta = meta + self._paperclip_managed_sessions = {} + return result + + def extract(kwargs): + admission = _CONTEXT.get() + return admission["content"] if admission is not None else original_extract(kwargs) + + def list_models(self, current_model_id): + admission = _CONTEXT.get() + selected = admission["model"] if admission is not None else current_model_id + result = original_list_models(self, selected) + if admission is not None and result.current_model_id != selected: + _fail("provider rejected the exact requested model") + return result + + @functools.wraps(original_create_config) + async def create_config(self, *args, **kwargs): + admission = _CONTEXT.get() + if admission is not None and ( + kwargs.get("model_id") != admission["model"] + or kwargs.get("system_instructions") != admission["content"] + ): + _fail("native model or instruction input differs") + if admission is not None: + requested_filter = kwargs.get("tool_filter", tool_filter.ToolFilterConfig()) + if not isinstance(requested_filter, tool_filter.ToolFilterConfig): + _fail("invalid managed native tool filter") + kwargs["tool_filter"] = dataclasses.replace(requested_filter, disabled_tools=tuple(dict.fromkeys((*(requested_filter.disabled_tools or ()), "invoke_subagent", "run_workflow", "schedule")))) + result = await original_create_config(self, *args, **kwargs) + if admission is not None: + config = result[0] if isinstance(result, tuple) else result + external_hooks = (acp_hooks.ExternalPreInvocationHook, acp_hooks.ExternalPostInvocationHook, acp_hooks.ExternalPreToolCallDecideHook, acp_hooks.ExternalPostToolCallHook, acp_hooks.ExternalOnSessionEndHook) + if any(isinstance(hook, external_hooks) for hook in config.hooks): + _fail("managed workspace command hooks are unavailable") + return result + + def wrap_open(original, is_new): + @functools.wraps(original) + async def open_session(self, *args, **kwargs): + admission = _read_request(kwargs) + session_id = None if is_new else kwargs.get("session_id", args[0] if args else None) + managed = getattr(self, "_paperclip_managed_sessions", {}) + if admission is None: + if session_id in managed: + _fail("managed reload requires a fresh exact content acknowledgment") + return await original(self, *args, **kwargs) + if any(item["nonce"] == admission["nonce"] for item in managed.values()): + _fail("request nonce was already used") + token = _CONTEXT.set(admission) + try: + result = await original(self, *args, **kwargs) + session_id = result.session_id if is_new else session_id + receipt = _receipt(self, session_id, admission) + managed[session_id] = admission + self._paperclip_managed_sessions = managed + return _add_receipt(result, receipt) + finally: + _CONTEXT.reset(token) + return open_session + + @functools.wraps(original_rebuild) + async def rebuild(self, session_id): + admission = getattr(self, "_paperclip_managed_sessions", {}).get(session_id) or _CONTEXT.get() + if admission is None: + return await original_rebuild(self, session_id) + token = _CONTEXT.set(admission) + try: + result = await original_rebuild(self, session_id) + _receipt(self, session_id, admission) + return result + finally: + _CONTEXT.reset(token) + + @functools.wraps(original_replace) + async def replace(self, session_id): + if session_id in getattr(self, "_paperclip_managed_sessions", {}): + # Never let the native enterprise poller substitute a model on this path. + return + return await original_replace(self, session_id) + + @functools.wraps(original_prompt) + async def prompt(self, *args, **kwargs): + session_id = kwargs.get("session_id", args[1] if len(args) > 1 else None) + admission = getattr(self, "_paperclip_managed_sessions", {}).get(session_id) + if admission is not None: + _receipt(self, session_id, admission) + return await original_prompt(self, *args, **kwargs) + + def wrap_setting(original): + @functools.wraps(original) + async def setting(self, *args, **kwargs): + session_id = kwargs.get("session_id", args[1] if len(args) > 1 else None) + managed = getattr(self, "_paperclip_managed_sessions", {}) + if session_id not in managed: + return await original(self, *args, **kwargs) + admission = _read_request(kwargs) + if admission is None or admission["nonce"] == managed[session_id]["nonce"]: + _fail("managed configuration change requires a fresh admission request") + session = self._sessions[session_id] + if not session.agent.conversation.is_idle: + _fail("cannot change managed configuration during an active turn") + token = _CONTEXT.set(admission) + try: + result = await original(self, *args, **kwargs) + session = self._sessions[session_id] + if session.current_model_id != admission["model"]: + _fail("configuration selected a different model") + session.system_prompt = admission["content"] + managed[session_id] = admission + await rebuild(self, session_id) + return _add_receipt(result, _receipt(self, session_id, admission)) + finally: + _CONTEXT.reset(token) + return setting + + @functools.wraps(original_cleanup) + async def cleanup(self, session_id=None): + try: + return await original_cleanup(self, session_id) + finally: + managed = getattr(self, "_paperclip_managed_sessions", {}) + if session_id is None: + managed.clear() + else: + managed.pop(session_id, None) + + async def models(self, params): + if ( + not isinstance(params, dict) + or set(params) != {"schema", "nonce", "authMethod"} + or params["schema"] != MODELS_SCHEMA + or params["authMethod"] != "oauth-personal" + or not isinstance(params["nonce"], str) + or not 1 <= len(params["nonce"]) <= 512 + ): + _fail("invalid model catalog request") + + def session_free(): + if ( + getattr(self, "_sessions", {}) + or getattr(self, "_paperclip_managed_sessions", {}) + or _CONTEXT.get() is not None + ): + _fail("model catalog requires a session-free connection") + + def require_personal(): + state = self._ensure_auth_state() + if state is None or state.method_id != "oauth-personal": + raise exceptions.RequestError( + code=-32000, + message="Google personal sign-in is required before model discovery", + ) + + session_free() + try: + require_personal() + # Set the persistent instance policy BEFORE the native auth assertion: + # both assertion and catalog token reads may otherwise open a browser. + manager = self._ensure_oauth_mgr() + manager._paperclip_noninteractive = True + await self._assert_authenticated() + require_personal() + except Exception: + raise exceptions.RequestError( + code=-32000, + message="Google personal sign-in must be renewed through Paperclip before model discovery", + ) from None + session_free() + try: + state = await asyncio.to_thread(self._list_available_models, "") + require_personal() + values = [ + {"id": model.model_id, "label": model.name} + for model in state.available_models + ] + ids = [model["id"] for model in values] + if ( + not values + or any(not isinstance(model["id"], str) or not model["id"] + or not isinstance(model["label"], str) or not model["label"] + for model in values) + or len(set(ids)) != len(ids) + or state.current_model_id not in ids + ): + raise ValueError("invalid native model catalog") + except Exception: + raise exceptions.RequestError( + code=-32000, + message="Google Antigravity could not list models for this connection", + ) from None + session_free() + return { + "schema": MODELS_SCHEMA, + "nonce": params["nonce"], + "authMethod": "oauth-personal", + "models": values, + "defaultModelId": state.current_model_id, + } + + async def ext_method(self, method, params): + if method.lstrip("_") == "paperclip/models": + return await models(self, params) + if method.lstrip("_") != "paperclip/admission": + if original_ext is None: + _fail("unknown extension method") + return await original_ext(self, method, params) + if not isinstance(params, dict) or set(params) != {"sessionId", "nonce"}: + _fail("invalid admission status request") + admission = getattr(self, "_paperclip_managed_sessions", {}).get(params["sessionId"]) + if admission is None or admission["nonce"] != params["nonce"]: + _fail("unknown admission session or nonce") + return {"paperclipAntigravityAdmission": _receipt(self, params["sessionId"], admission)} + + adapter_type.initialize = initialize + if original_oauth_mgr is not None: + adapter_type._ensure_oauth_mgr = ensure_oauth_mgr + acp_hooks.load_hooks_config = hooks_config + acp_hooks.load_hooks_config_async = hooks_config_async + acp_hooks.has_workspace_hooks = has_workspace_hooks + system_prompt.extract_system_prompt = extract + adapter_type._list_available_models = list_models + adapter_type._create_agent_config = create_config + adapter_type.new_session = wrap_open(adapter_type.new_session, True) + adapter_type.load_session = wrap_open(adapter_type.load_session, False) + adapter_type.resume_session = wrap_open(adapter_type.resume_session, False) + adapter_type._rebuild_session_agent = rebuild + adapter_type._maybe_replace_unavailable_model = replace + adapter_type.prompt = prompt + adapter_type.cleanup = cleanup + adapter_type.set_session_model = wrap_setting(adapter_type.set_session_model) + adapter_type.set_config_option = wrap_setting(adapter_type.set_config_option) + adapter_type.ext_method = ext_method + adapter_type._paperclip_admission_installed = True diff --git a/packages/paperclip-runner/scripts/patch-antigravity-runtime.mjs b/packages/paperclip-runner/scripts/patch-antigravity-runtime.mjs new file mode 100644 index 00000000000..ed56967b806 --- /dev/null +++ b/packages/paperclip-runner/scripts/patch-antigravity-runtime.mjs @@ -0,0 +1,289 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { chmod, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { inflateRawSync } from "node:zlib"; + +export const ANTIGRAVITY_PATCH_SCHEMA = "paperclip.antigravity.native_patch.v1"; +export const ANTIGRAVITY_PATCH_VERSION = "1.4.3-paperclip.5"; +const SERVER_HASH = "1251927db3e21f3d693fd042d6890d82f65eb00587e97f1f3d8f0d798e7f0954"; +const HARNESS_HASH = "9e8fdc6e20a3262ca0a771850ca4a8414ca839a642cc964ec5c547cc2c33c200"; +const ACP_ROOT = "google3/cloud/developer_experience/antigravity_extensions/acp_server/"; +const SDK_ROOT = "google3/third_party/py/google/antigravity/connections/local/"; +const SERVER_SOURCE = ACP_ROOT + "server.py"; +const SDK_SOURCE = SDK_ROOT + "local_connection.py"; +const EVENT_PROCESSOR_SOURCE = SDK_ROOT + "event_processor.py"; +const ADMISSION_SOURCE = ACP_ROOT + "paperclip_admission.py"; +const REMOVED_BYTECODE = new Set([ + ACP_ROOT + "__pycache__/server.cpython-314.pyc", + SDK_ROOT + "__pycache__/local_connection.cpython-314.pyc", + SDK_ROOT + "__pycache__/event_processor.cpython-314.pyc", +]); +const digest = value => createHash("sha256").update(value).digest("hex"); +const fail = message => { throw new Error(`Antigravity native patch: ${message}`); }; + +export function antigravityParSection(bytes) { + if (!Buffer.isBuffer(bytes) || bytes.length < 32 || bytes.readUInt32LE(0) !== 0xfeedfacf || bytes.readUInt32LE(4) !== 0x0100000c) fail("expected the pinned ARM64 Mach-O executable"); + const commandCount = bytes.readUInt32LE(16); + let cursor = 32; + const sections = []; + for (let index = 0; index < commandCount; index++) { + if (cursor + 8 > bytes.length) fail("truncated Mach-O command"); + const command = bytes.readUInt32LE(cursor); + const size = bytes.readUInt32LE(cursor + 4); + if (size < 8 || cursor + size > bytes.length) fail("invalid Mach-O command bounds"); + if (command === 0x19) { + if (size < 72) fail("truncated Mach-O segment"); + const count = bytes.readUInt32LE(cursor + 64); + if (72 + count * 80 > size) fail("invalid Mach-O sections"); + for (let section = 0; section < count; section++) { + const offset = cursor + 72 + section * 80; + if (bytes.subarray(offset, offset + 16).toString("utf8").split("\0")[0] !== ".par_data") continue; + const length = Number(bytes.readBigUInt64LE(offset + 40)); + const start = bytes.readUInt32LE(offset + 48); + if (!Number.isSafeInteger(length) || length < 22 || start + length > bytes.length) fail("invalid embedded Python archive bounds"); + sections.push({ start, length }); + } + } + cursor += size; + } + if (sections.length !== 1) fail("expected exactly one embedded Python archive"); + return sections[0]; +} + +const crcTable = Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); + return value >>> 0; +}); +function crc32(bytes) { + let value = 0xffffffff; + for (const byte of bytes) value = (value >>> 8) ^ crcTable[(value ^ byte) & 0xff]; + return (value ^ 0xffffffff) >>> 0; +} + +/** Operates only after the complete vendor executable's SHA-256 is verified. */ +export function antigravityEmbeddedZip(bytes) { + const end = bytes.length - 22; + if (end < 0 || bytes.readUInt32LE(end) !== 0x06054b50 || bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6) || bytes.readUInt16LE(end + 20)) fail("unsupported embedded ZIP ending"); + const count = bytes.readUInt16LE(end + 10); + const centralOffset = bytes.readUInt32LE(end + 16); + if (bytes.readUInt16LE(end + 8) !== count || centralOffset + bytes.readUInt32LE(end + 12) !== end) fail("invalid embedded ZIP index"); + const entries = []; + const names = new Set(); + let cursor = centralOffset; + for (let index = 0; index < count; index++) { + if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50) fail("invalid embedded ZIP entry"); + const flags = bytes.readUInt16LE(cursor + 8); + const method = bytes.readUInt16LE(cursor + 10); + const compressedLength = bytes.readUInt32LE(cursor + 20); + const length = bytes.readUInt32LE(cursor + 24); + const nameLength = bytes.readUInt16LE(cursor + 28); + const extraLength = bytes.readUInt16LE(cursor + 30); + const commentLength = bytes.readUInt16LE(cursor + 32); + const local = bytes.readUInt32LE(cursor + 42); + const next = cursor + 46 + nameLength + extraLength + commentLength; + if (next > end || (flags & ~0x800) || ![0, 8].includes(method) || local + 30 > centralOffset || bytes.readUInt32LE(local) !== 0x04034b50) fail("unsupported embedded ZIP entry"); + const name = bytes.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8"); + if (names.has(name) || !Buffer.from(name).equals(bytes.subarray(cursor + 46, cursor + 46 + nameLength))) fail("duplicate or invalid embedded ZIP name"); + names.add(name); + const dataStart = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28); + if (dataStart + compressedLength > centralOffset) fail("invalid embedded ZIP payload bounds"); + entries.push({ name, method, length, checksum: bytes.readUInt32LE(cursor + 16), localHeader: bytes.subarray(local, dataStart), payload: bytes.subarray(dataStart, dataStart + compressedLength), central: bytes.subarray(cursor, next) }); + cursor = next; + } + if (cursor !== end) fail("invalid embedded ZIP index length"); + return entries; +} + +function content(entry) { + const value = entry.method === 8 ? inflateRawSync(entry.payload, { maxOutputLength: entry.length }) : entry.payload; + if (value.length !== entry.length || crc32(value) !== entry.checksum) fail("embedded source integrity mismatch"); + return value; +} + +function storedEntry(name, value) { + const filename = Buffer.from(name); + const checksum = crc32(value); + const localHeader = Buffer.alloc(30 + filename.length); + localHeader.writeUInt32LE(0x04034b50); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(33, 12); // 1980-01-01, stable ZIP date. + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(value.length, 18); + localHeader.writeUInt32LE(value.length, 22); + localHeader.writeUInt16LE(filename.length, 26); + filename.copy(localHeader, 30); + const central = Buffer.alloc(46 + filename.length); + central.writeUInt32LE(0x02014b50); + central.writeUInt16LE(0x314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(33, 14); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(value.length, 20); + central.writeUInt32LE(value.length, 24); + central.writeUInt16LE(filename.length, 28); + central.writeUInt32LE((0o100644 << 16) >>> 0, 38); + filename.copy(central, 46); + return { name, method: 0, length: value.length, checksum, localHeader, payload: value, central }; +} + +export function replaceAntigravitySource(source, needle, replacement) { + if (source.split(needle).length !== 2) fail("pinned native source anchor changed"); + return source.replace(needle, replacement); +} + +export function antigravityPatchedSources(server, sdk, eventProcessor) { + replaceAntigravitySource(server, "class AgyAdapter(Agent):", "class AgyAdapter(Agent):"); + server = replaceAntigravitySource(server, +` # Immediately flush open tool calls on error step (validation/execution + # failure). + if step.status == sdk_types_module.StepStatus.ERROR: + if open_tool_calls: + logging.warning( + "Step error encountered; flushing open tool calls: %s. Error: %s", + open_tool_calls, + step.error, + ) + for tc_id in list(open_tool_calls): + open_tool_calls.pop(tc_id, None) + await self._client.session_update( + session_id=session_id, + update=schema.ToolCallProgress( + session_update="tool_call_update", + tool_call_id=tc_id, + raw_output=step.error or "Tool execution failed", + status="failed", + ), + ) + +`, +` # A step error belongs only to that step's tool calls. The terminal block + # below resolves their execution/permission identities. Unattributed errors + # remain diagnostics; turn cleanup still settles unfinished work. + if step.status == sdk_types_module.StepStatus.ERROR: + logging.warning( + "Step error encountered for %s. Error: %s", step.id, step.error + ) + +`, + ); + server += "\n\n# Paperclip's owned admission patch, applied to this pinned release only.\nfrom google3.cloud.developer_experience.antigravity_extensions.acp_server import paperclip_admission\npaperclip_admission.install(AgyAdapter)\n"; + sdk = replaceAntigravitySource(sdk, + " harness_config = self._build_harness_config()\n process, ws, ws_url = await self._spawn_harness_and_connect_ws()", + " harness_config = self._build_harness_config()\n from google3.cloud.developer_experience.antigravity_extensions.acp_server import paperclip_admission\n paperclip_prepared = paperclip_admission.prepare_native_config(harness_config, self)\n process, ws, ws_url = await self._spawn_harness_and_connect_ws()", + ); + sdk = replaceAntigravitySource(sdk, + " init_resp = init_resp_event.initialize_conversation_response\n parsed = event_processor.parse_initialize_response(init_resp)", + " self._paperclip_native_admission = paperclip_admission.acknowledge_native_config(paperclip_prepared, init_resp_event, harness_config)\n init_resp = init_resp_event.initialize_conversation_response\n parsed = event_processor.parse_initialize_response(init_resp)", + ); + eventProcessor = replaceAntigravitySource(eventProcessor, +` result = await self._execute_client_tool(tc) + if result is not None: + await self._send_tool_results([result]) +`, +` result = await self._execute_client_tool(tc) + if result is not None: + # Native StepUpdate events suppress local custom-tool identities. Close + # ACP file callbacks from their actual result, preserving the same id. + client_file_result = tc.name in { + "client_view_file", "client_create_file", "client_edit_file" + } + if client_file_result and ( + result.id != tc.id or result.step_id != tc.step_id + ): + raise ValueError("Client file result identity differs from its call") + await self._send_tool_results([result]) + if client_file_result: + await self.step_queue.put(tool_call_step.model_copy(update={ + "status": types.StepStatus.ERROR + if result.error is not None else types.StepStatus.DONE, + "content": result.result if isinstance(result.result, str) else "", + "error": result.error or "", + "tool_calls": [tc], + })) +`, + ); + return { server, sdk, eventProcessor }; +} + +function rebuildZip(entries, length) { + const paddingName = "paperclip_native_patch_padding"; + const used = entries.reduce((total, entry) => total + entry.localHeader.length + entry.payload.length + entry.central.length, 0); + const paddingLength = length - used - 22 - 30 - 46 - Buffer.byteLength(paddingName) * 2; + if (paddingLength < 0 || entries.length + 1 > 0xffff) fail("patched source exceeds the pinned native section"); + entries = [...entries, storedEntry(paddingName, Buffer.alloc(paddingLength))]; + const locals = []; + const centrals = []; + let offset = 0; + for (const entry of entries) { + const central = Buffer.from(entry.central); + central.writeUInt32LE(offset, 42); + locals.push(entry.localHeader, entry.payload); + centrals.push(central); + offset += entry.localHeader.length + entry.payload.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); + const result = Buffer.concat([...locals, central, end]); + if (result.length !== length) fail("patched section size changed"); + return result; +} + +export function patchAntigravityServerBytes(server, admissionSource) { + if (digest(server) !== SERVER_HASH) fail("official server SHA-256 mismatch"); + const section = antigravityParSection(server); + const entries = antigravityEmbeddedZip(server.subarray(section.start, section.start + section.length)); + const byName = new Map(entries.map(entry => [entry.name, entry])); + if (!byName.has(SERVER_SOURCE) || !byName.has(SDK_SOURCE) || !byName.has(EVENT_PROCESSOR_SOURCE) || byName.has(ADMISSION_SOURCE) || [...REMOVED_BYTECODE].some(name => !byName.has(name))) fail("pinned Python runtime layout changed"); + const patched = antigravityPatchedSources(content(byName.get(SERVER_SOURCE)).toString("utf8"), content(byName.get(SDK_SOURCE)).toString("utf8"), content(byName.get(EVENT_PROCESSOR_SOURCE)).toString("utf8")); + const replacements = new Map([[SERVER_SOURCE, Buffer.from(patched.server)], [SDK_SOURCE, Buffer.from(patched.sdk)], [EVENT_PROCESSOR_SOURCE, Buffer.from(patched.eventProcessor)]]); + const updated = entries.filter(entry => !REMOVED_BYTECODE.has(entry.name)).map(entry => replacements.has(entry.name) ? storedEntry(entry.name, replacements.get(entry.name)) : entry); + updated.push(storedEntry(ADMISSION_SOURCE, admissionSource)); + const archive = rebuildZip(updated, section.length); + const result = Buffer.from(server); + archive.copy(result, section.start); + return result; +} + +/** Explicit install step; uses Node built-ins and the macOS signing tool only. */ +export async function patchAntigravityRuntime({ source, destination } = {}) { + if (process.platform !== "darwin" || process.arch !== "arm64") fail("only native macOS ARM64 is supported by this patch"); + if (!source || !destination) fail("explicit source and destination directories are required"); + const original = resolve(source); + const target = resolve(destination); + const serverPath = join(original, "agy_acp_server.par"); + const harnessPath = join(original, "localharness_external"); + for (const file of [serverPath, harnessPath]) { + const stat = await lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail("vendor closure contains links or special files"); + } + const [server, harness, admission] = await Promise.all([readFile(serverPath), readFile(harnessPath), readFile(new URL("./patch-antigravity-runtime.admission.py", import.meta.url))]); + if (digest(harness) !== HARNESS_HASH) fail("official helper SHA-256 mismatch"); + const patched = patchAntigravityServerBytes(server, admission); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + try { await lstat(target); fail("destination exists; refusing to replace a runtime"); } + catch (error) { if (error.code !== "ENOENT") throw error; } + const temporary = await mkdtemp(join(dirname(target), ".antigravity-native-patch-")); + try { + const executable = join(temporary, "agy_acp_server.par"); + await writeFile(executable, patched, { mode: 0o700 }); + execFileSync("/usr/bin/codesign", ["--force", "--sign", "-", "--timestamp=none", executable], { stdio: "pipe", timeout: 30_000 }); + execFileSync("/usr/bin/codesign", ["--verify", "--strict", executable], { stdio: "pipe", timeout: 30_000 }); + await writeFile(join(temporary, "localharness_external"), harness, { mode: 0o555 }); + await chmod(executable, 0o555); + await chmod(join(temporary, "localharness_external"), 0o555); + const signed = await readFile(executable); + const result = { schema: ANTIGRAVITY_PATCH_SCHEMA, version: ANTIGRAVITY_PATCH_VERSION, sourceServerSha256: SERVER_HASH, sourceHarnessSha256: HARNESS_HASH, admissionSourceSha256: digest(admission), unsignedServerSha256: digest(patched), serverSha256: digest(signed), harnessSha256: HARNESS_HASH }; + await rename(temporary, target); + return result; + } catch (error) { + await rm(temporary, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/paperclip-runner/scripts/patch-antigravity-runtime.test.mjs b/packages/paperclip-runner/scripts/patch-antigravity-runtime.test.mjs new file mode 100644 index 00000000000..2e9cfc257f6 --- /dev/null +++ b/packages/paperclip-runner/scripts/patch-antigravity-runtime.test.mjs @@ -0,0 +1,611 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { copyFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { antigravityParSection, patchAntigravityServerBytes, replaceAntigravitySource } from "./patch-antigravity-runtime.mjs"; + +const hash = value => createHash("sha256").update(value).digest("hex"); +const source = process.env.PAPERCLIP_ANTIGRAVITY_NATIVE_TEST_SOURCE; + +function runNative(executable, home, requests) { + return new Promise((resolve, reject) => { + const child = execFile(executable, [], { + cwd: dirname(executable), + env: { PATH: process.env.PATH, HOME: home, GEMINI_HOME: join(home, ".gemini"), BROWSER: "/usr/bin/true", AGY_ACP_FORCE_FILE_STORAGE: "1", ANTIGRAVITY_HARNESS_PATH: join(executable, "..", "localharness_external") }, + timeout: 30_000, maxBuffer: 2 * 1024 * 1024, + }, (error, stdout, stderr) => error ? reject(Object.assign(error, { stdout, stderr })) : resolve({ events: stdout.trim().split("\n").filter(Boolean).map(line => JSON.parse(line)), stderr })); + child.stdin.end(requests.map(request => JSON.stringify(request)).join("\n") + "\n"); + }); +} + +function initialize() { + return { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientInfo: { name: "paperclip-runner", version: "test" }, clientCapabilities: {} } }; +} + +function admission(content) { + return { schema: "paperclip.antigravity.admission.v1", nonce: "credential-free-regression", model: "gemini-3.1-flash-lite", digest: "sha256:" + hash(content), byteLength: Buffer.byteLength(content) }; +} + +// This fixture runs inside the real vendor hermetic Python interpreter. It +// tests native protobuf boundaries without creating a session or using auth. +const nativeBoundaryTests = ` + +def _paperclip_native_boundary_regressions(): + import sys + from types import SimpleNamespace + from google3.learning.genai.api.localharness import localharness_pb2 as pb + def rejected(fn): + try: + fn() + except exceptions.RequestError: + return + raise AssertionError("mismatched native admission was accepted") + for content in (" exact café 🧷\\n", ""): + raw = content.encode("utf-8") + request = {"schema": SCHEMA, "nonce": "native-regression", "model": "literal-model", "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), "byteLength": len(raw)} + metadata = {"antigravity": {"paperclipAdmission": request, "systemPrompt": {"append": content}}} + expected = _read_request(metadata) + assert expected["content"] == content + bad = {"antigravity": {"paperclipAdmission": {**request, "byteLength": len(raw) + 1}, "systemPrompt": {"append": content}}} + rejected(lambda: _read_request(bad)) + config = pb.HarnessConfig(cascade_id="native-session", models=[pb.ModelConfig(name="literal-model", types=[1])]) + if content: + config.system_instructions.appended.appended_sections.add(content=content) + token = _CONTEXT.set(expected) + try: + strategy = SimpleNamespace(_binary_path="/untrusted/cached/helper", _env={"ANTIGRAVITY_HARNESS_PATH": "/untrusted/env/helper"}) + prepared = prepare_native_config(config, strategy) + assert strategy._binary_path == os.environ["ANTIGRAVITY_HARNESS_PATH"] + assert strategy._env["ANTIGRAVITY_HARNESS_PATH"] == strategy._binary_path + native_ack = pb.OutputEvent(initialize_conversation_response=pb.InitializeConversationResponse(cascade_id="native-session")) + receipt = acknowledge_native_config(prepared, native_ack, config) + assert prepare_native_config(config, strategy)["connectionId"] != prepared["connectionId"] + assert receipt["digest"] == request["digest"] and receipt["byteLength"] == len(raw) + assert receipt["sessionId"] == "native-session" and receipt["connectionId"] + rejected(lambda: acknowledge_native_config(prepared, pb.OutputEvent(), config)) + wrong = pb.OutputEvent(initialize_conversation_response=pb.InitializeConversationResponse(cascade_id="another-session")) + rejected(lambda: acknowledge_native_config(prepared, wrong, config)) + config.models[0].name = "substituted-model" + rejected(lambda: prepare_native_config(config, strategy)) + rejected(lambda: acknowledge_native_config(prepared, native_ack, config)) + config.models[0].name = "literal-model" + config.system_instructions.appended.appended_sections.add(content="unexpected instruction") + rejected(lambda: prepare_native_config(config, strategy)) + del config.system_instructions.appended.appended_sections[:] + if content: + config.system_instructions.appended.appended_sections.add(content=content) + config.harness_side_tools.subagents.enabled = True + rejected(lambda: prepare_native_config(config, strategy)) + config.harness_side_tools.subagents.enabled = False + config.harness_side_tools.run_workflow.enabled = True + rejected(lambda: prepare_native_config(config, strategy)) + config.harness_side_tools.run_workflow.enabled = False + config.harness_side_tools.schedule.enabled = True + rejected(lambda: prepare_native_config(config, strategy)) + config.harness_side_tools.schedule.enabled = False + config.custom_subagents.add(name="unpromised-browser-agent") + rejected(lambda: prepare_native_config(config, strategy)) + finally: + _CONTEXT.reset(token) + sys.stderr.write("paperclip-native-boundary-regressions: passed\\n") + +_paperclip_native_boundary_regressions() +`; + +// Calls the real SDK subprocess/stdio/WebSocket startup. The native helper has +// not received InitializeConversationEvent, so no auth or inference is used. +const nativeHelperIsolationTests = ` + +def _paperclip_native_helper_isolation_regression(): + import asyncio + import subprocess + import sys + from google.antigravity import types as sdk_types + from google.antigravity.tools.tool_runner import ToolRunner + from google.antigravity.connections.local.local_connection_config import LocalAgentConfig + async def run(): + home = os.environ["HOME"] + os.makedirs(home, exist_ok=True) + sentinel = os.path.join(home, "untrusted-cached-helper") + marker = os.path.join(home, "untrusted-helper-launched") + with open(sentinel, "w") as output: + output.write("#!/bin/sh\\n: > " + marker + "\\nexit 0\\n") + os.chmod(sentinel, 0o700) + config = LocalAgentConfig(conversation_id="helper-isolation-session-1234567890123456", system_instructions="exact helper isolation", capabilities=sdk_types.CapabilitiesConfig(enable_subagents=False, disabled_tools=[sdk_types.BuiltinTools.SCHEDULE]), models=[sdk_types.ModelTarget(name="literal-model", types=[sdk_types.ModelType.TEXT], endpoint=sdk_types.GeminiAPIEndpoint())], env={"ANTIGRAVITY_HARNESS_PATH": sentinel}) + strategy = config.create_strategy(tool_runner=None, hook_runner=None) + assert strategy._binary_path == sentinel, "test must begin with an already-cached override" + raw = b"exact helper isolation" + admission = {"schema": SCHEMA, "nonce": "helper-isolation", "model": "literal-model", "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), "byteLength": len(raw), "content": raw.decode()} + token = _CONTEXT.set(admission) + owned = [] + launched_paths = [] + ws = None + original_popen = subprocess.Popen + def tracked_popen(*args, **kwargs): + process = original_popen(*args, **kwargs) + owned.append(process) + launched_paths.append(args[0][0]) + return process + try: + native = strategy._build_harness_config() + prepare_native_config(native, strategy) + subprocess.Popen = tracked_popen + process, ws, _url = await asyncio.wait_for(strategy._spawn_harness_and_connect_ws(), timeout=10) + assert process.poll() is None, "the actual pinned native helper must be running" + assert len(launched_paths) == 1 and launched_paths[0] == strategy._binary_path + assert launched_paths[0] != sentinel + assert strategy._env["ANTIGRAVITY_HARNESS_PATH"] == launched_paths[0] + assert not os.path.exists(marker), "the cached override must never execute" + finally: + subprocess.Popen = original_popen + _CONTEXT.reset(token) + if ws is not None: + await ws.close() + for process in owned: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None: + stream.close() + assert owned and all(process.poll() is not None for process in owned) + asyncio.run(run()) + sys.stderr.write("paperclip-native-helper-isolation-regression: passed\\n") + +_paperclip_native_helper_isolation_regression() +`; + +const nativeManagedSurfaceTests = ` + +def _paperclip_managed_surface_regressions(): + import asyncio + import json + import sys + from types import SimpleNamespace + from google.antigravity import types as sdk_types + from google.antigravity.tools.tool_runner import ToolRunner + from google3.cloud.developer_experience.antigravity_extensions.acp_server import browser_subagent + from google3.cloud.developer_experience.antigravity_extensions.acp_server.server import AgyAdapter, AuthState + install(AgyAdapter) + patched_create_config = AgyAdapter._create_agent_config + install(AgyAdapter) + assert AgyAdapter._create_agent_config is patched_create_config + class MemoryStore: + def read(self): return None + def write(self, value): pass + def clear(self): pass + class FixtureCredentials(OAuthCredentialManager): + def __init__(self): + super().__init__(client_id="fixture", client_secret="fixture", scopes=[], token_path=os.path.join(os.environ["HOME"], "unused-credentials.json"), keychain_service="fixture", keychain_account="fixture", store=MemoryStore()) + self.interactive_calls = 0 + self.tier_info = SimpleNamespace(is_empty=True) + def _authenticate_interactively(self): + self.interactive_calls += 1 + return SimpleNamespace(valid=True, token="controlled-fixture-token", refresh_token=None) + def _save_credentials(self, credentials): pass + def _resolve_backend(self, token, interactive_login=False): pass + async def run(): + workspace = os.path.join(os.environ["HOME"], "managed-surface-workspace") + os.makedirs(workspace, exist_ok=True) + hooks_path = acp_hooks.get_global_hooks_path() + os.makedirs(os.path.dirname(hooks_path), exist_ok=True) + hooks = {"fixture": {"enabled": True, "PreInvocation": [{"hooks": [{"type": "command", "command": "false"}]}]}} + with open(hooks_path, "w") as output: json.dump(hooks, output) + for workspace_hooks_path in acp_hooks.get_workspace_hooks_paths(workspace): + os.makedirs(os.path.dirname(workspace_hooks_path), exist_ok=True) + with open(workspace_hooks_path, "w") as output: json.dump(hooks, output) + assert acp_hooks.has_workspace_hooks(workspace) + assert acp_hooks.load_hooks_config(workspace), "unmanaged source loader must remain functional" + content = " Exact managed source config 🧷\\n" + raw = content.encode() + admission = {"schema": SCHEMA, "nonce": "managed-source-policy", "model": "gemini-3.1-flash-lite", "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), "byteLength": len(raw), "content": content} + adapter = AgyAdapter() + adapter._auth_state = AuthState(method_id="gemini-api-key", api_key="controlled-fixture-key-not-used") + original_preflight = browser_subagent.preflight_launcher + def forbidden_preflight(): raise AssertionError("managed browser launcher must never be probed") + token = _CONTEXT.set(admission) + try: + assert not acp_hooks.has_workspace_hooks(workspace) + assert acp_hooks.load_hooks_config(workspace) == {} + assert await acp_hooks.load_hooks_config_async(workspace) == {} + browser_subagent.preflight_launcher = forbidden_preflight + config, teardown = await adapter._create_agent_config(cwd=workspace, session_id="managed-source-policy-1234567890123456", gemini_home=os.environ["GEMINI_HOME"], model_id=admission["model"], system_instructions=content) + assert teardown is None + assert not config.capabilities.enable_subagents and not config.subagents + assert all(isinstance(hook, acp_hooks.ExternalOnInteractionHook) for hook in config.hooks) + strategy = config.create_strategy(tool_runner=ToolRunner(tools=config._get_all_custom_tools()), hook_runner=None) + native = strategy._build_harness_config() + assert not native.harness_side_tools.subagents.enabled + assert not native.harness_side_tools.run_workflow.enabled and not native.custom_subagents + assert not native.harness_side_tools.schedule.enabled + prepare_native_config(native, strategy) + managed_manager = FixtureCredentials() + adapter._oauth_mgr = managed_manager + assert adapter._ensure_oauth_mgr() is managed_manager + finally: + browser_subagent.preflight_launcher = original_preflight + _CONTEXT.reset(token) + try: + managed_manager.get_access_token() + except ValueError as error: + assert "renewed through Paperclip" in str(error) + else: + raise AssertionError("managed missing/expired credentials launched interactive auth") + assert managed_manager.interactive_calls == 0, "managed policy must survive outside its request context" + standalone = AgyAdapter() + interactive_manager = FixtureCredentials() + standalone._oauth_mgr = interactive_manager + await standalone.authenticate("oauth-personal") + assert interactive_manager.interactive_calls == 1, "explicit standalone authenticate must retain its interactive path" + assert not getattr(interactive_manager, "_paperclip_noninteractive", False) + assert acp_hooks.has_workspace_hooks(workspace) + assert acp_hooks.load_hooks_config(workspace), "unmanaged hooks policy must be unchanged" + asyncio.run(run()) + sys.stderr.write("paperclip-managed-surface-regressions: passed\\n") + +_paperclip_managed_surface_regressions() +`; + +// Exercises the shipped authentication assertion and CCPA model parser. Only +// the metadata response and in-memory credentials are controlled. Session, +// native helper, browser, network, and enterprise-poller factories must not run. +const nativeModelCatalogTests = ` + +def _paperclip_model_catalog_regressions(): + import asyncio + import contextlib + import json + import socket + import subprocess + import sys + import threading + import urllib.request + import webbrowser + from types import SimpleNamespace + from unittest import mock + from google.auth import exceptions as auth_exceptions + from google3.cloud.developer_experience.antigravity_extensions.acp_server import client_info + from google3.cloud.developer_experience.antigravity_extensions.acp_server import server as native_server + from google3.cloud.developer_experience.antigravity_extensions.acp_server import settings_writer + from google3.cloud.developer_experience.antigravity_extensions.acp_server.ccpa_connection import ccpa_client + AgyAdapter = native_server.AgyAdapter + AuthState = native_server.AuthState + install(AgyAdapter) + calls = [] + main_thread = threading.get_ident() + response = { + "models": { + "gemini-exact-low-effort": {"displayName": " Exact café Model 🧷 ", "description": "private description must not escape"}, + "gemini-exact-high-effort": {"displayName": "Native Default"}, + "other-provider": {"displayName": "Filtered for this client"}, + "gemini-disabled": {"displayName": "Not entitled", "disabled": True}, + "gemini-unlisted": {"displayName": "Not advertised in sorts"}, + }, + "agentModelSorts": [{"groups": [{"modelIds": ["gemini-exact-low-effort", "other-provider", "gemini-disabled", "gemini-exact-low-effort", "gemini-exact-high-effort"]}]}], + "defaultAgentModelId": "gemini-exact-high-effort", + } + class MemoryStore: + def read(self): return None + def write(self, value): pass + def clear(self): pass + class Credentials(OAuthCredentialManager): + def __init__(self): + super().__init__(client_id="fixture", client_secret="fixture", scopes=[], token_path=os.path.join(os.environ["HOME"], "unused-model-credentials.json"), keychain_service="fixture", keychain_account="fixture", store=MemoryStore()) + self._creds = SimpleNamespace(valid=True, token="controlled-catalog-token", refresh_token=None) + self.project_id = "controlled-project" + self.endpoint_url = "https://catalog.invalid" + self.interactive_calls = 0 + self.resolutions = 0 + def _authenticate_interactively(self): + self.interactive_calls += 1 + raise AssertionError("catalog must never authenticate interactively") + def _save_credentials(self, credentials): pass + def _resolve_backend(self, token, interactive_login=False): + assert getattr(self, "_paperclip_noninteractive", False), "noninteractive policy must precede native authentication" + assert not interactive_login + self.resolutions += 1 + def forbidden(*args, **kwargs): + raise AssertionError("session-free model discovery reached a forbidden factory") + async def rejected(adapter, params, message=None): + try: + await adapter.ext_method("_paperclip/models", params) + except exceptions.RequestError as error: + text = str(error) + assert "controlled-secret" not in text + assert "private description" not in text + if message is not None: assert message in text, text + return error + raise AssertionError("invalid model catalog operation succeeded") + request = {"schema": MODELS_SCHEMA, "nonce": "catalog-nonce 🧷", "authMethod": "oauth-personal"} + def adapter(): + value = AgyAdapter(sdk_agent_factory=forbidden) + value._client_info = client_info.parse(SimpleNamespace(name="paperclip-runner", title="Paperclip Runner", version="test")) + value._oauth_mgr = Credentials() + return value + def fetch(token, endpoint, project, user_agent): + assert threading.get_ident() != main_thread, "catalog must use the bounded caller's worker thread" + assert token == "controlled-catalog-token" and endpoint == "https://catalog.invalid" and project == "controlled-project" + calls.append(user_agent) + return response + async def run(): + settings_writer.save_auth_type("oauth-personal") + value = adapter() + assert value._auth_state is None, "saved settings must select the actual native auth method" + result = await value.ext_method("_paperclip/models", request) + assert result == {"schema": MODELS_SCHEMA, "nonce": request["nonce"], "authMethod": "oauth-personal", "models": [{"id": "gemini-exact-low-effort", "label": " Exact café Model 🧷 "}, {"id": "gemini-exact-high-effort", "label": "Native Default"}], "defaultModelId": "gemini-exact-high-effort"} + assert value._auth_state.method_id == "oauth-personal" + assert len(calls) == 1 and value._oauth_mgr.resolutions == 2 + assert value._oauth_mgr._paperclip_noninteractive and value._oauth_mgr.interactive_calls == 0 + assert value._sessions == {} and value._background_tasks == {} + assert value._proxy_server is None and value._baic_proxy_server is None and value._admin_controls_mgr is None + assert "private description" not in json.dumps(result) + for invalid in (None, {}, {**request, "schema": "other"}, {**request, "authMethod": "oauth-business"}, {**request, "authMethod": "gemini-api-key"}, {**request, "nonce": ""}, {**request, "nonce": 3}, {**request, "nonce": "x" * 513}, {**request, "extra": True}): + await rejected(value, invalid, "invalid model catalog request") + assert len(calls) == 1 + for method in ("gemini-api-key", "oauth-business", "agent-platform", "gateway"): + wrong = adapter() + wrong._auth_state = AuthState(method_id=method) + await rejected(wrong, request, "sign-in must be renewed") + assert not getattr(wrong._oauth_mgr, "_paperclip_noninteractive", False) + logged_out = adapter() + logged_out._auth_state = AuthState(method_id="oauth-personal") + logged_out._logged_out = True + await rejected(logged_out, request, "sign-in must be renewed") + assert not getattr(logged_out._oauth_mgr, "_paperclip_noninteractive", False) + settings_writer.clear_auth_type() + await rejected(adapter(), request, "sign-in must be renewed") + settings_writer.save_auth_type("oauth-personal") + active = adapter() + active._sessions["not-created"] = object() + await rejected(active, request, "session-free") + active._sessions.clear() + active._paperclip_managed_sessions = {"not-created": {}} + await rejected(active, request, "session-free") + token = _CONTEXT.set({"model": "must-not-resolve"}) + try: await rejected(adapter(), request, "session-free") + finally: _CONTEXT.reset(token) + assert len(calls) == 1 + # The persistent manager policy must still hold after this request returns. + manager = value._oauth_mgr + manager._creds = None + try: manager.get_access_token() + except ValueError: pass + else: raise AssertionError("catalog manager lost its noninteractive policy") + await rejected(value, request, "sign-in must be renewed") + assert manager.interactive_calls == 0 and len(calls) == 1 + expired = adapter() + class Expired: + valid = False + refresh_token = "controlled-refresh" + def refresh(self, request): raise auth_exceptions.RefreshError("controlled-secret refresh rejected") + expired._oauth_mgr._creds = Expired() + await rejected(expired, request, "sign-in must be renewed") + assert len(calls) == 1 + # Provider failures and empty/malformed catalogs must not produce custom or + # hardcoded model fallbacks, or expose backend/credential response text. + with mock.patch.object(ccpa_client, "fetch_available_models", side_effect=RuntimeError("controlled-secret backend detail")): + await rejected(adapter(), request, "could not list models") + for bad in ({}, {"models": {}, "defaultAgentModelId": "unoffered"}, {**response, "models": {**response["models"], "gemini-exact-low-effort": {"displayName": {"controlled-secret": True}}}}): + with mock.patch.object(ccpa_client, "fetch_available_models", return_value=bad): + await rejected(adapter(), request, "could not list models") + with contextlib.ExitStack() as stack: + for owner, name in ((AgyAdapter, "_create_agent_config"), (AgyAdapter, "_ensure_business_auth_mgr"), (AgyAdapter, "_ensure_admin_controls_mgr"), (AgyAdapter, "_init_admin_controls_for_session"), (native_server.session_store, "trajectory_path"), (native_server.browser_subagent, "preflight_launcher"), (native_server.LocalAgentConfig, "create_strategy"), (subprocess, "Popen"), (asyncio, "create_subprocess_exec"), (socket, "create_connection"), (urllib.request, "urlopen"), (webbrowser, "open"), (webbrowser, "open_new_tab")): + stack.enter_context(mock.patch.object(owner, name, side_effect=forbidden)) + stack.enter_context(mock.patch.object(ccpa_client, "fetch_available_models", side_effect=fetch)) + stack.enter_context(mock.patch.dict(os.environ, {"AGY_ACP_DEFAULT_MODEL": "unoffered-custom-fallback"})) + asyncio.run(run()) + sys.stderr.write("paperclip-model-catalog-regressions: passed\\n") + +_paperclip_model_catalog_regressions() +`; + +// These state tests use the shipped SDK config builder, ACP response classes, +// and SessionInfo. The native acknowledgment is controlled; no agent is entered +// and no authentication or network request occurs. +const nativeStateTests = ` + +def _paperclip_managed_state_regressions(): + import asyncio + import contextlib + import sys + from types import SimpleNamespace + from acp import schema + from google.antigravity import types as sdk_types + from google.antigravity.connections.local.local_connection_config import LocalAgentConfig + from google3.learning.genai.api.localharness import localharness_pb2 as pb + from google3.cloud.developer_experience.antigravity_extensions.acp_server.server import SessionInfo + sid = "native-managed-session-12345678901234567890" + def metadata(content, nonce): + raw = content.encode("utf-8") + return {"antigravity": {"paperclipAdmission": {"schema": SCHEMA, "nonce": nonce, "model": "literal-model", "digest": "sha256:" + hashlib.sha256(raw).hexdigest(), "byteLength": len(raw)}, "systemPrompt": {"append": content}}} + async def rejected(fn): + try: + await fn() + except exceptions.RequestError: + return + raise AssertionError("managed state mismatch was accepted") + class FixtureAdapter: + def __init__(self): + self._sessions = {} + self.prompts = 0 + self.replacements = 0 + async def initialize(self, **kwargs): + return schema.InitializeResponse(protocol_version=1, agent_capabilities=schema.AgentCapabilities(), auth_methods=[]) + def _list_available_models(self, model): + return schema.SessionModelState(current_model_id=model, available_models=[]) + async def _create_agent_config(self, **kwargs): + disabled = kwargs["tool_filter"].resolve_disabled_builtins() + assert sdk_types.BuiltinTools.INVOKE_SUBAGENT in disabled and sdk_types.BuiltinTools.RUN_WORKFLOW in disabled + assert sdk_types.BuiltinTools.SCHEDULE in disabled + return LocalAgentConfig(conversation_id=kwargs["session_id"], system_instructions=kwargs["system_instructions"], capabilities=sdk_types.CapabilitiesConfig(enable_subagents=False, disabled_tools=list(disabled)), models=[sdk_types.ModelTarget(name=kwargs["model_id"], types=[sdk_types.ModelType.TEXT], endpoint=sdk_types.GeminiAPIEndpoint())]) + async def _initialize_fixture(self, session_id): + expected = _CONTEXT.get() + model = self._list_available_models("native-default").current_model_id + config = await self._create_agent_config(session_id=session_id, model_id=model, system_instructions=expected["content"]) + strategy = config.create_strategy(tool_runner=None, hook_runner=None) + native = strategy._build_harness_config() + prepared = prepare_native_config(native, strategy) + ack = pb.OutputEvent(initialize_conversation_response=pb.InitializeConversationResponse(cascade_id=session_id)) + strategy._paperclip_native_admission = acknowledge_native_config(prepared, ack, native) + agent = SimpleNamespace(_strategy=strategy, conversation=SimpleNamespace(is_idle=True)) + self._sessions[session_id] = SessionInfo(agent=agent, stack=contextlib.AsyncExitStack(), current_model_id=model, system_prompt=expected["content"]) + async def new_session(self, cwd, **kwargs): + await self._initialize_fixture(sid) + return schema.NewSessionResponse(session_id=sid) + async def load_session(self, session_id, cwd, **kwargs): + await self._initialize_fixture(session_id) + return schema.LoadSessionResponse() + async def resume_session(self, session_id, cwd, **kwargs): + await self._initialize_fixture(session_id) + return schema.ResumeSessionResponse() + async def _rebuild_session_agent(self, session_id): + await self._initialize_fixture(session_id) + async def _maybe_replace_unavailable_model(self, session_id): + self.replacements += 1 + async def prompt(self, prompt, session_id, **kwargs): + self.prompts += 1 + return schema.PromptResponse(stop_reason="end_turn") + async def set_session_model(self, model_id, session_id, **kwargs): + self._sessions[session_id].current_model_id = model_id + return schema.SetSessionModelResponse() + async def set_config_option(self, config_id, session_id, value, **kwargs): + self._sessions[session_id].current_model_id = value + return schema.SetSessionConfigOptionResponse(config_options=[]) + async def cleanup(self, session_id=None): + if session_id is None: + self._sessions.clear() + else: + self._sessions.pop(session_id, None) + async def run(): + adapter = FixtureAdapter() + await adapter.initialize() + first = await adapter.new_session("/fixture", **metadata(" exact café 🧷\\n", "open-nonce")) + receipt = first.field_meta["paperclipAntigravityAdmission"] + assert receipt["sessionId"] == sid and receipt["nonce"] == "open-nonce" + status = await adapter.ext_method("paperclip/admission", {"sessionId": sid, "nonce": "open-nonce"}) + assert status["paperclipAntigravityAdmission"] == receipt + session = adapter._sessions[sid] + session.mode_id = "yolo" + await rejected(lambda: adapter.prompt([], sid)) + assert adapter.prompts == 0 + session.mode_id = "default" + await adapter._maybe_replace_unavailable_model(sid) + assert adapter.replacements == 0 + changed = await adapter.set_config_option("model", sid, "literal-model", **metadata("", "setter-nonce")) + changed_receipt = changed.field_meta["paperclipAntigravityAdmission"] + assert changed_receipt["nonce"] == "setter-nonce" and changed_receipt["byteLength"] == 0 + assert changed_receipt["connectionId"] != receipt["connectionId"] + await rejected(lambda: adapter.ext_method("paperclip/admission", {"sessionId": sid, "nonce": "open-nonce"})) + await rejected(lambda: adapter.load_session(sid, "/fixture")) + loaded = await adapter.load_session(sid, "/fixture", **metadata(" exact reload ", "load-nonce")) + assert loaded.field_meta["paperclipAntigravityAdmission"]["nonce"] == "load-nonce" + resumed = await adapter.resume_session(sid, "/fixture", **metadata(" exact resume ", "resume-nonce")) + assert resumed.field_meta["paperclipAntigravityAdmission"]["nonce"] == "resume-nonce" + await adapter.cleanup(sid) + assert not adapter._paperclip_managed_sessions + original_extract = system_prompt.extract_system_prompt + try: + install(FixtureAdapter) + asyncio.run(run()) + finally: + system_prompt.extract_system_prompt = original_extract + sys.stderr.write("paperclip-managed-state-regressions: passed\\n") + +_paperclip_managed_state_regressions() +`; + +test("native patch rejects an unpinned executable before decoding", () => { + assert.throws(() => patchAntigravityServerBytes(Buffer.from("ambient executable"), Buffer.from("module")), /SHA-256 mismatch/); +}); + +test("source changes require a single exact pinned anchor", () => { + assert.throws(() => replaceAntigravitySource("changed", "original", "patch"), /anchor changed/); + assert.throws(() => replaceAntigravitySource("original original", "original", "patch"), /anchor changed/); +}); + +test("Mach-O section decoding rejects invalid architecture and out-of-bounds native archives", () => { + assert.throws(() => antigravityParSection(Buffer.alloc(64)), /ARM64 Mach-O/); + const bytes = Buffer.alloc(32 + 72 + 80); + bytes.writeUInt32LE(0xfeedfacf); + bytes.writeUInt32LE(0x0100000c, 4); + bytes.writeUInt32LE(1, 16); + bytes.writeUInt32LE(0x19, 32); + bytes.writeUInt32LE(152, 36); + bytes.writeUInt32LE(1, 96); + bytes.write(".par_data", 104); + bytes.writeBigUInt64LE(1000n, 144); + bytes.writeUInt32LE(10, 152); + assert.throws(() => antigravityParSection(bytes), /archive bounds/); +}); + +test("pinned native closure reproducibly loads exact admission and rejects requests before authentication", { + skip: !source || process.platform !== "darwin" || process.arch !== "arm64", + timeout: 180_000, +}, async t => { + const root = await mkdtemp(join(tmpdir(), "paperclip-antigravity-native-patch-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const originalHash = hash(await readFile(join(source, "agy_acp_server.par"))); + const { patchAntigravityRuntime } = await import("./patch-antigravity-runtime.mjs"); + const first = await patchAntigravityRuntime({ source, destination: join(root, "first") }); + const second = await patchAntigravityRuntime({ source, destination: join(root, "second") }); + assert.deepEqual(second, first, "the complete signed patch must be reproducible"); + assert.equal(hash(await readFile(join(source, "agy_acp_server.par"))), originalHash, "vendor source remains immutable"); + const content = " exact café 🧷\n"; + const valid = admission(content); + const create = (id, value) => ({ jsonrpc: "2.0", id, method: "session/new", params: { cwd: root, mcpServers: [], _meta: { antigravity: { paperclipAdmission: value, systemPrompt: { append: content } } } } }); + const models = { schema: "paperclip.antigravity.models.v1", nonce: "native-models-nonce", authMethod: "oauth-personal" }; + const result = await runNative(join(root, "first/agy_acp_server.par"), join(root, "home"), [initialize(), create(2, { ...valid, digest: "sha256:" + "0".repeat(64) }), create(3, valid), { jsonrpc: "2.0", id: 4, method: "_paperclip/admission", params: { sessionId: "missing", nonce: valid.nonce } }, { jsonrpc: "2.0", id: 5, method: "_paperclip/models", params: models }, { jsonrpc: "2.0", id: 6, method: "_paperclip/models", params: { ...models, extra: true } }]); + const byId = new Map(result.events.filter(event => event.id !== undefined).map(event => [event.id, event])); + assert.equal(byId.get(1).result.agentInfo.name, "antigravity-acp"); + assert.equal(byId.get(1).result.agentCapabilities._meta.antigravity.paperclipAdmission, 1); + assert.equal(byId.get(1).result.agentCapabilities._meta.antigravity.paperclipModels, 1); + assert.match(byId.get(2).error.message, /digest mismatch/); + assert.equal(byId.get(3).error.code, -32000, "valid metadata reaches the native authentication gate"); + assert.match(byId.get(4).error.message, /unknown admission session or nonce/, "native status extension fails closed without a session"); + assert.equal(byId.get(5).error.code, -32000, "session-free model discovery requires saved personal sign-in"); + assert.match(byId.get(5).error.message, /sign-in must be renewed through Paperclip/); + assert.equal(byId.get(6).error.code, -32602, "the native model request contract rejects extra fields"); + assert(!result.events.some(event => event.result?._meta?.paperclipAntigravityAdmission), "no native init means no fabricated receipt"); + + const fixtureScripts = join(root, "fixture-scripts"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(fixtureScripts); + await copyFile(new URL("./patch-antigravity-runtime.mjs", import.meta.url), join(fixtureScripts, "patch-antigravity-runtime.mjs")); + const module = await readFile(new URL("./patch-antigravity-runtime.admission.py", import.meta.url), "utf8"); + await writeFile(join(fixtureScripts, "patch-antigravity-runtime.admission.py"), module + nativeBoundaryTests + nativeHelperIsolationTests + nativeManagedSurfaceTests + nativeModelCatalogTests + nativeStateTests); + const fixture = await import(pathToFileURL(join(fixtureScripts, "patch-antigravity-runtime.mjs"))); + await fixture.patchAntigravityRuntime({ source, destination: join(root, "boundary-fixture") }); + const tested = await runNative(join(root, "boundary-fixture/agy_acp_server.par"), join(root, "fixture-home"), [initialize()]); + assert.match(tested.stderr, /paperclip-native-boundary-regressions: passed/); + assert.match(tested.stderr, /paperclip-native-helper-isolation-regression: passed/); + assert.match(tested.stderr, /paperclip-managed-surface-regressions: passed/); + assert.match(tested.stderr, /paperclip-model-catalog-regressions: passed/); + assert.match(tested.stderr, /paperclip-managed-state-regressions: passed/); + assert.equal(tested.events[0].result.agentCapabilities._meta.antigravity.paperclipAdmission, 1); + assert.equal(tested.events[0].result.agentCapabilities._meta.antigravity.paperclipModels, 1); +}); + + +test("native client file callbacks settle exact tool identity without broadcasting errors", { + skip: !source || process.platform !== "darwin" || process.arch !== "arm64", + timeout: 90_000, +}, async t => { + const root = await mkdtemp(join(tmpdir(), "paperclip-antigravity-tool-lifecycle-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const fixtureScripts = join(root, "scripts"); + const { mkdir } = await import("node:fs/promises"); + await mkdir(fixtureScripts); + await copyFile(new URL("./patch-antigravity-runtime.mjs", import.meta.url), join(fixtureScripts, "patch-antigravity-runtime.mjs")); + const module = await readFile(new URL("./patch-antigravity-runtime.admission.py", import.meta.url), "utf8"); + const regression = await readFile(new URL("./patch-antigravity-runtime.tool-lifecycle.test.py", import.meta.url), "utf8"); + await writeFile(join(fixtureScripts, "patch-antigravity-runtime.admission.py"), module + "\n" + regression); + const fixture = await import(pathToFileURL(join(fixtureScripts, "patch-antigravity-runtime.mjs"))); + await fixture.patchAntigravityRuntime({ source, destination: join(root, "runtime") }); + const tested = await runNative(join(root, "runtime/agy_acp_server.par"), join(root, "home"), [initialize()]); + assert.match(tested.stderr, /paperclip-tool-lifecycle-regressions: passed/); + assert.equal(tested.events[0].result.agentCapabilities._meta.antigravity.paperclipAdmission, 1); +}); diff --git a/packages/paperclip-runner/scripts/patch-antigravity-runtime.tool-lifecycle.test.py b/packages/paperclip-runner/scripts/patch-antigravity-runtime.tool-lifecycle.test.py new file mode 100644 index 00000000000..b16c7ebc4b6 --- /dev/null +++ b/packages/paperclip-runner/scripts/patch-antigravity-runtime.tool-lifecycle.test.py @@ -0,0 +1,160 @@ +"""Credential-free regressions executed in the real pinned hermetic SDK.""" + + +def _paperclip_tool_lifecycle_regressions(): + import asyncio + import contextlib + import json + import socket + import subprocess + import sys + from pathlib import Path + from types import SimpleNamespace + from unittest import mock + from acp import schema + from google.antigravity import types as sdk_types + from google.antigravity.connections.local.event_processor import LocalHarnessEventProcessor + from google.antigravity.proto import localharness_pb2 as pb + from google.antigravity.tools.tool_context import ToolContext + from google.antigravity.tools.tool_runner import ToolRunner + from google3.cloud.developer_experience.antigravity_extensions.acp_server import tools + from google3.cloud.developer_experience.antigravity_extensions.acp_server.server import AgyAdapter, SessionInfo + + sid = "offline-tool-lifecycle" + workspace = Path(os.environ["HOME"]) / "tool-workspace" + workspace.mkdir(parents=True) + + class FileClient: + def __init__(self): + self.updates = [] + self.blocked = asyncio.Event() + self.release = asyncio.Event() + + async def session_update(self, session_id, update): + assert session_id == sid + self.updates.append(update) + + async def write_text_file(self, session_id, path, content): + assert session_id == sid + Path(path).write_bytes(content.encode("utf-8")) + + async def read_text_file(self, session_id, path, line=None, limit=None): + assert session_id == sid + if Path(path).name == "blocked.txt": + self.blocked.set() + await self.release.wait() + return schema.ReadTextFileResponse(content=Path(path).read_bytes().decode("utf-8")) + + class Adapter(AgyAdapter): + async def _maybe_send_usage_update(self, session_id): + pass + + def forbidden(*args, **kwargs): + raise AssertionError("offline lifecycle regression reached network or process launch") + + async def run(): + client = FileClient() + adapter = Adapter() + adapter._client = client + info = SessionInfo(agent=SimpleNamespace(), stack=contextlib.AsyncExitStack(), cwd=str(workspace), current_model_id="offline") + adapter._sessions[sid] = info + runner = ToolRunner(tools=[tools.make_client_view_file(client, str(workspace)), tools.make_client_create_file(client, str(workspace)), tools.make_client_edit_file(client, str(workspace))]) + runner.set_context(ToolContext(SimpleNamespace(conversation_id=sid))) + wire = [] + + async def send(event): + assert event.HasField("tool_response") + wire.append(event.tool_response) + + processor = LocalHarnessEventProcessor(send_input_event_fn=send, tool_runner=runner) + opened = {} + + async def drain(): + steps = [] + while not processor.step_queue.empty(): + step = processor.step_queue.get_nowait() + steps.append(step) + await adapter._stream_live_step(sid, step, opened) + return steps + + async def call(name, call_id, args, permission_frame=None): + if permission_frame: + info.pending_permission_frames[name] = [permission_frame] + await processor.handle_tool_call(pb.ToolCall(id=call_id, name=name, trajectory_id=sid, arguments_json=json.dumps(args))) + steps = await drain() + assert len(steps) == 2, "actual client ToolResult must yield an ACTIVE and terminal step" + assert [s.tool_calls[0].id for s in steps] == [call_id, call_id] + assert all(s.id == call_id and s.tool_calls[0].step_id is None for s in steps), "preserve the callback protocol's real identity without inventing a step id" + assert wire[-1].id == call_id + frame = permission_frame or call_id + terminals = [u for u in client.updates if u.tool_call_id == frame and u.status in ("completed", "failed")] + assert len(terminals) == 1 + assert frame not in opened + return steps[-1], terminals[0], wire[-1] + + proof = workspace / "proof.txt" + exact = " exact café 🧷\n" + terminal, update, response = await call("client_create_file", "create-call", {"target_file": str(proof), "code_content": exact}, "create-permission") + assert terminal.status == sdk_types.StepStatus.DONE and terminal.content == "" + assert update.status == "completed" and update.raw_output == "" + assert proof.read_bytes() == exact.encode("utf-8") + assert json.loads(response.response_json) == {"result": None} + terminal, update, response = await call("client_view_file", "read-call", {"absolute_path": str(proof)}) + assert terminal.content == exact and update.raw_output == exact + assert json.loads(response.response_json) == {"result": exact} + edited = "replacement\n" + await call("client_edit_file", "edit-call", {"target_file": str(proof), "code_content": edited}) + assert proof.read_bytes() == edited.encode("utf-8") + + failed_path = workspace / "missing-parent" / "failed-write.txt" + terminal, update, response = await call("client_create_file", "failed-write-call", {"target_file": str(failed_path), "code_content": "must not exist"}) + assert terminal.status == sdk_types.StepStatus.ERROR and update.status == "failed" + assert update.raw_output == terminal.error == response.error_message + assert not failed_path.exists() + + # A real pending callback overlaps a different callback's actual failure. + (workspace / "blocked.txt").write_bytes(b"still independent\n") + pending = asyncio.create_task(processor.handle_tool_call(pb.ToolCall(id="pending-call", name="client_view_file", trajectory_id=sid, arguments_json=json.dumps({"absolute_path": str(workspace / "blocked.txt")})))) + await client.blocked.wait() + await drain() + assert "pending-call" in opened + terminal, update, response = await call("client_view_file", "missing-call", {"absolute_path": str(workspace / "missing.txt")}, "missing-permission") + assert terminal.status == sdk_types.StepStatus.ERROR and terminal.error + assert update.status == "failed" and update.raw_output == terminal.error + assert response.error_message == terminal.error + assert "pending-call" in opened, "a genuine callback failure must not fail its concurrent sibling" + assert not [u for u in client.updates if u.tool_call_id == "pending-call" and u.status == "failed"] + client.release.set() + await pending + await drain() + assert "pending-call" not in opened + assert [u for u in client.updates if u.tool_call_id == "pending-call" and u.status == "completed"][0].raw_output == "still independent\n" + + # Native errors close only the failing call, including permission identity. + def native_step(call_id, status, error="", calls=True): + return sdk_types.Step(id="native-step", trajectory_id=sid, type=sdk_types.StepType.TOOL_CALL, status=status, error=error, tool_calls=[sdk_types.ToolCall(id=call_id, step_id="native-step", name="native_tool")] if calls else []) + info.pending_permission_frames["native_tool"] = ["native-permission"] + await adapter._stream_live_step(sid, native_step("native-execution", sdk_types.StepStatus.ACTIVE), opened) + opened["unrelated"] = "native_other_tool" + await adapter._stream_live_step(sid, native_step("native-execution", sdk_types.StepStatus.ERROR, "exact native failure"), opened) + assert "native-permission" not in opened and "unrelated" in opened + failures = [u for u in client.updates if u.status == "failed"] + assert {u.tool_call_id for u in failures} == {"failed-write-call", "missing-permission", "native-permission"} + assert failures[-1].raw_output == "exact native failure" + before = len(client.updates) + await adapter._stream_live_step(sid, native_step("unattributed", sdk_types.StepStatus.ERROR, "turn-only error", calls=False), opened) + assert len(client.updates) == before and "unrelated" in opened, "an unattributed error has no authority to fail an unrelated tool" + # The existing failed-turn cleanup still settles unresolved work, without + # copying an unrelated step's error or fabricating completed callbacks. + await adapter._close_open_tool_calls(sid, set(opened), "failed") + assert client.updates[-1].tool_call_id == "unrelated" and client.updates[-1].status == "failed" + assert client.updates[-1].raw_output is None + + with contextlib.ExitStack() as stack: + for owner, name in ((socket, "create_connection"), (subprocess, "Popen"), (asyncio, "create_subprocess_exec")): + stack.enter_context(mock.patch.object(owner, name, side_effect=forbidden)) + asyncio.run(run()) + sys.stderr.write("paperclip-tool-lifecycle-regressions: passed\n") + + +_paperclip_tool_lifecycle_regressions() diff --git a/packages/paperclip-runner/scripts/probe-antigravity-metadata.mjs b/packages/paperclip-runner/scripts/probe-antigravity-metadata.mjs new file mode 100644 index 00000000000..e6c1aae1521 --- /dev/null +++ b/packages/paperclip-runner/scripts/probe-antigravity-metadata.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import { pathToFileURL } from "node:url"; +import { antigravityRuntimeCachePath } from "./antigravity-provisioner-layout.mjs"; +import { antigravityDistribution, verifyAntigravityDistribution } from "./materialize-antigravity-distribution.mjs"; + +const MAX_WIRE_BYTES = 1024 * 1024; +const CLIENT_INFO = Object.freeze({ name: "paperclip-runner", title: "Paperclip Runner", version: "1" }); +const AUTH_METHOD_IDS = ["oauth-personal", "oauth-business", "gemini-api-key", "agent-platform"]; +const plainObject = value => value !== null && typeof value === "object" && !Array.isArray(value); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function probeLimits(options) { + const limits = { startupTimeoutMs: options.startupTimeoutMs ?? 25_000, maxBytes: options.maxBytes ?? MAX_WIRE_BYTES, + eofTimeoutMs: options.eofTimeoutMs ?? 500, terminateTimeoutMs: options.terminateTimeoutMs ?? 500, killTimeoutMs: options.killTimeoutMs ?? 500 }; + for (const [name, maximum] of [["startupTimeoutMs", 30_000], ["maxBytes", MAX_WIRE_BYTES], ["eofTimeoutMs", 5_000], ["terminateTimeoutMs", 5_000], ["killTimeoutMs", 5_000]]) { + if (!Number.isSafeInteger(limits[name]) || limits[name] < 1 || limits[name] > maximum) throw new Error("Invalid Antigravity metadata probe limits"); + } + return limits; +} + +/** No ambient credentials, proxy, hooks, Python, Node, or configuration variables. */ +export function antigravityMetadataEnvironment(root, runtimeDirectory) { + return { + PATH: "/usr/bin:/bin", HOME: join(root, "home"), GEMINI_HOME: join(root, "gemini"), + XDG_CONFIG_HOME: join(root, "config"), XDG_DATA_HOME: join(root, "data"), XDG_CACHE_HOME: join(root, "cache"), + TMPDIR: join(root, "tmp"), AGY_ACP_FORCE_FILE_STORAGE: "1", ANTIGRAVITY_HARNESS_PATH: join(runtimeDirectory, "localharness_external"), + NO_COLOR: "1", LANG: "en_US.UTF-8", + }; +} + +/** One closed protocol exchange. Provider-authored text never enters diagnostics. */ +export function createAntigravityMetadataRpc(child, { startupTimeoutMs = 25_000, maxBytes = MAX_WIRE_BYTES } = {}) { + if (!Number.isSafeInteger(startupTimeoutMs) || startupTimeoutMs < 1 || startupTimeoutMs > 30_000 || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_WIRE_BYTES) throw new Error("Invalid Antigravity metadata probe limits"); + const decoder = new StringDecoder("utf8"); + const utf8Validator = new TextDecoder("utf-8", { fatal: true }); + let buffer = "", bytes = 0, stderrBytes = 0, started = false, settled = false, failure, timer; + let resolveInitialization, rejectInitialization; + const initialized = new Promise((resolve, reject) => { resolveInitialization = resolve; rejectInitialization = reject; }); + // Failures before initialize() still have an owned rejection handler. + initialized.catch(() => {}); + function fail(code) { + failure ??= new Error(code); + clearTimeout(timer); + if (!settled) { settled = true; rejectInitialization(failure); } + } + function parse(line) { + if (!line.trim()) return; + let message; + try { message = JSON.parse(line); } catch { fail("antigravity_malformed_json"); return; } + if (!plainObject(message) || message.jsonrpc !== "2.0" || message.id !== 1 || "method" in message || settled) { fail("antigravity_unexpected_message"); return; } + if ("error" in message) { fail("antigravity_initialize_rejected"); return; } + if (!plainObject(message.result)) { fail("antigravity_malformed_initialize_result"); return; } + clearTimeout(timer); + settled = true; + resolveInitialization(message.result); + } + child.stdout.on("data", chunk => { + bytes += chunk.length; + if (bytes > maxBytes) { fail("antigravity_stdout_limit"); return; } + try { utf8Validator.decode(chunk, { stream: true }); } + catch { fail("antigravity_malformed_utf8"); return; } + buffer += decoder.write(chunk); + while (!failure && buffer.includes("\n")) { + const end = buffer.indexOf("\n"); + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + parse(line); + } + }); + child.stdout.once("end", () => { + try { utf8Validator.decode(); } + catch { fail("antigravity_malformed_utf8"); return; } + buffer += decoder.end(); + if (buffer.trim()) fail("antigravity_truncated_stream"); + }); + child.stderr.on("data", chunk => { + stderrBytes += chunk.length; + if (stderrBytes > maxBytes) fail("antigravity_stderr_limit"); + }); + child.once("error", () => fail("antigravity_spawn_failure")); + child.once("close", () => { if (!settled) fail("antigravity_exited_before_initialize"); }); + child.stdin.on("error", () => { if (!settled) fail("antigravity_stdin_failure"); }); + return { + initialize() { + if (started) throw new Error("Antigravity metadata initialization can only be sent once"); + started = true; + timer = setTimeout(() => fail("antigravity_initialize_timeout"), startupTimeoutMs); + if (!failure) child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientInfo: CLIENT_INFO, clientCapabilities: {} } })}\n`); + return initialized; + }, + assertHealthy() { if (failure) throw failure; }, + close() { clearTimeout(timer); }, + }; +} + +function ownedGroupAlive(child) { + if (!child.pid) return false; + try { process.kill(-child.pid, 0); return true; } + catch (error) { + if (error.code === "ESRCH") return false; + // Darwin can report EPERM for a group containing only an orphaned zombie. + // Inspect numeric state only; never read process commands or environments. + if (error.code === "EPERM" && process.platform === "darwin") { + const snapshot = spawnSync("/bin/ps", ["-axo", "pid=,pgid=,stat="], { encoding: "utf8", timeout: 1_000, maxBuffer: 1024 * 1024, env: { PATH: "/usr/bin:/bin", LANG: "C" } }); + if (snapshot.status === 0) { + const rows = snapshot.stdout.trim().split("\n").filter(Boolean).map(line => line.trim().split(/\s+/)); + if (rows.every(row => row.length === 3 && /^\d+$/.test(row[0]) && /^\d+$/.test(row[1]) && /^[A-Za-z+<>NLsWX-]+$/.test(row[2]))) return rows.some(row => Number(row[1]) === child.pid && !row[2].startsWith("Z")); + } + } + throw new Error(`antigravity_process_group_check_failed:${error.code}`); + } +} + +function signalOwnedGroup(child, signal) { + if (!child.pid) return; + try { process.kill(-child.pid, signal); } + catch (error) { if (error.code !== "ESRCH") throw new Error(`antigravity_process_group_signal_failed:${error.code}`); } +} + +/** Finite shutdown stages also retire descendants after their parent exits. */ +export async function stopAntigravityMetadataChild(child, { eofTimeoutMs = 500, terminateTimeoutMs = 500, killTimeoutMs = 500 } = {}) { + if (!child) return { method: "not-started", exitCode: null, signal: null }; + for (const limit of [eofTimeoutMs, terminateTimeoutMs, killTimeoutMs]) if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5_000) throw new Error("Invalid Antigravity shutdown limits"); + const closed = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise(resolve => child.once("close", resolve)); + let cleanupUncertainty; + const groupMayBeAlive = () => { + try { return ownedGroupAlive(child); } + catch (error) { cleanupUncertainty = error; return true; } + }; + child.stdin?.end(); + for (const [method, signal, timeout] of [["eof", null, eofTimeoutMs], ["sigterm", "SIGTERM", terminateTimeoutMs], ["sigkill", "SIGKILL", killTimeoutMs]]) { + if (!groupMayBeAlive()) return { method, exitCode: child.exitCode, signal: child.signalCode }; + if (signal) { + try { signalOwnedGroup(child, signal); } + catch (error) { cleanupUncertainty = error; } + } + const deadline = Date.now() + timeout; + let timer; + try { await Promise.race([closed, new Promise(resolve => { timer = setTimeout(resolve, timeout); })]); } + finally { clearTimeout(timer); } + if (!groupMayBeAlive()) return { method, exitCode: child.exitCode, signal: child.signalCode }; + // A descendant may have closed its inherited pipes. One deadline per stage + // gives that owned process group time to exit without an idle poll loop. + const remaining = deadline - Date.now(); + if (remaining > 0) await delay(remaining); + if (!groupMayBeAlive()) return { method, exitCode: child.exitCode, signal: child.signalCode }; + } + if (cleanupUncertainty) throw cleanupUncertainty; + throw new Error("antigravity_process_tree_cleanup_unconfirmed"); +} + +function safeMetadata(result, distribution) { + if (result.protocolVersion !== 1 || !plainObject(result.agentInfo) || result.agentInfo.name !== "antigravity-acp") throw new Error("antigravity_initialize_identity_mismatch"); + if (result.agentInfo.version !== distribution.version) throw new Error("antigravity_initialize_version_mismatch"); + if (!plainObject(result.agentCapabilities) || !Array.isArray(result.authMethods)) throw new Error("antigravity_malformed_initialize_capabilities"); + const capabilities = result.agentCapabilities; + if (distribution.patchVersion && capabilities._meta?.antigravity?.paperclipAdmission !== 1) throw new Error("antigravity_native_admission_patch_not_advertised"); + const flag = (object, key) => plainObject(object) && object[key] === true; + const present = (object, key) => plainObject(object) && Object.hasOwn(object, key) && plainObject(object[key]); + return { + schema: "paperclip.antigravity_metadata_probe.v1", version: distribution.version, patchVersion: distribution.patchVersion, + platform: distribution.platform, architecture: distribution.architecture, archiveSha256: distribution.archiveSha256, vendorClosureSha256: distribution.vendorClosureSha256, + closureSha256: distribution.closureSha256, admissionSourceSha256: distribution.admissionSourceSha256, + protocolVersion: 1, agentInfo: { name: "antigravity-acp", title: "Google Antigravity", version: distribution.version }, + agentCapabilities: { + loadSession: flag(capabilities, "loadSession"), + promptCapabilities: Object.fromEntries(["image", "audio", "embeddedContext"].map(key => [key, flag(capabilities.promptCapabilities, key)])), + mcpCapabilities: Object.fromEntries(["http", "sse"].map(key => [key, flag(capabilities.mcpCapabilities, key)])), + sessionCapabilities: Object.fromEntries(["list", "resume"].map(key => [key, present(capabilities.sessionCapabilities, key)])), + logout: present(capabilities.auth, "logout"), + antigravity: { tier: flag(capabilities._meta?.antigravity, "tier"), systemPromptAppend: flag(capabilities._meta?.antigravity?.systemPrompt, "append"), paperclipAdmission: 1 }, + }, + authMethodIds: AUTH_METHOD_IDS.filter(id => result.authMethods.some(method => plainObject(method) && method.id === id)), + requestsSent: ["initialize"], authenticateRequestsSent: 0, sessionRequestsSent: 0, promptRequestsSent: 0, + inferenceVerified: false, qualification: "pending", + }; +} + +/** Credential-free cache probe. Installation is always a separate explicit action. */ +export async function probeAntigravityMetadata(options = {}) { + const limits = probeLimits(options); + const distribution = await antigravityDistribution(options.platform, options.architecture); + const runtimeDirectory = antigravityRuntimeCachePath(distribution.closureSha256, distribution.platform, distribution.architecture, options.home); + await verifyAntigravityDistribution(runtimeDirectory, distribution); + const root = await mkdtemp(join(tmpdir(), "paperclip-antigravity-metadata-")); + let child, rpc, result, shutdown, failure, cleanupFailure; + try { + const env = antigravityMetadataEnvironment(root, runtimeDirectory); + for (const key of ["HOME", "GEMINI_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "TMPDIR"]) await mkdir(env[key], { mode: 0o700 }); + child = spawn(join(runtimeDirectory, distribution.executable), distribution.args, { cwd: root, env, stdio: "pipe", detached: true }); + rpc = createAntigravityMetadataRpc(child, limits); + result = safeMetadata(await rpc.initialize(), distribution); + } catch (error) { + failure = error; + } finally { + try { shutdown = await stopAntigravityMetadataChild(child, limits); } + catch (error) { cleanupFailure = error; } + finally { rpc?.close(); await rm(root, { recursive: true, force: true }); } + } + if (failure && cleanupFailure) throw new AggregateError([failure, cleanupFailure], `${failure.message}; ${cleanupFailure.message}`); + if (failure) throw failure; + if (cleanupFailure) { + try { rpc?.assertHealthy(); } + catch (error) { throw new AggregateError([error, cleanupFailure], `${error.message}; ${cleanupFailure.message}`); } + throw cleanupFailure; + } + rpc.assertHealthy(); + return { ...result, shutdown }; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + probeAntigravityMetadata().then(result => console.log(JSON.stringify(result, null, 2))).catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/packages/paperclip-runner/scripts/probe-antigravity-metadata.test.mjs b/packages/paperclip-runner/scripts/probe-antigravity-metadata.test.mjs new file mode 100644 index 00000000000..bdad055564a --- /dev/null +++ b/packages/paperclip-runner/scripts/probe-antigravity-metadata.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { EventEmitter } from "node:events"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { antigravityMetadataEnvironment, createAntigravityMetadataRpc } from "./probe-antigravity-metadata.mjs"; + +const hash = bytes => createHash("sha256").update(bytes).digest("hex"); +const response = { + protocolVersion: 1, agentInfo: { name: "antigravity-acp", title: "private-provider-text 🛰", version: "1.4.3" }, + agentCapabilities: { loadSession: true, promptCapabilities: { image: true }, mcpCapabilities: { http: true }, sessionCapabilities: { list: {} }, auth: { logout: {} }, _meta: { antigravity: { tier: true, systemPrompt: { append: true }, paperclipAdmission: 1 } } }, + authMethods: [{ id: "oauth-personal", name: "private-provider-text" }, { id: "untrusted-auth-method", name: "private-provider-text" }], +}; + +async function fixture(t, behavior = "success") { + const root = await mkdtemp(join(tmpdir(), "paperclip-antigravity-probe-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", "probe-antigravity-metadata.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"), "export const ANTIGRAVITY_PATCH_VERSION='1.4.3-paperclip.1'; export function patchAntigravityRuntime(){throw new Error('Metadata must never patch or install');}"); + const auditPath = join(root, "audit.json"); + const script = `#!${process.execPath} +const { writeFileSync } = require('node:fs'); +const { spawn } = require('node:child_process'); +const behavior=${JSON.stringify(behavior)}; +const result=${JSON.stringify(response)}; +let buffer='';let descendant; +if(behavior==='ignore-stop') { process.on('SIGTERM',()=>{});setInterval(()=>{},1000); } +process.stdin.on('data',chunk=>{ + buffer+=chunk; + if(!buffer.includes('\\n'))return; + const requests=buffer.trim().split('\\n').map(line=>JSON.parse(line)); + writeFileSync(${JSON.stringify(auditPath)},JSON.stringify({pid:process.pid,home:process.env.HOME,geminiHome:process.env.GEMINI_HOME,cwd:process.cwd(),env:process.env,requests})); + if(requests.length!==1||requests[0].method!=='initialize')process.exit(41); + if(behavior==='timeout')return; + if(behavior==='malformed')return process.stdout.write('not-json\\n'); + if(behavior==='oversized')return process.stdout.write('x'.repeat(8192)); + if(behavior==='stderr-limit')return process.stderr.write('sensitive'.repeat(8192)); + if(behavior==='unexpected-method')return process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:99,method:'authenticate',params:{token:'sensitive'}})+'\\n'); + if(behavior==='wrong-version')result.agentInfo.version='1.4.2'; + if(behavior==='wrong-identity')result.agentInfo.name='another-harness'; + if(behavior==='wrong-protocol')result.protocolVersion=2; + if(behavior==='bad-capabilities')result.agentCapabilities=[]; + if(behavior==='missing-patch')delete result.agentCapabilities._meta.antigravity.paperclipAdmission; + if(behavior==='orphan-child') { + descendant=spawn(process.execPath,['-e',"setInterval(()=>{},1000)"],{stdio:'ignore'}); + writeFileSync(${JSON.stringify(join(root, "descendant.json"))},JSON.stringify({pid:descendant.pid})); + } + const payload=JSON.stringify({jsonrpc:'2.0',id:1,result}); + process.stderr.write('private-provider-text sensitive-secret\\n'); + if(behavior==='truncated') { process.stdout.write(payload);process.exit(0); } + process.stdout.write(payload+'\\n'+(behavior==='extra-message'?'{}\\n':'')); +}); +process.stdin.on('end',()=>{if(behavior!=='ignore-stop')process.exit(0)}); +`; + const nativeFiles = [ + { path: "agy_acp_server.par", content: Buffer.from(script) }, + { path: "localharness_external", content: Buffer.from("#!/bin/sh\nexit 0\n") }, + ]; + const entries = nativeFiles.map(file => ({ path: file.path, sha256: hash(file.content), size: file.content.length, executable: true })); + const closureSha256 = hash(JSON.stringify(entries)); + const item = { url: "https://dl.google.com/agy-extensions/releases/macos/fixture.zip", archiveSha256: "a".repeat(64), closureSha256, vendorClosureSha256: closureSha256, vendorEntries: entries, patchVersion: "1.4.3-paperclip.1", admissionSourceSha256, provisioning: "verified-closure", executable: "agy_acp_server.par", args: [], entries }; + await writeFile(join(root, "antigravity-distributions.json"), JSON.stringify({ schema: "paperclip.antigravity_distribution.v1", version: "1.4.3", platforms: { "darwin-arm64": item } })); + const home = join(root, "operator-home"); + const runtime = join(home, ".paperclip/runtimes/antigravity/darwin-arm64", closureSha256); + await mkdir(runtime, { recursive: true, mode: 0o700 }); + for (const file of nativeFiles) await writeFile(join(runtime, file.path), file.content, { mode: 0o500 }); + await writeFile(join(runtime, ".paperclip-antigravity-closure.json"), JSON.stringify({ schema: "paperclip.antigravity_closure.v1", version: "1.4.3", patchVersion: item.patchVersion, admissionSourceSha256, archiveSha256: item.archiveSha256, vendorClosureSha256: closureSha256, closureSha256, entries }), { mode: 0o400 }); + const api = await import(pathToFileURL(join(scripts, "probe-antigravity-metadata.mjs"))); + return { + root, runtime, auditPath, + probe: options => api.probeAntigravityMetadata({ home, platform: "darwin", architecture: "arm64", startupTimeoutMs: 2_000, eofTimeoutMs: 60, terminateTimeoutMs: 60, killTimeoutMs: 250, ...options }), + }; +} + +test("probe environment has only explicit sterile settings and cannot inherit credentials", () => { + const env = antigravityMetadataEnvironment("/isolated", "/verified/runtime"); + assert.equal(env.HOME, "/isolated/home"); + assert.equal(env.GEMINI_HOME, "/isolated/gemini"); + assert.equal(env.AGY_ACP_FORCE_FILE_STORAGE, "1"); + assert.equal(env.ANTIGRAVITY_HARNESS_PATH, "/verified/runtime/localharness_external"); + assert.deepEqual(Object.keys(env).sort(), ["AGY_ACP_FORCE_FILE_STORAGE", "ANTIGRAVITY_HARNESS_PATH", "GEMINI_HOME", "HOME", "LANG", "NO_COLOR", "PATH", "TMPDIR", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME"]); +}); + +test("initialize uses Paperclip identity, preserves split UTF-8 and cannot send another method", async () => { + const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough() }); + let request = ""; + child.stdin.on("data", chunk => { request += chunk; }); + const rpc = createAntigravityMetadataRpc(child); + const pending = rpc.initialize(); + const payload = Buffer.from(`${JSON.stringify({ jsonrpc: "2.0", id: 1, result: response })}\n`); + const split = payload.indexOf(Buffer.from("🛰")) + 1; + child.stdout.write(payload.subarray(0, split)); + child.stdout.write(payload.subarray(split)); + assert.equal((await pending).agentInfo.title, response.agentInfo.title); + assert.deepEqual(JSON.parse(request).params.clientInfo, { name: "paperclip-runner", title: "Paperclip Runner", version: "1" }); + assert.throws(() => rpc.initialize(), /only be sent once/); + rpc.close(); +}); + +test("invalid UTF-8 is rejected instead of silently replacing wire bytes", async () => { + const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough() }); + const rpc = createAntigravityMetadataRpc(child); + const pending = rpc.initialize(); + child.stdout.write(Buffer.from([0xff, 0x0a])); + await assert.rejects(pending, /malformed_utf8/); + rpc.close(); +}); + +test("real stdio probe sends initialize only, sanitizes metadata and removes its isolated profile", async t => { + const current = await fixture(t); + const output = await current.probe(); + const audit = JSON.parse(await readFile(current.auditPath, "utf8")); + assert.deepEqual(audit.requests.map(request => request.method), ["initialize"]); + assert.equal(audit.env.AGY_ACP_FORCE_FILE_STORAGE, "1"); + assert.equal(audit.env.GEMINI_API_KEY, undefined); + assert.equal(audit.env.GOOGLE_APPLICATION_CREDENTIALS, undefined); + assert.equal(audit.env.NODE_OPTIONS, undefined); + assert.notEqual(audit.home, process.env.HOME); + assert.notEqual(audit.geminiHome, process.env.GEMINI_HOME); + await assert.rejects(readFile(join(audit.home, "anything")), { code: "ENOENT" }); + await assert.rejects(readFile(audit.cwd), { code: "ENOENT" }); + assert.deepEqual(output.authMethodIds, ["oauth-personal"]); + assert.equal(output.agentInfo.version, "1.4.3"); + assert.equal(output.promptRequestsSent, 0); + assert.equal(output.shutdown.exitCode, 0); + assert.equal(output.shutdown.method, "eof"); + assert.doesNotMatch(JSON.stringify(output), /sensitive|private-provider-text|paperclip-antigravity-metadata-/); +}); + +test("identity, version and protocol mismatches are rejected with sanitized diagnostics", async t => { + for (const [behavior, error] of [["wrong-version", /version_mismatch/], ["wrong-identity", /identity_mismatch/], ["wrong-protocol", /identity_mismatch/], ["bad-capabilities", /malformed_initialize_capabilities/], ["missing-patch", /native_admission_patch_not_advertised/]]) { + const current = await fixture(t, behavior); + await assert.rejects(current.probe(), error); + } +}); + +test("malformed, oversized, truncated and unexpected protocol streams fail closed", async t => { + for (const [behavior, error] of [["malformed", /malformed_json/], ["oversized", /stdout_limit/], ["stderr-limit", /stderr_limit/], ["unexpected-method", /unexpected_message/], ["truncated", /truncated_stream/], ["extra-message", /unexpected_message/]]) { + const current = await fixture(t, behavior); + await assert.rejects(current.probe({ maxBytes: 4096 }), error); + } +}); + +test("startup deadline retires the native process and isolated profile", async t => { + const current = await fixture(t, "timeout"); + await assert.rejects(current.probe({ startupTimeoutMs: 600 }), /initialize_timeout/); + const audit = JSON.parse(await readFile(current.auditPath, "utf8")); + assert.throws(() => process.kill(audit.pid, 0), { code: "ESRCH" }); + await assert.rejects(readFile(audit.cwd), { code: "ENOENT" }); +}); + +test("SIGKILL bounds shutdown when native work ignores EOF and SIGTERM", async t => { + const current = await fixture(t, "ignore-stop"); + const output = await current.probe(); + const audit = JSON.parse(await readFile(current.auditPath, "utf8")); + assert.equal(output.shutdown.method, "sigkill"); + assert.equal(output.shutdown.signal, "SIGKILL"); + assert.throws(() => process.kill(audit.pid, 0), { code: "ESRCH" }); +}); + +test("shutdown retires an owned child after its native parent exits", async t => { + const current = await fixture(t, "orphan-child"); + try { await current.probe(); } + catch (error) { + // macOS sandboxing can deny probing an already-extinct process group. + // Preserve that fail-closed result and independently check the actual child. + assert.equal(process.platform, "darwin"); + assert.match(error.message, /^antigravity_process_group_check_failed:EPERM$/); + } + const descendant = JSON.parse(await readFile(join(current.root, "descendant.json"), "utf8")); + assert.throws(() => process.kill(descendant.pid, 0), { code: "ESRCH" }); +}); + +test("tampered cache fails verification before any native process starts", async t => { + const current = await fixture(t); + const child = join(current.runtime, "localharness_external"); + await chmod(child, 0o700); + await writeFile(child, "corrupt"); + await assert.rejects(current.probe(), /full execution closure digest mismatch/); + await assert.rejects(readFile(current.auditPath), { code: "ENOENT" }); +}); + +test("invalid startup or shutdown limits fail before any native process starts", async t => { + const current = await fixture(t); + for (const options of [{ startupTimeoutMs: 0 }, { startupTimeoutMs: 30_001 }, { maxBytes: 1024 * 1024 + 1 }, { eofTimeoutMs: -1 }, { killTimeoutMs: 5_001 }]) await assert.rejects(current.probe(options), /Invalid Antigravity metadata probe limits/); + await assert.rejects(readFile(current.auditPath), { code: "ENOENT" }); +}); diff --git a/packages/paperclip-runner/scripts/provision-antigravity.mjs b/packages/paperclip-runner/scripts/provision-antigravity.mjs new file mode 100644 index 00000000000..027c210fd37 --- /dev/null +++ b/packages/paperclip-runner/scripts/provision-antigravity.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +import { lstat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { antigravityProvisionerDestination } from "./antigravity-provisioner-layout.mjs"; +import { antigravityDistribution, materializePinnedAntigravityDistribution, verifyAntigravityDistribution } from "./materialize-antigravity-distribution.mjs"; + +/** Explicit public installation; never invoked by an npm lifecycle hook. */ +export async function provisionAntigravityRuntime(options = {}) { + const distribution = await antigravityDistribution(options.platform, options.architecture); + const destination = antigravityProvisionerDestination(options.moduleUrl ?? import.meta.url, distribution, options.home); + const installed = await lstat(destination).catch(error => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (installed) { + if (!installed.isDirectory() || installed.isSymbolicLink()) throw new Error("Antigravity runtime assets must be a real directory"); + await verifyAntigravityDistribution(destination, distribution); + } else { + await materializePinnedAntigravityDistribution({ destination, platform: distribution.platform, architecture: distribution.architecture, archivePath: options.archivePath }); + } + return { ...distribution, destination, executable: resolve(destination, distribution.executable) }; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + provisionAntigravityRuntime().then(result => { + console.log(`Verified Google Antigravity ${result.version} (${result.platform}-${result.architecture}), ${result.patchVersion}`); + console.log(`Runtime: ${result.destination}. Run setup as the same OS user that runs Paperclip.`); + console.log("Native Runner execution remains pending acceptance in this build."); + }).catch(error => { console.error(error.message); process.exitCode = 1; }); +}