feat(web): guided sign-in cards for Claude, Codex and GitHub - #317
Conversation
Onboarding meant finding the right terminal tab, reading a wrapped
OSC-8 link out of a TUI, pasting a code into a prompt that echoes
nothing — and, for GitHub, leaving the box entirely to mint a PAT by
hand. The settings page now has a Connections card per tool that does
that walk for you.
Nothing here reimplements a credential flow. Each card runs the tool's
OWN command in its own tmux session — `claude auth login`,
`codex login --device-auth`, `gh auth login --web` — reads the sign-in
URL off the pane, renders it as a link (host-anchored, so a future CLI
change cannot pick the destination), types the pasted code back in, and
asks the CLI itself whether it worked (`claude auth status` answers
JSON). The credential is written by the CLI to ~/.claude, ~/.codex or
~/.config/gh, exactly as a terminal sign-in would have written it: the
daemon holds no token, no client id and no OAuth state. GitHub needs no
PAT and no GitHub App — gh ships GitHub's own device-flow client.
The tmux session IS the state, so a card survives a reload, a second
tab or a daemon restart. Two details are load-bearing:
- the pane is 512 columns wide, so a sign-in URL never wraps and
capture-pane reads it whole (the wrapped-link problem the README
documents for the terminal flow cannot happen here);
- the code and the Enter keypress are separate writes, because the
prompt treats one large chunk as a paste and swallows a trailing
carriage return (documented failure in raphaeltm/simple-agent-manager's
setup-token driver, reproduced with real ~100-character codes).
connect_start refuses to run when no tmux server is up rather than
letting `tmux new-session` start one: a server parented by this daemon
would put every session the supervisor later spawns in the settings
daemon's namespace and cgroup instead of the hardened agent unit's. The
card says so and offers no button.
Hand-set keys keep working and keep winning — every one of these CLIs
prefers its environment variable over a stored credential — and the
card says which key it found.
Refs #207, #208, #313.
|
Merge-order note, since #315 (opencode support) is in flight on the same generated files: whichever of the two lands second needs Behaviourally the two are independent: |
The VM-test job names its checks one by one, so tests/connect.nix built nothing on the first green run of this branch. Add it to the list (and to the per-check comment above it) so the test actually runs.
An activation script would have to be ordered after `users` — the dir's parent is the agent's home, which that script creates — and getting that wrong fails the BOOT rather than the test. The pane can just mkdir it.
Found by pointing the cards at the REAL claude, codex and gh on a box whose GitHub token comes from the env store: the GitHub card said "Not signed in" while `gh auth status` in any session says "Logged in to github.com account … (GH_TOKEN)". Two causes, both about whose environment the probe runs in. The status call stripped the same variables the SIGN-IN pane strips — but gh only refuses an env token at login time, so stripping it for `auth status` asked the wrong question. And the settings unit never loads the env store at all (the supervisor's spawn wrapper does, per session), so even unstripped it would have missed a hand-set key. The probe now lifts exactly the flow's own keys out of the store, so the pill answers the question the user is actually asking — "are my sessions signed in?" — and names the source gh reports. The pane keeps stripping them, which is the login-time rule. No value is rendered. The VM stub now mirrors gh's precedence (env token wins over its store, source named), so the test can assert the card follows a key set through the page.
cmd carries its own single quotes (e.g. "list-sessions -F '#S'"); splicing it into a hand-written 'env ... tmux ... ' + cmd + "'" string closed the outer quote early, leaving a bare # that shell-comments out the rest of the line. That dropped -F's argument and broke both subtests that assert on the pane name. Reuse as_agent's shlex.quote instead, same fix already used in tests/sessions.nix's tmux().
|
CI failed on 09984ff with two distinct, unrelated causes: 1. 2. Worth an actual design call rather than a knob tweak: as installed CLI count grows, worst case is
Didn't touch this one myself since it changes page semantics / JS timing rather than being a mechanical fix — flagging for whoever picks this back up. Happy to implement either direction if you want to point at one. |
settings-page.nix went red on this branch, and the cause was mine: every render of the settings page asked all three cards whether they were signed in, and each answer forks a real CLI. `gh auth status` makes a network call, so on a box with no route to GitHub the page — a page that has nothing to do with these cards — hung past its 10s client timeout. The stubbed connect.nix could never have caught this; the un-stubbed settings-page did. Probes now run in a background thread behind a stale-then-refresh cache, so a render never forks anything: it costs one `tmux list-sessions` and answers in milliseconds (measured 13ms against a status call rigged to take 8s). A card with no answer yet says "Checking…" rather than "Not signed in", because the latter would invite a sign-in a signed-in box does not need. That async read exposed a second, worse lie: for the two seconds between the CLI exiting and the next probe, a SUCCESSFUL sign-in rendered as "Not signed in" with the failure step. The pane's own exit code now wins over the stale cache — exit=0 holds the card on "Finishing sign-in…" and forces a re-probe, while a non-zero exit is still a real failure with its redacted error line. connect.nix keeps 63cab16's tmux() fix and gains a bound on the render itself, so a probe creeping back onto the request path fails a test rather than another test's timeout.
connect.nix went red with "claude stuck in idle, wanted waiting", and the
bug it found is worse than the test: "Sign in again" never worked. The
state machine reaped a live pane whenever the cached status said
"connected" — and that status is exactly why the user pressed the button,
so the pane died within milliseconds of starting. Any re-authentication
(expired credential, wrong account, switching to Console) was a button
that did nothing.
A live pane now owns its card outright: the status answer only decides a
card with NO pane. Success comes from the pane's own exit code, confirmed
by a FRESH probe before the card claims it, so a stale "connected" can
neither kill a new flow nor fake a finished one.
Also: a failed flow reported its whole visible transcript — the
instructions, the sign-in URL, and the echo of the prompt the code was
typed into, which carries the code itself. It now reports the CLI's own
complaint ("OAuth error: Request failed with status code 400") and nothing
else.
Both are now asserted: a sign-in-again subtest that starts from a card the
CLI reports as signed in, and a rejected-code subtest that checks what the
error line does NOT contain.
Two mistakes in the coverage I added with 053563e, both mine, both found by CI rather than by me: - the stub matched the failing code by EQUALITY on "bad", but the daemon refuses anything under four characters before it reaches the pane, so the test's "badcode1" took the SUCCESS path and the card went connected instead of failed. Match the prefix. - that subtest leaves claude signed out, and it sat before the one that starts from a signed-in card, which would have failed next. Order the subtests by the state they leave behind. Also walked the whole subtest sequence against a local rig before pushing this time (scratch harness, same driver, stub CLIs) — every step in order, which is what would have caught both of these in seconds rather than in a 13-minute CI round trip.
Closes #207. Refs #208, #313.
What this does
The settings page grows a Connections section: one card per tool — Claude Code, Codex, GitHub — that signs you in without opening a terminal.
Nothing here reimplements a credential flow. Each card runs the tool's OWN command in its own tmux session, reads the sign-in URL off that pane, renders it as a link, types the pasted code back in, and then asks the CLI itself whether it worked:
claude auth loginclaude auth status(JSON)~/.claudecodex login --device-authcodex login status~/.codexgh auth login --webgh auth status~/.config/ghSo the daemon holds no token, no client id, no PKCE verifier and no OAuth state. GitHub needs no PAT and no GitHub App —
ghalready ships GitHub's own device-flow client, which is the whole reason this can work for a self-hosted box that nobody registers an app for.A hand-set
GH_TOKEN/CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_API_KEYunder Environment secrets keeps working and keeps winning (every one of these CLIs prefers its env var over a stored credential). The card says which key it found rather than silently losing to it.What it looks like
A device flow that prints its code in the pane (gh, codex) shows it as
EE45-B423between steps 1 and 2 instead of asking for a paste. Pills reuse the session dots: green signed in, amber waiting, grey not signed in, red failed.Rendered PNGs of both states (waiting and settled) are in the session that opened this PR —
~/downloadscould not host them (#316), so they are not linked here.Design notes worth reviewing
capture-pane -p -Jreads it whole — the wrapped-link problem the README documents for the terminal flow cannot happen here.raphaeltm/simple-agent-manager'ssetup-tokendriver, whose approach this borrows — minus its token scraping, which we do not need becauseclaude auth statusanswers JSON).claude.com/claude.ai/anthropic.com,github.com,openai.com/chatgpt.com), so nothing that reaches the pane can choose where the user is sent. The VM stub prints a decoyhttps://evil.example.com/claude.com/...FIRST and the test asserts the card still linksclaude.com.connect_startrefuses to start a tmux server.tmux new-sessionwould start one if none is running, and that server outlives the request — every session the supervisor later spawned would then be a child of the settings daemon rather than the hardened agent unit, inheriting its namespace and cgroup (and itsNoNewPrivileges=false, which exists for the sudo'd password helper). With no server the card says "no terminal session is running" and offers no button; the POST answers 409.sk-ant-…,gh[pousr]_…,github_pat_…are stripped). Codes aretype=passwordfields and are validated as printable, whitespace-free and bounded before they are typed into the pane.--device-authdrops the stored credential as it starts.tmux list-sessionsanswers both "is the server up" and "is this pane live" for all cards, and status calls are cached for 5 s — the page polls every 2.5 s while a flow is in flight, and each status call forks a real binary.Security effects
No new privilege, no new listener, no new stored secret. The daemon already ran as the user with access to that user's tmux socket; the new POSTs go through the existing
_same_originCSRF gate. The sign-in pane is a child of the agent unit's tmux server, so credentials are written in the same namespace a terminal sign-in would have used.Checks run
nix run .#assemble+nix build .#checks.aarch64-linux.module-generated-up-to-date— oknix run .#update-golden(reviewed: the daemon payload plus oneAGENT_BOX_CONNECT_BINSline per settings unit) +golden-snapshot— okmodule-single-file,multi-user— oknix eval .#checks.x86_64-linux.connect.drvPath— evaluates (the VM test itself is x86-only; CI runs it)check-testscript.sh tests/connect.nix(ty + ruff) — okGH_TOKENwarning shown and never echoing the value.New test
tests/connect.nix(nix build .#checks.x86_64-linux.connect) drives the daemon over its unix socket with stubbed CLIs — the driver is what is under test, and a real OAuth round trip is not available offline. It covers every behaviour in that list, including the two that are easy to regress silently: the decoy-URL rejection and the refusal to start a tmux server (it stopsagent-box-agent.serviceand asserts no server appears).Since the approval (four commits, two real bugs)
Driving the cards against the REAL
claude,codexandghon a live box — not the stubs — found two defects that the first version shipped with, and CI found a third:09984ff— the status probe asked the wrong question. The GitHub card said "Not signed in" on a box whose every session authenticates fine, because the probe strippedGH_TOKEN(a login-time rule forgh, not a status-time one) and because this daemon's unit never loads the env store at all. It now lifts the flow's own keys out of the store, so the pill answers "are my sessions signed in?" and names the sourceghreports.949af8d— the page render forked three CLIs.gh auth statusmakes a network call, so a box with no route to GitHub held the whole settings page past its 10s client timeout — which is what turnedsettings-page.nixred, a test that has nothing to do with these cards. Probes now run in a background thread behind a stale-then-refresh cache: a render costs onetmux list-sessionsand answers in ~13ms with a status call rigged to take 8s. New pill states "Checking…" and "Finishing sign-in…" cover the gaps honestly.053563e— "Sign in again" never worked. The state machine reaped a live pane whenever the cached status said "connected", and that status is exactly why a user presses that button, so the pane died milliseconds after starting. Every re-authentication path (expired credential, wrong account, switching to Console) was a dead button. A live pane now owns its card; status only decides a card with no pane, and success needs the pane's exit code plus a FRESH probe. The same commit stops a failed flow from reporting its whole visible transcript — which included the sign-in URL and the echo of the prompt the code was typed into, i.e. the code itself. It now reports the CLI's own complaint and nothing else.63cab16(pushed by a hook session the approval spawned — see Review-approval spawns a hook session even while the PR's owning session is live #319) fixesconnect.nix'stmux()quoting:-F '#S'closed thesu -c '…'quote early, so tmux saw a bare-F.Extra coverage added with them: a sign-in-again subtest that starts from a card the CLI reports as signed in; a rejected-code subtest asserting what the error line must NOT contain (the pasted code, the URL); a bound on the render itself, so a probe creeping back onto the request path fails THIS test rather than another one's timeout; and the gh stub now mirrors gh's real precedence (env token wins over its store, source named) so the env-store probe is asserted end to end.