Skip to content

feat(web): guided sign-in cards for Claude, Codex and GitHub - #317

Merged
defangdevs merged 8 commits into
masterfrom
feat/207-guided-signin-cards
Aug 21, 2026
Merged

feat(web): guided sign-in cards for Claude, Codex and GitHub#317
defangdevs merged 8 commits into
masterfrom
feat/207-guided-signin-cards

Conversation

@defangdevs

@defangdevs defangdevs commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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:

card starts success signal credential lands in
Claude Code claude auth login claude auth status (JSON) ~/.claude
Codex codex login --device-auth codex login status ~/.codex
GitHub gh auth login --web gh auth status ~/.config/gh

So the daemon holds no token, no client id, no PKCE verifier and no OAuth state. GitHub needs no PAT and no GitHub Appgh already 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_KEY under 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

Connections
Sign in without leaving this page. Each card runs that tool's OWN sign-in command in a
terminal you never have to find, and the tool stores its own credential — this page never
sees a token. Setting a key by hand under Environment secrets still works, and still wins.
┌───────────────────────────────────────────────────────────────────────────────────────┐
│ Claude Code                                              ● Waiting for you            │
│ Runs claude auth login — your Claude subscription or Console account. The CLI stores   │
│ the credential in ~/.claude.                                                          │
├───────────────────────────────────────────────────────────────────────────────────────┤
│ 1. Open the sign-in page  and approve the request.            (a real link, new tab)  │
│ 2. Paste the code the page gives you back                                             │
│    [ code from the sign-in page        ]  [ Submit code ]                              │
│    [ Cancel ]                                                                          │
├───────────────────────────────────────────────────────────────────────────────────────┤
│ GitHub                     ● Signed in — github.com account you (keyring)  [Sign in…] │
│ Runs gh auth login --web — GitHub's own device flow. No app to register, no token to   │
│ copy, and git reads it through gh's credential helper.                                 │
├───────────────────────────────────────────────────────────────────────────────────────┤
│ GH_TOKEN is set under Environment secrets. These CLIs prefer their environment          │
│ variable over a stored credential, so the value you set by hand is what your sessions  │
│ use — signed in here or not.                                    (amber, not an error)  │
└───────────────────────────────────────────────────────────────────────────────────────┘

