From 77dba07b3c8d8887028dd729178c7823e94d01d5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 01:15:25 +0900 Subject: [PATCH 1/6] docs(devlog): fold wp3 audit residual into L2 design --- .../020_l2_native_main_reauth_api.md | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md index 3bc7a1315f..bde091a235 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md @@ -31,6 +31,13 @@ MODIFY `src/oauth/chatgpt-device.ts` poll deadline and abort. Add a service-owned per-fetch deadline (fetch + body) so a stuck TCP cannot hold the flow until TTL. This is the Kuhn blocker "poll timer does not bound fetch/body deadlines". + Audit-folded: one FRESH 30s timeout per fetch attempt inside the poll + loop (AbortSignal.any([ctrl.signal, AbortSignal.timeout(30_000)]), the + main-account.ts:239-241 pattern) — a single 30s signal across the whole + poll would kill the 15-minute grant. Abort-timeout maps to + device_authorization_failed. The shared helper also bounds hung POOL + device logins at 30s per fetch — an intended improvement, called out in + the PR. MODIFY `src/codex/main-account.ts` - New `beginNativeMainReauth`: captures the existing @@ -43,6 +50,13 @@ MODIFY `src/codex/main-account.ts` together, advances the mutation epoch, and reconciles runtime/quota state. Old identity token is never retained beside new credentials. No claim held during human polling. + Audit-folded: do NOT reuse persistRefreshedMainAuthJson (:190-195) — it + spreads expected.tokens and never writes id_token, so the old identity + token would survive beside the new grant. The commit uses a SIBLING + persist that sets access_token/refresh_token/id_token/account_id + together and overwrites any prior id_token (adding the key is safe: + readMainAuthJsonCredential :122 tolerates it and + native-profile-store.ts:476-481 expects it). NEW `src/codex/main-device-reauth.ts` - One process-owned active flow (opaque UUID, AbortController, bounded @@ -68,6 +82,12 @@ MODIFY `src/cli/account-main.ts` management API; reject extra args before start. Register capability/help; regenerate skill surface with `bun run skill:surface` if the capability registry changes (tests/ci-workflows/skill-ocx.test.ts gates this). + Audit-folded: the native-main CLI branch point is account-main.ts (:181 + region, beside add/switch) with USAGE in src/cli/account.ts:64; the + management route-registry (src/server/management/route-registry.ts + MANAGEMENT_ROUTES) must gain the POST/GET/DELETE rows or + management-route-registry.test.ts and the capabilities ratchet go red — + do NOT grow UNDECLARED_ROUTES_2026_08_28. ## Hub fence resolution (open decision 1, resolved here for audit) @@ -103,8 +123,11 @@ contract: strict keys, 400/404/409 shapes, unauthorized rejected, `__main__` still refused by `/api/codex-auth/login`. MODIFY `tests/oauth/chatgpt-device-auth.test.ts` — native result retains idToken in-process; per-fetch deadline fires on a hung stub fetch. -MODIFY `tests/cli/cli-account.test.ts` — reauth --device surface, status, -cancel, arg rejection. +Audit-folded: native-main CLI tests land in +tests/cli/cli-native-profile.test.ts (native-main CLI); the pool +cli-account.test.ts keeps only the __main__ login rejection cases. +MODIFY `tests/cli/cli-native-profile.test.ts` — reauth --device surface, +status, cancel, arg rejection. All NEW files: layout.json explicit + expected-fixture entries. ## Docs / ownership From 2c5022c24ce4ebe5802ee8a8b98f4b5e039b6b16 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 01:37:07 +0900 Subject: [PATCH 2/6] feat(codex): native-main device reauth API for headless hubs (#3898) --- scripts/test-layout/layout.json | 4 +- src/codex/main-account.ts | 103 +++++++ src/codex/main-device-reauth-api.ts | 89 ++++++ src/codex/main-device-reauth.ts | 206 +++++++++++++ src/oauth/chatgpt-device.ts | 67 ++++- src/server/management-api.ts | 7 + src/server/management/route-registry.ts | 5 + .../main-device-reauth-api.test.ts | 146 +++++++++ .../main-device-reauth.test.ts | 278 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 4 +- tests/oauth/chatgpt-device-auth.test.ts | 67 ++++- 11 files changed, 968 insertions(+), 8 deletions(-) create mode 100644 src/codex/main-device-reauth-api.ts create mode 100644 src/codex/main-device-reauth.ts create mode 100644 tests/codex-integration/main-device-reauth-api.test.ts create mode 100644 tests/codex-integration/main-device-reauth.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index eb48d9e900..8ef047a4a1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1371,7 +1371,9 @@ "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", - "usage-log-ws-stage.test.ts": "usage" + "usage-log-ws-stage.test.ts": "usage", + "main-device-reauth.test.ts": "codex-integration", + "main-device-reauth-api.test.ts": "codex-integration" }, "migrated": [ "adapters", diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts index a412046797..1dd9f70816 100644 --- a/src/codex/main-account.ts +++ b/src/codex/main-account.ts @@ -218,6 +218,109 @@ export function setMainAuthJsonBeforeRenameHookForTests(hook: (() => void) | nul beforeMainAuthJsonRenameForTests = hook; } +/** Complete token set a native device reauth commits into the main slot (#3898). */ +export interface NativeMainReauthTokens { + accessToken: string; + refreshToken: string; + idToken: string; + chatgptAccountId: string; +} + +export class NativeMainReauthUnavailableError extends Error { + constructor(message = "Native main credential cannot be reauthenticated in this state") { + super(message); + this.name = "NativeMainReauthUnavailableError"; + } +} + +export class NativeMainReauthIdentityMismatchError extends Error { + constructor() { + super("Device login completed for a different ChatGPT account than the native main identity"); + this.name = "NativeMainReauthIdentityMismatchError"; + } +} + +/** + * The reauth twin of persistRefreshedMainAuthJson (#3898). That function + * spreads expected.tokens and never writes id_token, which would keep the + * OLD identity token beside the new grant; this sibling sets all four + * credential fields together and overwrites any prior id_token. Everything + * else — allowed root metadata, the pre-rename snapshot guards, the + * mutation epoch — follows the refresh path exactly. + */ +function persistNativeMainReauthTokens( + expected: MainAuthJsonCredential, + tokens: NativeMainReauthTokens, +): void { + assertNotRealCodexHomeUnderTest(resolveCodexHomeDir()); + const nextTokens = { + ...expected.tokens, + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + id_token: tokens.idToken, + account_id: tokens.chatgptAccountId, + }; + atomicWriteFile( + expected.path, + JSON.stringify({ ...expected.root, tokens: nextTokens }, null, 2) + "\n", + undefined, + { + beforeRename: () => { + assertMainAuthJsonSnapshotUnchanged(expected); + const hook = beforeMainAuthJsonRenameForTests; + beforeMainAuthJsonRenameForTests = null; + hook?.(); + }, + validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected), + }, + ); + advanceCodexCredentialMutationEpoch(); +} + +/** + * Prepare a same-identity reauth of the native __main__ slot (#3898). + * + * The existing credential snapshot is captured NOW and held only inside the + * closure — callers (the device-reauth service) never see the expected + * account id, so a flow cannot be steered toward a different identity. No + * claim is held while the human completes the device page. The returned + * commit, called once the device grant exists: + * + * 1. requires the SAME chatgpt account identity as the snapshot; + * 2. acquires the owner-independent exclusive claim (native-main-claim) — + * deliberately NOT assertNativeMainOwner, which a headless hub cannot + * satisfy; + * 3. re-verifies the snapshot (path + hash + dev/ino) inside the claim; + * 4. writes access/refresh/id token + account_id atomically and clears the + * main account's reauth quarantine for the new credential generation. + */ +export function beginNativeMainReauth(): { + commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }>; +} { + const expected = readMainAuthJsonCredential(); + if (!expected || !expected.chatgptAccountId) { + throw new NativeMainReauthUnavailableError( + "No native main credential exists to reauthenticate; enrollment is the native profile workflow", + ); + } + return { + async commit(tokens: NativeMainReauthTokens): Promise<{ chatgptAccountId: string }> { + if (!tokens.accessToken || !tokens.refreshToken || !tokens.idToken) { + throw new NativeMainReauthUnavailableError("Device grant did not produce a complete token set"); + } + if (tokens.chatgptAccountId !== expected.chatgptAccountId) { + throw new NativeMainReauthIdentityMismatchError(); + } + return withNativeMainExclusiveClaim(resolveNativeProfileContext(), async () => { + assertMainAuthJsonSnapshotUnchanged(expected); + persistNativeMainReauthTokens(expected, tokens); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + return { chatgptAccountId: tokens.chatgptAccountId }; + }); + }, + }; +} + async function resolveMainAccountToken( dependencies: NativeMainRefreshDependencies = {}, rejectedAccessToken?: string, diff --git a/src/codex/main-device-reauth-api.ts b/src/codex/main-device-reauth-api.ts new file mode 100644 index 0000000000..538a4ead58 --- /dev/null +++ b/src/codex/main-device-reauth-api.ts @@ -0,0 +1,89 @@ +import type { OcxConfig } from "../types"; +import { jsonResponse } from "../server/auth-cors"; +import { + cancelMainDeviceReauth, + getMainDeviceReauthStatus, + MainDeviceReauthFlowBusyError, + startMainDeviceReauth, +} from "./main-device-reauth"; +import { NativeMainReauthUnavailableError } from "./main-account"; + +/** + * Dedicated native-main device reauth route (#3898). + * + * `/api/codex-auth/login` stays pool-only and keeps rejecting __main__; + * this namespace is the only device-reauth surface for the native main slot. + * DTOs carry flowId/status/verificationUrl/deviceCode and safe failure codes + * — never tokens, emails, or raw account ids. The route is registered in + * management-api ahead of the generic /api/codex-auth/* dispatch, so the + * existing management origin/auth/session controls wrap it unchanged. + */ + +const ROUTE = "/api/codex-auth/main/reauth-device"; + +function errorResponse( + req: Request, + config: OcxConfig, + message: string, + code: string, + status: number, +): Response { + return jsonResponse({ error: message, code }, status, req, config); +} + +function flowIdFromQuery(url: URL): string | null { + for (const key of url.searchParams.keys()) { + if (key !== "flowId") return null; + } + const flowId = url.searchParams.get("flowId"); + return flowId && flowId.trim() ? flowId : null; +} + +export async function handleMainDeviceReauthAPI( + req: Request, + url: URL, + config: OcxConfig, +): Promise { + if (url.pathname !== ROUTE) return null; + + if (req.method === "POST") { + // Strict body: no request keys exist for start; anything supplied is an error. + const text = await req.text(); + if (text.trim()) { + return errorResponse(req, config, "The reauth-device start takes no request body", "invalid_request", 400); + } + try { + return jsonResponse(startMainDeviceReauth(), 200, req, config); + } catch (error) { + if (error instanceof MainDeviceReauthFlowBusyError) { + return errorResponse(req, config, error.message, error.code, 409); + } + if (error instanceof NativeMainReauthUnavailableError) { + return errorResponse(req, config, error.message, "native_main_unavailable", 503); + } + throw error; + } + } + + if (req.method === "GET") { + const flowId = flowIdFromQuery(url); + if (!flowId) { + return errorResponse(req, config, "An exact flowId query is required", "invalid_request", 400); + } + const status = getMainDeviceReauthStatus(flowId); + if (!status) return errorResponse(req, config, "Unknown or expired reauth flow", "unknown_flow", 404); + return jsonResponse(status, 200, req, config); + } + + if (req.method === "DELETE") { + const flowId = flowIdFromQuery(url); + if (!flowId) { + return errorResponse(req, config, "An exact flowId query is required", "invalid_request", 400); + } + const status = cancelMainDeviceReauth(flowId); + if (!status) return errorResponse(req, config, "Unknown or expired reauth flow", "unknown_flow", 404); + return jsonResponse(status, 200, req, config); + } + + return errorResponse(req, config, "Method not allowed", "method_not_allowed", 405); +} diff --git a/src/codex/main-device-reauth.ts b/src/codex/main-device-reauth.ts new file mode 100644 index 0000000000..2c3a56243a --- /dev/null +++ b/src/codex/main-device-reauth.ts @@ -0,0 +1,206 @@ +import { randomUUID } from "node:crypto"; +import { loginChatGPTNativeDevice, type NativeDeviceLogin } from "../oauth/chatgpt-device"; +import type { OAuthController } from "../oauth/types"; +import { + beginNativeMainReauth, + MainAuthJsonChangedDuringRefreshError, + NativeMainReauthIdentityMismatchError, + NativeMainReauthUnavailableError, + type NativeMainReauthTokens, +} from "./main-account"; + +/** + * Process-owned native-main device reauth flow (#3898). + * + * Exactly one active flow per process, started from the management API or the + * CLI. The human-facing DTO carries only flowId, status, the verification + * URL and the device code: tokens, emails, and raw account ids never leave + * the device/grant layer, and the opaque device_auth_id never leaves + * chatgpt-device.ts at all. The grant runs on this flow's own + * AbortController — deliberately NOT through startLoginFlow("chatgpt"), + * which would overwrite the pool scratch slot and collide with pool logins. + */ + +export type MainDeviceReauthStatus = + | { flowId: string; status: "pending"; verificationUrl: string; deviceCode: string } + | { flowId: string; status: "committing" } + | { flowId: string; status: "succeeded"; credentialUpdated: true } + | { flowId: string; status: "cancelled" } + | { + flowId: string; + status: "failed"; + credentialUpdated?: true; + code: + | "identity_mismatch" + | "credential_changed" + | "native_main_unavailable" + | "device_authorization_failed" + | "publication_failed" + | "reconciliation_failed"; + }; + +export class MainDeviceReauthFlowBusyError extends Error { + readonly code = "flow_in_progress"; + constructor() { + super("A native main device reauth is already in progress"); + this.name = "MainDeviceReauthFlowBusyError"; + } +} + +interface ActiveFlow { + flowId: string; + controller: AbortController; + status: MainDeviceReauthStatus; + /** Set once auth.json has been replaced; cancellation can no longer win. */ + published: boolean; + /** Snapshot-holding commit prepared at start; closure-private identity. */ + prepared: { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> }; +} + +/** Bounded terminal retention so status/cancel stay answerable after completion. */ +const TERMINAL_RETENTION_MS = 300_000; + +let activeFlow: ActiveFlow | null = null; +const terminalFlows = new Map(); + +export interface MainDeviceReauthDeps { + login?: (ctrl: OAuthController) => Promise; + beginCommit?: () => { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> }; + flowId?: () => string; + now?: () => number; +} + +function isTerminal(status: MainDeviceReauthStatus): boolean { + return status.status === "succeeded" || status.status === "cancelled" || status.status === "failed"; +} + +function sweepTerminal(now: number): void { + for (const [flowId, row] of terminalFlows) { + if (row.expiresAt <= now) terminalFlows.delete(flowId); + } +} + +function finish(flow: ActiveFlow, status: MainDeviceReauthStatus, now: number): void { + // Publication beats a racing cancellation: once auth.json was replaced the + // honest terminal is succeeded, never cancelled (080). Every other terminal + // is first-write-wins so a superseded or cancelled completion cannot + // publish a later result. + if (isTerminal(flow.status)) { + if (!(flow.published && status.status === "succeeded")) return; + } + if (status.status === "succeeded") flow.published = true; + flow.status = status; + terminalFlows.set(flow.flowId, { status, expiresAt: now + TERMINAL_RETENTION_MS }); +} + +function mapFailure(flowId: string, error: unknown): MainDeviceReauthStatus { + if (error instanceof NativeMainReauthIdentityMismatchError) { + return { flowId, status: "failed", code: "identity_mismatch" }; + } + if (error instanceof MainAuthJsonChangedDuringRefreshError) { + return { flowId, status: "failed", code: "credential_changed" }; + } + if (error instanceof NativeMainReauthUnavailableError) { + return { flowId, status: "failed", code: "native_main_unavailable" }; + } + const code = (error as { code?: unknown } | null)?.code; + if (code === "NATIVE_MAIN_CLAIM_UNAVAILABLE" || code === "NATIVE_MAIN_OWNER_UNAVAILABLE") { + return { flowId, status: "failed", code: "native_main_unavailable" }; + } + const name = (error as { name?: unknown } | null)?.name; + if (name === "TimeoutError" || name === "AbortError") { + return { flowId, status: "failed", code: "device_authorization_failed" }; + } + if (error instanceof Error && /device authorization/.test(error.message)) { + return { flowId, status: "failed", code: "device_authorization_failed" }; + } + return { flowId, status: "failed", code: "publication_failed" }; +} + +/** + * Start the one active flow. Returns the pending status; the URL/code arrive + * with the usercode response and are visible through the status endpoint. + */ +export function startMainDeviceReauth(deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus { + const now = (deps.now ?? Date.now)(); + sweepTerminal(now); + if (activeFlow && !isTerminal(activeFlow.status)) throw new MainDeviceReauthFlowBusyError(); + // Prepare NOW: the existing credential snapshot is captured at start (080), + // so a hub with no reauthenticatable main credential fails fast with + // native_main_unavailable instead of after the human completes the page. + const prepared = (deps.beginCommit ?? beginNativeMainReauth)(); + const flowId = (deps.flowId ?? randomUUID)(); + const flow: ActiveFlow = { + flowId, + controller: new AbortController(), + status: { flowId, status: "pending", verificationUrl: "", deviceCode: "" }, + published: false, + prepared, + }; + activeFlow = flow; + const login = deps.login ?? loginChatGPTNativeDevice; + const clock = deps.now ?? Date.now; + void (async () => { + try { + const grant = await login({ + signal: flow.controller.signal, + onAuth: info => { + // A superseded or cancelled flow may not publish its URL/code. + if (activeFlow !== flow || isTerminal(flow.status)) return; + flow.status = { + flowId, + status: "pending", + verificationUrl: info.url, + deviceCode: info.deviceCode ?? "", + }; + }, + }); + if (flow.controller.signal.aborted) return; + if (!isTerminal(flow.status)) flow.status = { flowId, status: "committing" }; + await flow.prepared.commit({ + accessToken: grant.credential.access, + refreshToken: grant.credential.refresh, + idToken: grant.idToken, + chatgptAccountId: grant.credential.accountId!, + }); + flow.published = true; + finish(flow, { flowId, status: "succeeded", credentialUpdated: true }, clock()); + } catch (error) { + if (flow.controller.signal.aborted && !flow.published) return; + finish(flow, mapFailure(flowId, error), clock()); + } + })(); + return flow.status; +} + +export function getMainDeviceReauthStatus(flowId: string, deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus | null { + const now = (deps.now ?? Date.now)(); + sweepTerminal(now); + if (activeFlow?.flowId === flowId) return activeFlow.status; + return terminalFlows.get(flowId)?.status ?? null; +} + +/** + * Cancel the flow. Cancellation after publication returns the published + * terminal (succeeded), never cancelled; a pending/committing flow aborts its + * grant and settles cancelled. + */ +export function cancelMainDeviceReauth(flowId: string, deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus | null { + const now = (deps.now ?? Date.now)(); + sweepTerminal(now); + if (activeFlow?.flowId === flowId && !isTerminal(activeFlow.status)) { + activeFlow.controller.abort(); + finish(activeFlow, { flowId, status: "cancelled" }, now); + return activeFlow.status; + } + return terminalFlows.get(flowId)?.status ?? null; +} + +/** Test hook: drop all in-memory flow state. Production never calls this. */ +export function resetMainDeviceReauthForTests(): void { + // Abort first: a reset that only clears the maps leaves a pending grant + // polling against real timers for up to the 15-minute device TTL. + activeFlow?.controller.abort(); + activeFlow = null; + terminalFlows.clear(); +} diff --git a/src/oauth/chatgpt-device.ts b/src/oauth/chatgpt-device.ts index 74fbeeabd9..fa4bdc9838 100644 --- a/src/oauth/chatgpt-device.ts +++ b/src/oauth/chatgpt-device.ts @@ -23,6 +23,19 @@ export const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device"; /** The grant's own lifetime. Polling past this only produces a worse error message. */ const DEVICE_FLOW_TTL_MS = 15 * 60 * 1000; +/** + * Per-fetch deadline for every device-flow HTTP call (#3898). Until this + * existed the only bounds were the 15-minute grant TTL and the caller's + * abort, so one stuck TCP connection could hold the login slot for the whole + * grant. A FRESH timeout per fetch attempt is required — a single timeout + * shared across the poll loop would kill the 15-minute grant. + */ +const DEVICE_FETCH_TIMEOUT_MS = 30_000; + +function deviceFetchSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(DEVICE_FETCH_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} const DEFAULT_POLL_INTERVAL_MS = 5_000; const MIN_POLL_INTERVAL_MS = 1_000; /** @@ -86,7 +99,7 @@ async function requestUserCode(signal?: AbortSignal): Promise { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }), - signal, + signal: deviceFetchSignal(signal), }); if (!response.ok) throw deviceError("request", response.status); const payload = (await response.json()) as Record; @@ -123,7 +136,7 @@ async function pollForGrant( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }), - signal, + signal: deviceFetchSignal(signal), }); if (response.status === 403 || response.status === 404) { // Cap the wait at the time actually left. Sleeping a full interval past @@ -149,7 +162,7 @@ async function pollForGrant( throw new Error("ChatGPT device authorization expired"); } -async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise { +async function exchangeGrantRaw(grant: DeviceGrant, signal?: AbortSignal): Promise> { const response = await fetch(CHATGPT_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -160,10 +173,54 @@ async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise< code_verifier: grant.codeVerifier, redirect_uri: DEVICE_REDIRECT_URI, }).toString(), - signal, + signal: deviceFetchSignal(signal), }); if (!response.ok) throw deviceError("token exchange", response.status); - return credsFromToken((await response.json()) as Record); + return (await response.json()) as Record; +} + +async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise { + return credsFromToken(await exchangeGrantRaw(grant, signal)); +} + +/** + * The native-main reauth result (#3898): the projected credential PLUS the + * id_token the pool projection deliberately drops. The id_token is the + * identity document the native auth.json requires + * (native-profile-store.ts), and it never leaves this process — it is + * written to the native main slot by the caller, never serialized into a + * DTO, log, or error. + */ +export interface NativeDeviceLogin { + credential: OAuthCredentials; + idToken: string; +} + +async function exchangeGrantNative(grant: DeviceGrant, signal?: AbortSignal): Promise { + const payload = await exchangeGrantRaw(grant, signal); + const credential = credsFromToken(payload); + const idToken = nonEmptyString(payload.id_token); + if (!idToken) throw new Error("ChatGPT device token response missing id_token"); + if (!credential.refresh) throw new Error("ChatGPT device token response missing refresh token"); + if (!credential.accountId) throw new Error("ChatGPT device token response missing account identity"); + return { credential, idToken }; +} + +/** + * Device flow for the native __main__ slot. Same grant as the pool flow, but + * nothing is persisted here and no OAuth store is touched: the caller + * (main-device-reauth service) owns the fenced commit into CODEX_HOME + * auth.json. + */ +export async function loginChatGPTNativeDevice(ctrl: OAuthController): Promise { + const device = await requestUserCode(ctrl.signal); + ctrl.onAuth?.({ + url: DEVICE_VERIFICATION_URL, + instructions: `Enter code: ${device.userCode}`, + deviceCode: device.userCode, + }); + const grant = await pollForGrant(device, ctrl.signal); + return exchangeGrantNative(grant, ctrl.signal); } /** diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 19b6aeec25..3eb0d775fa 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -388,6 +388,13 @@ export async function handleManagementAPI( } if (url.pathname.startsWith("/api/codex-auth/")) { + // Native-main device reauth (#3898): a dedicated namespace the generic + // codex-auth dispatch must not swallow (it would 404 as an unknown pool + // route). Same management origin/auth/session wrapping as every /api/*. + if (url.pathname === "/api/codex-auth/main/reauth-device") { + const { handleMainDeviceReauthAPI } = await import("../codex/main-device-reauth-api"); + return handleMainDeviceReauthAPI(req, url, config); + } const { handleCodexAuthAPI } = await import("../codex/auth-api"); const { ConfigMutationLockError } = await import("../config"); const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store"); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 9fb71e662c..105cdb2ba6 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -96,6 +96,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/accounts/clear-cooldown", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/accounts/refresh", module: "codex/auth-api", mutates: true }, + // codex/main-device-reauth-api (#3898): the native-main device reauth namespace; + // /api/codex-auth/login stays pool-only and keeps rejecting __main__. + { method: "POST", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: true }, + { method: "GET", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: false }, + { method: "DELETE", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/login", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/login/cancel", module: "codex/auth-api", mutates: true }, { method: "POST", path: "/api/codex-auth/login/code", module: "codex/auth-api", mutates: true }, diff --git a/tests/codex-integration/main-device-reauth-api.test.ts b/tests/codex-integration/main-device-reauth-api.test.ts new file mode 100644 index 0000000000..64b6d35fba --- /dev/null +++ b/tests/codex-integration/main-device-reauth-api.test.ts @@ -0,0 +1,146 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleMainDeviceReauthAPI } from "../../src/codex/main-device-reauth-api"; +import { resetMainDeviceReauthForTests } from "../../src/codex/main-device-reauth"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #3898 route contract: /api/codex-auth/main/reauth-device is the only + * device-reauth surface for the native main slot. Safe 400/404/405/409/503 + * shapes, strict request keys, and no token material in any payload. + */ + +const ROUTE = "http://localhost/api/codex-auth/main/reauth-device"; +const USERCODE = "https://auth.openai.com/api/accounts/deviceauth/usercode"; +const DEVICE_TOKEN = "https://auth.openai.com/api/accounts/deviceauth/token"; + +const realFetch = globalThis.fetch; +let home: string; +let previousCodexHome: string | undefined; + +const config = { port: 0 } as OcxConfig; + +function call(method: string, query = "", body?: string): Promise { + const url = new URL(ROUTE + query); + const req = body === undefined + ? new Request(url, { method }) + : new Request(url, { method, body, headers: { "content-type": "application/json" } }); + return handleMainDeviceReauthAPI(req, url, config); +} + +/** Device endpoints that stay pending forever, so the flow never leaves pending. */ +function stubPendingDevice(): void { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === USERCODE) { + return new Response(JSON.stringify({ + device_auth_id: "auth-id-opaque", + user_code: "ABCD-1234", + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + if (url === DEVICE_TOKEN) { + return new Response("{}", { status: 403, headers: { "Content-Type": "application/json" } }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; +} + +beforeEach(() => { + resetMainDeviceReauthForTests(); + home = mkdtempSync(join(tmpdir(), "ocx-main-reauth-api-")); + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; +}); + +afterEach(() => { + globalThis.fetch = realFetch; + resetMainDeviceReauthForTests(); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); +}); + +function writeMainCredential(): void { + writeFileSync(join(home, "auth.json"), JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: "old-access", + refresh_token: "old-refresh", + account_id: "acct-main-1", + }, + })); +} + +describe("native main device reauth route (#3898)", () => { + test("other paths fall through", async () => { + const url = new URL("http://localhost/api/codex-auth/login"); + const handled = await handleMainDeviceReauthAPI(new Request(url, { method: "POST" }), url, config); + expect(handled).toBeNull(); + }); + + test("start without a native credential answers 503 native_main_unavailable", async () => { + const response = await call("POST"); + expect(response?.status).toBe(503); + const body = await response!.json() as { code: string }; + expect(body.code).toBe("native_main_unavailable"); + }); + + test("start rejects an unexpected body", async () => { + writeMainCredential(); + const response = await call("POST", "", JSON.stringify({ id: "__main__" })); + expect(response?.status).toBe(400); + }); + + test("status requires an exact flowId query", async () => { + expect((await call("GET"))?.status).toBe(400); + expect((await call("GET", "?flowId="))?.status).toBe(400); + expect((await call("GET", "?flowId=x&extra=1"))?.status).toBe(400); + }); + + test("unknown flows answer 404 for status and cancel", async () => { + expect((await call("GET", "?flowId=nope"))?.status).toBe(404); + expect((await call("DELETE", "?flowId=nope"))?.status).toBe(404); + }); + + test("unsupported methods answer 405", async () => { + expect((await call("PUT"))?.status).toBe(405); + }); + + test("start, poll and cancel round trip with a pending device grant", async () => { + writeMainCredential(); + stubPendingDevice(); + const started = await call("POST"); + expect(started?.status).toBe(200); + const pending = await started!.json() as { flowId: string; status: string }; + expect(pending.status).toBe("pending"); + // The URL/code arrive with the usercode response; give the microtask a turn. + await Bun.sleep(20); + const polled = await call("GET", `?flowId=${pending.flowId}`); + const polledBody = await polled!.json() as Record; + expect(polledBody.status).toBe("pending"); + expect(polledBody.deviceCode).toBe("ABCD-1234"); + expect(String(polledBody.verificationUrl)).toContain("codex/device"); + const cancelled = await call("DELETE", `?flowId=${pending.flowId}`); + expect(cancelled?.status).toBe(200); + expect(await cancelled!.json() as Record).toMatchObject({ status: "cancelled" }); + for (const payload of [pending, polledBody, await (await call("GET", `?flowId=${pending.flowId}`))!.json()]) { + const json = JSON.stringify(payload); + expect(json).not.toContain("old-access"); + expect(json).not.toContain("old-refresh"); + expect(json).not.toContain("acct-main-1"); + } + }); + + test("a second start while active answers 409 flow_in_progress", async () => { + writeMainCredential(); + stubPendingDevice(); + const first = await call("POST"); + expect(first?.status).toBe(200); + const second = await call("POST"); + expect(second?.status).toBe(409); + expect((await second!.json() as { code: string }).code).toBe("flow_in_progress"); + }); +}); diff --git a/tests/codex-integration/main-device-reauth.test.ts b/tests/codex-integration/main-device-reauth.test.ts new file mode 100644 index 0000000000..e2f18b8e07 --- /dev/null +++ b/tests/codex-integration/main-device-reauth.test.ts @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + cancelMainDeviceReauth, + getMainDeviceReauthStatus, + MainDeviceReauthFlowBusyError, + resetMainDeviceReauthForTests, + startMainDeviceReauth, + type MainDeviceReauthStatus, +} from "../../src/codex/main-device-reauth"; +import { + beginNativeMainReauth, + MainAuthJsonChangedDuringRefreshError, + NativeMainReauthIdentityMismatchError, + NativeMainReauthUnavailableError, + setMainAuthJsonBeforeRenameHookForTests, +} from "../../src/codex/main-account"; +import type { NativeDeviceLogin } from "../../src/oauth/chatgpt-device"; +import type { OAuthController } from "../../src/oauth/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * #3898: the headless-hub native-main device reauth. One process-owned flow, + * same-identity fenced commit, and a DTO that can never carry tokens. + */ + +function grant(accountId = "acct-main-1"): NativeDeviceLogin { + return { + credential: { + access: "new-access-token", + refresh: "new-refresh-token", + expires: Date.now() + 3600_000, + accountId, + } as NativeDeviceLogin["credential"], + idToken: "new-id-token", + }; +} + +function loginStub( + behavior: (ctrl: OAuthController) => Promise, +): (ctrl: OAuthController) => Promise { + return behavior; +} + +async function waitForTerminal(flowId: string, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const status = getMainDeviceReauthStatus(flowId); + if (status && status.status !== "pending" && status.status !== "committing") return status; + if (Date.now() > deadline) throw new Error(`flow ${flowId} never settled: ${JSON.stringify(status)}`); + await Bun.sleep(5); + } +} + +beforeEach(() => resetMainDeviceReauthForTests()); +afterEach(() => resetMainDeviceReauthForTests()); + +describe("native main device reauth flow (#3898)", () => { + test("start publishes the verification URL and human code, then succeeds", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async ctrl => { + ctrl.onAuth?.({ url: "https://auth.openai.com/codex/device", deviceCode: "ABCD-1234" }); + return grant(); + }), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + expect(started.status).toBe("pending"); + const pending = getMainDeviceReauthStatus(started.flowId); + expect(pending).toMatchObject({ status: "pending", deviceCode: "ABCD-1234" }); + expect((pending as { verificationUrl?: string }).verificationUrl).toContain("codex/device"); + const terminal = await waitForTerminal(started.flowId); + expect(terminal).toMatchObject({ status: "succeeded", credentialUpdated: true }); + }); + + test("a second start while active is refused", () => { + let release!: (value: NativeDeviceLogin) => void; + const gate = new Promise(resolve => { release = resolve; }); + startMainDeviceReauth({ + login: loginStub(() => gate), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + expect(() => startMainDeviceReauth({ + login: loginStub(async () => grant()), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + })).toThrow(MainDeviceReauthFlowBusyError); + release(grant()); + }); + + test("identity mismatch fails without touching the credential", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async () => grant("acct-OTHER")), + beginCommit: () => ({ + commit: async () => { throw new NativeMainReauthIdentityMismatchError(); }, + }), + }); + const terminal = await waitForTerminal(started.flowId); + expect(terminal).toMatchObject({ status: "failed", code: "identity_mismatch" }); + expect((terminal as { credentialUpdated?: boolean }).credentialUpdated).toBeUndefined(); + }); + + test("a cancelled flow cannot publish a late grant", async () => { + let release!: (value: NativeDeviceLogin) => void; + const gate = new Promise(resolve => { release = resolve; }); + let commitCalled = false; + const started = startMainDeviceReauth({ + login: loginStub(() => gate), + beginCommit: () => ({ commit: async () => { commitCalled = true; return { chatgptAccountId: "acct-main-1" }; } }), + }); + const cancelled = cancelMainDeviceReauth(started.flowId); + expect(cancelled).toMatchObject({ status: "cancelled" }); + release(grant()); + await Bun.sleep(20); + expect(getMainDeviceReauthStatus(started.flowId)).toMatchObject({ status: "cancelled" }); + expect(commitCalled).toBe(false); + }); + + test("cancellation after publication returns succeeded, never cancelled", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async ctrl => { + ctrl.onAuth?.({ url: "https://auth.openai.com/codex/device", deviceCode: "WXYZ-9999" }); + return grant(); + }), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + await waitForTerminal(started.flowId); + expect(cancelMainDeviceReauth(started.flowId)).toMatchObject({ status: "succeeded" }); + }); + + test("claim-unavailable maps to native_main_unavailable", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async () => grant()), + beginCommit: () => ({ + commit: async () => { + const error = new Error("claim held elsewhere") as Error & { code: string }; + error.code = "NATIVE_MAIN_CLAIM_UNAVAILABLE"; + throw error; + }, + }), + }); + expect(await waitForTerminal(started.flowId)).toMatchObject({ + status: "failed", + code: "native_main_unavailable", + }); + }); + + test("device authorization failures map to device_authorization_failed", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async () => { throw new Error("ChatGPT device authorization poll failed: HTTP 500"); }), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + expect(await waitForTerminal(started.flowId)).toMatchObject({ + status: "failed", + code: "device_authorization_failed", + }); + }); + + test("no DTO ever carries token material", async () => { + const started = startMainDeviceReauth({ + login: loginStub(async ctrl => { + ctrl.onAuth?.({ url: "https://auth.openai.com/codex/device", deviceCode: "ABCD-1234" }); + return grant(); + }), + beginCommit: () => ({ commit: async () => ({ chatgptAccountId: "acct-main-1" }) }), + }); + const terminal = await waitForTerminal(started.flowId); + for (const dto of [started, getMainDeviceReauthStatus(started.flowId), terminal]) { + const json = JSON.stringify(dto); + expect(json).not.toContain("new-access-token"); + expect(json).not.toContain("new-refresh-token"); + expect(json).not.toContain("new-id-token"); + expect(json).not.toContain("acct-main-1"); + } + }); +}); + +describe("beginNativeMainReauth commit (#3898)", () => { + let home: string; + let previousCodexHome: string | undefined; + let authPath: string; + + const original = { + auth_mode: "chatgpt", + tokens: { + access_token: "old-access", + refresh_token: "old-refresh", + id_token: "old-id-token", + account_id: "acct-main-1", + future_token_field: "preserve-token", + }, + future_root_field: { preserve: true }, + }; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-main-reauth-")); + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + authPath = join(home, "auth.json"); + writeFileSync(authPath, JSON.stringify(original)); + }); + + afterEach(() => { + setMainAuthJsonBeforeRenameHookForTests(null); + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + }); + + function readTokens(): Record { + return (JSON.parse(readFileSync(authPath, "utf8")) as { tokens: Record }).tokens; + } + + test("same-identity commit writes all four token fields and preserves metadata", async () => { + const prepared = beginNativeMainReauth(); + const result = await prepared.commit({ + accessToken: "new-access", + refreshToken: "new-refresh", + idToken: "new-id-token", + chatgptAccountId: "acct-main-1", + }); + expect(result.chatgptAccountId).toBe("acct-main-1"); + const tokens = readTokens(); + expect(tokens.access_token).toBe("new-access"); + expect(tokens.refresh_token).toBe("new-refresh"); + expect(tokens.id_token).toBe("new-id-token"); + expect(tokens.account_id).toBe("acct-main-1"); + expect(tokens.future_token_field).toBe("preserve-token"); + expect(JSON.parse(readFileSync(authPath, "utf8")).future_root_field).toEqual({ preserve: true }); + }); + + test("a different account identity is refused and the file is untouched", async () => { + const before = readFileSync(authPath, "utf8"); + const prepared = beginNativeMainReauth(); + await expect(prepared.commit({ + accessToken: "new-access", + refreshToken: "new-refresh", + idToken: "new-id-token", + chatgptAccountId: "acct-someone-else", + })).rejects.toThrow(NativeMainReauthIdentityMismatchError); + expect(readFileSync(authPath, "utf8")).toBe(before); + }); + + test("an incomplete token set is refused before any claim work", async () => { + const before = readFileSync(authPath, "utf8"); + const prepared = beginNativeMainReauth(); + await expect(prepared.commit({ + accessToken: "new-access", + refreshToken: "", + idToken: "new-id-token", + chatgptAccountId: "acct-main-1", + })).rejects.toThrow(NativeMainReauthUnavailableError); + expect(readFileSync(authPath, "utf8")).toBe(before); + }); + + test("a concurrent writer during publish fails the commit closed", async () => { + const before = readFileSync(authPath, "utf8"); + setMainAuthJsonBeforeRenameHookForTests(() => { + writeFileSync(authPath, JSON.stringify({ tokens: { refresh_token: "foreign-writer" } })); + }); + const prepared = beginNativeMainReauth(); + await expect(prepared.commit({ + accessToken: "new-access", + refreshToken: "new-refresh", + idToken: "new-id-token", + chatgptAccountId: "acct-main-1", + })).rejects.toThrow(MainAuthJsonChangedDuringRefreshError); + expect(readFileSync(authPath, "utf8")).not.toBe(before); + expect(readTokens().refresh_token).toBe("foreign-writer"); + }); + + test("preparation without an existing credential fails fast", () => { + removeTreeWithRetry(home); + home = mkdtempSync(join(tmpdir(), "ocx-main-reauth-empty-")); + process.env.CODEX_HOME = home; + expect(() => beginNativeMainReauth()).toThrow(NativeMainReauthUnavailableError); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index ed114b8a07..efb1afca69 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1203,5 +1203,7 @@ "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers", - "usage-log-ws-stage.test.ts": "usage" + "usage-log-ws-stage.test.ts": "usage", + "main-device-reauth.test.ts": "codex-integration", + "main-device-reauth-api.test.ts": "codex-integration" } diff --git a/tests/oauth/chatgpt-device-auth.test.ts b/tests/oauth/chatgpt-device-auth.test.ts index 533f4da015..7753f9b6c1 100644 --- a/tests/oauth/chatgpt-device-auth.test.ts +++ b/tests/oauth/chatgpt-device-auth.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { loginChatGPT } from "../../src/oauth/chatgpt"; -import { loginChatGPTDevice } from "../../src/oauth/chatgpt-device"; +import { loginChatGPTDevice, loginChatGPTNativeDevice } from "../../src/oauth/chatgpt-device"; import type { OAuthController } from "../../src/oauth/types"; /** @@ -243,4 +243,69 @@ describe("ChatGPT device auth", () => { expect(calls.urls[0]).toBe(USERCODE); expect(creds.accountId).toBe("acct_device_123"); }); + + test("loginChatGPTNativeDevice retains the id_token the pool projection drops (#3898)", async () => { + routeFetch(); + const result = await loginChatGPTNativeDevice({}); + expect(result.credential.accountId).toBe("acct_device_123"); + expect(result.credential.refresh).toBe("refresh-value"); + expect(result.idToken).toBe(idToken()); + }); + + test("loginChatGPTNativeDevice refuses a grant without id_token", async () => { + routeFetch({ tokenBody: { access_token: "access-value", refresh_token: "refresh-value" } }); + await expect(loginChatGPTNativeDevice({})).rejects.toThrow(/missing id_token/); + }); + + test("loginChatGPTNativeDevice refuses a grant without account identity", async () => { + routeFetch({ tokenBody: { access_token: "access-value", refresh_token: "refresh-value", id_token: "header.e30.sig" } }); + await expect(loginChatGPTNativeDevice({})).rejects.toThrow(/missing account identity/); + }); + + test("every device fetch carries a fresh bounded signal (#3898)", async () => { + const signals: (AbortSignal | null | undefined)[] = []; + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + urls.push(url); + signals.push(init?.signal); + if (url === USERCODE) { + return jsonResponse({ device_auth_id: "auth-id-opaque", user_code: "ABCD-EFGH" }); + } + if (url === DEVICE_TOKEN) { + return jsonResponse({ authorization_code: "auth-code", code_verifier: "server-verifier" }); + } + if (url === OAUTH_TOKEN) { + return jsonResponse({ access_token: "a", refresh_token: "r", id_token: idToken() }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + await loginChatGPTNativeDevice({}); + expect(urls).toEqual([USERCODE, DEVICE_TOKEN, OAUTH_TOKEN]); + for (const signal of signals) { + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + } + }); + + test("the pool device login also carries the bounded per-fetch signal", async () => { + const signals: (AbortSignal | null | undefined)[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + signals.push(init?.signal); + if (url === USERCODE) { + return jsonResponse({ device_auth_id: "auth-id-opaque", user_code: "ABCD-EFGH" }); + } + if (url === DEVICE_TOKEN) { + return jsonResponse({ authorization_code: "auth-code", code_verifier: "server-verifier" }); + } + if (url === OAUTH_TOKEN) { + return jsonResponse({ access_token: "a", refresh_token: "r", id_token: idToken() }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + await loginChatGPTDevice({}); + expect(signals.length).toBe(3); + for (const signal of signals) expect(signal).toBeInstanceOf(AbortSignal); + }); }); From 647e52f30760fea81dd6735d046ef612dc47efde Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 01:37:07 +0900 Subject: [PATCH 3/6] feat(cli): ocx account main reauth --device with registry, docs, and structure sync (#3898) --- .../fr/reference/cli/providers-accounts.md | 3 + .../ja/reference/cli/providers-accounts.md | 3 + .../ko/reference/cli/providers-accounts.md | 3 + .../docs/reference/cli/providers-accounts.md | 5 ++ .../ru/reference/cli/providers-accounts.md | 3 + .../tr/reference/cli/providers-accounts.md | 3 + .../zh-cn/reference/cli/providers-accounts.md | 3 + .../zh-tw/reference/cli/providers-accounts.md | 3 + .../ocx/references/01_management_surface.md | 27 ++++++- src/cli/account-main.ts | 80 +++++++++++++++++++ src/cli/account.ts | 2 +- src/cli/capabilities.ts | 22 +++++ structure/codex-home.md | 13 +++ tests/cli/cli-native-profile.test.ts | 42 ++++++++++ 14 files changed, 209 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index b28642e0ca..b531e1b307 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -315,6 +315,9 @@ ocx account main doctor [--json] ocx account main list [--json] ocx account main register