Skip to content
Closed
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
Expand Up @@ -225,6 +225,10 @@ them anyway: it routes to the account-pool login, so `ocx login codex --reauth`
`ocx account reauth codex`. The dashboard Codex account pool (Reauthenticate) does it too. That route
runs inside the proxy, so it needs a running one.

For the ChatGPT OAuth provider, an expired or revoked refresh grant requires signing in
again. Temporary token-service failures, timeouts and cancellation leave the account
eligible for a later refresh.

```bash
ocx login xai
ocx login anthropic
Expand Down
68 changes: 49 additions & 19 deletions src/oauth/chatgpt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,50 @@
import { OAuthCallbackFlow } from "./callback-server";
import type { OAuthController, OAuthCredentials } from "./types";
import { generatePKCE } from "./pkce";
import { classifyChatgptRefreshFailure } from "../codex/chatgpt-refresh-failure";

/**
* Per-fetch deadline for ChatGPT auth endpoint calls. The deviceauth grant got the
* same bound in #3898 after one stuck TCP connection held the login slot for the
* whole grant; a hung refresh would pin the refresh intent lock indefinitely.
*/
export const CHATGPT_FETCH_TIMEOUT_MS = 30_000;

function chatGptFetchSignal(signal?: AbortSignal, timeoutMs = CHATGPT_FETCH_TIMEOUT_MS): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}

/**
* Token-endpoint failure carrying the HTTP status, the allowlisted OAuth error code,
* and a terminal verdict (revoked/expired grant) taken from the same classifier the
* Codex pool's refresh path uses — a dead grant must mark needsReauth, not retry.
*/
export class ChatGptTokenError extends Error {
constructor(
public readonly httpStatus: number,
public readonly oauthError: string | undefined,
public readonly terminal: boolean,
message: string,
) {
super(message);
this.name = "ChatGptTokenError";
}
}

async function chatGptTokenError(response: Response, label: string): Promise<ChatGptTokenError> {
const body = await response.text().catch(() => "");
const failure = classifyChatgptRefreshFailure(response.status, body);
// The message carries the status and the allowlisted OAuth code only — never the
// free-text `error_description`, which can echo OAuth material into log surfaces
// (the same closed vocabulary the pool's noteChatgptRefreshFailure logs with).
return new ChatGptTokenError(
response.status,
failure.code,
failure.reason !== "unknown",
`${label} failed: ${response.status} code=${failure.code ?? "none"}`,
);
}

const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
Expand Down Expand Up @@ -212,24 +256,13 @@ export class ChatGPTOAuthFlow extends OAuthCallbackFlow {
redirect_uri: redirectUri,
code_verifier: this.#verifier,
}).toString(),
signal: chatGptFetchSignal(this.ctrl.signal),
});
if (!resp.ok) {
const errDesc = await safeErrorDescription(resp);
throw new Error(`ChatGPT token exchange failed: ${resp.status} ${errDesc}`);
}
if (!resp.ok) throw await chatGptTokenError(resp, "ChatGPT token exchange");
return credsFromToken((await resp.json()) as Record<string, unknown>);
}
}

function safeErrorDescription(resp: Response): Promise<string> {
return resp.text().catch(() => "").then(text => {
try {
const parsed = JSON.parse(text) as { error?: string; error_description?: string };
return [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${resp.status}`;
} catch { return `HTTP ${resp.status}`; }
});
}

/**
* How the user proves identity. `browser` runs the localhost:1455 callback flow;
* `device` runs the deviceauth grant, which needs no local browser or listener
Expand All @@ -255,7 +288,7 @@ export async function loginChatGPT(
// Codex-rs uses JSON for refresh — intentional divergence; both accepted by auth.openai.com.
export async function refreshChatGPTToken(
refreshToken: string,
options: { signal?: AbortSignal } = {},
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<OAuthCredentials> {
const resp = await fetch(TOKEN_URL, {
method: "POST",
Expand All @@ -265,11 +298,8 @@ export async function refreshChatGPTToken(
client_id: CLIENT_ID,
refresh_token: refreshToken,
}).toString(),
signal: options.signal,
signal: chatGptFetchSignal(options.signal, options.timeoutMs),
});
if (!resp.ok) {
const errDesc = await safeErrorDescription(resp);
throw new Error(`ChatGPT refresh failed: ${resp.status} ${errDesc}`);
}
if (!resp.ok) throw await chatGptTokenError(resp, "ChatGPT refresh");
return credsFromToken((await resp.json()) as Record<string, unknown>);
}
7 changes: 5 additions & 2 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenReques
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
import { loginKimi, refreshKimiToken } from "./kimi";
import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous";
import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
import { ChatGptTokenError, loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
import { loginCursor, refreshCursorToken } from "./cursor";
import { loginZed, refreshZedToken } from "./zed";
Expand Down Expand Up @@ -354,7 +354,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
},
chatgpt: {
login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }),
refresh: (rt) => refreshChatGPTToken(rt),
refresh: (rt, signal) => refreshChatGPTToken(rt, { signal }),
providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
defaultModel: "gpt-5.6-luna",
},
Expand Down Expand Up @@ -715,6 +715,9 @@ function terminal(error:unknown):boolean{
if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??"");
if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined;
if(error instanceof NousTokenError)return error.terminal===true||["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??"");
// ChatGPT carries the pool classifier's verdict on the error itself: revoked and
// expired grants are terminal; unknown/transient failures stay retryable.
if(error instanceof ChatGptTokenError)return error.terminal;
// Local durable-write/read/cleanup failures are operational, not credential
// death: the provider credential was never rejected or consumed. Never mark
// the account needsReauth for broken local persistence infrastructure.
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ rewrite rules and the routed-id settlement.
| `src/providers/model-rename-fields.ts`, `src/providers/model-rename-migration.ts` | Classifies every provider config field for a declared model rename. Exact-model records, lists and nested request-pacing keys follow the replacement; an already saved replacement entry wins. Provider-wide settings, including response-tier authority and project-context consent, and credential fields are not model identities. |
| `src/providers/resolved-model-policy.ts`, `src/providers/resolved-model-policy-merge.ts` | Static provider/model policy resolution for the final upstream wire model, plus its pure clone/merge/URL/family helpers. The resolver detaches and freezes registry defaults, operator overrides, exact explicit input-modality declarations, provider-scoped hard wire pins (including Command Code's `claude-` prefix), aliases, and explicit false/empty values with field-level provenance. Provider derivation, routing, catalog hints, gather admission, and adapter selection consume its detached frozen result. Callers supply transport match, the exact capability row, and a credential-free effective auth decision; credential bytes, usability evidence, account/quota/health state, and observed limits remain outside the result. |

| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. |
| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Command Code manual callback JSON remains opaque to the shared `code#state` parser and is state-validated by its provider parser. A raw Command Code paste with an explicit `#state` suffix must match the flow state on the direct prompt as well. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. ChatGPT token exchange and refresh in `src/oauth/chatgpt.ts` compose caller cancellation with a fresh 30-second fetch deadline. The registration in `src/oauth/index.ts` preserves that signal and uses the shared Codex classifier through `ChatGptTokenError`: terminal grant refusals mark only the matching generation `needsReauth`; transient failures remain retryable. Errors expose only status and allowlisted code. `tests/oauth/chatgpt-token-expiry.test.ts` and `tests/oauth/oauth-refresh-generic-lock.test.ts` cover these boundaries with synthetic responses. |
| `src/combos/failover.ts` | Recognizes the exact bounded ChatGPT plan/model HTTP400 refusal in a bare message or a single `detail`/`error.message` envelope. Competing envelopes stop before code-based hops; finite evidence survives display truncation and SSE error reconstruction without retaining raw bodies. This permits another declared target before output commitment, never credential revocation or provider-wide quota evidence. |
| `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. |
| `src/adapters/openai-responses.ts`, `src/adapters/openai-responses/` | Native OpenAI/ChatGPT Responses passthrough. The canonical ChatGPT adapter forces its upstream-only `stream: true` requirement without changing caller `store`; downstream JSON negotiation remains owned by the [Responses HTTP/SSE contract](transports/responses.md#responses-httpsse). |
Expand Down
Loading
Loading