@sagentlab/navarch-runtime 0.1.106 → 0.1.111

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Host-mode build, verify, and QA leases requiring `browser-use` receive a
6
6
  `navarch_browser` MCP server with `capture_visual_evidence` in Codex, Claude,
7
- Gemini, OpenCode, and ACP sessions. It runs Chromium in the runtime process's
7
+ Antigravity, OpenCode, and ACP sessions. It runs Chromium in the runtime process's
8
8
  environment, outside the coding agent's OS sandbox, with a fresh browser
9
9
  context for desktop and touch-enabled mobile captures. It neither uses the
10
10
  operator's browser profile nor executes agent-provided scripts.
@@ -93,7 +93,7 @@ when the run finishes, so the task view can follow a connected run live.
93
93
  Live snapshots hold the current turn's stderr back until the turn ends.
94
94
 
95
95
  The machine operator selects **Claude Code**, **OpenAI Codex**, **Google
96
- Gemini CLI**, **OpenCode**, or an **Agent Client Protocol (ACP)** agent when connecting a worker. Any selected agent can
96
+ Antigravity CLI**, **OpenCode**, or an **Agent Client Protocol (ACP)** agent when connecting a worker. Any selected agent can
97
97
  run any unpinned project task; task eligibility depends on capabilities and
98
98
  project gates. A task pinned to a runtime waits for a worker that advertises
99
99
  that adapter and runs on it — see "Choosing an agent" below.
@@ -116,7 +116,7 @@ Before generating the command, prepare the machine:
116
116
  - install Node.js 20 or later (the standard installation includes `npm` and
117
117
  `npx`) and Git;
118
118
  - install the coding-agent CLI selected in Navarch — `claude`, `codex`,
119
- `gemini`, `opencode`, `dsh` for the default ACP integration, or `qoder` — and complete its normal authentication flow; and
119
+ `agy`, `opencode`, `dsh` for the default ACP integration, or `qoder` — and complete its normal authentication flow; and
120
120
  - for Docker-backed sessions, install and start Docker and use an image that
121
121
  contains the selected agent CLI and the repository toolchain, with provider
122
122
  credentials supplied through the approved machine or project configuration.
@@ -131,7 +131,7 @@ node --version # 20 or later
131
131
  npm --version
132
132
  npx --version
133
133
  git --version
