feat: sign in to a backend from codeoid, instead of pasting an API key - #331
Merged
Conversation
Every backend codeoid runs already has a first-party login that mints a
subscription credential — `claude setup-token`, `codex login`, qwen's OAuth.
Each is an interactive terminal command, so using one meant having a shell on
the machine the daemon runs on. When codeoid IS the whole surface — a hosted
sandbox, a phone — nobody does, and an API key was the only way in. The gap was
never that login is impossible; it was that it was unreachable from the only UI
the user has.
Settings → Claude now has it: open the link, paste back the code, done.
## Brokering, not reimplementing
The daemon runs the vendor's own command. It does not implement anyone else's
OAuth. That distinction is the design, not an implementation detail — scraping a
client id out of someone's CLI and driving their token endpoint ourselves works
right until they change it, and puts us in the business of maintaining another
company's auth. Running the command they ship means their flow can change under
us and keep working.
Two steps, because that is the shape their commands already have:
1. `backend.login.start` — run the command, return the authorize URL it
prints. The user opens it in THEIR browser; the daemon never needs one,
which is what makes this work headless.
2. `backend.login.submit` — the vendor's callback page shows a code, the user
pastes it back, the daemon hands it to the still-waiting command.
No new dependency: the `claude` binary is already in `node_modules`, shipped by
the Agent SDK's platform package, and is what every Claude turn already runs
through. Verified end to end against it — real `setup-token`, real PKCE URL,
4.5s.
## The awkward part is the terminal
These commands are TUIs. `claude setup-token` writes nothing at all to a pipe:
it detects the absence of a TTY and waits forever (verified — zero bytes, hung).
Under a pty it prints its URL in under two seconds. So it needs a pty, and
codeoid has no pty dependency; node-pty is a native module, i.e. a build
toolchain in every image for one feature. `script(1)` is the pty — util-linux on
Linux (Essential, so present even in debian-slim), BSD script on macOS, a
different argv on each.
What comes back is raw terminal output: ANSI, OSC-8 hyperlinks, spinner redraws,
an 80-column wrap that chops the URL across lines. Rather than strip escapes and
hope, every pattern matches on a character class that control bytes cannot
appear in — so escapes are simply not matchable — and longest-match wins, which
is what picks the hyperlink's unwrapped copy of the URL over the visible
truncated one.
## What the tests found
They run a FAKE vendor command through the REAL pty and the real matchers,
because the parts likely to be wrong here are not the state machine but the seam
with a terminal. That caught a bug worth naming: a pty echoes its input, so a
user who pastes something credential-shaped (the exact mistake made when "code"
is confused with "key") had their own typo read back as the vendor's answer —
success reported, garbage stored. Nothing a vendor returns for an exchange is a
substring of the code that requested it, and that is now the rule.
## Storage, scope, hygiene
The credential is written through the ordinary settings path, so it lands in the
same 0600 `.env`, shows as set in the same snapshot, and is cleared by the same
control as one that was typed — a credential the UI can create but not show or
revoke is one nobody can reason about.
Gated on `settings:write`, not a new scope: a completed login writes to the same
file that scope already governs, so a separate one would imply a privilege
boundary that does not exist and cost a token migration to express.
The submitted code and the resulting credential are never logged, never echoed
to a client, and never in an error; the transcript is dropped when the attempt
ends. One attempt per backend, superseded on restart so a reloaded page is not
locked out by its own abandonment, killed by process group on cancel and on
daemon drain.
Claude is wired. codex, gemini and qwen are a `LoginFlow` entry each — filed
separately rather than guessed at, since each one's terminal output has to be
read before its patterns can be written.
3 tasks
jalbrethsen-highflame
approved these changes
Sep 9, 2026
A second verification pass over #331. Runtime side was clean — the pty tests pass five runs out of five, and the EPIPE crash I expected from writing to a dead child's stdin does not reproduce under bun (probed directly: no throw, no uncaught error). Reading the diff found three things worth fixing. 1. Cancelling mid-exchange blamed the command. `#dispose` wakes every waiter by marking the attempt exited and THEN drops the transcript, so an in-flight `submit` racing a `cancel` resumed against an empty window and reported "the sign-in command failed" for what the user had just done on purpose. It now notices the attempt is no longer its own and says it was cancelled. The same race exists for the 10-minute TTL, and the same check covers it. 2. The verification URL is now required to be https, in the broker. It becomes an `href` in every client, and today's patterns are anchored on `https://claude.com/…` so they cannot produce anything else — the point is that a future flow with a looser regex cannot turn scraped terminal output into a `javascript:` link. Enforced in one place rather than trusted to each flow's pattern. 3. `buildAgentEnv` now has a test for CLAUDE_CODE_OAUTH_TOKEN. It is the last link in the chain — sign-in writes the token, the settings store puts it in process.env live, and that allowlist decides whether the agent subprocess ever sees it. Tighten the prefixes and the sign-in keeps reporting success while authenticating as nothing, which is a failure with no error to follow. Also verified, not changed: bun.lock pins all eight `claude-agent-sdk-<platform>` packages including both musl variants, so the binary the broker resolves is present for every arch the workspace image builds for. 2494 pass, 0 fail. Typecheck and lint clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Settings → Claude → Sign in with Claude. Open the link, paste back the code, done — no API key.
The gap
Every backend codeoid runs already has a first-party login that mints a subscription credential —
claude setup-token,codex login, qwen's OAuth. Each is an interactive terminal command, so using one meant having a shell on the machine the daemon runs on. When codeoid is the whole surface — a hosted sandbox, a phone — nobody does, and an API key was the only way in.The gap was never that login is impossible. It was that it was unreachable from the only UI the user has.
Brokering, not reimplementing
The daemon runs the vendor's own command. It does not implement anyone else's OAuth.
That distinction is the design, not an implementation detail. Scraping a client id out of someone's CLI and driving their token endpoint ourselves works right up until they change it, and puts us in the business of maintaining another company's auth. Running the command they ship means their flow can change under us and keep working.
Two steps, because that is the shape their commands already have:
backend.login.start— run the command, return the authorize URL it prints. The user opens it in their browser. The daemon never needs one, which is what makes this work headless.backend.login.submit— the vendor's callback page displays a code; the user pastes it back; the daemon hands it to the still-waiting command.No new dependency. The
claudebinary is already innode_modules— the Agent SDK's platform package (@anthropic-ai/claude-agent-sdk-<platform>) ships it, and it is what every Claude turn already runs through. So this adds no image bytes and by definition exists wherever the Claude backend works. Verified end to end against it: realsetup-token, real PKCE authorize URL, 4.5s.The awkward part is the terminal
These commands are TUIs.
claude setup-tokenwrites nothing at all to a pipe — it detects the absence of a TTY and waits forever (verified: zero bytes, hung). Under a pty it prints its URL in under two seconds.So it needs a pty, and codeoid has no pty dependency — node-pty is a native module, i.e. a build toolchain in every image for one feature.
script(1)is the pty: util-linux on Linux (Essential, so present even indebian-slim), BSD script on macOS, a different argv on each.What comes back is raw terminal output: ANSI, OSC-8 hyperlinks, spinner redraws, an 80-column wrap that chops the URL across lines. Rather than strip escapes and hope, every pattern matches on a character class that terminal control bytes cannot appear in — so the escapes are simply not matchable — and longest-match wins, which is what picks the hyperlink's unwrapped copy of the URL over the visible truncated one.
What the tests found
They run a fake vendor command through the real pty and the real matchers, because the parts likely to be wrong here are not the state machine but the seam with a terminal — whether
scriptis invoked right, whether the URL survives the hyperlink and the wrap, whether writing a line to stdin reaches a program blocked onread. Mocking the process would test the half that was never in doubt.That caught a bug worth naming: a pty echoes its input, so a user who pastes something credential-shaped (the exact mistake made when "code" is confused with "key") had their own typo read back as the vendor's answer — success reported, garbage stored. Nothing a vendor returns for an exchange is a substring of the code that requested it, and that is now the rule. Containment rather than equality, because a wrapped echo can match as a prefix.
Storage, scope, hygiene
.env, shows as set in the same snapshot, and is cleared by the same control as one that was typed. A credential the UI can create but not show or revoke is one nobody can reason about.settings:write, not a new scope. A completed login writes to the same file that scope already governs; a separate scope would imply a privilege boundary that does not exist, and cost a token migration to express.scriptalone would orphan the command it wrapped.Not in scope
claude loginon a machine with a shell still works and is still picked up; nothing here replaces it. Only Claude is wired — codex, gemini and qwen are aLoginFlowentry each, filed separately rather than guessed at, since each one's terminal output has to be read before its patterns can be written.Verification
bun run test— 2486 pass, 0 fail (26 new, insrc/tests/backend-login.test.ts)bun run test:web— 509 pass (7 new, inweb/src/state/backend-login.test.ts)bun run typecheck(root + protocol + core) andweb/ tsc --noEmit— cleanbun run lint— clean;web/ eslint— no new warningsclaudebinary resolved from the SDK platform package,setup-tokendriven under the pty, authorize URL returned withcode_challenge/code_challenge_method=S256/stateintact