Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
970cc70
refactor: refresh small-cycle extraction onto current dev
lcxhh521 Oct 15, 2026
188b370
refactor(oauth): refresh cycle extraction on current parent
lcxhh521 Oct 15, 2026
5026d48
test: ratchet src/ import cycles and keep core entrypoints off them
Oct 15, 2026
fd133ec
Merge #6454 head (import-cycle ratchet) into the OAuth child
Oct 15, 2026
7afc9e5
test: shrink the import-cycle baseline to 35 and hold router.ts off c…
Oct 15, 2026
a0fec4e
fix: parse cycle edges with Bun and require readable launchd plist
lcxhh521 Oct 15, 2026
2882b60
Merge parent cycle-scanner and launchd review fixes
lcxhh521 Oct 15, 2026
49071f3
Merge latest dev into small-cycle refactor
lcxhh521 Oct 15, 2026
5f792eb
Merge refreshed parent into OAuth import-cycle extraction
lcxhh521 Oct 15, 2026
21090dc
Merge dev into import-cycle refactor and preserve GCP credential markers
lidge-jun Oct 16, 2026
19df04c
Merge refreshed import-cycle parent and current dev into OAuth refactor
lidge-jun Oct 16, 2026
1059ee2
chore: remove trailing blank line from import-cycle scanner
lcxhh521 Oct 16, 2026
acc126f
Merge import-cycle scanner whitespace fix into OAuth refactor
lcxhh521 Oct 16, 2026
13e0a70
Merge dev c03eafd46b into import-cycle refactor
lidge-jun Oct 16, 2026
b49f52f
Merge refreshed import-cycle parent and dev c03eafd46b
lidge-jun Oct 16, 2026
1811da0
fix(quota): defer account-row projection to keep import cycles bounded
lidge-jun Oct 16, 2026
0927618
Merge parent quota import-cycle repair
lidge-jun Oct 16, 2026
a01b337
test(codex): import auth handler from owning routes in new quota suites
lidge-jun Oct 16, 2026
96ea459
Merge origin/dev into #6594 for landing
lidge-jun Oct 16, 2026
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
1 change: 0 additions & 1 deletion src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export {
} from "./auth-api/pool-mode-gate";
export { createResetCreditWhamClient } from "./auth-api/reset-credit-service";
export type { CodexAuthCatalogConvergence } from "./auth-api/login-flow";
export { handleCodexAuthAPI } from "./auth-api/routes";
import { getEffectiveActiveCodexAccountId } from "./routing";
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
import type { OcxConfig } from "../types";
Expand Down
2 changes: 1 addition & 1 deletion src/oauth/chatgpt-device.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { OAuthController, OAuthCredentials } from "./types";
import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt";
import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt-shared";

/**
* OpenAI deviceauth (device-code) grant for the ChatGPT/Codex provider.
Expand Down
83 changes: 83 additions & 0 deletions src/oauth/chatgpt-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { OAuthCredentials } from "./types";

/**
* What the browser callback grant and the deviceauth grant share: the public PKCE client and the
* way a token response becomes credentials.
*
* This is a leaf on purpose. `./chatgpt-device` needs these, and `./chatgpt` needs the device
* grant for `flow: "device"`; importing them straight from `./chatgpt` made the pair a cycle.
*/

const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const TOKEN_URL = "https://auth.openai.com/oauth/token";

/** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
export const CHATGPT_CLIENT_ID = CLIENT_ID;
export const CHATGPT_TOKEN_URL = TOKEN_URL;

export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
const parts = token.split(".");
if (parts.length !== 3 || !parts[1]) return undefined;
try {
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
} catch {
return undefined;
}
}

export function extractAccountId(idToken?: string, accessToken?: string): string | undefined {
for (const token of [idToken, accessToken]) {
if (!token) continue;
const payload = decodeJwtPayload(token);
if (!payload) continue;
if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id;
const ns = payload["https://api.openai.com/auth"];
if (ns && typeof ns === "object" && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string") {
return (ns as Record<string, unknown>).chatgpt_account_id as string;
}
const orgs = payload.organizations;
if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string;
}
return undefined;
}

export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
for (const token of [idToken, accessToken]) {
if (!token) continue;
const payload = decodeJwtPayload(token);
if (!payload) continue;
if (typeof payload.email === "string") return payload.email.toLowerCase();
}
return undefined;
}

export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
// This parses a response from an external boundary, so the access token is
// validated rather than cast. A 200 carrying no access_token would otherwise
// resolve a login as successful with an undefined credential, which then gets
// silently declined at persistence — a success message and no account.
const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
? data.access_token
: undefined;
if (!accessToken) throw new Error("ChatGPT token response missing access token");
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
// ?? only guards null/undefined; NaN or a string expires_in would otherwise
// produce a NaN expiry that never compares as expired, and a negative duration
// would stamp an already-past expiry — both block refresh semantics.
const expiresIn =
typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0
? data.expires_in
: 3600;
// The computed timestamp itself must stay finite: Number.MAX_VALUE passes
// Number.isFinite but overflows to Infinity once multiplied by 1000.
const computedExpires = Date.now() + expiresIn * 1000;
const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
return {
access: accessToken,
refresh: refreshToken,
expires,
accountId: extractAccountId(idToken, accessToken),
email: extractEmail(idToken, accessToken),
};
}
80 changes: 8 additions & 72 deletions src/oauth/chatgpt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OAuthCallbackFlow } from "./callback-server";
import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken, decodeJwtPayload, extractAccountId, extractEmail } from "./chatgpt-shared";
import type { OAuthController, OAuthCredentials } from "./types";
import { generatePKCE } from "./pkce";
import { classifyChatgptRefreshFailure } from "../codex/chatgpt-refresh-failure";
Expand Down Expand Up @@ -102,44 +103,20 @@ function chatGptTokenError(status: number, body: string, label: string): ChatGpt
);
}

const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
/**
* The client and token decoding live in a leaf shared with the device grant.
* Re-exported so existing import sites keep their public helper surface.
*/
export { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken, decodeJwtPayload, extractAccountId, extractEmail };
const CLIENT_ID = CHATGPT_CLIENT_ID;
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
const TOKEN_URL = "https://auth.openai.com/oauth/token";
const TOKEN_URL = CHATGPT_TOKEN_URL;

/** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
export const CHATGPT_CLIENT_ID = CLIENT_ID;
export const CHATGPT_TOKEN_URL = TOKEN_URL;
const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
const CALLBACK_PORT = 1455;
const CALLBACK_PATH = "/auth/callback";
const ORIGINATOR = "opencodex";

export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
const parts = token.split(".");
if (parts.length !== 3 || !parts[1]) return undefined;
try {
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
} catch {
return undefined;
}
}

export function extractAccountId(idToken?: string, accessToken?: string): string | undefined {
for (const token of [idToken, accessToken]) {
if (!token) continue;
const payload = decodeJwtPayload(token);
if (!payload) continue;
if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id;
const ns = payload["https://api.openai.com/auth"];
if (ns && typeof ns === "object" && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string") {
return (ns as Record<string, unknown>).chatgpt_account_id as string;
}
const orgs = payload.organizations;
if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string;
}
return undefined;
}

/**
* Three-way answer to "is this token marked as belonging to the ChatGPT account domain".
* Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the
Expand Down Expand Up @@ -196,16 +173,6 @@ export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim {
return accountId ? { kind: "valid", accountId } : { kind: "invalid" };
}

export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
for (const token of [idToken, accessToken]) {
if (!token) continue;
const payload = decodeJwtPayload(token);
if (!payload) continue;
if (typeof payload.email === "string") return payload.email.toLowerCase();
}
return undefined;
}

/**
* Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the
* existing extractAccountId precedence (top-level, then namespaced, then organizations[0]).
Expand Down Expand Up @@ -233,37 +200,6 @@ export function extractAccountIdClaims(token?: string): { accountId: string | un
};
}

export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
// This parses a response from an external boundary, so the access token is
// validated rather than cast. A 200 carrying no access_token would otherwise
// resolve a login as successful with an undefined credential, which then gets
// silently declined at persistence — a success message and no account.
const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
? data.access_token
: undefined;
if (!accessToken) throw new Error("ChatGPT token response missing access token");
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
// ?? only guards null/undefined; NaN or a string expires_in would otherwise
// produce a NaN expiry that never compares as expired, and a negative duration
// would stamp an already-past expiry — both block refresh semantics.
const expiresIn =
typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0
? data.expires_in
: 3600;
// The computed timestamp itself must stay finite: Number.MAX_VALUE passes
// Number.isFinite but overflows to Infinity once multiplied by 1000.
const computedExpires = Date.now() + expiresIn * 1000;
const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
return {
access: accessToken,
refresh: refreshToken,
expires,
accountId: extractAccountId(idToken, accessToken),
email: extractEmail(idToken, accessToken),
};
}

export class ChatGPTOAuthFlow extends OAuthCallbackFlow {
#verifier = "";
forceLogin = false;
Expand Down
2 changes: 1 addition & 1 deletion src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1786,7 +1786,7 @@ export async function runLogin(
if (provider !== "chatgpt") {
try {
const { clearModelCache } = await import("../codex/model-cache");
const { clearGatherRoutedModelsInflight } = await import("../codex/catalog");
const { clearGatherRoutedModelsInflight } = await import("../codex/catalog/routed-gather");
clearModelCache(provider);
clearGatherRoutedModelsInflight();
const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota");
Expand Down
2 changes: 1 addition & 1 deletion src/server/management-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ export async function handleManagementAPI(
const { handleMainDeviceReauthAPI } = await import("../codex/main-device-reauth-api");
return handleMainDeviceReauthAPI(req, url, config);
}
const { handleCodexAuthAPI } = await import("../codex/auth-api");
const { handleCodexAuthAPI } = await import("../codex/auth-api/routes");
const { ConfigMutationLockError } = await import("../config");
const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store");
try {
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ there. Feature code is grouped by responsibility:
| Group | Directories |
| --- | --- |
| Data plane | `src/adapters/`, `src/responses/`, `src/chat/`, `src/claude/`, `src/grok/`, `src/images/`, `src/vision/`, `src/web-search/` |
| Codex integration | `src/codex/`, `src/combos/`, `src/providers/`, `src/oauth/` |
| Codex integration | `src/codex/`, `src/combos/`, `src/providers/`, `src/oauth/`. Browser/device grants share unchanged credential helpers in the type-only-dependent `src/oauth/chatgpt-shared.ts` leaf; `src/oauth/chatgpt.ts` retains their exports and the bounded token-request logic. Management dispatch imports `src/codex/auth-api/routes.ts` directly, without an auth-api handler re-export; OAuth cache invalidation imports `src/codex/catalog/routed-gather.ts` directly. |
| Surfaces | `src/server/`, `src/cli/`, `src/tray/`, `src/github/` |
| Evidence and contracts | `src/compatibility/`, `src/lab/` |
| Support | `src/lib/`, `src/storage/` (Worker policy fixtures synchronize after loading the initial policy; `src/storage/policy.ts` bounds the test barrier at 10 seconds), `src/usage/`, `src/update/` (compiled executables report standalone update guidance rather than a source checkout; [package refresh](ops/service-and-sidecars.md#package-cache-refresh); `desktop-badge.ts` holds bounded process-local display state, never install authority), `src/generated/` |
Expand Down
1 change: 1 addition & 0 deletions tests/ci-workflows/import-cycle-ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { repoRoot } from "../helpers/repo-root";
*/
const CORE_OFF_CYCLE = [
"src/config.ts",
"src/router.ts",
"src/server/lifecycle.ts",
"src/server/responses/core.ts",
"src/server/management-api.ts",
Expand Down
2 changes: 1 addition & 1 deletion tests/codex-integration/codex-auth-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { join } from "node:path";
import { getConfigPath } from "../../src/config";
import { setPersistedConfigMutationBeforeCommitForTests } from "../../src/config/persisted-mutation";
import * as mutationLock from "../../src/config/mutation-lock";
import { handleCodexAuthAPI } from "../../src/codex/auth-api";
import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes";
import { saveCodexAccountCredential } from "../../src/codex/account-store";
import {
clearCodexUpstreamHealth,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { join } from "node:path";
import type { OcxConfig } from "../../src/types";
import { loadConfig } from "../../src/config";
import { flushConfigDirHardeningForTests } from "../../src/config/paths";
import { handleCodexAuthAPI, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api";
import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes";
import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api";
import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store";
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account";
import { clearCodexUpstreamHealth, clearThreadAccountMap, resolveCodexAccountForThread } from "../../src/codex/routing";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes";
import {
clearAccountNeedsReauth, clearMainAccountInfoCache, handleCodexAuthAPI, isAccountNeedsReauth, markAccountNeedsReauth,
clearAccountNeedsReauth, clearMainAccountInfoCache, isAccountNeedsReauth, markAccountNeedsReauth,
type CodexAuthAccountDto,
} from "../../src/codex/auth-api";
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../../src/codex/main-account";
Expand Down
3 changes: 2 additions & 1 deletion tests/codex-integration/codex-preserve-signed-in.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { getDefaultConfig } from "../../src/config/proxy-env";
import { loadConfig } from "../../src/config";
import { configSchema } from "../../src/config/schema/config-schema";
import { flushConfigDirHardeningForTests } from "../../src/config/paths";
import { handleCodexAuthAPI, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api";
import { clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api";
import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes";
import { saveCodexAccountCredential } from "../../src/codex/account-store";
import * as runtimeConfig from "../../src/codex/auth-api/runtime-config";
import * as authRead from "../../src/codex/auth-collision";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { observeCodexLowQuota } from "../../src/codex/low-quota-observer";
import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state";
import { clearPoolRotationState } from "../../src/codex/pool-rotation";
import { deleteCodexAccount } from "../../src/codex/account-lifecycle";
import { handleCodexAuthAPI } from "../../src/codex/auth-api";
import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes";
import { configSchema } from "../../src/config/schema/config-schema";
import { validateConfigCandidate } from "../../src/config/diagnostics";
import { requireManagementAuth, type ManagementAuthState } from "../../src/server/management-auth";
Expand Down
26 changes: 1 addition & 25 deletions tests/fixtures/import-cycle-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,24 @@
"src/codex/account-lifecycle.ts",
"src/codex/auth-api.ts",
"src/codex/auth-api/account-list.ts",
"src/codex/auth-api/login-flow.ts",
"src/codex/auth-api/routes.ts",
"src/codex/catalog/aggregation.ts",
"src/codex/catalog/bundled.ts",
"src/codex/catalog/combo-member.ts",
"src/codex/catalog/configured-tiers.ts",
"src/codex/catalog/effort.ts",
"src/codex/catalog/gather-capture.ts",
"src/codex/catalog/metadata.ts",
"src/codex/catalog/model-hints.ts",
"src/codex/catalog/model-visibility.ts",
"src/codex/catalog/parsing.ts",
"src/codex/catalog/provider-fetch.ts",
"src/codex/catalog/provider-models.ts",
"src/codex/catalog/routed-gather.ts",
"src/codex/inject.ts",
"src/codex/inject/config-write-section.ts",
"src/codex/inject/restore.ts",
"src/codex/journal.ts",
"src/codex/main-account.ts",
"src/codex/native-profile-manager.ts",
"src/codex/native-profile-startup.ts",
"src/codex/subagent-model-fallback.ts",
"src/grok/inject.ts",
"src/integrations/native/ownership-preflight.ts",
"src/lib/state-store-registrations.ts",
"src/oauth/anthropic-model-quota.ts",
"src/oauth/anthropic-routing.ts",
"src/oauth/devin.ts",
"src/oauth/health.ts",
"src/oauth/index.ts",
"src/oauth/token-guardian.ts",
"src/providers/key-failover.ts",
"src/providers/kiro-account-state-disk.ts",
"src/providers/kiro-model-catalog.ts",
"src/providers/kiro-usage.ts",
"src/providers/quota.ts",
"src/providers/quota/account-cache.ts",
Expand All @@ -50,14 +33,7 @@
"src/providers/quota/report-cache.ts",
"src/providers/quota/vendor-probes-key.ts",
"src/providers/quota/vendor-probes-oauth.ts",
"src/router.ts",
"src/routing/capability.ts",
"src/routing/compatibility/assemble.ts",
"src/routing/evaluator.ts",
"src/routing/quota.ts",
"src/server/effort-policy.ts",
"src/service.ts",
"src/service/cli.ts",
"src/vision/eligibility.ts"
"src/service/cli.ts"
]
}
6 changes: 4 additions & 2 deletions tests/oauth/chatgpt-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,16 @@ describe("ChatGPT OAuth JWT helpers", () => {

describe("ChatGPT OAuth constants", () => {
test("uses auth.openai.com endpoints (not auth0.openai.com)", async () => {
const source = await Bun.file("src/oauth/chatgpt.ts").text();
const source = await Bun.file("src/oauth/chatgpt.ts").text()
+ await Bun.file("src/oauth/chatgpt-shared.ts").text();
expect(source).toContain('auth.openai.com/oauth/authorize');
expect(source).toContain('auth.openai.com/oauth/token');
expect(source).not.toContain("auth0.openai.com");
});

test("uses official Codex client_id", async () => {
const source = await Bun.file("src/oauth/chatgpt.ts").text();
const source = await Bun.file("src/oauth/chatgpt.ts").text()
+ await Bun.file("src/oauth/chatgpt-shared.ts").text();
expect(source).toContain("app_EMoamEEZ73f0CkXaXp7hrann");
expect(source).not.toContain("DRivsnm2Mu42T3KOpqdtwB3NYviHYzwD");
});
Expand Down
Loading