134
- codex --version # or: claude / gemini / opencode / qoder / dsh --version
134
+ codex --version # or: claude / agy / opencode / qoder / dsh --version
135
135
  ```
136
136
 
137
137
  Every command above must succeed before the long-running worker starts. See
@@ -239,11 +239,11 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
239
239
 
240
240
  | Command | Purpose |
241
241
  |---|---|
242
- | `register --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
243
- | `connect --token <t> --name <n> [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
244
- | `start [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
245
- | `supervise [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
246
- | `doctor [--agent claude-code\|codex\|gemini\|opencode\|acp\|qoder]` | Prints resolved config + Docker/registration status; no side effects. |
242
+ | `register --token <t> --name <n> [--agent claude-code\|codex\|antigravity\|opencode\|acp\|qoder] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
243
+ | `connect --token <t> --name <n> [--agent claude-code\|codex\|antigravity\|opencode\|acp\|qoder] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
244
+ | `start [--agent claude-code\|codex\|antigravity\|opencode\|acp\|qoder]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
245
+ | `supervise [--agent claude-code\|codex\|antigravity\|opencode\|acp\|qoder]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
246
+ | `doctor [--agent claude-code\|codex\|antigravity\|opencode\|acp\|qoder]` | Prints resolved config + Docker/registration status; no side effects. |
247
247
 
248
248
  ### Running multiple agents on one machine
249
249
 
@@ -457,7 +457,7 @@ unchanged across the deployment.
457
457
  | `NAVARCH_SANDBOX_PROFILE` | `trusted-development` | Named security profile for Docker sessions (`src/sandbox-profile.cts`): `trusted-development` (image-default user, uncapped, open egress), `untrusted-code` (non-root, 2 CPU / 4g / 512 PIDs, deny-by-default egress, read-only shared git), `elevated-verification` (non-root, 4 CPU / 8g / 2048 PIDs, egress limited to GitHub plus package registries). The default is the exact pre-profile flag set. |
458
458
  | `NAVARCH_SANDBOX_EGRESS_NETWORK` | _(unset)_ | Docker network that enforces a profile's egress allowlist. Docker cannot filter by domain itself, so an allowlist profile without this fails closed to `--network=none` and records the denial. |
459
459
  | `NAVARCH_DOCKER_IMAGE` | `ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0` | Version-pinned per-session image with Node 20, git, GitHub CLI, ripgrep, jq, SSH, and Claude Code 2.1.218. Override with an image tag or digest you control. |
460
- | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent adapter: `claude-code`, `codex`, `gemini`, `opencode`, `acp`, or `qoder`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
460
+ | `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent adapter: `claude-code`, `codex`, `agy`, `opencode`, `acp`, or `qoder`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
461
461
  | `NAVARCH_RUNTIMES` | resolved local agent choice | Comma list of installed/authenticated adapters advertised to dispatch. Unpinned tasks run on the preferred agent; a task runtime pin selects the matching adapter. Unrecognized ids are dropped with a warning that names them, so a typo cannot silently shrink the advertised set. |
462
462
  | `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
463
463
  | `NAVARCH_AUTO_UPDATE` | on under `supervise` | Set `off`, `false`, or `0` to report releases without staging or activating them. Automatic activation is always off under plain `start`. |
@@ -465,8 +465,8 @@ unchanged across the deployment.
465
465
  | `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. Runtime sessions default to an empty `--setting-sources` list so machine/user/project hooks cannot leak into temporary checkouts. The generated host-mode settings retain only the user-level `apiKeyHelper` needed for authentication; supply `--setting-sources=<sources>` here to opt into other settings deliberately. |
466
466
  | `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
467
467
  | `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
468
- | `NAVARCH_GEMINI_BIN` | `gemini` | Path/name of the Google Gemini CLI binary. |
469
- | `NAVARCH_GEMINI_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP settings, `stream-json`, and unattended defaults (Gemini). |
468
+ | `NAVARCH_ANTIGRAVITY_BIN` | `agy` | Path/name of the Antigravity CLI binary, version 1.3.1+. |
469
+ | `NAVARCH_ANTIGRAVITY_EXTRA_ARGS` | — | Comma list: only `--agent`, `--model`, `--effort`, `--sandbox`, `--dangerously-skip-permissions`; protocol, deadline and resume flags are owned by Navarch. |
470
470
  | `NAVARCH_OPENCODE_BIN` | `opencode` | Path/name of the OpenCode CLI binary. |
471
471
  | `NAVARCH_OPENCODE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `run`, the prompt, and the generated `--format json` argument. Explicit `--format`, `--model`, or `--variant` values replace the corresponding per-session default. |
472
472
  | `NAVARCH_ACP_BIN` | `dsh` | Path/name of an Agent Client Protocol v1 stdio server. DeepSeek Harness is the default implementation. |
@@ -484,15 +484,14 @@ unchanged across the deployment.
484
484
  ## Worktree boundary guard (host mode)
485
485
 
486
486
  A machine typically runs several sessions concurrently (`NAVARCH_MAX_SESSIONS`),
487
- each in its own git worktree. The runtime enforces the same boundary for all
488
- supported coding agents, using each CLI's native enforcement point:
487
+ each in its own git worktree. The runtime enforces adapter-specific boundaries where verified; unsupported guarded paths fail
488
+ closed rather than claim equivalent isolation:
489
489
 
490
490
  Every adapter receives an empty, session-owned `GH_CONFIG_DIR` and, when the
491
491
  project supplies GitHub credentials, the lease-scoped `GITHUB_TOKEN`. This
492
492
  keeps `gh` independent of the operator's selected account and prevents tasks
493
493
  from reading unrelated GitHub credentials. Docker already mounts the session
494
- metadata directory; Gemini's nested host sandbox receives the `gh` directory
495
- as an explicit read-only mount.
494
+ metadata directory.
496
495
 
497
496
  - **Claude Code:** the runtime uses `--permission-mode auto`, which sends
498
497
  approval decisions through Claude Code's background safety classifier. It
@@ -525,12 +524,13 @@ as an explicit read-only mount.
525
524
  the same write roots. Without these, iOS builds and simulator tests fail
526
525
  under the sandbox with
527
526
  `access to /Library/Developer/PrivateFrameworks/CoreSimulator.framework is blocked`.
528
- - **Gemini:** the runtime uses Gemini CLI's native sandbox with only the
529
- current worktree, shared gitdir, session `gh` config, and approved extra
530
- roots mounted. The `gh` config and lease MCP settings are read-only; header
531
- values remain environment-variable references rather than literals.
532
- Docker-mode sessions disable Gemini's implicit YOLO sandbox to avoid nesting
533
- it inside Navarch's already isolated session container.
527
+ - **Antigravity:** guarded host execution and read-only Navi are unsupported.
528
+ Use an isolated Docker image containing authenticated `agy` 1.3.1+ and Node,
529
+ or explicitly disable the machine-wide guard only on a dedicated trusted
530
+ worker. Antigravity's global MCP servers, permissions, plugins and hooks
531
+ remain active: the lease relay is not an exclusive tool boundary. Navarch
532
+ does not automatically add `--dangerously-skip-permissions`; preconfigure
533
+ scoped permissions or explicitly opt into that flag on an isolated worker.
534
534
  - **OpenCode:** the CLI cannot currently express a host-side boundary for all
535
535
  shell side effects, so with the guard enabled it is resolved out of the
536
536
  machine's advertised runtimes at startup — and a machine that offers only
@@ -611,9 +611,10 @@ export NAVARCH_AGENT=claude-code
611
611
  # Claude Code prerequisite above.
612
612
  export NAVARCH_AGENT=codex
613
613
 
614
- # Google Gemini CLI — requires an authenticated `gemini` CLI (or
615
- # NAVARCH_GEMINI_BIN pointing at it).
616
- export NAVARCH_AGENT=gemini
614
+ # Google Antigravity CLI — requires authenticated agy 1.3.1+.
615
+ # Use Docker with agy + Node installed, or an explicit guard opt-out only
616
+ # on a dedicated trusted worker. Gemini auth/config is not transferred.
617
+ export NAVARCH_AGENT=antigravity
617
618
 
618
619
  # OpenCode — requires an authenticated `opencode` CLI (or
619
620
  # NAVARCH_OPENCODE_BIN pointing at it). A guarded host refuses to start with
@@ -631,16 +632,16 @@ export NAVARCH_AGENT=acp
631
632
  export NAVARCH_AGENT=qoder
632
633
 
633
634
  # Advanced compatibility mode: advertise every installed adapter. On a guarded
634
- # host, `opencode` is dropped from this list (see the boundary notes above);
635
+ # host, `opencode` and `antigravity` are dropped from this list (see the boundary notes above);
635
636
  # the rest are advertised as written.
636
- export NAVARCH_RUNTIMES=claude-code,codex,gemini,opencode,acp,qoder
637
+ export NAVARCH_RUNTIMES=claude-code,codex,antigravity,opencode,acp,qoder
637
638
  ```
638
639
 
639
640
  For the legacy single-runtime setting, priority is `start --agent` →
640
641
  `NAVARCH_AGENT` → the locally saved choice → `claude-code`.
641
642
  `NAVARCH_RUNTIMES` expands what the worker advertises. For normal projects,
642
643
  the locally selected `NAVARCH_AGENT` handles every unpinned task. A task with a
643
- runtime override (for example `gemini`) is claimed only by a worker that
644
+ runtime override (for example `antigravity`) is claimed only by a worker that
644
645
  advertises that adapter, and the claim returns it as the session runtime.
645
646
  Sandbox projects remain constrained to Claude Code, so they claim only unpinned
646
647
  or `claude-code`-pinned tasks. List only adapters this worker can really run:
@@ -657,10 +658,10 @@ through to `complete()` unchanged by whatever happened during the run.
657
658
  The control plane also resolves the project's model and the task's execution
658
659
  profile on every claim. The runtime passes those values as per-session CLI
659
660
  overrides (`codex exec --model ... -c model_reasoning_effort=...`,
660
- `claude -p --model ... --effort ...`, `gemini --model ...`, or `opencode run
661
+ `claude -p --model ... --effort ...`, `agy --model ...`, or `opencode run
661
662
  --model ... --variant ...`) and records the effective model, profile, and
