mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
feat(runner): provision pinned Google Antigravity native runtime (#18187)
## Thinking Path > - Paperclip manages agents and their work. > - Native Runner providers need verified executables before they can run. > - Google Antigravity ships a native ACP server and a helper executable. > - An archive hash alone does not verify the installed runtime or its child process cleanup. > - This pull request adds a pinned installer and a small, verified compatibility patch. > - Later pull requests add Runner execution, account setup, and the recorded user journey eval. ## Linked Issues or Issue Description Refs: #12503, #14938. Those pull requests use the legacy CLI adapter. This stack adds native Runner ACP support. **Agent or provider** Google Antigravity ACP 1.4.3. **Why this adapter is useful** Users need a reproducible runtime before they can select Antigravity for a Paperclip agent. **How the agent is invoked** The installer prepares the native ACP server and helper. This pull request does not enable task execution. Default execution remains closed pending qualification. ## What Changed - Pin the complete macOS ARM64 runtime closure. Keep other platforms as inventory only. - Verify archive entries, installed bytes, ownership, and cache layout. - Patch exact upstream code anchors for model admission, instruction loading, native process ownership, and per-call file tool completion. - Package a self-contained installer and expose `paperclipai runtime setup antigravity`. - Record candidate limitations and historical acceptance evidence. ## Verification - Runtime provisioning, packaging, metadata, and patch support tests: 62 passed. The separate exact pinned native patch run passed all 5 tests with no skips and no Google authentication. - CLI runtime setup tests: 5 passed. - Map contract check passed. Staged diff whitespace check passed. - The first test command ran before dependency installation and failed on missing esbuild. The same command passed after installation. - Current master has a stale lockfile. Dependencies were resolved locally with scripts disabled. The generated lockfile is excluded from this pull request. - Final head: required `ci / verify` and `ci / e2e` passed, and Greptile is 5/5 with both review threads resolved. The optional external Paperclip Review remains queued. Two original timing failures are retained; one failed-job rerun passed without source or deadline changes. ## Risks - The patch depends on exact upstream native code. A new upstream release must get a new verified closure. - Only macOS ARM64 has a complete verified runtime. Installation does not qualify authentication, task execution, or other platforms. - The installer downloads official Google assets when the verified cache is absent. ## Model Used OpenAI Codex (GPT-6). The host did not expose the exact model revision or context limit. The model used tools for code edits, test execution, and review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5d79d88d68
commit
09c05c5505
22 files changed
+3477
-6
No files matched your search
@@ -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");
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -93,6 +93,43 @@ export async function setupCursorRuntime(): Promise<void> {
|
||||
} 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<string> {
|
||||
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<void> {
|
||||
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<void>((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 <provider>")
|
||||
.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");
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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="
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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); }
|
||||
});
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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; });
|
||||
}
|
||||
Reference in new issue
Block a user