From fa5e38438033a5812a241087744ee9b425f6e2c3 Mon Sep 17 00:00:00 2001
From: kina0630
Date: Fri, 31 Jul 2026 23:47:09 +0800
Subject: [PATCH 1/6] docs(spec): webui codex oauth device-flow login design
---
.../2026-07-31-webui-codex-oauth-design.md | 76 +++++++++++++++++++
1 file changed, 76 insertions(+)
create mode 100644 coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md
diff --git a/coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md b/coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md
new file mode 100644
index 000000000..96251266a
--- /dev/null
+++ b/coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md
@@ -0,0 +1,76 @@
+# WebUI Codex OAuth 登录(device flow)— 设计
+
+日期:2026-07-31
+状态:已确认(决策记录在 PR 描述,ADR-lite)
+
+## 背景与目标
+
+o6n 部署的 coding agent 支持 provider=codex(ChatGPT OAuth),但:
+
+- OAuth 登录只有 CLI 入口(`coding-agent oauth login codex`),网页端无登录能力;
+- 当前 pod 内 refresh token 已失效(403),恢复只能进 pod 操作;
+- `~/.coding-agent/oauth/auth.json` 位于容器 overlay fs,pod 重建即丢。
+
+目标:在 webui 完成 codex device-code 登录全流程,OAuth 记录持久化到数据 PVC,并支持多个并发登录流程的管理。
+
+非目标:其他 provider 的 OAuth(anthropic/copilot redirect 型)、logout/撤销(CLI 已有)、多账号并存存储(见下)。
+
+## 关键决策
+
+1. **形态:只做 codex device flow**。Codex OAuth 原生是 device-code 流程(`CodexOAuthClient.request_device_code` / `poll_device_token` / `exchange_device_code`),天然适合 headless 服务器,无需托管 redirect。
+2. **持久化:`OAuthStore` 路径加 env 覆盖,helm 指到数据 PVC**(`/var/lib/coding-agent/data/oauth/auth.json`)。不引入 k8s secret 方案(RBAC + 重启才生效,token 刷新还要写 secret,过重)。
+3. **多流程管理:允许并发 in-flight flows**,每个 flow 有服务端生成的 `flow_id`,可独立查询/取消;不做"同一时刻仅一个流程"的互斥。存储层仍是一份 codex 记录(provider 为 key),**最后一次成功登录覆盖旧记录**——单用户内网部署,这是预期行为。
+4. **鉴权:与现有 API 一致**(`verify_api_key`,X-API-Key)。
+
+## 服务端设计
+
+### 新端点(挂在 `server/http_server.py`,契约文档同步更新)
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| POST | `/oauth/codex/start` | 请求 device code,创建 flow,起后台轮询任务 → `{flow_id, verification_url, user_code, expires_in}` |
+| GET | `/oauth/codex/flows` | 列出所有 in-flight 及近期完成的 flow(state/时间) |
+| GET | `/oauth/codex/flows/{flow_id}` | 单 flow 状态:`{state: pending|authorized|error|expired|cancelled, verification_url?, user_code?, error?}` |
+| POST | `/oauth/codex/flows/{flow_id}/cancel` | 中止该 flow 的轮询任务,标记 cancelled |
+| GET | `/oauth/codex/status` | 当前是否已连接:`{connected: bool, account?: {...}}`(读 OAuthStore) |
+
+### Flow 管理器(新模块 `server/oauth_flows.py`)
+
+- 内存注册表 `dict[flow_id, FlowState]`;FlowState 含 state、verification_url、user_code、expires_at、error、created_at、task handle。
+- 后台任务:`poll_device_token`(client 自带轮询/超时)→ 成功 `exchange_device_code` → 写 `OAuthStore` → state=authorized;异常 → state=error(消息保留,空 str 用 `exception_error_message()` 兜底,复用 #685 的 helper)。
+- TTL:device code 过期或 10 分钟未完成 → state=expired,任务退出。已完成/过期/取消的 flow 保留在注册表供查询,服务端重启即清空(不需要持久化 flow 状态)。
+- 并发:任意数量 flow 并存;`OAuthStore` 自带文件锁,最后一次写入生效。
+
+### 存储路径
+
+- `OAuthStore.__init__` 已接受 path 参数;新增 env `CODING_AGENT_OAUTH_AUTH_PATH`,默认 `~/.coding-agent/oauth/auth.json` 不变。
+- `LLMProviderPlugin` 的 codex 分支与 oauth CLI 共用该解析逻辑(单点:`oauth/store.py` 加 `default_auth_path()`)。
+- helm values(SRE 仓库):`CODING_AGENT_OAUTH_AUTH_PATH=/var/lib/coding-agent/data/oauth/auth.json`(data PVC 已挂载,无需新卷)。
+
+## 前端设计(Settings 面板新增 "Codex 连接" 卡片)
+
+- 初始:GET `/oauth/codex/status` → 显示"已连接(account 信息)"或"未连接"。
+- 点击"连接 codex"→ POST start → 卡片显示 verification_url(链接,新窗口打开)+ user_code(大号等宽,一键复制)→ 前端每 3s 轮询 `flows/{flow_id}` → authorized:显示成功并刷新 status;error/expired:显示原因 + 重试按钮。
+- 多 flow:卡片历史区列出近期 flow(state 徽章),pending 的可取消。
+- 失败兜底:轮询期间页面关闭不影响服务端任务;重新打开卡片可从 `GET /oauth/codex/flows` 恢复进行中的 flow。
+
+## 错误处理
+
+| 场景 | 行为 |
+|---|---|
+| device code 请求失败(网络/401) | start 返回 502 + 错误详情,无 flow 创建 |
+| 轮询中超时/用户未授权 | flow → expired,前端提示重试 |
+| exchange 失败 | flow → error(带消息) |
+| 重复连接(已 connected) | 允许,登录成功即覆盖旧记录(重登录场景) |
+
+## 测试
+
+- 服务端(`tests/ui/test_oauth_flows.py`):start 成功返回 url+code;并发两个 flow 互不干扰;authorized 后写 OAuthStore(mock client);error/expired/cancel 状态流转;status 端点 connected/未连接。
+- 前端:卡片状态渲染(未连接/pending/authorized/error)、轮询恢复、取消、复制 code。
+- 回归:`tests/coding_agent/plugins/test_llm_provider.py` 的 codex 分支 + oauth store 测试(env 路径覆盖)。
+
+## 交付拆分
+
+1. Coding-Agent-Loop PR:服务端端点 + flow 管理器 + env 路径 + 前端卡片 + 契约文档 + 测试。
+2. SRE PR:helm values 加 `CODING_AGENT_OAUTH_AUTH_PATH`。
+3. 部署后在 `agent.mesh.kinaz.me` 走一遍真实登录验证。
From e2db050a33f4a69d9272e598bf37b177b2673152 Mon Sep 17 00:00:00 2001
From: kina0630
Date: Fri, 31 Jul 2026 23:59:34 +0800
Subject: [PATCH 2/6] docs(spec): multi-account codex oauth design
---
.../2026-07-31-webui-codex-oauth-design.md | 89 +++++++++++--------
1 file changed, 53 insertions(+), 36 deletions(-)
diff --git a/coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md b/coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md
index 96251266a..a79b55129 100644
--- a/coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md
+++ b/coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md
@@ -1,76 +1,93 @@
-# WebUI Codex OAuth 登录(device flow)— 设计
+# WebUI Codex OAuth 登录(device flow)+ 多账号 — 设计
-日期:2026-07-31
+日期:2026-07-31(同日修订:加入多账号并存)
状态:已确认(决策记录在 PR 描述,ADR-lite)
## 背景与目标
o6n 部署的 coding agent 支持 provider=codex(ChatGPT OAuth),但:
-- OAuth 登录只有 CLI 入口(`coding-agent oauth login codex`),网页端无登录能力;
+- OAuth 登录只有 CLI 入口,网页端无登录能力;
- 当前 pod 内 refresh token 已失效(403),恢复只能进 pod 操作;
-- `~/.coding-agent/oauth/auth.json` 位于容器 overlay fs,pod 重建即丢。
+- `~/.coding-agent/oauth/auth.json` 位于容器 overlay fs,pod 重建即丢;
+- 只支持单账号记录,无法在多个 ChatGPT 账号间切换。
-目标:在 webui 完成 codex device-code 登录全流程,OAuth 记录持久化到数据 PVC,并支持多个并发登录流程的管理。
+目标:在 webui 完成 codex device-code 登录全流程;OAuth 记录持久化到数据 PVC;**多账号并存,新建会话时可选账号**;支持多个并发登录流程的管理。
-非目标:其他 provider 的 OAuth(anthropic/copilot redirect 型)、logout/撤销(CLI 已有)、多账号并存存储(见下)。
+非目标:其他 provider 的 OAuth(anthropic/copilot redirect 型)、远端 revoke(只删本地记录,CLI 已有 revoke)。
## 关键决策
-1. **形态:只做 codex device flow**。Codex OAuth 原生是 device-code 流程(`CodexOAuthClient.request_device_code` / `poll_device_token` / `exchange_device_code`),天然适合 headless 服务器,无需托管 redirect。
-2. **持久化:`OAuthStore` 路径加 env 覆盖,helm 指到数据 PVC**(`/var/lib/coding-agent/data/oauth/auth.json`)。不引入 k8s secret 方案(RBAC + 重启才生效,token 刷新还要写 secret,过重)。
-3. **多流程管理:允许并发 in-flight flows**,每个 flow 有服务端生成的 `flow_id`,可独立查询/取消;不做"同一时刻仅一个流程"的互斥。存储层仍是一份 codex 记录(provider 为 key),**最后一次成功登录覆盖旧记录**——单用户内网部署,这是预期行为。
-4. **鉴权:与现有 API 一致**(`verify_api_key`,X-API-Key)。
+1. **形态:只做 codex device flow**。Codex OAuth 原生 device-code 流程(`CodexOAuthClient.request_device_code` / `poll_device_token` / `exchange_device_code`),天然适合 headless 服务器。
+2. **多账号 = 复合 provider key**:`OAuthStore`/`StoreBackedTokenSource` 本就按 provider_name 做 key,直接用 `codex`(默认账号)和 `codex:
+
);
diff --git a/coding-agent/webui/app/src/lib/api.ts b/coding-agent/webui/app/src/lib/api.ts
index a5f89c80a..e91e17af7 100644
--- a/coding-agent/webui/app/src/lib/api.ts
+++ b/coding-agent/webui/app/src/lib/api.ts
@@ -3,9 +3,12 @@ import type {
ApprovalPolicy,
ApprovalScope,
CheckpointMetadata,
+ CodexFlow,
+ CodexFlowStart,
DisplayStreamEvent,
MemoryReviewRecord,
MemoryReviewTransitionStatus,
+ OAuthAccount,
ProviderModels,
RuntimeRun,
SessionSummary,
@@ -328,6 +331,101 @@ export class AgentClient {
};
}
+ // Codex OAuth device flow (multi-account). All parsers coerce defensively —
+ // these endpoints may be served by an older server (404) or return partial
+ // payloads while the contract settles.
+ async startCodexOAuth(label?: string): Promise {
+ const body: Record = {};
+ if (label?.trim()) body.label = label.trim();
+ const r = await this.check(
+ await fetch(this.url("/oauth/codex/start"), {
+ method: "POST",
+ headers: this.headers(true),
+ body: JSON.stringify(body),
+ }),
+ );
+ const raw = ((await r.json().catch(() => null)) ?? {}) as Record;
+ if (typeof raw.flow_id !== "string" || !raw.flow_id) {
+ throw new Error("codex oauth start response missing flow_id");
+ }
+ return {
+ flow_id: raw.flow_id,
+ verification_url: typeof raw.verification_url === "string" ? raw.verification_url : "",
+ user_code: typeof raw.user_code === "string" ? raw.user_code : "",
+ expires_in: typeof raw.expires_in === "number" ? raw.expires_in : 0,
+ };
+ }
+
+ async listCodexFlows(): Promise {
+ const r = await this.check(
+ await fetch(this.url("/oauth/codex/flows"), { headers: this.headers() }),
+ );
+ const data: unknown = await r.json().catch(() => null);
+ // Accept either a bare array or a { flows: [...] } envelope.
+ const items = Array.isArray(data)
+ ? data
+ : Array.isArray((data as { flows?: unknown } | null)?.flows)
+ ? (data as { flows: unknown[] }).flows
+ : [];
+ return items.flatMap(parseCodexFlow);
+ }
+
+ async getCodexFlow(flowId: string): Promise {
+ const r = await this.check(
+ await fetch(this.url(`/oauth/codex/flows/${encodeURIComponent(flowId)}`), {
+ headers: this.headers(),
+ }),
+ );
+ const data: unknown = await r.json().catch(() => null);
+ const parsed = parseCodexFlow(data);
+ if (parsed.length === 0) throw new Error("codex oauth flow response missing flow_id");
+ return parsed[0];
+ }
+
+ async cancelCodexFlow(flowId: string): Promise {
+ await this.check(
+ await fetch(this.url(`/oauth/codex/flows/${encodeURIComponent(flowId)}/cancel`), {
+ method: "POST",
+ headers: this.headers(),
+ }),
+ );
+ }
+
+ async listOAuthAccounts(): Promise {
+ const r = await this.check(
+ await fetch(this.url("/oauth/accounts"), { headers: this.headers() }),
+ );
+ const data: unknown = await r.json().catch(() => null);
+ const items = Array.isArray(data)
+ ? data
+ : Array.isArray((data as { accounts?: unknown } | null)?.accounts)
+ ? (data as { accounts: unknown[] }).accounts
+ : [];
+ return items.flatMap((item) => {
+ const raw = (item ?? {}) as Record;
+ if (typeof raw.provider !== "string" || !raw.provider) return [];
+ return [
+ {
+ provider: raw.provider,
+ label: typeof raw.label === "string" ? raw.label : raw.provider,
+ email: typeof raw.email === "string" ? raw.email : undefined,
+ plan: typeof raw.plan === "string" ? raw.plan : undefined,
+ connected_at: typeof raw.connected_at === "string" ? raw.connected_at : undefined,
+ } satisfies OAuthAccount,
+ ];
+ });
+ }
+
+ // provider_key contains ":" for named accounts ("codex:work") — URL-encode it.
+ async deleteOAuthAccount(providerKey: string): Promise {
+ await this.check(
+ await fetch(this.url(`/oauth/accounts/${encodeURIComponent(providerKey)}`), {
+ method: "DELETE",
+ headers: this.headers(),
+ }),
+ );
+ }
+
private async runDisplayEvents(runId: string): Promise {
const events: DisplayStreamEvent[] = [];
let lastEventId: string | null = null;
@@ -372,6 +470,23 @@ export class AgentClient {
}
}
+function parseCodexFlow(item: unknown): CodexFlow[] {
+ const raw = (item ?? {}) as Record;
+ if (typeof raw.flow_id !== "string" || !raw.flow_id) return [];
+ return [
+ {
+ flow_id: raw.flow_id,
+ state: typeof raw.state === "string" ? raw.state : "pending",
+ verification_url:
+ typeof raw.verification_url === "string" ? raw.verification_url : undefined,
+ user_code: typeof raw.user_code === "string" ? raw.user_code : undefined,
+ account_label: typeof raw.account_label === "string" ? raw.account_label : undefined,
+ error: typeof raw.error === "string" ? raw.error : undefined,
+ created_at: typeof raw.created_at === "string" ? raw.created_at : undefined,
+ },
+ ];
+}
+
async function* readDisplayStream(
response: Response,
signal?: AbortSignal,
diff --git a/coding-agent/webui/app/src/lib/types.ts b/coding-agent/webui/app/src/lib/types.ts
index 5528090ee..e02d0d6cd 100644
--- a/coding-agent/webui/app/src/lib/types.ts
+++ b/coding-agent/webui/app/src/lib/types.ts
@@ -262,3 +262,41 @@ export interface ProviderModels {
models: string[];
source: "live" | "unavailable";
}
+
+// Codex OAuth device-flow endpoints (docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md).
+export type CodexFlowState =
+ | "pending"
+ | "authorized"
+ | "error"
+ | "expired"
+ | "cancelled"
+ | string;
+
+// POST /oauth/codex/start response.
+export interface CodexFlowStart {
+ flow_id: string;
+ verification_url: string;
+ user_code: string;
+ expires_in: number;
+}
+
+// GET /oauth/codex/flows[/{flow_id}] item.
+export interface CodexFlow {
+ flow_id: string;
+ state: CodexFlowState;
+ verification_url?: string;
+ user_code?: string;
+ account_label?: string;
+ error?: string;
+ created_at?: string;
+}
+
+// GET /oauth/accounts item. provider is the store key: "codex" (default
+// account) or "codex:" for named accounts.
+export interface OAuthAccount {
+ provider: string;
+ label: string;
+ email?: string;
+ plan?: string;
+ connected_at?: string;
+}
From 553d48910a17c0439b3a58974933e0159444d5c5 Mon Sep 17 00:00:00 2001
From: kina0630
Date: Sat, 1 Aug 2026 01:59:45 +0800
Subject: [PATCH 5/6] feat(server): multi-account codex OAuth device flow for
webui
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- oauth/store.py: default_auth_path() with CODING_AGENT_OAUTH_AUTH_PATH
env override; OAuthStore() and oauth CLI resolve through it
- server/oauth_flows.py: in-memory flow registry + background poll
tasks (pending/authorized/error/expired/cancelled, 10min TTL);
label from explicit request or id_token (email first, account-id
short code, -2/-3 suffix on conflict); local exception_error_message
helper (stands in for unmerged #685)
- server/http_server.py: POST /oauth/codex/start, GET
/oauth/codex/flows{,/{id}}, POST /oauth/codex/flows/{id}/cancel,
GET /oauth/accounts, DELETE /oauth/accounts/{key} (local delete,
no remote revoke); 400 at session create for unconnected codex:
- plugins/llm_provider.py: codex: routing — StoreBackedCodex-
TokenSource parameterized by provider key, refresh writes back same key
- server/schemas.py: CreateSessionRequest.provider accepts ProviderName
literals plus codex: (^[a-z0-9][a-z0-9-]{0,30}$)
- tests: tests/ui/test_oauth_flows.py (mocked CodexOAuthClient), plugin
codex:* routing tests, store env-path tests
- docs: web-client-api-contract.md gains the oauth endpoints section
---
coding-agent/docs/web-client-api-contract.md | 48 +++
coding-agent/src/coding_agent/oauth/cli.py | 4 +-
coding-agent/src/coding_agent/oauth/store.py | 17 +-
.../src/coding_agent/plugins/llm_provider.py | 19 +-
.../src/coding_agent/server/http_server.py | 154 +++++++
.../src/coding_agent/server/oauth_flows.py | 239 +++++++++++
.../src/coding_agent/server/schemas.py | 88 +++-
.../coding_agent/plugins/test_llm_provider.py | 64 +++
.../tests/coding_agent/test_codex_oauth.py | 36 ++
coding-agent/tests/ui/test_oauth_flows.py | 390 ++++++++++++++++++
10 files changed, 1046 insertions(+), 13 deletions(-)
create mode 100644 coding-agent/src/coding_agent/server/oauth_flows.py
create mode 100644 coding-agent/tests/ui/test_oauth_flows.py
diff --git a/coding-agent/docs/web-client-api-contract.md b/coding-agent/docs/web-client-api-contract.md
index bd6e5e6ec..5a3208b7a 100644
--- a/coding-agent/docs/web-client-api-contract.md
+++ b/coding-agent/docs/web-client-api-contract.md
@@ -43,6 +43,12 @@ Source of truth in code:
| `GET` | `/sessions/{id}/workspace/diff` | → `{ files[], additions, deletions }` |
| `GET` | `/sessions/{id}/workspace/patch` | → `{ format: "unified_diff", patch }` |
| `GET` | `/providers/{provider}/models` | → `ProviderModelsResponse` |
+| `POST` | `/oauth/codex/start` | `{ label? }` → `CodexOAuthStartResponse` |
+| `GET` | `/oauth/codex/flows` | → `CodexOAuthFlowListResponse` |
+| `GET` | `/oauth/codex/flows/{flow_id}` | → `CodexOAuthFlowResponse` |
+| `POST` | `/oauth/codex/flows/{flow_id}/cancel` | → `CodexOAuthFlowResponse` |
+| `GET` | `/oauth/accounts` | → `CodexOAuthAccountListResponse` |
+| `DELETE` | `/oauth/accounts/{provider_key}` | → `{ status, provider }` |
| `GET` | `/healthz` | → `{ status, sessions, version }` |
`CreateSessionRequest` (all optional unless noted):
@@ -50,6 +56,11 @@ Source of truth in code:
`provider`, `model`, `base_url`, `max_steps`, `run_target`,
`default_run_target`, `workspace_source`.
+`provider` accepts the `ProviderName` literals plus `codex:` for a named
+codex account (label must match `^[a-z0-9][a-z0-9-]{0,30}$`). Creating a session
+with an unconnected `codex:` fails with `400` and points at
+`POST /oauth/codex/start`.
+
`repo_path` is a shortcut for a local `default_run_target`. New clients must not
send `execution_binding`; the server rejects it. Stored legacy session payloads
that still contain `execution_binding` are migrated server-side into
@@ -84,6 +95,43 @@ context.
`topic_count`, `reviewed_memory_count`, `indexed_count`, `skipped_count`,
`deleted_count`, `indexed_ids[]`, `deleted_ids[]`).
+## Codex OAuth login & multi-account
+
+The server can run codex device-code logins headlessly and keep multiple
+ChatGPT accounts connected side by side. Storage keys: `codex` (default
+account, written by the CLI login) and `codex:` (named accounts).
+
+- `POST /oauth/codex/start` — body `{ label? }`; requests a device code and
+ starts a background poll → `CodexOAuthStartResponse`
+ (`flow_id`, `verification_url`, `user_code`, `expires_in`). `502` when the
+ device-code request to OpenAI fails (no flow is created). `label` must match
+ `^[a-z0-9][a-z0-9-]{0,30}$`; when omitted, the label is derived from the
+ id_token after login (email first, then a short `chatgpt_account_id` code,
+ with `-2`/`-3`... appended on conflicts). Logging in again with the same
+ explicit label overwrites that account's record.
+- `GET /oauth/codex/flows` — all in-flight and recently finished flows.
+ Flows are in-memory only: a server restart clears them; finished flows stay
+ queryable for the flow TTL (10 minutes).
+- `GET /oauth/codex/flows/{flow_id}` — `CodexOAuthFlowResponse`:
+ `state` is `pending` \| `authorized` \| `error` \| `expired` \| `cancelled`;
+ `verification_url`/`user_code` are always present, `account_label` is set
+ once `authorized`, `error` carries the failure message on `error`.
+ Poll timeout / no user authorization → `expired` (client may retry);
+ token-exchange failure → `error`. `404` for unknown flow ids.
+- `POST /oauth/codex/flows/{flow_id}/cancel` — aborts polling and marks the
+ flow `cancelled`; `404` for unknown flow ids.
+- `GET /oauth/accounts` — connected codex accounts:
+ `{ accounts: [{ provider, label, email?, plan?, connected_at }] }` where
+ `provider` is `codex` or `codex:` and the default account's `label`
+ is `"default"`.
+- `DELETE /oauth/accounts/{provider_key}` — deletes the local record only
+ (**no remote revoke**; use the CLI `oauth logout codex --revoke` or the
+ OpenAI account settings page for a full cleanup). `404` when the key is not
+ connected, `400` for non-codex keys.
+
+Clients pick an account at session creation by sending
+`provider: "codex:"`; `provider: "codex"` uses the default account.
+
## Provider model listing
`GET /providers/{provider}/models` returns the live model list for a provider,
diff --git a/coding-agent/src/coding_agent/oauth/cli.py b/coding-agent/src/coding_agent/oauth/cli.py
index 86ebc13ae..2a336473b 100644
--- a/coding-agent/src/coding_agent/oauth/cli.py
+++ b/coding-agent/src/coding_agent/oauth/cli.py
@@ -9,7 +9,7 @@
import click
from coding_agent.oauth.codex import CodexOAuthClient, redact_record
-from coding_agent.oauth.store import DEFAULT_AUTH_PATH, OAuthStore
+from coding_agent.oauth.store import OAuthStore, default_auth_path
@click.group(name="oauth")
@@ -148,7 +148,7 @@ def logout(provider: str, auth_file: Path | None, revoke: bool) -> None:
)
def doctor(auth_file: Path | None) -> None:
"""Inspect OAuth store health without printing tokens."""
- path = (auth_file or DEFAULT_AUTH_PATH).expanduser()
+ path = (auth_file or default_auth_path()).expanduser()
store = OAuthStore(path)
auth = store.load()
click.echo(f"Auth file: {path}")
diff --git a/coding-agent/src/coding_agent/oauth/store.py b/coding-agent/src/coding_agent/oauth/store.py
index 6f0007cf6..3306256b0 100644
--- a/coding-agent/src/coding_agent/oauth/store.py
+++ b/coding-agent/src/coding_agent/oauth/store.py
@@ -22,6 +22,21 @@
DEFAULT_AUTH_DIR = Path.home() / ".coding-agent" / "oauth"
DEFAULT_AUTH_PATH = DEFAULT_AUTH_DIR / "auth.json"
+AUTH_PATH_ENV = "CODING_AGENT_OAUTH_AUTH_PATH"
+
+
+def default_auth_path() -> Path:
+ """Resolve the OAuth auth file path.
+
+ The ``CODING_AGENT_OAUTH_AUTH_PATH`` environment variable takes priority
+ (used by deployments that persist OAuth records to a data volume);
+ otherwise the per-user default ``~/.coding-agent/oauth/auth.json`` is used.
+ """
+ override = os.environ.get(AUTH_PATH_ENV)
+ if override is not None and override.strip():
+ return Path(override.strip()).expanduser()
+ return DEFAULT_AUTH_PATH
+
class FileLock:
"""Cross-platform advisory file lock."""
@@ -83,7 +98,7 @@ class OAuthStore:
"""File-backed OAuth credential store with process-level locking."""
def __init__(self, path: Path | str | None = None) -> None:
- self.path = Path(path).expanduser() if path is not None else DEFAULT_AUTH_PATH
+ self.path = Path(path).expanduser() if path is not None else default_auth_path()
self.lock_path = self.path.with_suffix(self.path.suffix + ".lock")
def load(self) -> AuthFile:
diff --git a/coding-agent/src/coding_agent/plugins/llm_provider.py b/coding-agent/src/coding_agent/plugins/llm_provider.py
index bb68e8976..1e45f65a6 100644
--- a/coding-agent/src/coding_agent/plugins/llm_provider.py
+++ b/coding-agent/src/coding_agent/plugins/llm_provider.py
@@ -120,20 +120,22 @@ def provide_llm(self, **kwargs: Any) -> LLMProvider:
api_key=api_key,
base_url=self._base_url or "https://api.stepfun.com/v1",
)
- elif self._provider_name == "codex":
+ elif self._provider_name == "codex" or self._provider_name.startswith("codex:"):
from coding_agent.oauth.codex import CODEX_BASE_URL
from coding_agent.oauth.store import OAuthStore
from coding_agent.providers.codex_responses import CodexResponsesProvider
+ provider_key = self._provider_name
store = OAuthStore()
- record = store.get_provider("codex")
+ record = store.get_provider(provider_key)
if record is None:
raise RuntimeError(
- "Codex OAuth provider is not logged in. "
- "Run `coding-agent oauth login codex` first."
+ f"codex account not connected: {provider_key}. "
+ "Connect via POST /oauth/codex/start or run "
+ "`coding-agent oauth login codex` first."
)
- token_source = StoreBackedCodexTokenSource(store)
+ token_source = StoreBackedCodexTokenSource(store, provider_key)
base_url = self._base_url or CODEX_BASE_URL
self._instance = CodexResponsesProvider(
@@ -150,14 +152,15 @@ def provide_llm(self, **kwargs: Any) -> LLMProvider:
class StoreBackedCodexTokenSource:
"""Small adapter that avoids constructing network clients until refresh."""
- def __init__(self, store: Any) -> None:
+ def __init__(self, store: Any, provider_key: str = "codex") -> None:
self._store = store
+ self._provider_key = provider_key
async def get_token(self) -> Any:
from coding_agent.oauth.store import StoreBackedTokenSource
return await StoreBackedTokenSource(
- "codex",
+ self._provider_key,
store=self._store,
refresh_provider=self._refresh_record,
).get_token()
@@ -166,7 +169,7 @@ async def refresh_token(self) -> Any:
from coding_agent.oauth.store import StoreBackedTokenSource
return await StoreBackedTokenSource(
- "codex",
+ self._provider_key,
store=self._store,
refresh_provider=self._refresh_record,
).refresh_token()
diff --git a/coding-agent/src/coding_agent/server/http_server.py b/coding-agent/src/coding_agent/server/http_server.py
index 525478daf..810f0b31c 100644
--- a/coding-agent/src/coding_agent/server/http_server.py
+++ b/coding-agent/src/coding_agent/server/http_server.py
@@ -218,6 +218,13 @@
CheckpointMetadataResponse,
CheckpointRestoreResponse,
CloseSessionResponse,
+ CodexOAuthAccountDeleteResponse,
+ CodexOAuthAccountListResponse,
+ CodexOAuthAccountResponse,
+ CodexOAuthFlowListResponse,
+ CodexOAuthFlowResponse,
+ CodexOAuthStartRequest,
+ CodexOAuthStartResponse,
CreateSessionRequest,
DisplayEventResponse,
DisplayEventsResponse,
@@ -277,6 +284,11 @@
WorkerStatusResponse,
)
from coding_agent.server.session_manager import Session, SessionManager
+from coding_agent.server.oauth_flows import (
+ CODEX_PROVIDER_KEY_PATTERN,
+ CodexOAuthFlowManager,
+ exception_error_message,
+)
from coding_agent.server.stores.session_owner_store import (
SQLiteSessionOwnerStore,
SessionOwnershipConflictError,
@@ -1035,6 +1047,19 @@ async def _cleanup_stale_cloud_workspaces_periodically() -> None:
# Global session manager
session_manager = _build_session_manager()
+# Global codex OAuth flow manager (in-memory flow registry; tests may replace it)
+codex_oauth_flow_manager = CodexOAuthFlowManager()
+
+
+def _codex_account_not_connected_exception(provider_key: str) -> HTTPException:
+ return HTTPException(
+ status_code=400,
+ detail=(
+ f"codex account not connected: {provider_key}. "
+ "Connect via POST /oauth/codex/start first."
+ ),
+ )
+
def _key_error_detail(exc: KeyError) -> str:
if exc.args and isinstance(exc.args[0], str):
@@ -2516,6 +2541,18 @@ async def create_session(
approval_policy = approval_policy_map.get(approval_policy_str, ApprovalPolicy.AUTO)
provisioned_workspace: CloudWorkspaceRef | None = None
+ # Multi-account codex keys must reference a connected account up front;
+ # fail at creation instead of mid-turn (plain `codex` keeps lazy behavior).
+ if (
+ body is not None
+ and body.provider is not None
+ and body.provider.startswith("codex:")
+ and not await asyncio.to_thread(
+ codex_oauth_flow_manager.has_account, body.provider
+ )
+ ):
+ raise _codex_account_not_connected_exception(body.provider)
+
try:
default_run_target = _provisioned_run_target_from_request(
body,
@@ -7381,6 +7418,123 @@ async def close_session(
return CloseSessionResponse(status="closed", session_id=session_id)
+@app.post("/oauth/codex/start", response_model=CodexOAuthStartResponse)
+@limiter.limit(RateLimits.CREATE_SESSION)
+async def start_codex_oauth_flow(
+ request: Request,
+ body: CodexOAuthStartRequest | None = None,
+ api_key: str | None = Depends(verify_api_key),
+) -> CodexOAuthStartResponse:
+ """Start a codex device-code login flow (multi-account supported)."""
+ del api_key
+ label = body.label if body is not None else None
+ try:
+ flow = await codex_oauth_flow_manager.start(label)
+ except Exception as exc:
+ raise HTTPException(
+ status_code=502,
+ detail=(
+ f"codex device code request failed: {exception_error_message(exc)}"
+ ),
+ ) from exc
+ logger.info("Started codex OAuth flow: %s", flow.flow_id)
+ return CodexOAuthStartResponse(
+ flow_id=flow.flow_id,
+ verification_url=flow.device_code.verification_url,
+ user_code=flow.device_code.user_code,
+ expires_in=codex_oauth_flow_manager.ttl_seconds,
+ )
+
+
+@app.get("/oauth/codex/flows", response_model=CodexOAuthFlowListResponse)
+@limiter.limit(RateLimits.GET_SESSION)
+async def list_codex_oauth_flows(
+ request: Request,
+ api_key: str | None = Depends(verify_api_key),
+) -> CodexOAuthFlowListResponse:
+ """List in-flight and recently finished codex OAuth flows."""
+ del request, api_key
+ return CodexOAuthFlowListResponse(
+ flows=[
+ CodexOAuthFlowResponse(**flow.to_dict())
+ for flow in codex_oauth_flow_manager.list_flows()
+ ]
+ )
+
+
+@app.get("/oauth/codex/flows/{flow_id}", response_model=CodexOAuthFlowResponse)
+@limiter.limit(RateLimits.GET_SESSION)
+async def get_codex_oauth_flow(
+ request: Request,
+ flow_id: str,
+ api_key: str | None = Depends(verify_api_key),
+) -> CodexOAuthFlowResponse:
+ """Get the state of one codex OAuth flow."""
+ del request, api_key
+ flow = codex_oauth_flow_manager.get_flow(flow_id)
+ if flow is None:
+ raise HTTPException(status_code=404, detail="OAuth flow not found")
+ return CodexOAuthFlowResponse(**flow.to_dict())
+
+
+@app.post(
+ "/oauth/codex/flows/{flow_id}/cancel",
+ response_model=CodexOAuthFlowResponse,
+)
+@limiter.limit(RateLimits.CLOSE_SESSION)
+async def cancel_codex_oauth_flow(
+ request: Request,
+ flow_id: str,
+ api_key: str | None = Depends(verify_api_key),
+) -> CodexOAuthFlowResponse:
+ """Cancel a pending codex OAuth flow."""
+ del request, api_key
+ flow = codex_oauth_flow_manager.cancel(flow_id)
+ if flow is None:
+ raise HTTPException(status_code=404, detail="OAuth flow not found")
+ return CodexOAuthFlowResponse(**flow.to_dict())
+
+
+@app.get("/oauth/accounts", response_model=CodexOAuthAccountListResponse)
+@limiter.limit(RateLimits.GET_SESSION)
+async def list_oauth_accounts(
+ request: Request,
+ api_key: str | None = Depends(verify_api_key),
+) -> CodexOAuthAccountListResponse:
+ """List connected codex accounts (default key plus named codex:)."""
+ del request, api_key
+ accounts = await asyncio.to_thread(codex_oauth_flow_manager.list_accounts)
+ return CodexOAuthAccountListResponse(
+ accounts=[CodexOAuthAccountResponse(**account) for account in accounts]
+ )
+
+
+@app.delete(
+ "/oauth/accounts/{provider_key}",
+ response_model=CodexOAuthAccountDeleteResponse,
+)
+@limiter.limit(RateLimits.CLOSE_SESSION)
+async def delete_oauth_account(
+ request: Request,
+ provider_key: str,
+ api_key: str | None = Depends(verify_api_key),
+) -> CodexOAuthAccountDeleteResponse:
+ """Delete a codex account's local record (no remote revoke)."""
+ del request, api_key
+ if CODEX_PROVIDER_KEY_PATTERN.fullmatch(provider_key) is None:
+ raise HTTPException(
+ status_code=400,
+ detail=f"invalid codex provider key: {provider_key!r}",
+ )
+ deleted = await asyncio.to_thread(
+ codex_oauth_flow_manager.delete_account, provider_key
+ )
+ if not deleted:
+ raise HTTPException(status_code=404, detail="OAuth account not found")
+ logger.info("Deleted codex OAuth account: %s", provider_key)
+ return CodexOAuthAccountDeleteResponse(status="deleted", provider=provider_key)
+
+
async def wait_for_approval(
session_id: str,
approval_req: ApprovalRequest,
diff --git a/coding-agent/src/coding_agent/server/oauth_flows.py b/coding-agent/src/coding_agent/server/oauth_flows.py
new file mode 100644
index 000000000..5ee10f027
--- /dev/null
+++ b/coding-agent/src/coding_agent/server/oauth_flows.py
@@ -0,0 +1,239 @@
+"""In-memory codex OAuth device-flow registry for the HTTP server.
+
+Manages concurrent device-code login flows: each flow gets a server-side
+``flow_id``, polls in a background task, and on success writes the account
+record to :class:`OAuthStore` under ``codex:``. Flows live only in
+process memory (restarts clear them); finished flows stay queryable for the
+flow TTL so clients can poll their outcome.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+import uuid
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from datetime import UTC, datetime, timedelta
+from typing import Any, Literal
+
+import httpx
+
+from coding_agent.oauth.codex import CodexOAuthClient, DeviceCode
+from coding_agent.oauth.store import OAuthStore
+from coding_agent.oauth.types import OAuthAccount
+
+logger = logging.getLogger(__name__)
+
+FLOW_TTL_SECONDS = 10 * 60
+
+CODEX_PROVIDER_KEY_PATTERN = re.compile(r"^codex(:[a-z0-9][a-z0-9-]{0,30})?$")
+
+FlowState = Literal["pending", "authorized", "error", "expired", "cancelled"]
+
+_TERMINAL_STATES: frozenset[str] = frozenset(
+ {"authorized", "error", "expired", "cancelled"}
+)
+
+
+def exception_error_message(exc: BaseException) -> str:
+ """Best-effort human-readable message for an exception (never empty).
+
+ Local copy of the ``exception_error_message()`` helper from #685; replicate
+ here instead of depending on that unmerged change.
+ """
+ message = str(exc).strip()
+ if message:
+ return message
+ return type(exc).__name__ or "unknown error"
+
+
+def _sanitize_label(raw: str) -> str:
+ label = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-")
+ return label[:31].strip("-")
+
+
+def _derive_label(account: OAuthAccount) -> str:
+ """Derive an account label from id_token claims (email first)."""
+ if account.email:
+ label = _sanitize_label(account.email)
+ if label:
+ return label
+ if account.chatgpt_account_id:
+ label = _sanitize_label(account.chatgpt_account_id[:12])
+ if label:
+ return label
+ return "account"
+
+
+@dataclass
+class CodexOAuthFlow:
+ """One in-flight or recently finished codex device login flow."""
+
+ flow_id: str
+ device_code: DeviceCode
+ requested_label: str | None
+ created_at: datetime
+ expires_at: datetime
+ state: FlowState = "pending"
+ account_label: str | None = None
+ error: str | None = None
+ task: asyncio.Task[None] | None = field(default=None, repr=False)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "flow_id": self.flow_id,
+ "state": self.state,
+ "verification_url": self.device_code.verification_url,
+ "user_code": self.device_code.user_code,
+ "account_label": self.account_label,
+ "error": self.error,
+ }
+
+
+class CodexOAuthFlowManager:
+ """Registry of codex OAuth flows plus account list/delete helpers."""
+
+ def __init__(
+ self,
+ *,
+ store: OAuthStore | None = None,
+ client_factory: Callable[[], CodexOAuthClient] | None = None,
+ ttl_seconds: int = FLOW_TTL_SECONDS,
+ ) -> None:
+ self._store = store or OAuthStore()
+ self._client_factory = client_factory or (
+ lambda: CodexOAuthClient(store=self._store)
+ )
+ self._ttl_seconds = ttl_seconds
+ self._flows: dict[str, CodexOAuthFlow] = {}
+
+ @property
+ def ttl_seconds(self) -> int:
+ return self._ttl_seconds
+
+ async def start(self, label: str | None = None) -> CodexOAuthFlow:
+ """Request a device code and launch the background poll task.
+
+ Raises whatever ``request_device_code`` raises (caller maps to 502);
+ no flow is registered in that case.
+ """
+ client = self._client_factory()
+ try:
+ device_code = await asyncio.to_thread(client.request_device_code)
+ except BaseException:
+ client.close()
+ raise
+ now = datetime.now(UTC)
+ flow = CodexOAuthFlow(
+ flow_id=str(uuid.uuid4()),
+ device_code=device_code,
+ requested_label=label,
+ created_at=now,
+ expires_at=now + timedelta(seconds=self._ttl_seconds),
+ )
+ self._flows[flow.flow_id] = flow
+ flow.task = asyncio.create_task(self._run(flow, client))
+ return flow
+
+ def get_flow(self, flow_id: str) -> CodexOAuthFlow | None:
+ self._prune()
+ return self._flows.get(flow_id)
+
+ def list_flows(self) -> list[CodexOAuthFlow]:
+ self._prune()
+ return sorted(self._flows.values(), key=lambda flow: flow.created_at)
+
+ def cancel(self, flow_id: str) -> CodexOAuthFlow | None:
+ flow = self._flows.get(flow_id)
+ if flow is None:
+ return None
+ if flow.state == "pending":
+ flow.state = "cancelled"
+ if flow.task is not None:
+ flow.task.cancel()
+ return flow
+
+ def list_accounts(self) -> list[dict[str, Any]]:
+ """Connected codex accounts: the default key plus every codex:."""
+ auth = self._store.load()
+ accounts: list[dict[str, Any]] = []
+ for key in sorted(auth.providers):
+ if key != "codex" and not key.startswith("codex:"):
+ continue
+ record = auth.providers[key]
+ accounts.append(
+ {
+ "provider": key,
+ "label": key.split(":", 1)[1] if ":" in key else "default",
+ "email": record.account.email,
+ "plan": record.account.chatgpt_plan_type,
+ "connected_at": record.last_refresh_at,
+ }
+ )
+ return accounts
+
+ def delete_account(self, provider_key: str) -> bool:
+ """Delete the local record for a codex key (no remote revoke)."""
+ return self._store.delete_provider(provider_key) is not None
+
+ def has_account(self, provider_key: str) -> bool:
+ """Whether a local record exists for the given provider key."""
+ return self._store.get_provider(provider_key) is not None
+
+ async def _run(self, flow: CodexOAuthFlow, client: CodexOAuthClient) -> None:
+ try:
+ token_code = await asyncio.to_thread(
+ client.poll_device_token,
+ flow.device_code,
+ timeout_seconds=self._ttl_seconds,
+ )
+ record = await asyncio.to_thread(client.exchange_device_code, token_code)
+ except asyncio.CancelledError:
+ if flow.state == "pending":
+ flow.state = "cancelled"
+ raise
+ except httpx.HTTPStatusError as exc:
+ if exc.response.status_code in (403, 404):
+ flow.state = "expired"
+ else:
+ flow.state = "error"
+ flow.error = exception_error_message(exc)
+ return
+ except Exception as exc:
+ flow.state = "error"
+ flow.error = exception_error_message(exc)
+ logger.exception("Codex OAuth flow %s failed", flow.flow_id)
+ return
+ finally:
+ client.close()
+
+ if flow.state != "pending":
+ # Cancelled while the blocking poll/exchange was in flight.
+ return
+ label = flow.requested_label or self._allocate_label(record.account)
+ self._store.set_provider(f"codex:{label}", record)
+ flow.account_label = label
+ flow.state = "authorized"
+
+ def _allocate_label(self, account: OAuthAccount) -> str:
+ """Pick a derived label, appending -2/-3/... on store conflicts."""
+ base = _derive_label(account)
+ auth = self._store.load()
+ candidate = base
+ suffix = 2
+ while f"codex:{candidate}" in auth.providers:
+ candidate = f"{base[:28].rstrip('-')}-{suffix}"
+ suffix += 1
+ return candidate
+
+ def _prune(self) -> None:
+ now = datetime.now(UTC)
+ stale = [
+ flow_id
+ for flow_id, flow in self._flows.items()
+ if flow.state in _TERMINAL_STATES and now > flow.expires_at
+ ]
+ for flow_id in stale:
+ del self._flows[flow_id]
diff --git a/coding-agent/src/coding_agent/server/schemas.py b/coding-agent/src/coding_agent/server/schemas.py
index bb7177830..d6f325ba7 100644
--- a/coding-agent/src/coding_agent/server/schemas.py
+++ b/coding-agent/src/coding_agent/server/schemas.py
@@ -2,8 +2,9 @@
from __future__ import annotations
+import re
from datetime import datetime
-from typing import Annotated, Any, Literal
+from typing import Annotated, Any, Literal, get_args
from pydantic import (
AliasChoices,
@@ -18,6 +19,27 @@
from coding_agent.core.config import ProviderName
+CODEX_ACCOUNT_LABEL_PATTERN = r"^[a-z0-9][a-z0-9-]{0,30}$"
+
+_PROVIDER_NAME_VALUES = frozenset(get_args(ProviderName))
+
+
+def validate_provider_value(value: str | None) -> str | None:
+ """Allow ProviderName literals plus multi-account ``codex:`` keys."""
+ if value is None or value in _PROVIDER_NAME_VALUES:
+ return value
+ if value.startswith("codex:"):
+ label = value.removeprefix("codex:")
+ if re.fullmatch(CODEX_ACCOUNT_LABEL_PATTERN, label):
+ return value
+ raise ValueError(
+ f"codex account label must match {CODEX_ACCOUNT_LABEL_PATTERN}: {value!r}"
+ )
+ raise ValueError(
+ f"provider must be one of {sorted(_PROVIDER_NAME_VALUES)} "
+ f"or 'codex:', got {value!r}"
+ )
+
class DockerWorkspaceSourceRequest(BaseModel):
kind: Literal["docker"]
@@ -62,11 +84,16 @@ class CreateSessionRequest(BaseModel):
run_target: dict[str, Any] | None = None
workspace_source: WorkspaceSourceRequest | None = None
approval_policy: str = Field("auto", pattern="^(yolo|interactive|auto)$")
- provider: ProviderName | None = None
+ provider: str | None = None
model: str | None = Field(None, min_length=1, max_length=200)
base_url: str | None = Field(None, min_length=1, max_length=500)
max_steps: int | None = Field(None, ge=0)
+ @field_validator("provider")
+ @classmethod
+ def _validate_provider(cls, value: str | None) -> str | None:
+ return validate_provider_value(value)
+
class ThinkingConfigSchema(BaseModel):
"""Thinking/reasoning configuration for provider API calls."""
@@ -613,6 +640,63 @@ class ProviderModelsResponse(BaseModel):
source: Literal["live", "unavailable"]
+class CodexOAuthStartRequest(BaseModel):
+ """Request schema for starting a codex device-flow login."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ label: str | None = Field(None, pattern=CODEX_ACCOUNT_LABEL_PATTERN)
+
+
+class CodexOAuthStartResponse(BaseModel):
+ """Response schema for a started codex OAuth flow."""
+
+ flow_id: str
+ verification_url: str
+ user_code: str
+ expires_in: int
+
+
+class CodexOAuthFlowResponse(BaseModel):
+ """Response schema for one codex OAuth flow."""
+
+ flow_id: str
+ state: Literal["pending", "authorized", "error", "expired", "cancelled"]
+ verification_url: str | None = None
+ user_code: str | None = None
+ account_label: str | None = None
+ error: str | None = None
+
+
+class CodexOAuthFlowListResponse(BaseModel):
+ """Response schema for listing codex OAuth flows."""
+
+ flows: list[CodexOAuthFlowResponse]
+
+
+class CodexOAuthAccountResponse(BaseModel):
+ """Response schema for one connected codex account."""
+
+ provider: str
+ label: str
+ email: str | None = None
+ plan: str | None = None
+ connected_at: datetime | None = None
+
+
+class CodexOAuthAccountListResponse(BaseModel):
+ """Response schema for listing connected codex accounts."""
+
+ accounts: list[CodexOAuthAccountResponse]
+
+
+class CodexOAuthAccountDeleteResponse(BaseModel):
+ """Response schema for disconnecting a codex account."""
+
+ status: str
+ provider: str
+
+
class WorkspaceArchiveResponse(BaseModel):
format: Literal["tar.gz"]
archive_base64: str
diff --git a/coding-agent/tests/coding_agent/plugins/test_llm_provider.py b/coding-agent/tests/coding_agent/plugins/test_llm_provider.py
index 56567cf20..c92206d7a 100644
--- a/coding-agent/tests/coding_agent/plugins/test_llm_provider.py
+++ b/coding-agent/tests/coding_agent/plugins/test_llm_provider.py
@@ -87,6 +87,70 @@ def test_codex_provider_uses_responses_provider(self, monkeypatch, tmp_path):
assert result.model_name == "codex-mini"
assert result._base_url == "https://chatgpt.com/backend-api/codex"
+ def test_codex_label_provider_requires_connected_account(
+ self, monkeypatch, tmp_path
+ ):
+ import coding_agent.oauth.store as store_module
+
+ monkeypatch.setattr(store_module, "DEFAULT_AUTH_PATH", tmp_path / "auth.json")
+ plugin = LLMProviderPlugin(provider="codex:work", model="gpt-5.5", api_key="")
+
+ with pytest.raises(
+ RuntimeError, match="codex account not connected: codex:work"
+ ):
+ plugin.provide_llm()
+
+ def test_codex_label_provider_routes_to_labelled_record(
+ self, monkeypatch, tmp_path
+ ):
+ import coding_agent.oauth.store as store_module
+ from coding_agent.oauth.types import OAuthAccount
+
+ auth_path = tmp_path / "auth.json"
+ monkeypatch.setattr(store_module, "DEFAULT_AUTH_PATH", auth_path)
+ OAuthStore(auth_path).set_provider(
+ "codex:work",
+ OAuthProviderRecord(
+ issuer="https://auth.openai.com",
+ client_id="codex-client",
+ token_endpoint="https://auth.openai.com/oauth/token",
+ base_url="https://chatgpt.com/backend-api/codex",
+ tokens=OAuthTokens(access_token="work-access-token"),
+ account=OAuthAccount(chatgpt_account_id="account-work"),
+ ),
+ )
+ plugin = LLMProviderPlugin(provider="codex:work", model="gpt-5.5", api_key="")
+
+ result = plugin.provide_llm()
+
+ assert isinstance(result, CodexResponsesProvider)
+ assert result.model_name == "gpt-5.5"
+
+ def test_codex_label_token_source_uses_labelled_key(self, tmp_path):
+ import asyncio
+
+ from coding_agent.oauth.types import OAuthAccount
+ from coding_agent.plugins.llm_provider import StoreBackedCodexTokenSource
+
+ auth_path = tmp_path / "auth.json"
+ store = OAuthStore(auth_path)
+ store.set_provider(
+ "codex:work",
+ OAuthProviderRecord(
+ issuer="https://auth.openai.com",
+ client_id="codex-client",
+ token_endpoint="https://auth.openai.com/oauth/token",
+ tokens=OAuthTokens(access_token="work-access-token"),
+ account=OAuthAccount(chatgpt_account_id="account-work"),
+ ),
+ )
+ source = StoreBackedCodexTokenSource(store, "codex:work")
+
+ snapshot = asyncio.run(source.get_token())
+
+ assert snapshot.provider_name == "codex:work"
+ assert snapshot.access_token == "work-access-token"
+
class TestKimiProvider:
def test_kimi_creates_openai_compat_instance(self):
diff --git a/coding-agent/tests/coding_agent/test_codex_oauth.py b/coding-agent/tests/coding_agent/test_codex_oauth.py
index 69f74beb7..e00a67bd7 100644
--- a/coding-agent/tests/coding_agent/test_codex_oauth.py
+++ b/coding-agent/tests/coding_agent/test_codex_oauth.py
@@ -166,3 +166,39 @@ def test_redact_record_removes_token_values() -> None:
assert safe["tokens"]["access_token"] == ""
assert safe["tokens"]["refresh_token"] == ""
assert "secret-access" not in str(safe)
+
+
+def test_default_auth_path_prefers_env_override(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from coding_agent.oauth.store import default_auth_path
+
+ override = tmp_path / "pvc" / "auth.json"
+ monkeypatch.setenv("CODING_AGENT_OAUTH_AUTH_PATH", str(override))
+
+ assert default_auth_path() == override
+ assert OAuthStore().path == override
+
+
+def test_default_auth_path_falls_back_to_default(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ import coding_agent.oauth.store as store_module
+
+ monkeypatch.delenv("CODING_AGENT_OAUTH_AUTH_PATH", raising=False)
+ fallback = Path.home() / "custom-home" / "auth.json"
+ monkeypatch.setattr(store_module, "DEFAULT_AUTH_PATH", fallback)
+
+ assert store_module.default_auth_path() == fallback
+ assert OAuthStore().path == fallback
+
+
+def test_default_auth_path_ignores_blank_env_override(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from coding_agent.oauth.store import DEFAULT_AUTH_PATH, default_auth_path
+
+ monkeypatch.setenv("CODING_AGENT_OAUTH_AUTH_PATH", " ")
+
+ assert default_auth_path() == DEFAULT_AUTH_PATH
diff --git a/coding-agent/tests/ui/test_oauth_flows.py b/coding-agent/tests/ui/test_oauth_flows.py
new file mode 100644
index 000000000..f0fae131e
--- /dev/null
+++ b/coding-agent/tests/ui/test_oauth_flows.py
@@ -0,0 +1,390 @@
+"""Tests for codex OAuth device-flow endpoints (multi-account).
+
+All network access is mocked via a fake ``CodexOAuthClient``; the OAuth store
+is file-backed under ``tmp_path``.
+"""
+
+from __future__ import annotations
+
+import threading
+from datetime import UTC, datetime
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock
+
+import httpx
+import pytest
+from httpx import ASGITransport, AsyncClient
+
+import coding_agent.server.http_server as http_server
+from coding_agent.oauth.codex import DeviceCode
+from coding_agent.oauth.store import OAuthStore
+from coding_agent.oauth.types import OAuthAccount, OAuthProviderRecord, OAuthTokens
+from coding_agent.server.oauth_flows import (
+ CodexOAuthFlowManager,
+ exception_error_message,
+)
+from coding_agent.server.session_manager import SessionManager
+
+DEVICE_CODE = DeviceCode(
+ verification_url="https://auth.openai.com/codex/device",
+ user_code="ABCD-1234",
+ device_auth_id="device-auth-1",
+ interval=1,
+)
+
+
+def make_record(
+ *,
+ email: str | None = None,
+ account_id: str | None = None,
+ plan: str | None = "plus",
+) -> OAuthProviderRecord:
+ return OAuthProviderRecord(
+ issuer="https://auth.openai.com",
+ client_id="codex-client",
+ token_endpoint="https://auth.openai.com/oauth/token",
+ tokens=OAuthTokens(access_token="access-token", refresh_token="refresh-token"),
+ account=OAuthAccount(
+ email=email,
+ chatgpt_account_id=account_id,
+ chatgpt_plan_type=plan,
+ ),
+ last_refresh_at=datetime.now(UTC),
+ )
+
+
+def make_client(
+ *,
+ gate: threading.Event | None = None,
+ record: OAuthProviderRecord | None = None,
+ request_exc: BaseException | None = None,
+ poll_exc: BaseException | None = None,
+ exchange_exc: BaseException | None = None,
+) -> MagicMock:
+ client = MagicMock()
+ if request_exc is not None:
+ client.request_device_code.side_effect = request_exc
+ else:
+ client.request_device_code.return_value = DEVICE_CODE
+ if poll_exc is not None:
+ client.poll_device_token.side_effect = poll_exc
+ elif gate is not None:
+
+ def _blocking_poll(*args: Any, **kwargs: Any) -> MagicMock:
+ assert gate.wait(timeout=10), "test gate was never released"
+ return MagicMock()
+
+ client.poll_device_token.side_effect = _blocking_poll
+ else:
+ client.poll_device_token.return_value = MagicMock()
+ if exchange_exc is not None:
+ client.exchange_device_code.side_effect = exchange_exc
+ else:
+ client.exchange_device_code.return_value = record or make_record()
+ return client
+
+
+@pytest.fixture
+async def oauth_env(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+ isolated_http_session_manager: SessionManager,
+):
+ """Install an isolated flow manager backed by a tmp store + fake clients."""
+ del isolated_http_session_manager
+ store = OAuthStore(tmp_path / "auth.json")
+ clients: list[MagicMock] = []
+ pending_factories: list[MagicMock] = []
+
+ def factory() -> MagicMock:
+ client = pending_factories.pop(0) if pending_factories else make_client()
+ clients.append(client)
+ return client
+
+ manager = CodexOAuthFlowManager(store=store, client_factory=factory)
+ monkeypatch.setattr(http_server, "codex_oauth_flow_manager", manager)
+
+ transport = ASGITransport(app=http_server.app)
+ async with AsyncClient(transport=transport, base_url="http://test") as client:
+ yield SimpleNamespace(
+ store=store,
+ manager=manager,
+ clients=clients,
+ pending_factories=pending_factories,
+ http=client,
+ )
+
+
+async def wait_for_state(env, flow_id: str, *states: str, timeout: float = 5.0) -> dict:
+ import asyncio
+
+ deadline = asyncio.get_running_loop().time() + timeout
+ while True:
+ response = await env.http.get(f"/oauth/codex/flows/{flow_id}")
+ assert response.status_code == 200
+ data = response.json()
+ if data["state"] in states:
+ return data
+ assert asyncio.get_running_loop().time() < deadline, (
+ f"flow {flow_id} never reached {states}: {data}"
+ )
+ await asyncio.sleep(0.01)
+
+
+class TestStartFlow:
+ async def test_start_returns_flow_details(self, oauth_env):
+ gate = threading.Event()
+ oauth_env.pending_factories.append(make_client(gate=gate))
+ try:
+ response = await oauth_env.http.post("/oauth/codex/start", json={})
+ assert response.status_code == 200
+ data = response.json()
+ assert data["flow_id"]
+ assert data["verification_url"] == DEVICE_CODE.verification_url
+ assert data["user_code"] == DEVICE_CODE.user_code
+ assert data["expires_in"] == oauth_env.manager.ttl_seconds
+
+ flow = await wait_for_state(oauth_env, data["flow_id"], "pending")
+ assert flow["state"] == "pending"
+ finally:
+ gate.set()
+
+ async def test_start_device_code_failure_returns_502_and_no_flow(self, oauth_env):
+ oauth_env.pending_factories.append(
+ make_client(request_exc=RuntimeError("openai unreachable"))
+ )
+ response = await oauth_env.http.post("/oauth/codex/start", json={})
+ assert response.status_code == 502
+ assert "openai unreachable" in response.json()["detail"]
+
+ flows = await oauth_env.http.get("/oauth/codex/flows")
+ assert flows.json()["flows"] == []
+
+ async def test_start_rejects_invalid_label(self, oauth_env):
+ response = await oauth_env.http.post(
+ "/oauth/codex/start", json={"label": "Bad_Label!"}
+ )
+ assert response.status_code == 422
+
+
+class TestFlowCompletion:
+ async def test_authorized_writes_explicit_label_key(self, oauth_env):
+ record = make_record(email="work@example.com")
+ oauth_env.pending_factories.append(make_client(record=record))
+
+ response = await oauth_env.http.post(
+ "/oauth/codex/start", json={"label": "work"}
+ )
+ flow_id = response.json()["flow_id"]
+ flow = await wait_for_state(oauth_env, flow_id, "authorized")
+
+ assert flow["account_label"] == "work"
+ stored = oauth_env.store.get_provider("codex:work")
+ assert stored is not None
+ assert stored.tokens.access_token == "access-token"
+
+ async def test_authorized_derives_label_from_email(self, oauth_env):
+ record = make_record(email="Foo.Bar@Example.com")
+ oauth_env.pending_factories.append(make_client(record=record))
+
+ response = await oauth_env.http.post("/oauth/codex/start", json={})
+ flow = await wait_for_state(oauth_env, response.json()["flow_id"], "authorized")
+
+ assert flow["account_label"] == "foo-bar-example-com"
+ assert oauth_env.store.get_provider("codex:foo-bar-example-com") is not None
+
+ async def test_derived_label_falls_back_to_account_id(self, oauth_env):
+ record = make_record(email=None, account_id="9f8e7d6c")
+ oauth_env.pending_factories.append(make_client(record=record))
+
+ response = await oauth_env.http.post("/oauth/codex/start", json={})
+ flow = await wait_for_state(oauth_env, response.json()["flow_id"], "authorized")
+
+ assert flow["account_label"] == "9f8e7d6c"
+ assert oauth_env.store.get_provider("codex:9f8e7d6c") is not None
+
+ async def test_derived_label_conflict_appends_suffix(self, oauth_env):
+ oauth_env.store.set_provider(
+ "codex:foo-example-com", make_record(email="foo@example.com")
+ )
+ record = make_record(email="foo@example.com")
+ oauth_env.pending_factories.append(make_client(record=record))
+
+ response = await oauth_env.http.post("/oauth/codex/start", json={})
+ flow = await wait_for_state(oauth_env, response.json()["flow_id"], "authorized")
+
+ assert flow["account_label"] == "foo-example-com-2"
+ assert oauth_env.store.get_provider("codex:foo-example-com-2") is not None
+
+ async def test_explicit_label_relogin_overwrites(self, oauth_env):
+ oauth_env.store.set_provider("codex:work", make_record())
+ oauth_env.pending_factories.append(make_client())
+
+ response = await oauth_env.http.post(
+ "/oauth/codex/start", json={"label": "work"}
+ )
+ flow = await wait_for_state(oauth_env, response.json()["flow_id"], "authorized")
+
+ assert flow["account_label"] == "work"
+ assert "codex:work-2" not in oauth_env.store.load().providers
+
+ async def test_exchange_failure_marks_error(self, oauth_env):
+ oauth_env.pending_factories.append(
+ make_client(exchange_exc=RuntimeError("bad exchange"))
+ )
+
+ response = await oauth_env.http.post("/oauth/codex/start", json={})
+ flow = await wait_for_state(oauth_env, response.json()["flow_id"], "error")
+
+ assert flow["error"] == "bad exchange"
+ assert oauth_env.store.load().providers == {}
+
+ async def test_poll_timeout_marks_expired(self, oauth_env):
+ request = httpx.Request("POST", "https://auth.openai.com/token")
+ poll_exc = httpx.HTTPStatusError(
+ "forbidden", request=request, response=httpx.Response(403, request=request)
+ )
+ oauth_env.pending_factories.append(make_client(poll_exc=poll_exc))
+
+ response = await oauth_env.http.post("/oauth/codex/start", json={})
+ flow = await wait_for_state(oauth_env, response.json()["flow_id"], "expired")
+
+ assert flow["state"] == "expired"
+
+
+class TestFlowManagement:
+ async def test_cancel_flow(self, oauth_env):
+ gate = threading.Event()
+ oauth_env.pending_factories.append(make_client(gate=gate))
+ try:
+ response = await oauth_env.http.post("/oauth/codex/start", json={})
+ flow_id = response.json()["flow_id"]
+ await wait_for_state(oauth_env, flow_id, "pending")
+
+ cancel = await oauth_env.http.post(f"/oauth/codex/flows/{flow_id}/cancel")
+ assert cancel.status_code == 200
+ assert cancel.json()["state"] == "cancelled"
+
+ flow = await oauth_env.http.get(f"/oauth/codex/flows/{flow_id}")
+ assert flow.json()["state"] == "cancelled"
+ finally:
+ gate.set()
+ assert oauth_env.store.load().providers == {}
+
+ async def test_unknown_flow_returns_404(self, oauth_env):
+ response = await oauth_env.http.get("/oauth/codex/flows/nope")
+ assert response.status_code == 404
+ cancel = await oauth_env.http.post("/oauth/codex/flows/nope/cancel")
+ assert cancel.status_code == 404
+
+ async def test_concurrent_flows_are_independent(self, oauth_env):
+ gate_a = threading.Event()
+ gate_b = threading.Event()
+ oauth_env.pending_factories.append(make_client(gate=gate_a))
+ oauth_env.pending_factories.append(
+ make_client(gate=gate_b, record=make_record(email="b@example.com"))
+ )
+ try:
+ flow_a = (
+ await oauth_env.http.post("/oauth/codex/start", json={"label": "a"})
+ ).json()["flow_id"]
+ flow_b = (
+ await oauth_env.http.post("/oauth/codex/start", json={"label": "b"})
+ ).json()["flow_id"]
+ assert flow_a != flow_b
+
+ await oauth_env.http.post(f"/oauth/codex/flows/{flow_a}/cancel")
+ gate_b.set()
+ final_b = await wait_for_state(oauth_env, flow_b, "authorized")
+ final_a = (await oauth_env.http.get(f"/oauth/codex/flows/{flow_a}")).json()
+
+ assert final_a["state"] == "cancelled"
+ assert final_b["state"] == "authorized"
+ assert final_b["account_label"] == "b"
+
+ flows = (await oauth_env.http.get("/oauth/codex/flows")).json()["flows"]
+ assert {f["flow_id"] for f in flows} == {flow_a, flow_b}
+ finally:
+ gate_a.set()
+ gate_b.set()
+
+
+class TestAccounts:
+ async def test_list_accounts(self, oauth_env):
+ oauth_env.store.set_provider("codex", make_record(email="main@example.com"))
+ oauth_env.store.set_provider(
+ "codex:work",
+ make_record(email="work@example.com", plan="team"),
+ )
+ oauth_env.store.set_provider("copilot", make_record(email="nope@example.com"))
+
+ response = await oauth_env.http.get("/oauth/accounts")
+ assert response.status_code == 200
+ accounts = {a["provider"]: a for a in response.json()["accounts"]}
+
+ assert set(accounts) == {"codex", "codex:work"}
+ assert accounts["codex"]["label"] == "default"
+ assert accounts["codex"]["email"] == "main@example.com"
+ assert accounts["codex"]["plan"] == "plus"
+ assert accounts["codex"]["connected_at"] is not None
+ assert accounts["codex:work"]["label"] == "work"
+ assert accounts["codex:work"]["plan"] == "team"
+
+ async def test_delete_account(self, oauth_env):
+ oauth_env.store.set_provider("codex:work", make_record())
+
+ response = await oauth_env.http.delete("/oauth/accounts/codex:work")
+ assert response.status_code == 200
+ assert response.json() == {"status": "deleted", "provider": "codex:work"}
+ assert oauth_env.store.get_provider("codex:work") is None
+
+ missing = await oauth_env.http.delete("/oauth/accounts/codex:work")
+ assert missing.status_code == 404
+
+ async def test_delete_rejects_non_codex_key(self, oauth_env):
+ response = await oauth_env.http.delete("/oauth/accounts/anthropic")
+ assert response.status_code == 400
+
+
+class TestSessionProviderRouting:
+ async def test_create_session_with_connected_codex_label(self, oauth_env):
+ oauth_env.store.set_provider("codex:work", make_record())
+ response = await oauth_env.http.post(
+ "/sessions",
+ json={"provider": "codex:work", "model": "gpt-5.5"},
+ )
+ assert response.status_code == 200
+ assert response.json()["session_id"]
+
+ async def test_create_session_with_unconnected_codex_label_returns_400(
+ self, oauth_env
+ ):
+ response = await oauth_env.http.post(
+ "/sessions",
+ json={"provider": "codex:missing", "model": "gpt-5.5"},
+ )
+ assert response.status_code == 400
+ detail = response.json()["detail"]
+ assert "codex account not connected: codex:missing" in detail
+ assert "/oauth/codex/start" in detail
+
+ async def test_create_session_rejects_invalid_codex_label(self, oauth_env):
+ response = await oauth_env.http.post(
+ "/sessions",
+ json={"provider": "codex:Bad_Label!", "model": "gpt-5.5"},
+ )
+ assert response.status_code == 422
+
+ async def test_create_session_rejects_unknown_provider(self, oauth_env):
+ response = await oauth_env.http.post(
+ "/sessions",
+ json={"provider": "not-a-provider", "model": "x"},
+ )
+ assert response.status_code == 422
+
+
+class TestHelpers:
+ def test_exception_error_message_falls_back_to_class_name(self):
+ assert exception_error_message(RuntimeError("boom")) == "boom"
+ assert exception_error_message(RuntimeError()) == "RuntimeError"
From c8fa2ef45c7bf4915029676262c02177107788db Mon Sep 17 00:00:00 2001
From: kina0630
Date: Sat, 1 Aug 2026 02:10:43 +0800
Subject: [PATCH 6/6] fix(webui): stop polling lost codex oauth flows
Review P2: the flow poll catch swallowed every error, so a flow the
server forgot (restart cleared the in-memory registry, TTL prune) or a
failing auth path was retried every 3s until page close. Now a 404
marks the flow expired with a server-lost note, and other errors give
up after 5 consecutive failures. Expired rows render flow.error when
present.
---
.../src/components/CodexAccountsCard.test.tsx | 32 ++++++++++++++++++
.../app/src/components/CodexAccountsCard.tsx | 33 +++++++++++++++++--
2 files changed, 62 insertions(+), 3 deletions(-)
diff --git a/coding-agent/webui/app/src/components/CodexAccountsCard.test.tsx b/coding-agent/webui/app/src/components/CodexAccountsCard.test.tsx
index b02537c79..f67f95e5b 100644
--- a/coding-agent/webui/app/src/components/CodexAccountsCard.test.tsx
+++ b/coding-agent/webui/app/src/components/CodexAccountsCard.test.tsx
@@ -204,6 +204,38 @@ describe("CodexAccountsCard", () => {
expect(screen.getByTitle("retry flow flow-exp")).toBeTruthy();
});
+ it("stops polling and marks the flow expired when the server 404s it", async () => {
+ let polls = 0;
+ stubFetch((url) => {
+ if (url.endsWith("/oauth/accounts")) return Promise.resolve(jsonResponse([]));
+ if (url.endsWith("/oauth/codex/flows/flow-lost")) {
+ polls += 1;
+ return Promise.resolve(jsonResponse({ detail: "flow not found" }, 404));
+ }
+ if (url.endsWith("/oauth/codex/flows")) {
+ return Promise.resolve(
+ jsonResponse([
+ {
+ flow_id: "flow-lost",
+ state: "pending",
+ verification_url: "https://example.com/device",
+ user_code: "LOST-1111",
+ },
+ ]),
+ );
+ }
+ return undefined;
+ });
+
+ render();
+
+ // The flow shows up as pending, then the first 404 poll flips it to expired
+ // with the server-lost message instead of polling forever.
+ await screen.findByText("server lost this login flow; start a new one");
+ await new Promise((r) => setTimeout(r, 50));
+ expect(polls).toBeLessThanOrEqual(2);
+ });
+
it("resumes showing a pending flow recovered from the flows list on mount", async () => {
let cancelled = false;
stubFetch((url) => {
diff --git a/coding-agent/webui/app/src/components/CodexAccountsCard.tsx b/coding-agent/webui/app/src/components/CodexAccountsCard.tsx
index 72fce87c0..a3bb1eebf 100644
--- a/coding-agent/webui/app/src/components/CodexAccountsCard.tsx
+++ b/coding-agent/webui/app/src/components/CodexAccountsCard.tsx
@@ -31,6 +31,8 @@ export default function CodexAccountsCard({ client, pollMs = 3000 }: Props) {
// Flows whose "authorized" transition was already handled, so a re-render
// or an extra poll tick cannot refresh the accounts list twice.
const handledAuthorized = useRef(new Set());
+ // Consecutive poll-failure count per flow; gives up after 5 (see poll effect).
+ const pollFailures = useRef(new Map());
const refreshAccounts = useCallback(async () => {
try {
@@ -68,6 +70,7 @@ export default function CodexAccountsCard({ client, pollMs = 3000 }: Props) {
client
.getCodexFlow(flow.flow_id)
.then((next) => {
+ pollFailures.current.delete(next.flow_id);
setFlows((prev) => prev.map((f) => (f.flow_id === next.flow_id ? next : f)));
if (next.state === "authorized" && !handledAuthorized.current.has(next.flow_id)) {
handledAuthorized.current.add(next.flow_id);
@@ -78,8 +81,32 @@ export default function CodexAccountsCard({ client, pollMs = 3000 }: Props) {
void refreshAccounts();
}
})
- .catch(() => {
- // Transient poll failure — keep polling until the flow expires.
+ .catch((e) => {
+ // Server lost the flow (restart cleared the in-memory registry or
+ // TTL pruned it): stop polling it instead of retrying forever.
+ if (isNotFound(e)) {
+ setFlows((prev) =>
+ prev.map((f) =>
+ f.flow_id === flow.flow_id
+ ? { ...f, state: "expired" as const, error: "server lost this login flow; start a new one" }
+ : f,
+ ),
+ );
+ return;
+ }
+ // Other errors (auth failure, network): tolerate a few transient
+ // failures, then give up instead of polling until page close.
+ const fails = (pollFailures.current.get(flow.flow_id) ?? 0) + 1;
+ pollFailures.current.set(flow.flow_id, fails);
+ if (fails >= 5) {
+ setFlows((prev) =>
+ prev.map((f) =>
+ f.flow_id === flow.flow_id
+ ? { ...f, state: "error" as const, error: `polling failed: ${msg(e)}` }
+ : f,
+ ),
+ );
+ }
});
}
}, pollMs);
@@ -261,7 +288,7 @@ export default function CodexAccountsCard({ client, pollMs = 3000 }: Props) {
<>
{f.state === "expired"
- ? "Login flow expired before authorization."
+ ? (f.error ?? "Login flow expired before authorization.")
: `Login failed: ${f.error ?? "unknown error"}`}