662
- effort on completion. Gemini currently uses its `auto` model default and does
663
- not expose a reasoning-effort flag. OpenCode accepts any `provider/model`
663
+ effort on completion. Antigravity uses the `default` sentinel to preserve the CLI account model;
664
+ explicit low/medium/high effort is supported without inventing higher levels. OpenCode accepts any `provider/model`
664
665
  reference (the platform default is `opencode/x-preview-f-free`, Ox Alpha Free
665
666
  (Unlimited) on OpenCode Zen), or `default` to preserve the authenticated
666
667
  account's selection. ACP sessions use the agent's advertised default model and
@@ -709,20 +710,66 @@ not placed in argv. The JSONL parser accepts the verified top-level
709
710
  `item.completed` / `turn.completed` shape and retains the older `msg`
710
711
  envelope as a compatibility fallback.
711
712
 
712
- The Gemini adapter runs headlessly with `--output-format stream-json` and the
713
- prompt piped on stdin, which Gemini uses as the prompt when stdin is not a
714
- TTY. It resets pre-tool assistant text, retains the final
715
- post-tool answer as the report, and reads aggregate `input_tokens` and
716
- `output_tokens` from the terminal `result` event. Gemini does not report USD
717
- cost, so an unknown value remains absent rather than becoming zero.
718
-
719
- Gemini's native sandbox launcher converts piped stdin back into a `--prompt`
720
- argument for its child. The adapter rejects prompts of 128 KiB or more when
721
- that sandbox is enabled, with an explicit diagnostic; reducing context or
722
- selecting another runtime preserves the worktree boundary. Navarch Docker
723
- mode disables the nested Gemini sandbox by default and can still pipe larger
724
- prompts. All Gemini runs reject prompts above 8 MiB because Gemini's stdin
725
- reader silently truncates input beyond that limit. These checks use UTF-8 bytes.
713
+ ### Antigravity CLI contract and migration
714
+
715
+ The Antigravity adapter requires **agy 1.3.1 or later**. It validates
716
+ `agy --version` locally before each turn, then runs
717
+ `agy --input-format stream-json --output-format stream-json`, writes one
718
+ `{"event":"user","message":{"content":"..."}}` line, and closes stdin.
719
+ Only text prompts are supported. It never sends `control_request` or
720
+ `control_response`, which the CLI explicitly rejects. The final response
721
+ comes from `event: "result"` / `result.response`, not accumulated tool or
722
+ assistant progress. Raw `init`, `step_update` (including tool progress), and
723
+ `result` events remain available in the live transcript.
724
+
725
+ An OS exit of zero is insufficient: a missing/malformed/duplicate result,
726
+ wrong conversation ID, non-`SUCCESS` status, `AGY_ERROR`, nonempty or malformed
727
+ `denied_actions`, permission-denial notices, or a partial-timeout warning
728
+ fails the turn. Antigravity may soft-deny a required action while exiting zero.
729
+ The runtime owns deadlines; it does not set `--print-timeout`, whose expiration
730
+ can return partial output with exit zero. The vendor changelog states the
731
+ headless default became unlimited in 1.2.6, despite the older reference table.
732
+ Cancellation sends SIGINT to the owned process group, escalates to SIGKILL,
733
+ and closes lease MCP sockets and generated configuration; Docker cancellation
734
+ stops the session container.
735
+
736
+ Each corrective turn resumes only the verified Antigravity conversation from
737
+ this lease, using `--conversation <id>`. Provider, workspace, model, turn count
738
+ and monotonic cumulative usage are checked. No implicit `--continue`, arbitrary
739
+ extra-argument session ID, Gemini ID, persisted cross-lease ID, or credential
740
+ migration is accepted. Cumulative input/output counters are differenced on
741
+ resume. USD cost stays unknown; cache-read inclusion is not sufficiently
742
+ specified to invent an inclusive cache split. Raw counters remain in the
743
+ transcript.
744
+
745
+ MCP definitions use Antigravity's documented workspace `.agents/mcp_config.json`
746
+ stdio format. The runtime exclusively creates a secret-free config pointing to
747
+ a short-lived private Unix-socket relay. MCP-specific HTTP lease headers stay in the parent runtime's memory, not the AG config, argv, or relay child's environment. The existing managed Git credential helper still receives the machine token needed for its authorized refresh endpoint; this bridge does not eliminate all child-process credentials. The
748
+ source staging JSON uses Navarch-owned environment references, resolved by
749
+ Navarch itself; no Antigravity interpolation or alternate config-root API is
750
+ assumed. The repository must already ignore `/.agents/mcp_config.json` in a local `.gitignore`; Navarch never edits ignore rules. A post-run index check rejects forced staging/committing without rewriting history. Existing `.agents/mcp_config.json`, symlinked directories, unsupported
751
+ transports, or socket paths over 100 bytes fail closed. Configuration and
752
+ sockets are removed after the child exits; changed files are preserved and
753
+ reported as cleanup failures. Existing global/user config is never edited.
754
+ Host sockets use a short private system-temp directory. Docker requires a short session metadata path (socket pathname at most 100 bytes), the same-path metadata mount, and Node in the image;
755
+ Windows is unsupported for this relay. Live authenticated AG/Docker compatibility
756
+ has not been verified by the offline suite.
757
+
758
+ `gemini` is a retired identity, not an alias. Existing machine configuration,
759
+ credentials, task pins, session history and provider IDs remain untouched.
760
+ Selecting or advertising `gemini` fails with migration guidance; a stale claim
761
+ hits a tombstone adapter that starts no process. Install from the
762
+ [official Antigravity instructions](https://antigravity.google/docs/cli/install/),
763
+ authenticate in an interactive `agy` session, then deliberately choose
764
+ `--agent antigravity` / `NAVARCH_AGENT=antigravity`, update `NAVARCH_RUNTIMES`
765
+ and existing project/task pins. `NAVARCH_GEMINI_*` values are not reused.
766
+ API-key mode requires the operator's `modelProvider: "gemini"` settings and
767
+ `GEMINI_API_KEY`; Navarch does not create keys or rewrite auth settings.
768
+
769
+ Primary contracts checked October 12, 2026:
770
+ [headless input/output](https://antigravity.google/docs/cli/headless/),
771
+ [release changes](https://antigravity.google/docs/changelog), and
772
+ [MCP configuration](https://antigravity.google/docs/mcp/).
726
773
 
727
774
  The OpenCode adapter uses `opencode run --format json` with the prompt on
728
775
  stdin, and normalizes `text` and `step_finish` JSONL events into
@@ -755,9 +802,6 @@ rejects any single argv or environment string over 128 KiB
755
802
  (`MAX_ARG_STRLEN`), so an argv prompt made large review sessions fail with
756
803
  `spawn E2BIG` before the agent started. Docker-mode sessions forward the same
757
804
  stdin through `docker exec -i`.
758
- Gemini's own sandbox relaunch is an exception to this transport guarantee,
759
- as described above; moving the initial spawn to stdin does not remove its
760
- internal argv limit.
761
805
 
762
806
  ## Architecture
763
807
 
@@ -778,7 +822,7 @@ cli.cts
778
822
  (adapters/types.cts) by NAVARCH_AGENT, then .run(...):
779
823
  - claudeCodeAdapter (adapters/claude.cts) — `claude -p --mcp-config <path> --output-format stream-json --verbose < prompt`
780
824
  - codexAdapter (adapters/codex.cts) — `codex exec - --json -c mcp_servers.*=... < prompt`
781
- - geminiAdapter (adapters/gemini.cts) — `gemini --output-format stream-json < prompt`
825
+ - antigravityAdapter (adapters/antigravity.cts) — `agy --input-format stream-json --output-format stream-json`
782
826
  - openCodeAdapter (adapters/opencode.cts) — `opencode run --format json < prompt`
783
827
  - acpAdapter (adapters/acp.cts) — ACP v1 JSON-RPC/stdio; defaults to `dsh --profile acp`
784
828
  - qoderAdapter (adapters/qoder.cts) — `qoder -p --output-format json < prompt`
@@ -874,15 +918,14 @@ tests cover:
874
918
  - `adapters/codex.cts` — arg construction on both the host path (mocked `spawn`)
875
919
  and the docker-exec path (fake `CommandRunner`), and usage/report-text
876
920
  attachment from fixed JSONL fixtures (`tests/adapters/codex.test.cts`).
877
- - `adapters/gemini.cts` — host/Docker invocation, cancellation, sandbox/MCP
878
- credential hygiene, parser drift, and normalized report/usage extraction
879
- (`tests/adapters/gemini.test.cts`).
921
+ - `adapters/antigravity.cts` and `antigravity-mcp.cts` — strict streams, denial/timeout false-green prevention, usage/resume identity, private lease relay and cleanup (`tests/adapters/antigravity*.test.cts`).
922
+ - `adapters/gemini.cts` — retired-provider tombstone (`tests/adapters/gemini.test.cts`).
880
923
  - `adapters/opencode.cts` — real fake-binary invocation covering the prompt,
881
924
  isolated config and MCP header references, JSONL normalization, timeout,
882
925
  lease cancellation, and fail-closed host boundary
883
926
  (`tests/adapters/opencode.test.cts`).
884
927
  - `adapters/types.cts` — the shared process-result boundary is exercised for
885
- Claude, Codex, Gemini, and OpenCode (`tests/adapters/conformance.test.cts`).
928
+ Claude, Codex, and OpenCode (`tests/adapters/conformance.test.cts`).
886
929
  - `worktree-guard.cts` + `bin/worktree-guard-hook.cjs` — generated settings/
887
930
  config shape, native Codex permission-profile construction, and the hook's
888
931
  containment verdicts (in-worktree vs. sibling session vs. home dir, symlink
@@ -916,8 +959,7 @@ secrets absent from disk after exit"):
916
959
  flags, per-run MCP override keys, stdin behavior, and JSONL event/usage
917
960
  shape are now verified locally; the next dogfood run covers their
918
961
  production composition.
919
- - A full Gemini task that uses an authenticated Gemini CLI to claim, edit,
920
- open a PR, report usage/evidence, and clean up its session sandbox.
962
+ - A full authenticated Antigravity 1.3.1+ task with scoped permissions, real AG stdio MCP loading, existing global customizations, Docker image, resume, PR delivery and cancellation. Mock/relay tests are not that proof.
921
963
  - A full OpenCode task in an operator-owned Docker image that contains an
922
964
  authenticated OpenCode CLI, including lease MCP access, cancellation, PR
923
965
  creation, attribution, and cleanup.
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ANTIGRAVITY_STDIO_RELAY = void 0;
7
+ exports.prepareAntigravityMcp = prepareAntigravityMcp;
8
+ const node_child_process_1 = require("node:child_process");
9
+ const node_util_1 = require("node:util");
10
+ const node_fs_1 = require("node:fs");
11
+ const node_net_1 = __importDefault(require("node:net"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
14
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/shared/stdio.js");
15
+ /** No auth values in this code, config, argv or socket path. The stdio child
16
+ * has no HTTP credentials: it relays bytes to the owning runtime process.
17
+ */
18
+ exports.ANTIGRAVITY_STDIO_RELAY = `const net=require('node:net');const socket=net.createConnection(process.argv[1]);socket.on('error',()=>{process.stderr.write('Navarch MCP relay unavailable\\n');process.exitCode=1;process.stdin.destroy();});process.stdin.pipe(socket);socket.pipe(process.stdout);socket.on('close',()=>process.stdin.destroy());`;
19
+ /**
20
+ * AG has no documented per-run MCP-config flag or env interpolation. Use its
21
+ * documented workspace stdio format, exclusively created and removed here.
22
+ * Global AG configuration still loads; this is NOT an exclusive tool boundary.
23
+ */
24
+ async function prepareAntigravityMcp(options) {
25
+ if (!options.mcpConfigPath)
26
+ return { close: async () => undefined, failed: () => false };
27
+ if (process.platform === "win32")
28
+ throw new Error("Unix socket isolation required");
29
+ const cwd = node_path_1.default.resolve(options.cwd);
30
+ const parent = node_path_1.default.dirname(node_path_1.default.resolve(options.mcpConfigPath));
31
+ if (await node_fs_1.promises.realpath(cwd) !== cwd || await node_fs_1.promises.realpath(parent) !== parent)
32
+ throw new Error("Symlinked MCP parent refused");
33
+ const parsed = JSON.parse(await node_fs_1.promises.readFile(options.mcpConfigPath, "utf8"));
34
+ if (!record(parsed) || !record(parsed.mcpServers))
35
+ throw new Error("Invalid MCP source");
36
+ const servers = Object.entries(parsed.mcpServers).map(([name, raw]) => {
37
+ if (!/^[A-Za-z0-9_-]+$/.test(name) || !record(raw))
38
+ throw new Error("Invalid MCP server");
39
+ // Navarch's lease servers use Streamable HTTP. Never silently convert SSE
40
+ // or executable configs to a different transport.
41
+ if (raw.type !== "http" || typeof raw.url !== "string")
42
+ throw new Error("Unsupported lease MCP transport");
43
+ const url = new URL(raw.url);
44
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password)
45
+ throw new Error("Invalid lease MCP URL");
46
+ if (url.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
47
+ throw new Error("Insecure remote MCP refused");
48
+ if (raw.headers !== undefined && !record(raw.headers))
49
+ throw new Error("Invalid MCP headers");
50
+ const headers = {};
51
+ for (const [key, value] of Object.entries(raw.headers ?? {})) {
52
+ if (typeof value !== "string")
53
+ throw new Error("Invalid MCP header");
54
+ // This is Navarch's staging format, resolved by OUR bridge, not AG.
55
+ const ref = /^\{env:([A-Z_][A-Z0-9_]*)\}$/.exec(value);
56
+ if (ref && options.env[ref[1]] === undefined)
57
+ throw new Error("Missing lease MCP value");
58
+ headers[key] = ref ? options.env[ref[1]] : value;
59
+ }
60
+ return [name, { url, headers }];
61
+ });
62
+ if (servers.length === 0)
63
+ return { close: async () => undefined, failed: () => false };
64
+ if (await node_fs_1.promises.lstat(node_path_1.default.join(cwd, ".git")).catch(() => null)) {
65
+ // A normal git add -A must not publish the transient relay config. Require
66
+ // repository-local ignore coverage, which is also visible in the mounted
67
+ // Docker checkout. Do not rewrite tracked .gitignore or global excludes.
68
+ const { stdout } = await (0, node_util_1.promisify)(node_child_process_1.execFile)("git", ["-C", cwd, "check-ignore", "--no-index", "--verbose", "--", ".agents/mcp_config.json"], { timeout: 5_000, maxBuffer: 1024 * 1024 });
69
+ const match = /^(\.gitignore|\.agents\/\.gitignore):\d+:([^\t\n]+)\t\.agents\/mcp_config\.json\r?\n?$/.exec(stdout);
70
+ if (!match || match[2].startsWith("!"))
71
+ throw new Error("Repository-local MCP ignore rule required");
72
+ }
73
+ const agentsDir = node_path_1.default.join(cwd, ".agents");
74
+ const configPath = node_path_1.default.join(agentsDir, "mcp_config.json");
75
+ let createdAgentsDir = false;
76
+ try {
77
+ await node_fs_1.promises.mkdir(agentsDir, { mode: 0o700 });
78
+ createdAgentsDir = true;
79
+ }
80
+ catch (error) {
81
+ if (error.code !== "EEXIST")
82
+ throw error;
83
+ }
84
+ const agentsStat = await node_fs_1.promises.lstat(agentsDir);
85
+ if (!agentsStat.isDirectory() || agentsStat.isSymbolicLink())
86
+ throw new Error("Unsafe .agents directory");
87
+ // Reserve before making any sockets. wx refuses user-owned files/symlinks.
88
+ const config = await node_fs_1.promises.open(configPath, "wx", 0o600);
89
+ const owned = await config.stat();
90
+ let configText = "";
91
+ let tempDir;
92
+ let relayFailed = false;
93
+ let closed = false;
94
+ const relays = [];
95
+ const close = async () => {
96
+ if (closed)
97
+ return;
98
+ closed = true;
99
+ await config.close().catch(() => undefined);
100
+ for (const relay of relays) {
101
+ for (const socket of relay.sockets)
102
+ socket.destroy();
103
+ await Promise.allSettled([...relay.transports].map((transport) => transport.close()));
104
+ await new Promise((resolve) => relay.server.close(() => resolve()));
105
+ }
106
+ if (tempDir)
107
+ await node_fs_1.promises.rm(tempDir, { recursive: true, force: true });
108
+ // Do not erase a replacement supplied by the agent/user. No secret is in
109
+ // this file, but failure to remove our exact config is still a failed run.
110
+ let cleanupError = false;
111
+ // The transient config must never become a delivered repository file.
112
+ // Detect index/commit contamination without rewriting either user-owned
113
+ // history or the index; cleanup still removes our owned working file.
114
+ if (await node_fs_1.promises.lstat(node_path_1.default.join(cwd, ".git")).catch(() => null)) {
115
+ try {
116
+ await (0, node_util_1.promisify)(node_child_process_1.execFile)("git", ["--literal-pathspecs", "-C", cwd, "ls-files", "--error-unmatch", "--", ".agents/mcp_config.json"], { timeout: 5_000, maxBuffer: 1024 * 1024 });
117
+ cleanupError = true;
118
+ }
119
+ catch (error) {
120
+ if (error.code !== 1)
121
+ cleanupError = true;
122
+ }
123
+ }
124
+ try {
125
+ const current = await node_fs_1.promises.lstat(configPath);
126
+ if (current.isFile() && !current.isSymbolicLink() && current.ino === owned.ino && current.dev === owned.dev && await node_fs_1.promises.readFile(configPath, "utf8") === configText) {
127
+ await node_fs_1.promises.unlink(configPath);
128
+ }
129
+ else
130
+ cleanupError = true;
131
+ }
132
+ catch (error) {
133
+ if (error.code !== "ENOENT")
134
+ cleanupError = true;
135
+ }
136
+ if (createdAgentsDir)
137
+ await node_fs_1.promises.rmdir(agentsDir).catch(() => undefined);
138
+ if (cleanupError)
139
+ throw new Error("MCP config ownership changed");
140
+ };
141
+ try {
142
+ // Containers need the same-path session mount. Host workers use the
143
+ // short system temp root so ordinary per-project config directories do
144
+ // not exceed AF_UNIX's path limit. Neither directory holds credentials.
145
+ const socketRoot = options.dockerExec ? parent : await node_fs_1.promises.realpath("/tmp");
146
+ tempDir = await node_fs_1.promises.mkdtemp(node_path_1.default.join(socketRoot, "agy-"));
147
+ await node_fs_1.promises.chmod(tempDir, 0o700);
148
+ const mcpServers = {};
149
+ for (const [index, [name, target]] of servers.entries()) {
150
+ const socketPath = node_path_1.default.join(tempDir, `${index}.sock`);
151
+ if (Buffer.byteLength(socketPath) > 100)
152
+ throw new Error("Session metadata path too long for Unix socket");
153
+ const sockets = new Set();
154
+ const transports = new Set();
155
+ const server = node_net_1.default.createServer({ allowHalfOpen: true }, (socket) => {
156
+ sockets.add(socket);
157
+ const transport = new streamableHttp_js_1.StreamableHTTPClientTransport(target.url, { requestInit: { headers: target.headers }, reconnectionOptions: { maxReconnectionDelay: 1000, initialReconnectionDelay: 100, reconnectionDelayGrowFactor: 1.5, maxRetries: 1 } });
158
+ transports.add(transport);
159
+ const fail = () => { if (!closed)
160
+ relayFailed = true; socket.destroy(); void transport.close().catch(() => undefined); };
161
+ transport.onerror = fail;
162
+ let initializeId;
163
+ transport.onmessage = (message) => {
164
+ if ("id" in message && message.id === initializeId && "result" in message && record(message.result) && typeof message.result.protocolVersion === "string") {
165
+ transport.setProtocolVersion(message.result.protocolVersion);
166
+ }
167
+ if (!socket.destroyed)
168
+ socket.write((0, stdio_js_1.serializeMessage)(message));
169
+ };
170
+ transport.onclose = () => socket.end();
171
+ const started = transport.start();
172
+ let ready = started;
173
+ const pending = new Set();
174
+ const buffer = new stdio_js_1.ReadBuffer({ maxBufferSize: 16 * 1024 * 1024 });
175
+ socket.on("data", (chunk) => {
176
+ try {
177
+ buffer.append(chunk);
178
+ let message;
179
+ while ((message = buffer.readMessage()) !== null) {
180
+ const current = message;
181
+ if ("method" in current && current.method === "initialize" && "id" in current)
182
+ initializeId = current.id;
183
+ // Do not serialize complete HTTP requests: cancellation must be
184
+ // able to overtake an outstanding tools/call.
185
+ const operation = ready.then(() => transport.send(current));
186
+ if ("method" in current && ["initialize", "notifications/initialized"].includes(current.method))
187
+ ready = operation;
188
+ pending.add(operation);
189
+ void operation.then(() => pending.delete(operation), () => { pending.delete(operation); fail(); });
190
+ }
191
+ }
192
+ catch {
193
+ fail();
194
+ }
195
+ });
196
+ void started.catch(fail);
197
+ socket.on("end", () => { void Promise.all([...pending]).then(() => socket.end(), fail); });
198
+ socket.on("error", fail);
199
+ socket.on("close", () => { sockets.delete(socket); transports.delete(transport); void transport.close().catch(() => undefined); });
200
+ });
201
+ relays.push({ server, sockets, transports });
202
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, () => { server.removeListener("error", reject); resolve(); }); });
203
+ server.on("error", () => { if (!closed)
204
+ relayFailed = true; });
205
+ await node_fs_1.promises.chmod(socketPath, 0o600);
206
+ mcpServers[name] = {
207
+ command: options.dockerExec ? "node" : process.execPath,
208
+ args: ["-e", exports.ANTIGRAVITY_STDIO_RELAY, socketPath],
209
+ };
210
+ }
211
+ configText = JSON.stringify({ mcpServers }, null, 2);
212
+ await config.writeFile(configText, "utf8");
213
+ await config.close();
214
+ return { close, failed: () => relayFailed };
215
+ }
216
+ catch (error) {
217
+ await close();
218
+ throw error;
219
+ }
220
+ }
221
+ function record(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }