Gemini API-key accounts on ACP
Child C5 of epic 7425 (ACP runtime: every provider lane on ACP). Planned by Task 7428. Depends on C4 ("Gemini question tool and awaiting linger"): reuses C4's question-tool arming base helper and the `JAAH_QUESTION_TOOL` seam, and owns the MicroVM-downgrade arming test.
## Requirement (verbatim)
Epic goal: "Goal: every provider and auth lane can run on jaah-acp-host. ... Each fix removes its catalogue gap or legacy-only row in `apps/jaahd/agent_providers.py`, so Settings › Providers tells the truth. While a legacy path still exists, it keeps working."
Outcome 4, first half (A9): "Every lane runs on ACP. Gemini API-key accounts send `authenticate` (the key from the broker, never in env, launch.json or logs), which deletes `acp-api-key-legacy`."
Epic Done (relevant): "On dev: a Gemini API-key task ... run[s] on ACP and settle[s]".
Current behaviour (PRD §3): Nothing in `apps/jaahd/acp/` sends `authenticate`, and `key_delivery="acp_authenticate"` is never acted on. `GeminiAdapter.acp_launch` returns None for an API-key account (`gemini_runner.py:540-541`). Legacy exports `GEMINI_API_KEY` from `jaah-credential` (`fleet_dispatch.py:854-860`). `test_gemini_replay.py:158` pins the refusal.
Decided (PRD): same isolation bar as Codex's ACP key login. A key in env or launch.json is forbidden by ADR-0083. The Gemini API-key pair becomes switchable, default ON. MicroVM: an unreachable broker publishes a degradation row rather than claiming ACP.
## Approach (architecture §5, verbatim)
Recon facts: the host holds no credential, and `cred_broker` refuses non-root callers (`acp/runtime.py:20`, `acp/host.py:6`). The host does reach the root daemon over the vocabulary socket for `runtime.*` verbs, fenced so that **only the pane's root process (the host) may call them**, never the agent or its tools (`cred_broker.py:317-329` `_host_fence`, SO_PEERCRED plus start ticks). launch.json has no auth block (`acp/launch.py:23-24` `LAUNCH_FIELDS`).
**Chose:** a new fenced daemon verb that hands the key to the host in memory, **over** env, file or fd delivery.
Pinned contracts:
- **launch.json:** `LAUNCH_FIELDS` gains `authenticate`, either `null` or `{"method_id": str, "key_meta": str}` (Gemini: `{"method_id": "gemini-api-key", "key_meta": "api-key"}`). It holds no secret. `validate_secrets` keeps passing. `AcpLaunch` gains the same optional field, and `acp_runtime.launch_document` writes it.
- **Daemon verb** `runtime.account_key` (`agent_vocabulary.LOCAL_VERBS`, `scope=TASK`, not a wait verb): args `{session_id}`, answer `{key}`; served by `acp/runtime.py`'s hub from `_answer_local`, behind `_host_fence`. The hub resolves the account from the identity's runner record (the dispatched API-key account slug), **never from the args**, and returns `cred_broker.account_api_key(slug)`. **Single use per session:** the hub records that the session was served and refuses a second call. It refuses when the session's launch has `authenticate == null`. The answer is excluded from every log line: add the verb to the broker request log's answer-redaction list, or log the verb name only.
- **Host:** (1) after `initialize` and before `session/new` (`host.py` ~:505-515), if `launch.authenticate` is set, call `runtime.account_key`; (2) send ACP `authenticate` with `{"methodId": method_id, "_meta": {key_meta: key}}` through a new `client.authenticate()` in `acp/client.py`; (3) drop the reference. The `authenticate` request and its params must never pass through the event normaliser or a debug log of JSON-RPC traffic; if `client.py` has a wire-debug log, it masks `params._meta` for `authenticate`.
- **Fail closed (R6.3):** a broker refusal, a missing account or an `authenticate` error ends the attempt with a launch error naming the account slug, never the key. No `session/new` is sent.
- **Adapter:** `GeminiAdapter.acp_launch` drops the `api_key_account ⇒ None` refusal (`gemini_runner.py:540-541`) and sets `authenticate`. The legacy `GEMINI_API_KEY` export (`fleet_dispatch.py:854-860`) stays for switch-OFF. For ACP it is skipped, because `build_auth_lines` already blanks `api_key_account` when `acp is not None` (:1299). **Verify this holds for Gemini (test).**
- **MicroVM (R6.6):** VM callers already reach `runtime.*` verbs (`_host_fence` passes a VM identity). First try the same verb from a MicroVM host. If the guest relay does not carry it, `acp_launch` returns None on a MicroVM, using the same predicate as `CodexAdapter.acp_launch` (`agents/codex.py:726-729`), and the GEMINI row gains a degradation `microvm-api-key-legacy-runtime` worded like Codex's (`agent_providers.py:415-423`). Either outcome is acceptable; the catalogue must state whichever ships.
- **Catalogue:** GEMINI runtime pair `("api_key","acp")`, which makes it switchable and default ON through `acp_runtime._effective`. `key_delivery="acp_authenticate"` becomes true. Delete gap `acp-api-key-legacy` and its test row `(46, (GEMINI,), "acp-api-key-legacy")`. Update `test_gemini_replay.py:158`.
Question-tool arming (architecture §9 row, owned by C4, test owned here): the node arms `JAAH_QUESTION_TOOL=1` from `intent.runtime` (not from the launch path actually taken): whenever `intent.runtime == "acp"` and the provider's ACP `requests.user_input == "mcp_tool"`, the control MCP server's env carries the flag in the ACP launch **and** in a legacy launch the node fell back to. Use C4's base helper; add no new arming code path.
Seams (architecture §9): `acp/host.py` shared with C4 (idle loop) — disjoint functions. `GeminiAdapter.acp_launch`: edit only :540-541 and the `AcpLaunch(...)` `authenticate=` keyword; C7 owns the bind lines, C4 owns the `control_mcp_server` env. `fleet_dispatch.launch_runner`: relies on the `:1299` auth blanking, unchanged; C8 owns the prepare policy. GEMINI catalogue row: flip only the runtime pair and `key_delivery`, delete only `acp-api-key-legacy`; keep both sides on rebase.
Credentials & reach:
- Replay, unit, API and golden tests; `check_provider_branches.py`; `./dc check full` — Nothing beyond the worktree — verified
- Dev control plane — `app.dev.jaah.ai` — verified: HTTP 200 and a UI sign-in from h5-3 (every dev oracle uses `app.dev.jaah.ai`)
- Dev console login (operator) — `jaah/debug/main/operator-login` — verified
- Gemini API-key task (R6.7) — dev account `gem2` (stored Gemini API key) — verified: present on Accounts after the operator supplied it
Out of scope: Do NOT put the key in env, launch.json, a file, an fd, a log line, an event, a transcript, a stored row, a PR body or a report ("No secret in any persisted surface"). Do NOT remove the legacy `GEMINI_API_KEY` export (switch-OFF path). Do NOT touch the idle loop (C4), the bind lines (C7), the prepare policy (C8). Do NOT touch the chat ↔ terminal hand-off (Task 7370), registry and SDK v2 agents (Task 7429), billing rules, Settings › Providers layout. "No new provider-name branches (`tools/provider_branches.toml` allowlist growth)."
Done means:
- R6.1 For a launch whose `auth.key_delivery` is `acp_authenticate` and which uses an API-key account, the host fetches the key from the node credential broker and sends ACP `authenticate` (`methodId: "gemini-api-key"`, key in `_meta["api-key"]`) before `session/new`. — verify: a replay or host test with a stub agent records `authenticate` as the first request after `initialize`, with the methodId and a key equal to the broker stub's value.
- R6.2 The key never appears in launch.json, the process environment of the host or agent, a log line, the event stream, the transcript upload, or the session's stored rows. — verify: the test greps every written file, the captured host and agent environ, all emitted events, the transcript upload and captured logs (including any JSON-RPC wire-debug log and the broker request log) for the stub key (none found). The existing key-in-env self-check stays clean (its test passes).
- Verb hardening — verify: tests show `runtime.account_key` (a) resolves the account from the runner record, ignoring any account in args; (b) refuses a second call for the same session; (c) refuses when the session's launch has `authenticate == null`; (d) refuses a non-host caller (fence).
- R6.3 A broker failure fails closed: no `session/new`, a launch error that names the account, never the key, and no fallback to the environment. — verify: a test with the broker stub refusing. Observe an attempt-end error and no `session/new`.
- R6.4 `GeminiAdapter.acp_launch` serves API-key accounts. The catalogue pair `("api_key","legacy_only")` becomes `("api_key","acp")`, so it gains an operator switch, default ON like every switchable pair (`acp_runtime._effective`). Gap `acp-api-key-legacy` is deleted. `test_gemini_replay.py:158` is updated. — verify: `grep -rn acp-api-key-legacy apps tools docs` returns nothing, and `pytest` on the catalogue and Gemini replay tests passes; a test proves `build_auth_lines` emits no `GEMINI_API_KEY` export for a Gemini ACP launch.
- R6.5 With the switch OFF, a Gemini API-key task runs legacy as today. — verify: the existing legacy launch tests and golden pass, plus a switch-OFF launch test (L.1).
- R6.6 A MicroVM node: if the guest cannot reach the broker, an API-key Gemini session there may stay legacy, but only with a published degradation row worded like Codex's `microvm-api-key-legacy-runtime`. The catalogue never claims ACP where it is not served. — verify: either a MicroVM test that sends `authenticate`, or the new row is present and asserted in `tools/tests/test_agent_providers.py`.
- Question-tool arming on downgrade (architecture §9) — verify: a Gemini API-key dispatch with `runtime="acp"` on a MicroVM node, downgraded to legacy, has a charter question tool (`mcp_control_input_request`) that is in that session's control-server roster. (If the MicroVM path sends `authenticate` and never downgrades, assert the same on the ACP launch and record why the downgrade case is unreachable.)
- R6.7 (availability) — verify on dev (`app.dev.jaah.ai`): with Gemini API-key account `gem2` and the Gemini API-key switch ON (default), run a task to settle. Its Audit › Sessions row shows runtime ACP, and Settings › Providers shows a switch on the Gemini API-key line, not "Terminal only" (readable, untruncated at 390px and 1280px).
- D.1 `apps/jaahd/README.md` §"Gemini CLI on the ACP runtime" and §"The ACP host runtime" describe the `authenticate` path and the `runtime.account_key` verb in the present tense; `apps/jaahd/README.md` §"Local callers" verb list includes it if it lists `runtime.*` verbs. — verify: grep for `acp-api-key-legacy` returns nothing; `python3 tools/check_context.py` passes.
- T.3 No new gap is claimed fixed unless it is; a MicroVM loss is published as its own row. — verify: review in the PR, plus the catalogue test.
- T.5 `python3 tools/check_provider_branches.py` passes, and no provider-name branch is added to its allowlist. — verify: run it and observe "OK"; `git diff tools/provider_branches.toml` adds no `[[allow]]`.
Required checks: ./dc check full
Outcome
Gemini API-key accounts sign in on ACP via the fenced, single-use runtime.account_key (no key in env/launch.json/logs; MicroVM stays legacy, row published). PR #6074 merged, deploy green; reviews pass. Full gate red only on pre-existing develop failures. R6.7 live run unverified → draft #7622.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)