Skip to content

fix(oauth): switch Codex OAuth to ChatGPT device-code flow (desktop + runtime-host) - #1950

Merged
Astro-Han merged 4 commits into
mainfrom
fix/desktop-codex-oauth-device-code
Aug 3, 2026
Merged

fix(oauth): switch Codex OAuth to ChatGPT device-code flow (desktop + runtime-host)#1950
Astro-Han merged 4 commits into
mainfrom
fix/desktop-codex-oauth-device-code

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

背景

Maka 的 OpenAI Codex OAuth 登录不稳定,根因是 localhost:1455 环回授权流程的 4 个缺陷(已用真实服务类复现):

  1. IPv4/IPv6 环回不匹配 — redirect_uri 用 localhost,但回调服务器只监听 127.0.0.1;macOS 上 localhost 优先解析为 ::1,Safari/部分浏览器直接连不上("时好时坏"主因)。
  2. 端口 1455 单发独占 — 并发/重入登录直接 EADDRINUSE 失败。
  3. 错误 state 回调永久挂起completeAuthorization 永不返回,渲染层转圈卡死。
  4. pending TTL 不清理 — 弃用的授权把端口占住到下次登录。

官方 codex CLI 0.144.6(headless toolchain 钉的同一版本、同一 client_id)已改用 ChatGPT device-code 流程codex-rs login/src/device_code_auth.rs),环回是历史路径。本 PR 跟随上游,把 Maka 的两条 Codex 登录路径(桌面 service + runtime-host coordinator)都换成 device-code,环回机制整个删除。