A device flow that prints its code in the pane (gh, codex) shows it as EE45-B423 between 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 — ~/downloads could not host them (#316), so they are not linked here.

Design notes worth reviewing

  • The tmux session IS the state. Nothing is persisted by the daemon, so a card is reconstructed from the pane on every render: a reload, a second browser tab and a daemon restart all show the same step. Verified by restarting the daemon mid-flow.
  • 512-column pane. A sign-in URL then never wraps and capture-pane -p -J reads it whole — the wrapped-link problem the README documents for the terminal flow cannot happen here.
  • Code and Enter are separate writes, 1 s apart. The prompt treats one large chunk as a paste and swallows a trailing carriage return, so a real ~100-character code never submits otherwise (documented failure in raphaeltm/simple-agent-manager's setup-token driver, whose approach this borrows — minus its token scraping, which we do not need because claude auth status answers JSON).
  • Host-anchored URLs. The link comes from a host allowlist per flow (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 decoy https://evil.example.com/claude.com/... FIRST and the test asserts the card still links claude.com.
  • connect_start refuses to start a tmux server. tmux new-session would 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 its NoNewPrivileges=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.
  • Pane text almost never reaches the browser. Only two things do: the matched URL, and a short redacted tail when a flow ends without signing in (sk-ant-…, gh[pousr]_…, github_pat_… are stripped). Codes are type=password fields and are validated as printable, whitespace-free and bounded before they are typed into the pane.
  • Codex re-sign-in confirms first, because --device-auth drops the stored credential as it starts.
  • One tmux list-sessions answers 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_origin CSRF 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 — ok
  • nix run .#update-golden (reviewed: the daemon payload plus one AGENT_BOX_CONNECT_BINS line per settings unit) + golden-snapshot — ok
  • module-single-file, multi-user — ok
  • nix 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) — ok
  • Behaviour verified end to end against stub CLIs on a local rig on this box: cards render per installed CLI, start opens exactly one pane, decoy URL rejected, pasted code reaches the CLI, success comes from the CLI and reaps the pane, cancel leaves no false success, whitespace code refused 400, unknown flow 404, no-server start 409 + blocked card, manual-GH_TOKEN warning 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 stops agent-box-agent.service and asserts no server appears).


Since the approval (four commits, two real bugs)

Driving the cards against the REAL claude, codex and gh on a live box — not the stubs — found two defects that the first version shipped with, and CI found a third:

  1. 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 stripped GH_TOKEN (a login-time rule for gh, 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 source gh reports.
  2. 949af8d — the page render forked three CLIs. gh auth status makes a network call, so a box with no route to GitHub held the whole settings page past its 10s client timeout — which is what turned settings-page.nix red, 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 one tmux list-sessions and answers in ~13ms with a status call rigged to take 8s. New pill states "Checking…" and "Finishing sign-in…" cover the gaps honestly.
  3. 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.
  4. 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) fixes connect.nix's tmux() quoting: -F '#S' closed the su -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.

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.
@defangdevs

Copy link
Copy Markdown
Owner Author

Merge-order note, since #315 (opencode support) is in flight on the same generated files: whichever of the two lands second needs nix run .#assemble + nix run .#update-golden re-run on the rebase — modules/agent-box.nix and tests/golden/ will conflict, the sources will not.

Behaviourally the two are independent: connectBins maps over installAgents, so an opencode box would pass opencode=<binary> in AGENT_BOX_CONNECT_BINS, and the daemon ignores any id that has no CONNECT_DEFS row — no card, no error. Giving opencode a card is one table row plus its status parser, once #315 settles what its sign-in command is.

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().
@defangdevs

Copy link
Copy Markdown
Owner Author

CI failed on 09984ff with two distinct, unrelated causes:

1. tests/connect.nix's tmux() quoting bug — fixed in 63cab16.
It spliced cmd into a hand-written "...tmux -L agent-box " + cmd + "'" string. cmd itself carries quotes (list-sessions -F '#S'), which closed the outer ' early and left a bare # that shell-comments out the rest of the line — -F lost its argument (command list-sessions: -F expects an argument), failing both subtests that check the pane name. tests/sessions.nix already had the right pattern (route through as_agent's shlex.quote); connect.nix now does the same.

2. vm-test-run-agent-box-settings-page — real bug, not yet fixed.
render_page()render_connect()connect_state()connect_status() runs synchronously for every installed flow on every full-page render, not just the connect endpoint. On a cold cache (_connect_status_cache empty, e.g. right after the daemon starts) this shells out to the real CLI's own auth status per flow (connect_run's default timeout=15), serially, with no page-wide deadline. In settings-page.nix's VM (agent = "claude", no network), the real claude auth status call is slow enough on its own to blow past the test's curl --max-time 10 — the request never gets a chance to answer inside 10s, so grep 'Settings for agent' sees nothing and the whole VM test fails at the very first page load, unrelated to anything the sign-in cards test (which stubs the CLIs) exercises.

Worth an actual design call rather than a knob tweak: as installed CLI count grows, worst case is N × 15s on every cold-cache full-page load. Options I see, not prescribing one:

  • Never block the main page render on a live probe — render cards from whatever's cached (default to "idle"/"checking…" if nothing cached yet) and let the existing /agent/settings/connect?flow= JS poll fill in real state right after load.
  • Or put a hard page-wide deadline around the whole render_connect() pass instead of a per-flow one, so N slow CLIs can't multiply.

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.
@defangdevs
defangdevs merged commit 562a8e4 into master Aug 21, 2026
1 check passed
@defangdevs
defangdevs deleted the feat/207-guided-signin-cards branch August 21, 2026 19:58
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Agent-Box Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Build interactive auth flow for Claude

2 participants