Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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:<label>`(命名账号)并存,存储层零迁移——现有 `providers.codex` 单记录格式不变。
3. **账号路由:provider 字符串扩展**。`CreateSessionRequest.provider` 从 `ProviderName` 字面量放开为:字面量 ∪ `codex:<label>`(label 校验 `^[a-z0-9][a-z0-9-]{0,30}$`)。`LLMProviderPlugin` 增加 `codex:*` 分支:按完整 key 取 OAuth 记录、按 key 刷新;未连接的 key 报清晰错误("account not connected: codex:work,connect via /oauth/codex/start")。
4. **持久化:`OAuthStore` 路径加 env 覆盖**(`CODING_AGENT_OAUTH_AUTH_PATH`),helm 指到数据 PVC `/var/lib/coding-agent/data/oauth/auth.json`。不引入 k8s secret 方案。
5. **多并发登录流程**:每个 device flow 有服务端 `flow_id`,可独立查询/取消,无互斥。同名账号重复登录 = 覆盖该 label 的记录(重登录场景,预期行为)。
6. **label 来源**:start 时可传 `label`;不传则在登录成功后从 id_token 解析(email 优先,其次 chatgpt_account_id 短码),冲突时追加序号。
7. **鉴权**:与现有 API 一致(`verify_api_key`)。

## 服务端设计

### 新端点(`server/http_server.py`,契约文档同步更新)

| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/oauth/codex/start` | body `{label?}`;请求 device code,创建 flow,起后台轮询 → `{flow_id, verification_url, user_code, expires_in}` |
| GET | `/oauth/codex/flows` | 所有 in-flight 及近期完成的 flow |
| GET | `/oauth/codex/flows/{flow_id}` | `{state: pending|authorized|error|expired|cancelled, verification_url?, user_code?, account_label?, error?}` |
| POST | `/oauth/codex/flows/{flow_id}/cancel` | 中止轮询,标记 cancelled |
| GET | `/oauth/accounts` | 已连接账号列表:`[{provider: "codex"|"codex:<label>", label, email?, plan?, connected_at}]`(读 OAuthStore,解析 id_token claims) |
| DELETE | `/oauth/accounts/{provider_key}` | 删除该 key 的本地记录(**不做远端 revoke**:token 随记录删除,换账号/重登录无影响;需在 OpenAI 账号侧彻底清理时用 CLI `oauth logout codex --revoke` 或账号设置页) |
Comment thread
delete-cloud marked this conversation as resolved.

### Flow 管理器(新模块 `server/oauth_flows.py`)

- 内存注册表 `dict[flow_id, FlowState]`(state、urls、label、expires_at、error、task handle);TTL 10 分钟,过期/完成/取消的保留查询,重启清空。
- 后台任务:poll → exchange → 写 `OAuthStore`(key = `codex` 或 `codex:<label>`)→ authorized;异常 → error(空 str 用 `exception_error_message()` 兜底,复用 #685 helper)。
- 并发任意数量;`OAuthStore` 文件锁保证写入安全。

### Provider 插件改造(`plugins/llm_provider.py`)

- `elif self._provider_name == "codex" or self._provider_name.startswith("codex:")`:取完整 key 的记录;`StoreBackedCodexTokenSource` 参数化 provider key(现硬编码 "codex");refresh 回写同 key。
- 未连接 → `RuntimeError("codex account not connected: <key>")`。
- `schemas.py`:`CreateSessionRequest.provider` 类型放宽 + validator;`ProviderName` 字面量本身不动(其他端点不受影响)。
- 默认账号语义:provider 传 `codex` = 默认账号(记录 key `codex`),不传 provider = 服务端 config 默认(现状不变)。

### 存储路径

- `oauth/store.py` 加 `default_auth_path()`:`CODING_AGENT_OAUTH_AUTH_PATH` env 优先,否则 `~/.coding-agent/oauth/auth.json`。
- `LLMProviderPlugin`、oauth CLI、flow 管理器统一走该函数。
- SRE helm values 加 env(后续 SRE PR)。

## 前端设计

**Settings 面板 — "Codex 账号"卡片**
- 账号列表(`GET /oauth/accounts`):label/email/plan 徽章;每行"断开"(确认后 DELETE)。
- "添加账号"→ start(可选输入 label)→ 显示 verification_url(链接)+ user_code(大号等宽,一键复制)→ 3s 轮询 flow 状态 → 成功刷新账号列表;失败/过期显示原因可重试。
- 进行中的 flow 带取消按钮;页面重开可从 flows 列表恢复 pending 状态。

**新建会话(Header)**
- provider 下拉:静态 9 项之外,`GET /oauth/accounts` 返回的每个 codex 账号追加为 `codex:<label>` 选项;拉取失败则只显示静态 `codex`。
- codex 系 provider 的 model 输入维持手输(Responses API 无 models 列表),datalist 给 gpt-5.5/gpt-5.4/gpt-5.2 预设。

## 错误处理

| 场景 | 行为 |
|---|---|
| device code 请求失败 | start 502 + 详情,不建 flow |
| 轮询超时/用户未授权 | flow → expired,可重试 |
| exchange 失败 | flow → error(带消息) |
| 用未连接的 `codex:<label>` 建会话 | 创建即 400,错误消息指向连接入口 |
| 会话进行中账号被断开 | 下次调用 401 → 刷新失败 → turn 报错(不自动切换账号) |

## 测试

- 服务端(`tests/ui/test_oauth_flows.py`):start/list/cancel/status;并发两 flow 互不干扰;authorized 写入正确 key(显式 label / email 兜底 / 冲突加序号);accounts 列表与 DELETE;`codex:work` 建会话路由到对应记录(mock store);未连接 key 的 400。
- 插件:`llm_provider` codex:* 分支(记录查找、token source key、未连接报错);store `default_auth_path` env 覆盖。
- 前端:账号卡片渲染/添加/断开;provider 下拉动态账号项;轮询恢复。
- 回归:tests/coding_agent/plugins/test_llm_provider.py codex 既有用例(默认 key 行为不变)。

## 交付拆分

1. Coding-Agent-Loop PR:store env 路径 + flow 管理器与端点 + 插件 codex:* 路由 + 前端 + 契约文档 + 测试。
2. SRE PR:helm values 加 `CODING_AGENT_OAUTH_AUTH_PATH`。
3. 部署后在 `agent.mesh.kinaz.me` 真实走一遍:连接两个账号 → 分别建会话验证。
48 changes: 48 additions & 0 deletions coding-agent/docs/web-client-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,24 @@ 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):
`repo_path`, `approval_policy` (`auto` \| `interactive` \| `yolo`, default `auto`),
`provider`, `model`, `base_url`, `max_steps`, `run_target`,
`default_run_target`, `workspace_source`.

`provider` accepts the `ProviderName` literals plus `codex:<label>` for a named
codex account (label must match `^[a-z0-9][a-z0-9-]{0,30}$`). Creating a session
with an unconnected `codex:<label>` 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
Expand Down Expand Up @@ -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:<label>` (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:<label>` 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:<label>"`; `provider: "codex"` uses the default account.

## Provider model listing

`GET /providers/{provider}/models` returns the live model list for a provider,
Expand Down
4 changes: 2 additions & 2 deletions coding-agent/src/coding_agent/oauth/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}")
Expand Down
17 changes: 16 additions & 1 deletion coding-agent/src/coding_agent/oauth/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 11 additions & 8 deletions coding-agent/src/coding_agent/plugins/llm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -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()
Expand Down
Loading
Loading