feat(daemon): agentconnect auth logs a runtime in, and the console hands over the command - #1818
Conversation
A runtime's credential lives in that runtime's own state directory on the host sessions are seeded FROM, and every login flow it offers wants a human at a terminal — a browser consent URL, a pasted API key, or its own interactive CLI. So this is an operator command, not something the daemon can do for itself. `agentconnect auth [--runtime <id>] [--method <id>]` (delegated verbatim to the daemon bin) lists what is installed on the host, annotates each row with a REAL login probe rather than an on-disk heuristic, and then walks whichever ACP method was chosen: a `terminal` method by handing the operator's own terminal to the agent program (advertised via the new `auth.terminal` client capability), an `agent` method through `authenticate` plus the request-scoped URL/form elicitations the auth phase asks over. The runtime deliberately runs UNSANDBOXED against the operator's own HOME: a private-HOME launch would write the login into a directory thrown away with the process. Also here, because a login needs them: - request-scoped elicitation reaches an `onAuthElicit` hook instead of being declined, and `auth.terminal` / `elicitation.url` are advertised only when the caller can service them. - a loopback-redirect paste fallback, so a headless host can finish an OAuth flow whose 127.0.0.1 listener no browser can reach (Google retired the OOB flow in 2023). - stop() gives the child an EOF grace window before signalling, so a runtime that dumps a stack trace on SIGTERM no longer makes a successful login look like a crash. Verified: daemon typecheck + `auth-cli` (17) / `auth-picker` (13) / `spawn-driver` (11) suites green, and both paths run for real against the installed runtimes on this host — `--runtime antigravity-acp` lists its four Google/Gemini methods, and the bare list sweeps 14 installed runtimes and annotates each with its live login verdict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The warning told an operator that a runtime needs a login and then left them to work out where and how — the credential is on the daemon host, so the console can never run it. Now the amber strip on a runtime row is a button, and it opens the exact command: `agentconnect auth --runtime <id>`, with copy, alongside why it belongs on that host, what the command will ask, the npx / `--instance` variants, and the fact that sessions pick the credential up on their next start. Both runtime lists carry it — the daemon detail view's own rows (mobile) and the shared `FleetRuntimesCard` (desktop daemon detail, cluster, group). The card takes an optional `daemonName` so a single machine's dialog names its host; a cluster or group aggregates hosts and has none to name, so its copy stays generic. `RuntimeSelect`'s per-option warning is deliberately untouched: that row is a `button role="option"` whose click means "pick this runtime", and a nested button there is invalid markup with confused keyboard semantics. Verified: web typecheck + eslint clean, and the whole web suite (213 files / 2368 tests) green, including a new ClusterDetailView case that clicks the warning and asserts the command lands in the dialog. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Three reproducible issues need fixing in the new auth command:
- Browser login completion remains blocked on the paste prompt until the operator presses Enter.
- Form elicitation returns fixed
type/textfields instead of the runtime's requested fields. - Cancelling the runtime picker can leave unfinished probe processes running after the CLI exits.
Focused verification passed: 41 daemon auth/picker/spawn tests and 23 ClusterDetailView tests. Separate small harnesses reproduced all three findings. No repository changes were made.
sent by review-bot (Codex · gpt-6-astra) · open in session
| out.write('\nIf the browser could not reach the redirect (this host is headless, no port forward),\n') | ||
| out.write('copy the URL of the tab that failed to load and paste it here — it carries the code.\n') | ||
| while (!settled) { | ||
| const answer = (await ask('\nRedirect URL (Enter to keep waiting): ')).trim() |
There was a problem hiding this comment.
[P2] Unblock the paste prompt when authentication settles
A normal browser login commonly takes longer than the three-second delay. Once this ask() starts, resolving or rejecting login only changes settled; it cannot release the pending readline question. I reproduced a successful login remaining stuck here until Enter was pressed, so the command never reports success or exits on its own. Race the outstanding prompt against login completion and close/cancel its readline interface when the login settles.
There was a problem hiding this comment.
Fixed in 75e6016. You are right that settled could not release a question already waiting on stdin. offerLoopbackPaste now owns an AbortController that the login aborts on settle (either outcome), and readLine takes that signal: it resolves the pending question empty and closes the readline interface, so the loop exits and the command reports success on its own. The readLine test seam carries the signal too, and a new case drives exactly your repro — the prompt is answered by nobody, the login resolves 10ms later, and the command finishes with the success line without a keypress.
There was a problem hiding this comment.
Confirmed in 75e60163: login completed after the paste prompt appeared, and the command exited successfully without any terminal input. This finding is resolved.
sent by review-bot (Codex · gpt-6-astra) · open in session
| if (typeof message === 'string') out.write(`\n${message}\n`) | ||
| const answer = (await (opts.readLine ?? readLine)('Response (empty to decline): ')).trim() | ||
| return answer | ||
| ? ({ action: 'accept', content: { type: 'text', text: answer } } as unknown as CreateElicitationResponse) |
There was a problem hiding this comment.
[P2] Return the fields requested by the auth form
ACP form responses must put an object matching requestedSchema in content. This handler ignores that schema and always sends {type: 'text', text: answer}. A request for a required projectId therefore receives no projectId, even after the operator supplies it; I reproduced that through onAuthElicit. Read the requested properties and return their named, typed values, declining unsupported forms instead of accepting an unusable response.
There was a problem hiding this comment.
Fixed in 75e6016, and thanks — a fixed {type,text} payload answered no schema at all. The handler now reduces the form through the shared projection instead of inventing a shape: elicitForm(params, CLI_ELICIT_SURFACE) (a new surface next to the Slack and webchat ones, claiming every kind, since a terminal can pick or type any of them), one control per field — enum/boolean picked from the options the agent itself offered, text/number/multi-select typed and re-asked while fieldAccepts refuses them — then elicitFormAccepts + elicitFormContent so the accept is keyed on the schema property names with the schema types. Your projectId case is a test: it comes back as {projectId, region, sandbox}. A form whose required field this terminal cannot render is declined whole rather than half-answered, and nothing typed at all is a decline, not an empty accept.
There was a problem hiding this comment.
Confirmed in 75e60163: form responses now use the requested property names and types, and missing or unsupported required fields cause a decline. The tests and separate form-entry check passed. This finding is resolved.
sent by review-bot (Codex · gpt-6-astra) · open in session
| try { | ||
| return await pick(model, io, 'Select a runtime to log in') | ||
| } finally { | ||
| sweep.abort() |
There was a problem hiding this comment.
[P2] Finish probe teardown before the auth process exits
Aborting this signal only stops workers from starting subsequent probes; it does not stop or await children already running. When the operator cancels the picker, runAuth returns and the new CLI action calls process.exit(0), so those probes' deadlines and finally cleanup never run. LocalDriver launches detached process groups, and I reproduced a stalled runtime remaining alive after cancellation. Cancel active probes and await their teardown before exiting; selection can still proceed immediately while cleanup runs alongside the chosen login.
There was a problem hiding this comment.
Fixed in 75e6016. Two halves, since awaiting the old sweep would have meant waiting out per-runtime deadlines: probeRuntime now races opts.signal (and refuses to launch under an already-aborted one), so an abort ends each in-flight probe immediately and its existing finally stops the host — that is what makes waiting cheap. chooseRuntime then aborts AND awaits the sweep before returning, so no probe outlives the CLI actions process.exit. Covered by two prober cases (abort mid-flight tears the child down; a pre-aborted sweep launches nothing) and one CLI case asserting runAuth` does not resolve until the cancelled sweep has finished.
There was a problem hiding this comment.
Confirmed in 75e60163: rerunning the stalled-runtime reproduction showed that cancelling the picker stopped the probe before the CLI exited, with no surviving process. This finding is resolved.
sent by review-bot (Codex · gpt-6-astra) · open in session
… and awaits probe teardown
Three reproducible defects from review, all in the new command:
- The loopback-paste prompt could not be released. `settled` only recorded that the login had
resolved; the readline question already waiting on stdin kept the process parked, so a
browser login that finished (routinely later than the 3s offer) never reported success until
someone pressed Enter. The prompt now races an AbortController the login aborts, and
`readLine` takes that signal, answering empty and closing its interface on abort.
- Form elicitations were answered with a fixed `{type,text}` payload, which matches no
`requestedSchema`: a request for a required `projectId` came back without one. The handler
now reduces the form through the SHARED projection (`elicitForm` over a new
`CLI_ELICIT_SURFACE`, which claims every kind since a terminal can pick or type any of
them), asks one control per field — enum/boolean picked from the options the agent itself
offered, text/number/multi-select typed and re-asked while the field's own schema refuses
them — and accepts under the schema's own property names and types via `elicitFormAccepts` /
`elicitFormContent`. A form carrying a required field this terminal cannot render is declined
whole rather than half-answered, and nothing typed at all is a decline, not an empty accept.
- Cancelling the picker left probe children running. Aborting the sweep only stopped workers
from starting the NEXT probe, and the CLI action exits as soon as `runAuth` resolves, so
in-flight probes lost their deadline and teardown. `probeRuntime` now races its abort signal
(and refuses to launch under an already-aborted one), so a cancel ends each probe at once and
its `finally` stops the child; `chooseRuntime` then awaits that teardown, which costs the
operator nothing measurable.
Verified: daemon typecheck + eslint clean; auth-cli is 24 tests (the paste release, the four
form cases, and the awaited teardown are new), runtime-prober 43 (two new abort cases).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Approved at 75e60163. All three previous findings are fixed: completed login releases the paste prompt, auth forms return the requested field names and types, and picker cancellation waits for probe teardown.
Validation: 245 focused daemon tests passed. Separate process checks confirmed exit without terminal input, correct form responses, and no surviving probe after cancellation.
Non-blocking follow-up: suppress the timed paste prompt while an auth form is collecting input. Both readers currently receive the same input, so valid form answers also produce misleading “not a loopback redirect URL” messages. The form still submits successfully.
sent by review-bot (Codex · gpt-6-astra) · open in session
`}, 10)` on its own line is what test/no-shortened-test-budget.test.ts reads as a per-test timeout at or under the default. The deferred teardown is now a named function, so the setTimeout closes inline and the case says the same thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Approved at f957e20a. This revision only refactors the deferred teardown callback in an auth CLI test; production behavior is unchanged from the previously approved revision. All 24 auth CLI tests pass, and the diff check is clean. No new findings; the previously noted form/paste prompt overlap remains a non-blocking follow-up.
sent by review-bot (Codex · gpt-6-astra) · open in session
|
sent by |
A runtime that needs a login is currently a dead end: the daemon probes it, gets
authentication requiredback, and the console shows an amber "Login required — sign in onthe daemon host" strip that says nothing about how. There was no command to run either.
This adds both halves.
agentconnect auth(daemon)agentconnect auth [--runtime <id>] [--method <id>] [--skip-probe]—authis notCLI-owned, so the unified CLI delegates it verbatim to the active daemon with the terminal
attached (
cli-daemon-split.md§4.2).Why it is an operator command and not something the daemon does for itself: the credential
lands in the runtime's own state directory on the host — the one
RUNTIME_STATE_LOCATIONSseeds sessions FROM — and every login flow a runtime offers wants a human at a terminal (a
browser consent URL, a pasted API key, its own interactive CLI). The login therefore runs
UNSANDBOXED against the operator's own HOME, exactly as if they had run the runtime's CLI by
hand; a private-HOME launch would write the login into a directory thrown away with the
process.
fixed order, then annotated as the probe sweep answers (
logged in — 5 model(s), 0.75.1/not logged in). Nothing persists this —authRequiredlives in the daemon process, andevery on-disk heuristic misreads a runtime that authenticates from an env var — so the
status is a real probe, run the same way the login will be. A selection made before the
first verdict is honoured immediately, and choosing aborts the sweep.
terminalmethod is completed by the CLIENT re-launching theagent program on the operator's terminal, which agents only offer when the client
advertises
auth.terminal— so this command does, and a daemon session never does. Anagentmethod goes throughauthenticate, answering the request-scoped URL/formelicitations of the auth phase via a new
onAuthElicithook (previously declined, sincethey map to no live turn).
Antigravity binds its OAuth listener to 127.0.0.1 on the daemon host, which a browser on
another machine cannot reach. The redirect still happens though, so the command offers to
replay the pasted
?code=…URL from the host itself. Only loopback http(s) URLs areaccepted.
SIGTERM, which reads like a crash right after a login that in fact succeeded.
stop()nowgives the child a window to exit on stdin EOF before signalling.
The console hands the command over (web)
The amber warning on a runtime row is now a button that opens a dialog with the exact
command (
agentconnect auth --runtime <id>, with copy), why it belongs on that host, what itwill ask, the npx /
--instancevariants, and the fact that sessions pick the credential upon their next start with no daemon restart.
Both runtime lists carry it: the daemon detail view's own rows (mobile) and the shared
FleetRuntimesCard(desktop daemon detail, cluster, group). The card takes an optionaldaemonName, so one machine's dialog names its host while a cluster/group — which aggregateshosts — stays generic.
RuntimeSelect's per-option warning is deliberately untouched: thatrow is a
button role="option"meaning "pick this runtime", and nesting a button inside itis invalid markup with confused keyboard semantics.
Verification
pnpm -r typecheckclean; eslint clean (pre-push hook ran it).auth-cli(17) andauth-picker(13) suites, plusspawn-driver(11),runtime-prober,render,daemon-transcript— 267 tests green.ClusterDetailViewcasethat clicks the warning and asserts the command lands in the dialog.
auth --runtime antigravity-acphandshakes and lists itsfour Google/Gemini login methods; the bare list sweeps 14 installed runtimes and annotates
each with its live verdict, redrawing in place on a real pty.
Follow-up (not in this PR)
The sweep's status line reports a probe failure (
ACP connection closed, a timeout, abinary that won't start) as
not logged in — <error>, which claims more than the probeestablished. That wants its own split into a
check failedstate.🤖 Generated with Claude Code