端点(官方协议,已实测)

  • POST /api/accounts/deviceauth/usercode {"client_id"}{device_auth_id, user_code, interval, expires_at}(验证页固定 auth.openai.com/codex/device
  • 轮询 POST /api/accounts/deviceauth/token(403/404 = pending,200 = {authorization_code, code_challenge, code_verifier}
  • POST /oauth/token 交换(redirect_uri=auth.openai.com/deviceauth/callback

已用 Electron/undici 实测:usercode 返回 200、token 轮询返回 deviceauth_authorization_pending;deviceauth 端点对 undici 无 Cloudflare 挑战,因此可继续走 Maka 的 proxiedFetch(代理兼容)。

改动

commit 1 — 桌面 (063ce461c)

  • openai-codex-service.ts:授权重写为 device-auth(usercode → 轮询 → code 交换);删除 loopback server、端口 1455、PKCE URL 构建、callback HTML;刷新/持久化/claim 提取不变
  • openai-codex-helpers.ts:端点常量换成 device-auth
  • 测试重写:协议参数 + 全流程行为(pending 重试/超时/取消/交换形状)
  • renderer 文案:codex 分支改用「登录码:」显示 user_code

commit 2 — runtime-host (5f8a6c8ab)

  • codex-oauth-enrollment.ts(runtime):startCodexDeviceAuthorization / pollCodexDeviceAuthorization / exchangeCodexDeviceAuthorizationCode,镜像 xAI enrollment 接缝
  • oauth-provider-contracts.ts:openai-codex 加 deviceAuthBaseUrl / deviceVerifyUrl / deviceRedirectUri
  • oauth-coordinator.ts:openai-codex 走 #runCodexDeviceLogin(present 验证页 + stateHint=user_code → poll → exchange);删除 loopback server 与全套死代码(−214 行);#runAuthorizationCodeLogin 收窄为 claude-only
  • 测试:超时测试改 device 语义 + 新增成功路径测试 + runtime enrollment 单测 6 个

验证

  • 桌面测试 1397/1397、runtime 2709/2709、runtime-host 567/567
  • 全局 lint、desktop typecheck、knip 全过
  • 真实端点冒烟:usercode 拿真实 user_code、poll 进入 pending 循环
  • 全仓 1455//auth/callback 生产代码零残留(cursor/antigravity 是独立 provider 的 loopback,不在本 PR 范围)

有意保留

  • runtime 库 API buildOAuthLoginAuthorization / exchangeOAuthAuthorizationCode 的 codex 分支保留(公开 API + 测试钉住,无 Maka 生产调用者)

Astro-Han added a commit that referenced this pull request Aug 3, 2026
…ixes

Addresses all 4 P2 findings from the codex review of PR #1950:

1. Desktop duplicated the device-auth protocol with unvalidated token
   exchange (a malformed 200 with missing refresh_token/expires_in was
   reported as success and persisted an unparseable credential). The
   desktop service now reuses @maka/runtime's codex-oauth-enrollment
   (start/poll/exchange) as the single protocol authority and keeps only
   product lifecycle: authRequestId/pending state, browser opening,
   account snapshots, credential persistence. openai-codex-helpers.ts
   keeps only JWT claims + experimental gate (endpoints live once in
   runtime contracts).

2. Both polls could issue a request after the device window elapsed and
   accept a late grant. codex-oauth-enrollment polls first, sleeps at
   most until expires_at, and re-checks on every loop; a 200 issued
   in-window is honored even if it arrives just past the boundary.

3. Codex poll passed the caller's AbortSignal into the admitted request,
   so a cancellation could discard a poll that consumed the one-time
   code. The admitted request now uses an independent signal (matching
   xAI), bounded by its own deadline.

4. A local window expiry was encoded as invalid_grant and surfaced as
   provider_rejected (coordinator), mislabeling a user timeout as a
   provider rejection; my own coordinator test had pinned the wrong
   mapping. New OAuthDeviceAuthorizationExpiredError is thrown by both
   codex and xAI polls on local expiry; the coordinator maps it to
   authorization_failed.

oauth-login.ts codex loopback branches stay as a legacy public-library
API, now marked LEGACY.

Tests added: desktop malformed-200 no-write, expiry-stops-polling, and
credential-write-failure; runtime expiry classification, no-fetch-after-
expiry, and admission-cancel races (codex + xAI); coordinator codex
cancellation-deferral. Desktop 1399, runtime 2712, runtime-host 568.
Replace the localhost:1455 loopback authorization with the official
codex CLI device-auth flow (auth.openai.com/api/accounts/deviceauth/*):
- getAuthorizationUrl POSTs {client_id} to /deviceauth/usercode and
  surfaces the one-time user_code as stateHint.
- openAuthorizationUrl opens auth.openai.com/codex/device and starts
  polling /deviceauth/token (403/404 = pending, 15 min TTL).
- completeAuthorization exchanges the returned authorization code at
  /oauth/token with redirect_uri=auth.openai.com/deviceauth/callback.

Removes the loopback listener, PKCE authorize URL builder, and fixed
port 1455, eliminating the IPv4/IPv6 loopback mismatch, EADDRINUSE on
re-entry, wrong-state callback hangs, and port leaks. Refresh,
persistence, and JWT claim extraction are unchanged; requests still go
through proxiedFetch so Maka's proxy settings apply (verified the
deviceauth endpoints accept undici traffic).

Verified against the live endpoints (usercode returns 200, token poll
returns deviceauth_authorization_pending), 1397 desktop main tests,
renderer/main typecheck, and global lint.
Replace the localhost:1455 loopback listener in the Host OAuth
coordinator with the official codex CLI device-auth flow, closing the
same IPv4/IPv6 mismatch, port collision, and state-hang failures the
desktop service had.

- runtime: add codex-oauth-enrollment (startCodexDeviceAuthorization /
  pollCodexDeviceAuthorization / exchangeCodexDeviceAuthorizationCode)
  mirroring the xAI enrollment seam, and extend the openai-codex
  provider contract with deviceAuthBaseUrl / deviceVerifyUrl /
  deviceRedirectUri.
- coordinator: route openai-codex through #runCodexDeviceLogin
  (present verification URL + user code as stateHint, poll until
  approval or expiry, exchange the grant); delete the loopback server,
  callback constants, and deadline helpers. #runAuthorizationCodeLogin
  is now claude-subscription only.

567 runtime-host tests, 2709 runtime tests, 1397 desktop tests, lint
and typecheck pass.
…ixes

Addresses all 4 P2 findings from the codex review of PR #1950:

1. Desktop duplicated the device-auth protocol with unvalidated token
   exchange (a malformed 200 with missing refresh_token/expires_in was
   reported as success and persisted an unparseable credential). The
   desktop service now reuses @maka/runtime's codex-oauth-enrollment
   (start/poll/exchange) as the single protocol authority and keeps only
   product lifecycle: authRequestId/pending state, browser opening,
   account snapshots, credential persistence. openai-codex-helpers.ts
   keeps only JWT claims + experimental gate (endpoints live once in
   runtime contracts).

2. Both polls could issue a request after the device window elapsed and
   accept a late grant. codex-oauth-enrollment polls first, sleeps at
   most until expires_at, and re-checks on every loop; a 200 issued
   in-window is honored even if it arrives just past the boundary.

3. Codex poll passed the caller's AbortSignal into the admitted request,
   so a cancellation could discard a poll that consumed the one-time
   code. The admitted request now uses an independent signal (matching
   xAI), bounded by its own deadline.

4. A local window expiry was encoded as invalid_grant and surfaced as
   provider_rejected (coordinator), mislabeling a user timeout as a
   provider rejection; my own coordinator test had pinned the wrong
   mapping. New OAuthDeviceAuthorizationExpiredError is thrown by both
   codex and xAI polls on local expiry; the coordinator maps it to
   authorization_failed.

oauth-login.ts codex loopback branches stay as a legacy public-library
API, now marked LEGACY.

Tests added: desktop malformed-200 no-write, expiry-stops-polling, and
credential-write-failure; runtime expiry classification, no-fetch-after-
expiry, and admission-cancel races (codex + xAI); coordinator codex
cancellation-deferral. Desktop 1399, runtime 2712, runtime-host 568.
@Astro-Han
Astro-Han force-pushed the fix/desktop-codex-oauth-device-code branch from ab130df to 2c9423d Compare August 3, 2026 07:18
Astro-Han added a commit that referenced this pull request Aug 3, 2026
Round-2 codex review of PR #1950 found 3 P2 + 1 P3 (verdict FAIL) plus a
deletion/simplification list. All addressed:

P2 fixes:
- Desktop: the poll had already consumed the one-time device code when a
  cancellation arrived; the exchange reused the aborted signal and burned
  the code. Exchange now uses an independent signal so exchange +
  persistence complete even after cancel (deferred-poll test added).
- xAI device poll (runtime-host path) could sleep past the window and
  still issue a request, and mapped local expiry to provider_rejected.
  Symmetric to the Codex poll: sleep min(interval, remaining), re-check
  after sleep, and throw OAuthDeviceAuthorizationExpiredError on both
  local expiry and server-side expired_token.
- Codex enrollment now accepts the official CLI's  alias in
  addition to  (serde alias parity, test added).

P3 fix:
- After rebasing onto #1946 (which moved desktop xAI to a PKCE loopback),
  the renderer mislabeled xAI's random state prefix as a sign-in code.
  Codex shows the device code; xAI shows no code and its copy describes
  automatic browser write-back.

Deletion/simplification (per review):
- Removed the entire Codex loopback public path from oauth-login.ts
  (OAuthLoginProvider no longer includes openai-codex; buildCodex-
  AuthorizationUrl/CodexAuthorizationConfig deleted in favor of a private
  generic loopback builder; provider-config and contracts pruned of codex
  loopback fields; codex loopback tests deleted). Codex initial-token
  decoding stays via OAuthInitialTokenProvider.
- Deleted apps/desktop/.../openai-codex-helpers.ts: JWT claim extraction
  merged into runtime subscription-auth.ts (extractCodexAccountClaims),
  with extractCodexAccountId kept sub-free for header routing per #1946;
  experimental gate now delegates to runtime isOAuthEnrollmentProvider-
  Enabled('openai-codex'); claims tests migrated to runtime.
- Desktop service now persists runtime OAuthSubscriptionTokens directly
  (PersistedTokens removed).
- Desktop tests tightened: protocol assertions (poll body, exchange form)
  and the mid-sleep poll-count assertion are owned by the runtime
  enrollment tests; desktop keeps lifecycle/UI-reason assertions.
- Renamed stale "browser-loopback" wording in renderer + e2e fixture.

Desktop 1403, runtime 2731, runtime-host 570; lint, format, typecheck,
knip clean.
Round-2 codex review of PR #1950 found 3 P2 + 1 P3 (verdict FAIL) plus a
deletion/simplification list. All addressed:

P2 fixes:
- Desktop: the poll had already consumed the one-time device code when a
  cancellation arrived; the exchange reused the aborted signal and burned
  the code. Exchange now uses an independent signal so exchange +
  persistence complete even after cancel (deferred-poll test added).
- xAI device poll (runtime-host path) could sleep past the window and
  still issue a request, and mapped local expiry to provider_rejected.
  Symmetric to the Codex poll: sleep min(interval, remaining), re-check
  after sleep, and throw OAuthDeviceAuthorizationExpiredError on both
  local expiry and server-side expired_token.
- Codex enrollment now accepts the official CLI's usercode alias in
  addition to user_code (serde alias parity, test added).

P3 fix:
- After rebasing onto #1946 (which moved desktop xAI to a PKCE loopback),
  the renderer mislabeled xAI's random state prefix as a sign-in code.
  Codex shows the device code; xAI shows no code and its copy describes
  automatic browser write-back.

Deletion/simplification (per review):
- Removed the entire Codex loopback public path from oauth-login.ts
  (OAuthLoginProvider no longer includes openai-codex; the codex-specific
  authorization builder and config types were deleted in favor of a
  private generic loopback builder; provider config and contracts pruned
  of codex loopback fields; codex loopback tests deleted). Codex
  initial-token decoding stays via OAuthInitialTokenProvider.
- Deleted apps/desktop/src/main/oauth/openai-codex-helpers.ts: JWT claim
  extraction merged into runtime subscription-auth.ts
  (extractCodexAccountClaims), with extractCodexAccountId kept sub-free
  for header routing per #1946; the experimental gate now delegates to
  runtime isOAuthEnrollmentProviderEnabled('openai-codex'); claims tests
  migrated to runtime.
- Desktop service now persists runtime OAuthSubscriptionTokens directly
  (PersistedTokens removed).
- Desktop tests tightened: protocol assertions (poll body, exchange form)
  and the mid-sleep poll-count assertion are owned by the runtime
  enrollment tests; desktop keeps lifecycle/UI-reason assertions.
- Renamed stale "browser-loopback" wording in renderer + e2e fixture.

Desktop 1403, runtime 2731, runtime-host 570; lint, format, typecheck,
knip clean.
@Astro-Han
Astro-Han force-pushed the fix/desktop-codex-oauth-device-code branch from 5952871 to 4cb340e Compare August 3, 2026 07:50
@Astro-Han
Astro-Han marked this pull request as ready for review August 3, 2026 07:59
@Astro-Han
Astro-Han merged commit 1d80e88 into main Aug 3, 2026
5 checks passed
@Astro-Han
Astro-Han deleted the fix/desktop-codex-oauth-device-code branch August 3, 2026 08:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant