forked from admin/VolcanicAgenticCore
docs(spec): Claude Code re-login through the host UI
This commit is contained in:
176
docs/superpowers/specs/2026-09-25-claude-code-relogin-design.md
Normal file
176
docs/superpowers/specs/2026-09-25-claude-code-relogin-design.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Claude Code re-login through the host UI — design
|
||||
|
||||
Date: 2026-09-25
|
||||
Status: approved
|
||||
|
||||
## Problem
|
||||
|
||||
The `claude-code` provider drives the local `claude` CLI on a claude.ai subscription. When the CLI's OAuth
|
||||
session expires (the refresh token is gone), every request fails with a 401 / "OAuth token has expired" /
|
||||
"Please run /login" result. Today that surfaces as a generic `[protocol_stream]` error and the only fix is to
|
||||
shell into the host and run `claude auth login`. The bot owner should instead get the login link in the host UI
|
||||
(Telegram today), paste the confirmation code back, and have the paused request continue.
|
||||
|
||||
## Verified CLI behaviour
|
||||
|
||||
`claude auth login --claudeai` (2.1.280) works over plain pipes, no TTY:
|
||||
|
||||
```
|
||||
Opening browser to sign in…
|
||||
If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true&client_id=…&state=…
|
||||
Paste code here if prompted >
|
||||
```
|
||||
|
||||
It then reads the code from stdin, exchanges it and exits (0 on success). Each run has its own PKCE verifier and
|
||||
state, so a code only works with the process that printed its link. `claude auth status` (JSON) reports
|
||||
`loggedIn` and `authMethod` afterwards.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Pause and replay (option A).** An expired session found before any output streamed pauses the request, runs
|
||||
the login and replays the request once. The user never resends.
|
||||
- **Only the owner gets the link.** The owner is the first entry of `Telegram:AllowedUserIds`. The link and code
|
||||
prompt always go to the owner's private chat, whichever chat's request failed. Other chats only get a notice.
|
||||
- **One login at a time.** All sessions share one `claude` configuration, so there's never more than one login
|
||||
flow. Concurrent requests wait on the same flow.
|
||||
- **No proactive checks.** Claude refreshes tokens itself, so a 401 means the refresh token is really gone.
|
||||
There's no `auth status` call before each request.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Detection — `StreamEventMapper` and `ClaudeCodeException`
|
||||
|
||||
- `ClaudeCodeException` gains `bool LoginRequired` (constructor parameter, default false).
|
||||
- In `AcceptResult`, an error result is an expired session when `api_error_status` is 401, or when the detail text
|
||||
matches (case-insensitive) any of: `OAuth token has expired`, `Please run /login`, `Invalid API key`,
|
||||
`authentication_failed`, `Not logged in`. It throws
|
||||
`ClaudeCodeException(Codes.Auth, "Claude Code session expired: <detail>", loginRequired: true)`.
|
||||
- A pre-stream `system/api_retry` record with `error == "authentication_failed"` is noted. A following error
|
||||
result, or an exit with no terminal record, is then reported as the same login-required failure.
|
||||
- The existing `auth` failures (a denied environment variable, `apiKeySource != "none"`) keep
|
||||
`LoginRequired = false`: logging in doesn't fix them.
|
||||
- New fixtures, using the fixture naming convention: `claude-2.1.281-stream-auth-expired.jsonl` (a 401 error
|
||||
result) and `claude-2.1.281-stream-auth-login.jsonl` (an error result whose text is "Invalid API key · Please
|
||||
run /login").
|
||||
|
||||
### 2. The login flow — `ClaudeLogin`
|
||||
|
||||
`ClaudeLogin.RunAsync(string claudePath, IReadOnlyDictionary<string,string> env, ILoginPrompt prompt, ClaudeLoginOptions, CancellationToken) → Task<LoginOutcome>`
|
||||
|
||||
- `ILoginPrompt` separates the flow from the UI:
|
||||
`Task<string?> AskForCodeAsync(string url, int attempt, CancellationToken)`, which returns null when there's no
|
||||
answer or it was cancelled. It also has `void Report(string message)`.
|
||||
- For each attempt (max 3):
|
||||
1. Start `claude auth login --claudeai` through `ClaudeProcess`, with the allow-listed environment plus
|
||||
`BROWSER=/bin/false` on Unix (`BROWSER=none` on Windows). Stdout is read as text, not JSONL.
|
||||
2. Read stdout until a line contains `https://` followed by `oauth/authorize`. Take the URL up to the next
|
||||
whitespace. Allow 30 s (`ClaudeLoginOptions.UrlTimeout`). If there's no URL, or the process exits early,
|
||||
the result is `Failed("claude auth login printed no login URL: <stderr excerpt>")`.
|
||||
3. `prompt.AskForCodeAsync(url, attempt)`. A null result kills the process and returns
|
||||
`Failed("no code was entered")`.
|
||||
4. Trim the code and write it plus `\n` to stdin, then wait up to 60 s (`ClaudeLoginOptions.ExchangeTimeout`)
|
||||
for exit.
|
||||
5. Run `claude auth status` (through `ClaudeInstallation.RunAsync`). The attempt succeeds only when the exit
|
||||
code was 0, `loggedIn` is true and `authMethod` is `claude.ai`.
|
||||
6. Otherwise, `prompt.Report("That code was not accepted; here is a new link.")` and start the next attempt.
|
||||
- After 3 failed attempts: `Failed("the code was rejected 3 times")`.
|
||||
- The process is always killed (process tree) on cancel, timeout or exception. The code and URL never appear in
|
||||
exception messages or logs.
|
||||
- `LoginOutcome` is `record LoginOutcome(bool Success, string? Error)`.
|
||||
|
||||
### 3. Single flight — `ClaudeLoginCoordinator`
|
||||
|
||||
- `ClaudeLoginCoordinator.Shared` is process-wide, like `SessionRegistry.Shared`, and overridable through
|
||||
options for tests.
|
||||
- `Task<LoginOutcome> EnsureLoggedInAsync(Func<CancellationToken, Task<LoginOutcome>> runFlow, CancellationToken ct)`:
|
||||
- If no flow is running, it starts `runFlow` with the coordinator's own token (not the caller's), so one
|
||||
waiter's `/stop` doesn't kill the flow for everyone else. It stores the task.
|
||||
- Every caller awaits the stored task with `WaitAsync(ct)`. A cancelled caller only stops waiting.
|
||||
- When the task completes, the slot clears. The next expired-session request starts a new flow.
|
||||
- The flow is abandoned when the owner doesn't answer: the prompt timeout (10 min in `TelegramUi`) returns null,
|
||||
the flow fails, and every waiter fails.
|
||||
|
||||
### 4. The login channel — `ClaudeCodeProviderOptions.LoginUi`
|
||||
|
||||
- New option: `Func<CancellationToken, Task<IUserInterface?>>? LoginUi`. It returns where the owner is reached,
|
||||
or null if nobody can be reached right now.
|
||||
- An adapter, `UiLoginPrompt(IUserInterface ui)`, implements `ILoginPrompt`:
|
||||
- `AskForCodeAsync`: `ui.Notify("Claude Code needs you to sign in again.\n1. Open: <url>\n2. Sign in with the Claude subscription account.\n3. Paste the code shown on the page as a reply.")`,
|
||||
then `ui.InputAsync("Paste the Claude login code", "code", null, ct)`.
|
||||
- `Report`: `ui.Notify(message, NotifyKind.Warning)`.
|
||||
- After success the owner gets `Notify("Claude Code login complete ✓")`. After failure:
|
||||
`Notify("Claude Code login failed: <reason>", Error)`.
|
||||
- **Headless hosts** (`LoginUi` is null or returns null): no flow runs. The request fails with
|
||||
`[auth] Claude Code session expired; run "claude auth login" on the host (or /claude-code-login from a chat UI)`.
|
||||
- **Telegram host:** `Program.cs` registers a `ClaudeCodeProviderOptions` singleton. `ActivatorUtilities` hands it
|
||||
to the extension's constructor. Its `LoginUi` resolves lazily through the service provider:
|
||||
`ct => (await sp.GetRequiredService<ChatRegistry>().GetAsync(options.AllowedUserIds[0], ct)).Ui`. This
|
||||
resolves at call time to avoid a construction cycle. In a private chat, the chat id is the user id. If sending
|
||||
fails (the owner never started the bot), `InputAsync` returns null, the flow fails, and the error reads
|
||||
`the owner could not be reached; the first allowed user must start a chat with the bot`.
|
||||
|
||||
### 5. Provider integration — `ClaudeCodeApi`
|
||||
|
||||
- `RunRequestAsync` gets split: `RunAttemptAsync` does one process run and reports its outcome, which is either
|
||||
a failure exception or success, plus whether any event reached the channel. The outer method decides on the
|
||||
retry.
|
||||
- If attempt 1 fails with `LoginRequired`, **no event has been written**, the request wasn't cancelled and a
|
||||
login UI is available:
|
||||
1. Notify the requesting session (through its registry `Notify`):
|
||||
`Claude Code session expired; waiting for the bot owner to sign in…`
|
||||
2. `await coordinator.EnsureLoggedInAsync(flow, ct)`, where `flow` resolves `LoginUi`, then runs `ClaudeLogin`
|
||||
and the owner notifications.
|
||||
3. Success: run attempt 2 with the same inputs. A failure there, including another `LoginRequired`, is
|
||||
published as-is. There's no loop.
|
||||
4. Failure: publish `[auth] Claude Code login failed: <reason>`.
|
||||
- Cancelling while waiting publishes the usual `aborted` failure.
|
||||
- Waiting for login counts against neither the idle nor the total timeout. Those are per-process, and attempt 2
|
||||
gets fresh ones.
|
||||
- `ClaudeCodeApiOptions` gains `LoginUi` and `Coordinator`. `ClaudeCodeProviderExtension` fills them in from its
|
||||
options.
|
||||
|
||||
### 6. `/claude-code-login` command
|
||||
|
||||
- `LoginCommand.Name = "claude-code-login"`. It runs the same coordinator flow through the session's own UI
|
||||
(`ctx.Ui`), with no owner redirect: whoever runs the command is already allow-listed and at the keyboard. Without
|
||||
a UI (`!ctx.HasUi`), it appends a message saying the command needs an interactive UI.
|
||||
- The result is reported with `Notify`. It also runs when the session is still valid, to switch accounts or
|
||||
refresh early.
|
||||
- The doctor's "not logged in" line becomes: `run /claude-code-login (or claude auth login on the host)`.
|
||||
|
||||
## Error handling summary
|
||||
|
||||
| Situation | Result |
|
||||
|---|---|
|
||||
| 401 / expired, login UI available, owner pastes a valid code | request replays and succeeds |
|
||||
| owner pastes wrong code | new link, up to 3 attempts |
|
||||
| owner doesn't answer in 10 min | every waiting request fails `[auth] Claude Code login failed: no code was entered` |
|
||||
| requesting user sends `/stop` while waiting | that request aborts; the flow continues for the others |
|
||||
| no login UI (headless) | `[auth] Claude Code session expired; run "claude auth login"…` |
|
||||
| owner unreachable | `[auth] Claude Code login failed: the owner could not be reached…` |
|
||||
| expired again right after login | attempt 2's error is published, no loop |
|
||||
| `apiKeySource != none` / denied env var | unchanged `[auth]` failure, no login |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Mapper:** both new fixtures give `ClaudeCodeException { Code: auth, LoginRequired: true }`. A normal
|
||||
error result keeps `LoginRequired == false`. `apiKeySource` failures keep `LoginRequired == false`.
|
||||
- **`ClaudeLogin`:** a fake `claude` shell script (as in `ClaudeInstallationTests`) that prints the URL block, reads
|
||||
a line and then writes a marker file that its `auth status` branch reads. It covers success, a wrong code then
|
||||
the right one (the prompt sees attempt 2 with a new URL), three wrong codes, no URL, a null answer (process
|
||||
killed) and cancellation. These are Unix-only, like the existing script-based tests.
|
||||
- **Coordinator:** two concurrent callers share one flow invocation. Both see success or both see failure. A
|
||||
cancelled caller doesn't cancel the flow. A new flow starts after completion.
|
||||
- **Provider:** a fake `claude` whose first `-p` run emits the 401 fixture and whose second emits the tool-ok
|
||||
fixture. With a fake `LoginUi`, the request ends `Done`, the flow ran once and the session was notified. Two
|
||||
more cases: expired again after login is an error with no third run, and no `LoginUi` gives the headless
|
||||
`[auth]` message.
|
||||
- **Telegram:** with the fake bot client, a request in a non-owner chat hits an expired session. The link and the
|
||||
prompt appear in the owner's chat and the non-owner chat gets the waiting notice. A reply in the owner's chat
|
||||
resumes the request.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Proactive expiry checks, and token refresh on our side.
|
||||
- Choosing a login target other than the first allowed user.
|
||||
- Hosts other than Telegram. Any host can provide `LoginUi`.
|
||||
Reference in New Issue
Block a user