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..ed57a22d8
--- /dev/null
+++ b/coding-agent/docs/superpowers/specs/2026-07-31-webui-codex-oauth-design.md
@@ -0,0 +1,93 @@
+# WebUI Codex OAuth 登录(device flow)+ 多账号 — 设计
+
+日期:2026-07-31(同日修订:加入多账号并存)
+状态:已确认(决策记录在 PR 描述,ADR-lite)
+
+## 背景与目标
+
+o6n 部署的 coding agent 支持 provider=codex(ChatGPT OAuth),但:
+
+- OAuth 登录只有 CLI 入口,网页端无登录能力;
+- 当前 pod 内 refresh token 已失效(403),恢复只能进 pod 操作;
+- `~/.coding-agent/oauth/auth.json` 位于容器 overlay fs,pod 重建即丢;
+- 只支持单账号记录,无法在多个 ChatGPT 账号间切换。
+
+目标:在 webui 完成 codex device-code 登录全流程;OAuth 记录持久化到数据 PVC;**多账号并存,新建会话时可选账号**;支持多个并发登录流程的管理。
+
+非目标:其他 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 服务器。
+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;
+}