Skip to content

feat(daemon): agentconnect auth logs a runtime in, and the console hands over the command - #1818

Merged
spacedragon merged 4 commits into
mainfrom
feat/runtime-auth-cli
Sep 7, 2026
Merged

feat(daemon): agentconnect auth logs a runtime in, and the console hands over the command#1818
spacedragon merged 4 commits into
mainfrom
feat/runtime-auth-cli

Conversation

@spacedragon

Copy link
Copy Markdown
Contributor

A runtime that needs a login is currently a dead end: the daemon probes it, gets
authentication required back, and the console shows an amber "Login required — sign in on
the 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]auth is not
CLI-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_LOCATIONS
seeds 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.

  • Runtime list with real login verdicts. Every installed runtime is listed at once in a
    fixed order, then annotated as the probe sweep answers (logged in — 5 model(s), 0.75.1 /
    not logged in). Nothing persists this — authRequired lives in the daemon process, and
    every 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.
  • Both ACP method kinds. A terminal method is completed by the CLIENT re-launching the
    agent 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. An
    agent method goes through authenticate, answering the request-scoped URL/form
    elicitations of the auth phase via a new onAuthElicit hook (previously declined, since
    they map to no live turn).
  • Loopback paste fallback. Google retired the OOB flow in 2023, so a runtime like
    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 are
    accepted.
  • EOF grace on stop. Some runtimes (Antigravity's ACP server) dump a stack trace on
    SIGTERM, which reads like a crash right after a login that in fact succeeded. stop() now
    gives 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 it
will ask, the npx / --instance variants, and the fact that sessions pick the credential up
on 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 optional
daemonName, so one machine's dialog names its host while a cluster/group — which aggregates
hosts — stays generic. RuntimeSelect's per-option warning is deliberately untouched: that
row is a button role="option" meaning "pick this runtime", and nesting a button inside it
is invalid markup with confused keyboard semantics.

Verification

  • pnpm -r typecheck clean; eslint clean (pre-push hook ran it).
  • daemon: new auth-cli (17) and auth-picker (13) suites, plus spawn-driver (11),
    runtime-prober, render, daemon-transcript — 267 tests green.
  • web: full suite green (214 files / 2401 tests), including a new ClusterDetailView case
    that clicks the warning and asserts the command lands in the dialog.
  • Ran for real on this host. auth --runtime antigravity-acp handshakes and lists its
    four 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, a
binary that won't start) as not logged in — <error>, which claims more than the probe
established. That wants its own split into a check failed state.

🤖 Generated with Claude Code

spacedragon and others added 2 commits September 7, 2026 03:27
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>

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/text fields 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

Comment thread packages/daemon/src/cli/auth.ts Outdated
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/daemon/src/cli/auth.ts Outdated
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +173 to +176
try {
return await pick(model, io, 'Select a runtime to log in')
} finally {
sweep.abort()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

@agentconnect-md-test agentconnect-md-test Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@spacedragon
spacedragon enabled auto-merge (squash) September 7, 2026 04:00
@agentconnect-md-test

Copy link
Copy Markdown
Contributor

f957e20a is already approved with no blocking findings.

sent by review-bot (Codex · gpt-6-astra) · open in session

@spacedragon
spacedragon merged commit 02eeb21 into main Sep 7, 2026
14 checks passed
@spacedragon
spacedragon deleted the feat/runtime-auth-cli branch September 7, 2026 04:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant