Skip to content

feat: sign in to a backend from codeoid, instead of pasting an API key - #331

Merged
saucam merged 2 commits into
mainfrom
feat/backend-login-claude
Sep 9, 2026
Merged

feat: sign in to a backend from codeoid, instead of pasting an API key#331
saucam merged 2 commits into
mainfrom
feat/backend-login-claude

Conversation

@saucam

@saucam saucam commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Settings → ClaudeSign 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:

  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 displays 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 — 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: real setup-token, real PKCE authorize 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 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 script is invoked right, whether the URL survives the hyperlink and the wrap, whether writing a line to stdin reaches a program blocked on read. 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

  • Ordinary settings path. The credential 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.
  • 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.
  • The submitted code and the resulting credential are never logged, never echoed to a client, 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 — killing script alone would orphan the command it wrapped.

Not in scope

claude login on 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 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.

Verification

  • bun run test — 2486 pass, 0 fail (26 new, in src/tests/backend-login.test.ts)
  • bun run test:web — 509 pass (7 new, in web/src/state/backend-login.test.ts)
  • bun run typecheck (root + protocol + core) and web/ tsc --noEmit — clean
  • bun run lint — clean; web/ eslint — no new warnings
  • Manual: real claude binary resolved from the SDK platform package, setup-token driven under the pty, authorize URL returned with code_challenge / code_challenge_method=S256 / state intact

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.
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.
@saucam
saucam merged commit 5742806 into main Sep 9, 2026
4 checks passed
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.

2 participants