From bc8f2fe22606fcf06aecb846ed977b55b375cf5e Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Tue, 20 Oct 2026 17:04:20 +0300 Subject: [PATCH 01/11] fix(codex): list pool accounts without a native home --- .../docs/reference/cli/providers-accounts.md | 5 + scripts/test-layout/layout.json | 1 + src/codex/auth-api/account-list.ts | 4 +- src/codex/auth-api/http.ts | 6 + src/codex/auth-api/main-account-probe.ts | 4 +- structure/providers/openai-accounts.md | 6 +- .../codex-auth-headless-accounts.test.ts | 199 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 8 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 tests/codex-integration/codex-auth-headless-accounts.test.ts diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 30a9732d990..357bcfa4f6e 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -674,6 +674,11 @@ changes Orca files, or changes the active pool account. ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +Stored ChatGPT pool accounts remain listable on hosts without a native Codex home, including in the +dashboard and on account refresh. Listing does not create a Codex home or sign out the native main +account. Disabling the Codex client integration does not disable an existing native login's quota +observation; invalid explicit `CODEX_HOME` paths still report a configuration error. + Without a provider, lists the Codex pool, OAuth accounts, and configured API-key pools. Empty providers are skipped unless `--all` is present. With a provider, lists only that credential family. Human output uses `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS`; a manually chosen Codex row is marked diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0d1c7696e5d..f7f6db92563 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -66,6 +66,7 @@ "server-combo-plan-model-refusal.test.ts": "server", "codex-main-token-invalidated.test.ts": "codex-integration", "codex-main-grant-refusal.test.ts": "codex-integration", + "codex-auth-headless-accounts.test.ts": "codex-integration", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index c3046363f1f..4c60a58a85c 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -28,7 +28,7 @@ import type { OAuthAccountHealth, OAuthHealthLabel } from "../../oauth/health"; import { isSelectableCodexPoolAccount } from "../account-id"; import type { AdmissionLease } from "../../lib/admission"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; -import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable, isNativeMainObservationUnavailable } from "./http"; import { fetchMainAccountInfoAttempt, EMPTY_MAIN_ACCOUNT_INFO, mainResetCreditsForCurrentIdentity } from "./main-account-probe"; import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; import type { PoolQuotaResult } from "./pool-quota-probe"; @@ -442,7 +442,7 @@ export async function listCodexAuthAccountsSnapshot( try { return await withNativeMainCredentialClaim(async () => projectSnapshot(isMainAccountRefreshGrantRejected())); } catch (error) { - if (isNativeMainClaimUnavailable(error)) return projectSnapshot(); + if (isNativeMainObservationUnavailable(error)) return projectSnapshot(); throw error; } finally { projectionLease.release(); diff --git a/src/codex/auth-api/http.ts b/src/codex/auth-api/http.ts index 55c6983178a..8153c35b726 100644 --- a/src/codex/auth-api/http.ts +++ b/src/codex/auth-api/http.ts @@ -7,6 +7,12 @@ export function isNativeMainClaimUnavailable(error: unknown): error is NativePro && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE"); } +/** Missing native homes leave reads unobserved; credential operations still require their claim. */ +export function isNativeMainObservationUnavailable(error: unknown): error is NativeProfileError { + return error instanceof NativeProfileError + && (error.code === "CODEX_HOME_UNAVAILABLE" || isNativeMainClaimUnavailable(error)); +} + export function withNativeMainCredentialClaim(operation: () => Promise): Promise { return withNativeMainSharedClaim(resolveNativeProfileContext(), operation); } diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts index a8ae999f06f..d3aebe4da53 100644 --- a/src/codex/auth-api/main-account-probe.ts +++ b/src/codex/auth-api/main-account-probe.ts @@ -23,7 +23,7 @@ import type { AdmissionLease } from "../../lib/admission"; import { nonEmptyPlan } from "./runtime-config"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; -import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { withNativeMainCredentialClaim, isNativeMainObservationUnavailable } from "./http"; import { readMainAuthErrorCode, currentQuotaDispatchSequence, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe"; /** @@ -198,7 +198,7 @@ export async function fetchMainAccountInfoAttempt( try { return await withNativeMainCredentialClaim(operation); } catch (error) { - if (isNativeMainClaimUnavailable(error)) { + if (isNativeMainObservationUnavailable(error)) { return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 52f1d0b5fe0..0dd39400a96 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -250,9 +250,9 @@ restored from the Accounts picker or Codex Auth through gated recovery: missing from the canonical preset, disabled canonical rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` rows never receive that recovery path. -`GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401, and allowlisted -terminal 403 codes as `needsReauth`; generic permission failures remain non-terminal, and a -successful main usage refresh clears the runtime mark. +Account listing and explicit refresh return stored pool accounts without creating a missing native `CODEX_HOME`; an unchecked main observation leaves credential/reauth state unchanged. +When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 and allowlisted terminal 403 as `needsReauth`; successful refresh clears the runtime mark. +Generic permission failures remain non-terminal; invalid explicit home paths and unsafe storage still fail closed, and credential operations retain their strict claim requirements. `src/codex/chatgpt-refresh-failure.ts` classifies token-endpoint failures for both stored-pool and native-main refresh. Exact structured terminal codes apply only to authentication-error diff --git a/tests/codex-integration/codex-auth-headless-accounts.test.ts b/tests/codex-integration/codex-auth-headless-accounts.test.ts new file mode 100644 index 00000000000..8f1733687c6 --- /dev/null +++ b/tests/codex-integration/codex-auth-headless-accounts.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../../src/codex/account-runtime-state"; +import type { CodexAuthAccountDto } from "../../src/codex/auth-api/account-list"; +import { withNativeMainCredentialClaim } from "../../src/codex/auth-api/http"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import { + captureMainAccountIdentityGeneration, clearMainAccountInfoCache, + getMainAccountCredentialPresence, getMainAccountInfoCache, +} from "../../src/codex/main-account-cache"; +import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root: string; +let nativeHome: string; +let previousOcxHome: string | undefined; +let previousCodexHome: string | undefined; +let originalFetch: typeof fetch; +const poolId = "headless-pool"; + +function config(codexIntegration = false): OcxConfig { + return { + port: 10100, defaultProvider: "openai", providers: {}, + clientIntegrations: { codex: codexIntegration }, + codexAccounts: [{ id: poolId, email: "pool@example.test", plan: "plus", isMain: false }], + }; +} + +function savePool() { + saveCodexAccountCredential(poolId, { + accessToken: "fixture-pool-access", refreshToken: "fixture-pool-refresh", + chatgptAccountId: "fixture-pool-identity", expiresAt: Date.now() + 3_600_000, + }); + return getCodexAccountCredential(poolId); +} + +function usageResponse(): Response { + return Response.json({ + plan_type: "plus", + rate_limit: { primary_window: { used_percent: 17, limit_window_seconds: 18_000 } }, + }); +} + +async function listAccounts(cfg: OcxConfig, method = "GET", suffix = "") { + const url = new URL(`http://localhost/api/codex-auth/accounts${suffix}`); + const response = await handleCodexAuthAPI(new Request(url, { method }), url, cfg); + expect(response?.status).toBe(200); + return (await response!.json() as { accounts: CodexAuthAccountDto[] }).accounts; +} + +function resetRuntimeState() { + clearMainAccountInfoCache(); + clearAccountQuota(); + clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + clearAccountNeedsReauth(poolId); + resetMainCodexAccountIdentityTrackingForTests(); + resetQuotaQueryBackoffForTests(); + resetLifecycleDrainStateForTests(); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-headless-accounts-")); + nativeHome = join(root, "missing-codex-home"); + previousOcxHome = process.env.OPENCODEX_HOME; + previousCodexHome = process.env.CODEX_HOME; + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.CODEX_HOME = nativeHome; + mkdirSync(process.env.OPENCODEX_HOME); + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin !== "https://chatgpt.com" || !url.pathname.endsWith("/wham/usage")) { + throw new Error("unexpected fixture network request"); + } + return usageResponse(); + }) as typeof fetch; + resetRuntimeState(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetRuntimeState(); + if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOcxHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(root); +}); + +describe("account listing without a native Codex home", () => { + for (const integration of [false, true]) { + for (const [method, suffix] of [["GET", ""], ["GET", "?refresh=1"], ["POST", "/refresh"]]) { + test(`${method} accounts${suffix}, integration=${integration}: stored pool remains available`, async () => { + const credential = savePool(); + const accounts = await listAccounts(config(integration), method, suffix); + expect(accounts).toHaveLength(2); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, plan: "plus", quota: { shortPercent: 17 }, + }); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: false, needsReauth: false, quota: null, + }); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(existsSync(nativeHome)).toBe(false); + }); + } + } + + test("an explicit non-directory CODEX_HOME still refuses account listing", async () => { + writeFileSync(nativeHome, "fixture-not-a-directory"); + const credential = savePool(); + await expect(listAccounts(config())).rejects.toThrow("not a directory"); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(readFileSync(nativeHome, "utf8")).toBe("fixture-not-a-directory"); + }); + + test("a home removed during pool refresh does not fail final account projection", async () => { + mkdirSync(nativeHome); + savePool(); + globalThis.fetch = (async () => { + removeTreeWithRetry(nativeHome); + return usageResponse(); + }) as typeof fetch; + const accounts = await listAccounts(config()); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, quota: { shortPercent: 17 }, + }); + expect(existsSync(nativeHome)).toBe(false); + }); + + test("disabled client integration still observes an existing native login", async () => { + mkdirSync(nativeHome); + const auth = JSON.stringify({ tokens: { + access_token: "fixture-main-access", account_id: "fixture-main-identity", + } }); + writeFileSync(join(nativeHome, "auth.json"), auth); + savePool(); + const accounts = await listAccounts(config()); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: true, needsReauth: false, plan: "plus", quota: { shortPercent: 17 }, + }); + expect(readFileSync(join(nativeHome, "auth.json"), "utf8")).toBe(auth); + }); + + test("a missing home preserves previously observed identity and reauth state", async () => { + mkdirSync(nativeHome); + writeFileSync(join(nativeHome, "auth.json"), JSON.stringify({ tokens: { + access_token: "fixture-main-access", account_id: "fixture-main-identity", + } })); + savePool(); + await listAccounts(config()); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + const generation = captureMainAccountIdentityGeneration(); + const info = getMainAccountInfoCache(); + expect(info?.quota?.shortPercent).toBe(17); + expect(getMainAccountCredentialPresence()).toBe(true); + removeTreeWithRetry(nativeHome); + + const accounts = await listAccounts(config(), "GET", "?refresh=1"); + expect(accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)).toMatchObject({ + hasCredential: true, needsReauth: true, quota: null, + }); + expect(accounts.find(account => account.id === poolId)).toMatchObject({ + hasCredential: true, needsReauth: false, quota: { shortPercent: 17 }, + }); + expect(captureMainAccountIdentityGeneration()).toBe(generation); + expect(getMainAccountInfoCache()).toEqual(info); + expect(getMainAccountCredentialPresence()).toBe(true); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(true); + expect(existsSync(nativeHome)).toBe(false); + }); + + test("an unsafe OpenCodex storage root still refuses account listing", async () => { + mkdirSync(nativeHome); + const unsafeRoot = join(root, "not-a-config-directory"); + writeFileSync(unsafeRoot, "fixture-unsafe-root"); + process.env.OPENCODEX_HOME = unsafeRoot; + await expect(listAccounts(config())).rejects.toMatchObject({ code: "PROFILE_STORAGE_UNSAFE" }); + expect(readFileSync(unsafeRoot, "utf8")).toBe("fixture-unsafe-root"); + }); + + test("credential operations still refuse a missing native home before executing", async () => { + const mutationPath = join(root, "must-not-be-written"); + await expect((async () => withNativeMainCredentialClaim(async () => { + writeFileSync(mutationPath, "unexpected mutation"); + }))()).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(existsSync(mutationPath)).toBe(false); + expect(existsSync(nativeHome)).toBe(false); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 20aa180eed2..d46e3137dc9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -144,6 +144,7 @@ "server-combo-plan-model-refusal.test.ts": "server", "codex-main-token-invalidated.test.ts": "codex-integration", "codex-main-grant-refusal.test.ts": "codex-integration", + "codex-auth-headless-accounts.test.ts": "codex-integration", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", From 75b0ac3f4dadeaa6268882d49310964bd3c65c50 Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Wed, 21 Oct 2026 09:45:04 +0300 Subject: [PATCH 02/11] docs(codex): qualify main-account 401 reauthentication --- structure/providers/openai-accounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 0dd39400a96..beb982d0cc5 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -251,7 +251,7 @@ from the canonical preset, disabled canonical rows are re-enabled without replac model settings, and noncanonical `openai` rows never receive that recovery path. Account listing and explicit refresh return stored pool accounts without creating a missing native `CODEX_HOME`; an unchecked main observation leaves credential/reauth state unchanged. -When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 and allowlisted terminal 403 as `needsReauth`; successful refresh clears the runtime mark. +When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 responses with a known terminal code, unrecognized HTTP 401 responses when the access token is not verifiably live, and allowlisted terminal 403 responses as `needsReauth`; successful refresh clears the runtime mark. Generic permission failures remain non-terminal; invalid explicit home paths and unsafe storage still fail closed, and credential operations retain their strict claim requirements. `src/codex/chatgpt-refresh-failure.ts` classifies token-endpoint failures for both stored-pool From a251a54cd0585f55bf3c5a0f0d48efc2fdb802c3 Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Wed, 21 Oct 2026 10:07:16 +0300 Subject: [PATCH 03/11] fix(codex): validate storage before missing-home fallback --- .../docs/reference/cli/providers-accounts.md | 3 ++- src/codex/native-profile-store.ts | 3 ++- structure/providers/openai-accounts.md | 2 +- .../codex-auth-headless-accounts.test.ts | 22 +++++++++++++++++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 357bcfa4f6e..3eae4dbf394 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -677,7 +677,8 @@ changes Orca files, or changes the active pool account. Stored ChatGPT pool accounts remain listable on hosts without a native Codex home, including in the dashboard and on account refresh. Listing does not create a Codex home or sign out the native main account. Disabling the Codex client integration does not disable an existing native login's quota -observation; invalid explicit `CODEX_HOME` paths still report a configuration error. +observation; invalid explicit `CODEX_HOME` paths still report a configuration error. Unsafe +`OPENCODEX_HOME` storage reports an error even when the native Codex home is missing. Without a provider, lists the Codex pool, OAuth accounts, and configured API-key pools. Empty providers are skipped unless `--all` is present. With a provider, lists only that credential family. diff --git a/src/codex/native-profile-store.ts b/src/codex/native-profile-store.ts index 7ab797761d6..ae798a55184 100644 --- a/src/codex/native-profile-store.ts +++ b/src/codex/native-profile-store.ts @@ -353,8 +353,9 @@ export function assertNativeProfileLockPath(context: NativeProfileContext): void } export function resolveNativeProfileContext(options: { codexHome?: string; configDir?: string } = {}): NativeProfileContext { - const codexHome = canonicalExistingDirectory(options.codexHome ?? getCodexHome(), "The effective CODEX_HOME"); + // Read-only callers may tolerate an absent native home, but never unsafe OpenCodex storage. const configDir = canonicalizeDirectoryPath(options.configDir ?? getConfigDir()); + const codexHome = canonicalExistingDirectory(options.codexHome ?? getCodexHome(), "The effective CODEX_HOME"); const homeId = nativeProfileHomeId(codexHome); const foldedHomeId = process.platform === "win32" ? legacyWindowsNativeProfileHomeId(codexHome) : homeId; const instanceId = createHash("sha256").update(DOMAIN_INSTANCE).update(configDir).digest("hex"); diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index beb982d0cc5..9acc419beab 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -252,7 +252,7 @@ model settings, and noncanonical `openai` rows never receive that recovery path. Account listing and explicit refresh return stored pool accounts without creating a missing native `CODEX_HOME`; an unchecked main observation leaves credential/reauth state unchanged. When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 responses with a known terminal code, unrecognized HTTP 401 responses when the access token is not verifiably live, and allowlisted terminal 403 responses as `needsReauth`; successful refresh clears the runtime mark. -Generic permission failures remain non-terminal; invalid explicit home paths and unsafe storage still fail closed, and credential operations retain their strict claim requirements. +Generic permission failures remain non-terminal; invalid explicit home paths still fail closed. Native-profile context resolution validates OpenCodex storage before the native home, so unsafe storage fails closed even when that home is absent; credential operations retain their strict claim requirements. `src/codex/chatgpt-refresh-failure.ts` classifies token-endpoint failures for both stored-pool and native-main refresh. Exact structured terminal codes apply only to authentication-error diff --git a/tests/codex-integration/codex-auth-headless-accounts.test.ts b/tests/codex-integration/codex-auth-headless-accounts.test.ts index 8f1733687c6..ad763853b6b 100644 --- a/tests/codex-integration/codex-auth-headless-accounts.test.ts +++ b/tests/codex-integration/codex-auth-headless-accounts.test.ts @@ -179,13 +179,31 @@ describe("account listing without a native Codex home", () => { expect(existsSync(nativeHome)).toBe(false); }); - test("an unsafe OpenCodex storage root still refuses account listing", async () => { + for (const homeExists of [true, false]) { + test(`an unsafe OpenCodex storage root refuses account listing, native home exists=${homeExists}`, async () => { + if (homeExists) mkdirSync(nativeHome); + const unsafeRoot = join(root, "not-a-config-directory"); + writeFileSync(unsafeRoot, "fixture-unsafe-root"); + process.env.OPENCODEX_HOME = unsafeRoot; + await expect(listAccounts(config())).rejects.toMatchObject({ code: "PROFILE_STORAGE_UNSAFE" }); + expect(readFileSync(unsafeRoot, "utf8")).toBe("fixture-unsafe-root"); + expect(existsSync(nativeHome)).toBe(homeExists); + }); + } + + test("a home removed during refresh does not hide an unsafe storage root at projection", async () => { mkdirSync(nativeHome); + savePool(); const unsafeRoot = join(root, "not-a-config-directory"); writeFileSync(unsafeRoot, "fixture-unsafe-root"); - process.env.OPENCODEX_HOME = unsafeRoot; + globalThis.fetch = (async () => { + removeTreeWithRetry(nativeHome); + process.env.OPENCODEX_HOME = unsafeRoot; + return usageResponse(); + }) as typeof fetch; await expect(listAccounts(config())).rejects.toMatchObject({ code: "PROFILE_STORAGE_UNSAFE" }); expect(readFileSync(unsafeRoot, "utf8")).toBe("fixture-unsafe-root"); + expect(existsSync(nativeHome)).toBe(false); }); test("credential operations still refuse a missing native home before executing", async () => { From f7c7690894f3792e3c25d953a81fe026eaad5648 Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Wed, 21 Oct 2026 11:00:09 +0300 Subject: [PATCH 04/11] docs(codex): translate headless account-list guidance --- .../content/docs/ja/reference/cli/providers-accounts.md | 7 +++++++ .../content/docs/ko/reference/cli/providers-accounts.md | 7 +++++++ .../content/docs/ru/reference/cli/providers-accounts.md | 7 +++++++ .../content/docs/zh-cn/reference/cli/providers-accounts.md | 5 +++++ 4 files changed, 26 insertions(+) diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index f920a33c459..554f47240e0 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -131,6 +131,13 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +保存済みの ChatGPT プールアカウントは、ネイティブ Codex ホームがないホストでも、 +ダッシュボードやアカウント更新時を含めて一覧表示できます。一覧表示によって Codex ホームが +作成されたり、ネイティブのメインアカウントからサインアウトしたりすることはありません。 +Codex クライアント連携を無効にしても、既存のネイティブログインのクォータ観測は無効になりません。 +明示的に指定した `CODEX_HOME` のパスが無効な場合は、引き続き設定エラーを返します。 +`OPENCODEX_HOME` のストレージが安全でない場合は、ネイティブ Codex ホームがなくてもエラーを返します。 + プロバイダーを使用しない場合、Codex プール、OAuth アカウント、および設定された API キー プールが一覧表示されます。 `--all` が存在しない限り、空のプロバイダーはスキップされます。プロバイダーを使用すると、その資格情報ファミリーのみがリストされます。人間の出力では `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` を使用します。手動で選択した Codex 行には `selected` というマークが付けられます。利用可能な Kiro アカウントが 2 つ以上保存されている場合、既定では 429 を受けると別のアカウントへ自動的に切り替え、既知の残り利用枠が最も多いアカウントを優先します。この切り替えはアカウントの存在によって有効になり、無効にはできません。`oauthAccountFailover.enabled: false` が断るのは 429 復旧ではなく、送信前にアカウントを選ぶ動作だけです。`ocx account login kiro` はアカウントを 1 件ずつプールへ追加します。結果が空であっても成功です。 `--json` は次を返します: ```text diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index a60a7f21685..ab9cc226515 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -241,6 +241,13 @@ Codex pool selection applies to the next request after clearing existing affinit ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +저장된 ChatGPT 풀 계정은 네이티브 Codex 홈이 없는 호스트에서도 대시보드와 계정 새로 고침을 +포함해 계속 나열할 수 있습니다. 목록 조회는 Codex 홈을 만들거나 네이티브 메인 계정에서 +로그아웃하지 않습니다. Codex 클라이언트 통합을 비활성화해도 기존 네이티브 로그인의 할당량 +관측은 비활성화되지 않습니다. 명시적으로 지정한 `CODEX_HOME` 경로가 유효하지 않으면 계속 +설정 오류를 반환합니다. `OPENCODEX_HOME` 저장소가 안전하지 않으면 네이티브 Codex 홈이 +없어도 오류를 반환합니다. + 제공자를 지정하지 않으면 Codex 풀, OAuth 계정, 설정된 API 키 풀을 나열합니다. `--all`이 없으면 비어 있는 제공자는 건너뜁니다. 제공자를 지정하면 해당 자격 증명 계열만 나열합니다. 사람이 보는 출력은 `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` 형식을 사용하며, 수동으로 선택한 Codex 행에는 `selected`가 표시됩니다. 사용 가능한 Kiro 계정이 두 개 이상 저장되어 있으면 기본적으로 429 응답 시 다른 계정으로 자동 전환하며, 알려진 잔여 할당량이 가장 많은 계정을 우선합니다. 이 전환은 계정 존재만으로 활성화되며 끌 수 없습니다. `oauthAccountFailover.enabled: false`는 429 복구가 아니라 요청을 보내기 전 계정을 고르는 동작만 거부합니다. `ocx account login kiro`는 계정을 한 번에 하나씩 풀에 추가합니다. 빈 결과도 성공입니다. `--json`은 다음을 반환합니다: ```text diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 87a58230f1d..cc146f8f30f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -154,6 +154,13 @@ label и masked key. ### `ocx account list [provider] [--json] [--all]` +Сохранённые аккаунты пула ChatGPT доступны для просмотра даже на хостах без нативного каталога +Codex, в том числе в dashboard и при обновлении аккаунтов. Просмотр списка не создаёт каталог +Codex и не выполняет выход из нативного основного аккаунта. Отключение интеграции клиента Codex +не отключает наблюдение за квотой существующего нативного входа. Некорректный явно заданный +путь `CODEX_HOME` по-прежнему вызывает ошибку конфигурации. Небезопасное хранилище +`OPENCODEX_HOME` вызывает ошибку даже при отсутствии нативного каталога Codex. + Без провайдера команда показывает пул Codex, OAuth-аккаунты и настроенные API-key pool'ы. Пустые провайдеры пропускаются, если не задан `--all`. С провайдером выводится только это семейство credential'ов. Human-output использует формат diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 204e22636e0..68f2de7a441 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -149,6 +149,11 @@ OAuth 账号会显示为 `Account N`,而 plan/label 列会在 plan、屏蔽后 ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +即使主机上没有原生 Codex 主目录,保存的 ChatGPT 池账号仍可列出,包括在仪表盘中和刷新账号时。 +查询列表不会创建 Codex 主目录,也不会退出原生主账号。禁用 Codex 客户端集成不会停止 +观测已有原生登录的配额;显式设置的 `CODEX_HOME` 路径无效时,仍会报告配置错误。 +如果 `OPENCODEX_HOME` 存储不安全,即使没有原生 Codex 主目录,也会报告错误。 + 不指定提供方时,会列出 Codex 池、OAuth 账号和已配置的 API 密钥池。除非提供 `--all`,否则会跳过空的提供方。指定提供方时,只列出该凭据家族。人类可读输出 使用 `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS`;手动选中的 Codex 行会标记为 `selected`。 From 7d3d839e6f64b691234ae7f24551cef3e5b04838 Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Wed, 21 Oct 2026 11:39:11 +0300 Subject: [PATCH 05/11] fix(codex): require proven absence for native-home fallback --- .../ja/reference/cli/providers-accounts.md | 1 + .../ko/reference/cli/providers-accounts.md | 1 + .../docs/reference/cli/providers-accounts.md | 1 + .../ru/reference/cli/providers-accounts.md | 1 + .../zh-cn/reference/cli/providers-accounts.md | 1 + src/codex/auth-api/http.ts | 11 +++- structure/providers/openai-accounts.md | 2 +- .../codex-auth-headless-accounts.test.ts | 64 ++++++++++++++++++- 8 files changed, 77 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 554f47240e0..7e2a2c06150 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -137,6 +137,7 @@ Codex pool selection applies to the next request after clearing existing affinit Codex クライアント連携を無効にしても、既存のネイティブログインのクォータ観測は無効になりません。 明示的に指定した `CODEX_HOME` のパスが無効な場合は、引き続き設定エラーを返します。 `OPENCODEX_HOME` のストレージが安全でない場合は、ネイティブ Codex ホームがなくてもエラーを返します。 +既定の Codex ホームのパス解決では、ファイル、リンク先が存在しないシンボリックリンク、辿れない親ディレクトリもエラーになります。 プロバイダーを使用しない場合、Codex プール、OAuth アカウント、および設定された API キー プールが一覧表示されます。 `--all` が存在しない限り、空のプロバイダーはスキップされます。プロバイダーを使用すると、その資格情報ファミリーのみがリストされます。人間の出力では `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` を使用します。手動で選択した Codex 行には `selected` というマークが付けられます。利用可能な Kiro アカウントが 2 つ以上保存されている場合、既定では 429 を受けると別のアカウントへ自動的に切り替え、既知の残り利用枠が最も多いアカウントを優先します。この切り替えはアカウントの存在によって有効になり、無効にはできません。`oauthAccountFailover.enabled: false` が断るのは 429 復旧ではなく、送信前にアカウントを選ぶ動作だけです。`ocx account login kiro` はアカウントを 1 件ずつプールへ追加します。結果が空であっても成功です。 `--json` は次を返します: diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index ab9cc226515..f22c64f1f62 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -247,6 +247,7 @@ Codex pool selection applies to the next request after clearing existing affinit 관측은 비활성화되지 않습니다. 명시적으로 지정한 `CODEX_HOME` 경로가 유효하지 않으면 계속 설정 오류를 반환합니다. `OPENCODEX_HOME` 저장소가 안전하지 않으면 네이티브 Codex 홈이 없어도 오류를 반환합니다. +기본 Codex 홈의 경로를 확인할 때도 파일, 대상이 없는 심볼릭 링크, 탐색할 수 없는 상위 디렉터리는 오류를 발생시킵니다. 제공자를 지정하지 않으면 Codex 풀, OAuth 계정, 설정된 API 키 풀을 나열합니다. `--all`이 없으면 비어 있는 제공자는 건너뜁니다. 제공자를 지정하면 해당 자격 증명 계열만 나열합니다. 사람이 보는 출력은 `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` 형식을 사용하며, 수동으로 선택한 Codex 행에는 `selected`가 표시됩니다. 사용 가능한 Kiro 계정이 두 개 이상 저장되어 있으면 기본적으로 429 응답 시 다른 계정으로 자동 전환하며, 알려진 잔여 할당량이 가장 많은 계정을 우선합니다. 이 전환은 계정 존재만으로 활성화되며 끌 수 없습니다. `oauthAccountFailover.enabled: false`는 429 복구가 아니라 요청을 보내기 전 계정을 고르는 동작만 거부합니다. `ocx account login kiro`는 계정을 한 번에 하나씩 풀에 추가합니다. 빈 결과도 성공입니다. `--json`은 다음을 반환합니다: diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 3eae4dbf394..f9744115074 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -679,6 +679,7 @@ dashboard and on account refresh. Listing does not create a Codex home or sign o account. Disabling the Codex client integration does not disable an existing native login's quota observation; invalid explicit `CODEX_HOME` paths still report a configuration error. Unsafe `OPENCODEX_HOME` storage reports an error even when the native Codex home is missing. +Default-home resolution also reports errors for files, dangling links, and parent directories that cannot be traversed. Without a provider, lists the Codex pool, OAuth accounts, and configured API-key pools. Empty providers are skipped unless `--all` is present. With a provider, lists only that credential family. diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index cc146f8f30f..c1a53a5103f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -160,6 +160,7 @@ Codex и не выполняет выход из нативного основн не отключает наблюдение за квотой существующего нативного входа. Некорректный явно заданный путь `CODEX_HOME` по-прежнему вызывает ошибку конфигурации. Небезопасное хранилище `OPENCODEX_HOME` вызывает ошибку даже при отсутствии нативного каталога Codex. +При разрешении пути к каталогу Codex по умолчанию также возвращается ошибка, если он является файлом, ссылкой на отсутствующий объект или его родительский каталог не позволяет пройти по пути. Без провайдера команда показывает пул Codex, OAuth-аккаунты и настроенные API-key pool'ы. Пустые провайдеры пропускаются, если не задан `--all`. С провайдером выводится только это diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 68f2de7a441..d30ce4d9ea7 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -153,6 +153,7 @@ OAuth 账号会显示为 `Account N`,而 plan/label 列会在 plan、屏蔽后 查询列表不会创建 Codex 主目录,也不会退出原生主账号。禁用 Codex 客户端集成不会停止 观测已有原生登录的配额;显式设置的 `CODEX_HOME` 路径无效时,仍会报告配置错误。 如果 `OPENCODEX_HOME` 存储不安全,即使没有原生 Codex 主目录,也会报告错误。 +解析默认 Codex 主目录路径时,如果该路径是文件、目标不存在的符号链接,或父目录无法遍历,也会报告错误。 不指定提供方时,会列出 Codex 池、OAuth 账号和已配置的 API 密钥池。除非提供 `--all`,否则会跳过空的提供方。指定提供方时,只列出该凭据家族。人类可读输出 diff --git a/src/codex/auth-api/http.ts b/src/codex/auth-api/http.ts index 8153c35b726..61fe8c24199 100644 --- a/src/codex/auth-api/http.ts +++ b/src/codex/auth-api/http.ts @@ -1,3 +1,5 @@ +import { codexHomeIsAbsent } from "../codex-home-owner"; +import { getCodexHome } from "../paths"; import { withNativeMainSharedClaim } from "../native-main-claim"; import { resolveNativeProfileContext } from "../native-profile-store"; import { NativeProfileError } from "../native-profile-types"; @@ -9,8 +11,13 @@ export function isNativeMainClaimUnavailable(error: unknown): error is NativePro /** Missing native homes leave reads unobserved; credential operations still require their claim. */ export function isNativeMainObservationUnavailable(error: unknown): error is NativeProfileError { - return error instanceof NativeProfileError - && (error.code === "CODEX_HOME_UNAVAILABLE" || isNativeMainClaimUnavailable(error)); + if (isNativeMainClaimUnavailable(error)) return true; + if (!(error instanceof NativeProfileError) || error.code !== "CODEX_HOME_UNAVAILABLE") return false; + try { + return codexHomeIsAbsent(getCodexHome()); + } catch { + return false; + } } export function withNativeMainCredentialClaim(operation: () => Promise): Promise { diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 9acc419beab..6f41e009937 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -252,7 +252,7 @@ model settings, and noncanonical `openai` rows never receive that recovery path. Account listing and explicit refresh return stored pool accounts without creating a missing native `CODEX_HOME`; an unchecked main observation leaves credential/reauth state unchanged. When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 responses with a known terminal code, unrecognized HTTP 401 responses when the access token is not verifiably live, and allowlisted terminal 403 responses as `needsReauth`; successful refresh clears the runtime mark. -Generic permission failures remain non-terminal; invalid explicit home paths still fail closed. Native-profile context resolution validates OpenCodex storage before the native home, so unsafe storage fails closed even when that home is absent; credential operations retain their strict claim requirements. +Generic quota permission failures remain non-terminal; missing-home fallback requires proven absence, so non-directory homes, dangling links, and parent directories that cannot be traversed still fail closed for both default and explicit home paths. Native-profile context resolution validates OpenCodex storage before the native home, so unsafe storage fails closed even when that home is absent; credential operations retain their strict claim requirements. `src/codex/chatgpt-refresh-failure.ts` classifies token-endpoint failures for both stored-pool and native-main refresh. Exact structured terminal codes apply only to authentication-error diff --git a/tests/codex-integration/codex-auth-headless-accounts.test.ts b/tests/codex-integration/codex-auth-headless-accounts.test.ts index ad763853b6b..d5d1c815298 100644 --- a/tests/codex-integration/codex-auth-headless-accounts.test.ts +++ b/tests/codex-integration/codex-auth-headless-accounts.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; @@ -8,11 +8,13 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } import type { CodexAuthAccountDto } from "../../src/codex/auth-api/account-list"; import { withNativeMainCredentialClaim } from "../../src/codex/auth-api/http"; import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import * as codexHome from "../../src/codex/home"; import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountCredentialPresence, getMainAccountInfoCache, } from "../../src/codex/main-account-cache"; import { MAIN_CODEX_ACCOUNT_ID } from "../../src/codex/main-account"; +import { getCodexHome } from "../../src/codex/paths"; import { clearAccountQuota } from "../../src/codex/quota"; import { resetQuotaQueryBackoffForTests } from "../../src/codex/quota-query-backoff"; import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; @@ -23,8 +25,17 @@ let root: string; let nativeHome: string; let previousOcxHome: string | undefined; let previousCodexHome: string | undefined; +let defaultHomeSpy: { mockRestore(): void } | undefined; let originalFetch: typeof fetch; const poolId = "headless-pool"; +const originalDefaultCodexHome = codexHome.defaultCodexHome; + +function isolateDefaultHome(userHome: string): void { + // Keep real filesystem resolution while excluding unrelated Windows homes on WSL. + defaultHomeSpy = spyOn(codexHome, "defaultCodexHome").mockImplementation(() => originalDefaultCodexHome({ + homedir: () => userHome, env: {}, procVersion: "", release: "", + })); +} function config(codexIntegration = false): OcxConfig { return { @@ -86,6 +97,8 @@ beforeEach(() => { }); afterEach(() => { + defaultHomeSpy?.mockRestore(); + defaultHomeSpy = undefined; globalThis.fetch = originalFetch; resetRuntimeState(); if (previousOcxHome === undefined) delete process.env.OPENCODEX_HOME; @@ -123,6 +136,53 @@ describe("account listing without a native Codex home", () => { expect(readFileSync(nativeHome, "utf8")).toBe("fixture-not-a-directory"); }); + for (const [method, suffix] of [["GET", ""], ["GET", "?refresh=1"], ["POST", "/refresh"]]) { + for (const kind of ["missing", "file", "dangling-link"] as const) { + test(`${method} accounts${suffix}: default home ${kind} only tolerates proven absence`, async () => { + const userHome = join(root, "user"); + mkdirSync(userHome); + isolateDefaultHome(userHome); + delete process.env.CODEX_HOME; + nativeHome = join(userHome, ".codex"); + if (kind === "file") writeFileSync(nativeHome, "fixture-not-a-directory"); + if (kind === "dangling-link") symlinkSync(join(root, "missing-target"), nativeHome, "junction"); + expect(getCodexHome()).toBe(nativeHome); + const credential = savePool(); + + if (kind === "missing") { + const accounts = await listAccounts(config(), method, suffix); + expect(accounts.find(account => account.id === poolId)?.hasCredential).toBe(true); + expect(existsSync(nativeHome)).toBe(false); + } else { + await expect(listAccounts(config(), method, suffix)).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + if (kind === "file") expect(readFileSync(nativeHome, "utf8")).toBe("fixture-not-a-directory"); + else expect(lstatSync(nativeHome).isSymbolicLink()).toBe(true); + } + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + }); + } + } + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an inaccessible default home is not treated as absent", async () => { + const userHome = join(root, "unreadable-user"); + mkdirSync(userHome); + nativeHome = join(userHome, ".codex"); + mkdirSync(nativeHome); + isolateDefaultHome(userHome); + delete process.env.CODEX_HOME; + const credential = savePool(); + chmodSync(userHome, 0o000); + try { + expect(getCodexHome()).toBe(nativeHome); + await expect(listAccounts(config())).rejects.toMatchObject({ code: "CODEX_HOME_UNAVAILABLE" }); + expect(getCodexAccountCredential(poolId)).toEqual(credential); + expect(isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)).toBe(false); + } finally { + chmodSync(userHome, 0o700); + } + }); + test("a home removed during pool refresh does not fail final account projection", async () => { mkdirSync(nativeHome); savePool(); From 0538b2b9ac2b7d171e85b616860a6c5a0d8ca158 Mon Sep 17 00:00:00 2001 From: Aleksei Chistiakov Date: Wed, 21 Oct 2026 11:39:11 +0300 Subject: [PATCH 06/11] docs(codex): complete headless account-list translations --- .../content/docs/fr/reference/cli/providers-accounts.md | 9 +++++++++ .../content/docs/tr/reference/cli/providers-accounts.md | 8 ++++++++ .../docs/zh-tw/reference/cli/providers-accounts.md | 5 +++++ 3 files changed, 22 insertions(+) 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 89d1e6773f6..25e82d8de21 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 @@ -164,6 +164,15 @@ et la colonne plan/label recouvre le plan, l'e-mail masqué, l'étiquette et la ### `ocx account list [provider] [--json] [--all]` +Les comptes ChatGPT enregistrés dans le pool restent consultables sur les hôtes sans répertoire +Codex natif, y compris dans le tableau de bord et lors de l'actualisation des comptes. Cette consultation +ne crée pas de répertoire Codex et ne déconnecte pas le compte principal natif. Désactiver l'intégration +du client Codex ne désactive pas l'observation du quota d'une connexion native existante. Un chemin +`CODEX_HOME` explicite invalide continue de produire une erreur de configuration. Un stockage +`OPENCODEX_HOME` non sûr produit une erreur même si le répertoire Codex natif est absent. La résolution +du chemin Codex par défaut renvoie également une erreur s'il désigne un fichier, un lien symbolique +dont la cible n'existe pas, ou si son répertoire parent ne peut pas être traversé. + Sans fournisseur, répertorie le groupe de comptes Codex, les comptes OAuth et les groupes de clés API configurés. Les fournisseurs vides sont ignorés à moins que `--all` soit présent. Avec un fournisseur, répertorie uniquement cette famille d’informations d’identification. La sortie destinée aux utilisateurs utilise `PROVIDER TYPE ID PLAN/LABEL PRIORITY STATUS` ; une ligne Codex sélectionnée manuellement porte la mention diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index acb18d2c2a1..184e193ca23 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -175,6 +175,14 @@ kullanılamadığında atlanır): ### `ocx account list [provider] [--json] [--all]` +Kayıtlı ChatGPT havuz hesapları, yerel Codex ana dizini olmayan makinelerde de listelenebilir; +buna pano ve hesap yenileme işlemleri dahildir. Listeleme, Codex ana dizini oluşturmaz ve yerel ana +hesabın oturumunu kapatmaz. Codex istemci entegrasyonunu devre dışı bırakmak, mevcut bir yerel oturumun +kota gözlemini devre dışı bırakmaz. Açıkça belirtilmiş geçersiz `CODEX_HOME` yolları yine yapılandırma +hatası döndürür. Güvenli olmayan `OPENCODEX_HOME` depolaması, yerel Codex ana dizini eksik olsa bile +hata döndürür. Varsayılan Codex ana dizini yolu çözümlenirken dosyalar, hedefi bulunmayan sembolik +bağlantılar ve geçiş yapılamayan üst dizinler de hataya neden olur. + Bir sağlayıcı olmadan Codex havuzunu, OAuth hesaplarını ve yapılandırılmış API anahtarı havuzlarını listeler. `--all` bulunmadıkça boş sağlayıcılar atlanır. Bir sağlayıcı ile yalnızca bu kimlik bilgisi ailesini listeler. İnsan çıktısı diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 1fcf6399416..e5e3c6dd68f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -113,6 +113,11 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 ### `ocx account list [provider] [--json] [--all] [--quota [--refresh]]` +即使主機沒有原生 Codex 主目錄,仍可列出已儲存的 ChatGPT 池帳號,包括在儀表板中及重新整理帳號時。 +列出帳號不會建立 Codex 主目錄,也不會將原生主帳號登出。停用 Codex 用戶端整合不會停用對既有原生登入的額度觀測; +明確設定但無效的 `CODEX_HOME` 路徑仍會回報設定錯誤。若 `OPENCODEX_HOME` 儲存空間不安全,即使原生 Codex 主目錄不存在,也會回報錯誤。 +解析預設 Codex 主目錄路徑時,若該路徑是檔案、目標不存在的符號連結,或父目錄無法遍歷,也會回報錯誤。 + 未指定供應商時,列出 Codex 池、OAuth 帳號與已設定的 API-key 池。除非存在 `--all`,否則空的供應商會被跳過。指定供應商時,僅列出該憑證家族。人類輸出使用 `PROVIDER TYPE ID PLAN/LABEL STATUS`;手動選擇的 Codex 列標記為 `selected`。當儲存了兩個以上符合資格的 Kiro 帳號時,預設情況下 429 會自動輪換至另一個帳號,並優先選擇已知剩餘額度最多的帳號;輪換由帳號存在與否驅動,且無法關閉;`oauthAccountFailover.enabled: false` 拒絕的是送出前的帳號優選,而非 429 復原。`ocx account login kiro` 每次將一個帳號加入池中。空結果仍為成功。`--json` 回傳: ```text From f6843451ab4ff25fec4af82d21c75c660f8e183e Mon Sep 17 00:00:00 2001 From: Plamyth Date: Thu, 22 Oct 2026 07:44:46 +0900 Subject: [PATCH 07/11] feat(codex): expose safe account slot identity metadata --- .../ko/reference/cli/providers-accounts.md | 8 +++ .../docs/reference/cli/providers-accounts.md | 8 +++ src/cli/account-api.ts | 11 +++ src/codex/auth-api/account-identity.ts | 14 ++++ src/codex/auth-api/account-list.ts | 27 ++++++++ src/codex/collision-identity.ts | 28 ++++++-- structure/providers/openai-accounts.md | 1 + tests/cli/cli-account.test.ts | 18 +++++ tests/cli/cli-dto-fidelity.test.ts | 29 ++++++++ .../codex-stored-main-duplicate.test.ts | 67 +++++++++++++++++-- 10 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 src/codex/auth-api/account-identity.ts diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index f22c64f1f62..a0dae108083 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -255,6 +255,14 @@ Codex pool selection applies to the next request after clearing existing affinit { accounts: AccountRow[], notes: string[] } ``` +Codex JSON 행에는 등록된 `id`와 같은 `slotId`, 그리고 `native-main` 또는 `pool`인 +`slotKind`가 포함됩니다. 프록시가 상위 계정과 사용자 식별값을 모두 확인하면 +`uai1_` 뒤에 소문자 16진수 64자리가 붙는 `upstreamAccountKey`도 제공합니다. +같은 `openai` 제공자에서 키가 같으면 한 상위 사용자의 슬롯이며, 각 슬롯과 한도 상태는 +그대로 유지해야 합니다. 키는 이 설치 환경에 한정되고 관리자 토큰이 바뀌면 달라질 수 +있습니다. 키가 없을 때는 가려진 이메일이나 한도가 비슷하다는 이유로 묶지 않습니다. +상위 계정 ID·사용자 식별값·인증 정보 원문은 목록에 포함되지 않습니다. + ### `ocx account current [--json]` 활성 계정이나 키를 보여줍니다. 수동 고정이 없는 Codex 풀은 우선순위를 고려한 자동 선택을 보고합니다. 가장 우선순위가 높은 적격 tier를 고르고, 그 tier 안에서 할당량 라우팅 기준으로 가장 적게 사용한 항목을 선택합니다. 활성 자격 증명이 없는 다른 계열은 그 상태를 보고하고도 종료 코드 0으로 끝납니다. `--json`은 다음을 반환합니다: diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index f9744115074..46a343e2b85 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -698,6 +698,14 @@ returns: { accounts: AccountRow[], notes: string[] } ``` +Codex JSON rows include `slotId` (the registered `id`) and `slotKind` (`native-main` or +`pool`). When the proxy can verify both the upstream account and user identity, it also +includes `upstreamAccountKey` (`uai1_` followed by 64 lowercase hexadecimal characters). +Equal keys within the `openai` provider identify slots for the same upstream user; keep +each slot and its quota state separately. The key is local to this installation and can +change when its administrator token rotates. If it is absent, do not infer a match from +masked email or quota values. Raw upstream IDs, user claims and credentials are not listed. + An empty provider listing names the next command in human output and JSON `notes`: `ocx account login ` for OAuth/Codex, or `ocx account add-key ` with a human-controlled piped stdin source for API keys. An empty global listing diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts index 6ea43d96d9c..7a009dcaefd 100644 --- a/src/cli/account-api.ts +++ b/src/cli/account-api.ts @@ -23,6 +23,9 @@ export interface AccountRow { provider: string; type: AccountType; id: string; + slotId?: string; + slotKind?: "native-main" | "pool"; + upstreamAccountKey?: string; label?: string; email?: string; /** @@ -329,6 +332,9 @@ export interface ProviderQuotaReportDto { interface CodexAccountDto { id: string; + slotId?: string; + slotKind?: "native-main" | "pool"; + upstreamAccountKey?: string; alias?: string; email?: string; plan?: string; @@ -401,6 +407,11 @@ export async function fetchCodexRows( provider: "openai", type: "codex" as const, id: a.id, + slotId: a.id, + slotKind: a.id === "__main__" ? "native-main" as const : "pool" as const, + ...(a.slotId === a.id && typeof a.upstreamAccountKey === "string" + && /^uai1_[0-9a-f]{64}$/.test(a.upstreamAccountKey) + ? { upstreamAccountKey: a.upstreamAccountKey } : {}), label: a.alias ?? a.plan ?? a.email, email: a.email, plan: a.plan, diff --git a/src/codex/auth-api/account-identity.ts b/src/codex/auth-api/account-identity.ts new file mode 100644 index 00000000000..53d8f296357 --- /dev/null +++ b/src/codex/auth-api/account-identity.ts @@ -0,0 +1,14 @@ +import { createHmac } from "node:crypto"; + +/** A local, versioned join key for two slots backed by the same upstream user. */ +export function codexUpstreamAccountKey( + adminToken: string | null, + accountId: string | null | undefined, + userId: string | null | undefined, +): string | undefined { + if (!adminToken || !accountId || !userId || accountId.length > 512 || userId.length > 512) return undefined; + return `uai1_${createHmac("sha256", adminToken) + .update("opencodex-upstream-account-v1\0") + .update(JSON.stringify([accountId, userId])) + .digest("hex")}`; +} diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts index 4c60a58a85c..f97e58c5232 100644 --- a/src/codex/auth-api/account-list.ts +++ b/src/codex/auth-api/account-list.ts @@ -34,6 +34,10 @@ import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONC import type { PoolQuotaResult } from "./pool-quota-probe"; import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; import { isPassiveRead } from "../../lib/passive-read"; +import { configuredAdminToken } from "../../lib/admin-secrets"; +import { nativeUserIdClaims } from "../reserve-availability"; +import { extractAccountIdClaims } from "../../oauth/chatgpt"; +import { codexUpstreamAccountKey } from "./account-identity"; import { CODEX_MAIN_DUPLICATE_ACTION, configuredCodexMainDuplicate, getObservedMainCodexCollisionIdentity } from "../collision-identity"; export function quotaForPlan | StoredAccountQuota | null>( @@ -109,6 +113,7 @@ export function poolAccountDto( paused: boolean, priority: number, maskEmails: boolean, + upstreamAccountKey?: string, ): CodexAuthAccountDto { const plan = codexPlanValue(account.plan); const quota = quotaForPlan(quotaResult.quota, plan); @@ -136,6 +141,9 @@ export function poolAccountDto( const duplicateMain = configuredCodexMainDuplicate(config, account.id); return { id: account.id, + slotId: account.id, + slotKind: "pool", + ...(upstreamAccountKey ? { upstreamAccountKey } : {}), email: projectEmail(account.email, maskEmails) ?? account.email, ...(account.alias !== undefined ? { alias: account.alias } : {}), ...(plan !== undefined ? { plan } : {}), @@ -166,6 +174,11 @@ export function poolAccountDto( export interface CodexAuthAccountDto { id: string; + /** Registered slot identity; equal to id, including __main__ for native Codex. */ + slotId: string; + slotKind: "native-main" | "pool"; + /** Opaque local join key, present only with account and user claims. */ + upstreamAccountKey?: string; alias?: string; email: string; plan?: string | null; @@ -285,6 +298,7 @@ export async function listCodexAuthAccountsSnapshot( pruneCodexCredits([MAIN_CODEX_ACCOUNT_ID, ...poolAccounts.map(account => account.id)]); // One redaction decision for the whole snapshot, read once from the operator's config (#3859). const maskEmails = emailMaskingEnabled(runtimeConfig); + const identitySecret = configuredAdminToken(); // A passive read (a Remote Link Child's, #6959) reports what this machine already observed: no // token refresh, no WHAM probe, and so none of the plan or credential writes they make. const passive = isPassiveRead(); @@ -292,6 +306,7 @@ export async function listCodexAuthAccountsSnapshot( ? { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, hasCredential: false, identityGeneration: captureMainAccountIdentityGeneration() } : await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false, forceRefresh, false, runtimeConfig); + const observedMainIdentity = getObservedMainCodexCollisionIdentity(); const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { const cred = getCodexAccountCredential(account.id); let quotaResult: PoolQuotaResult; @@ -363,6 +378,10 @@ export async function listCodexAuthAccountsSnapshot( const dtoAccount = generationLive && quotaResult.freshPlan ? { ...currentAccount, plan: quotaResult.freshPlan } : currentAccount; + const user = nativeUserIdClaims(currentCredential.accessToken); + const accountClaim = extractAccountIdClaims(currentCredential.accessToken); + const verifiedAccountId = !accountClaim.conflict && accountClaim.accountId === currentCredential.chatgptAccountId + ? accountClaim.accountId : undefined; return [poolAccountDto( runtimeConfig, dtoAccount, @@ -371,6 +390,8 @@ export async function listCodexAuthAccountsSnapshot( isCodexAccountPaused(runtimeConfig, accountId), getCodexAccountPriority(runtimeConfig, accountId), maskEmails, + codexUpstreamAccountKey(identitySecret, verifiedAccountId, + user.conflict ? undefined : user.userId), )]; }); const projectSnapshot = (mainRefreshRefused = false): CodexAuthAccountsSnapshot => { @@ -402,8 +423,14 @@ export async function listCodexAuthAccountsSnapshot( ? mainResult.terminalAuthFailure === true && liveQuotaRefresh?.status === "http_error" ? "unauthorized" : "refresh_failed" : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; + const mainIdentity = mainSnapshotLive && getObservedMainCodexCollisionIdentity() === observedMainIdentity + ? observedMainIdentity : undefined; + const mainAccountKey = codexUpstreamAccountKey(identitySecret, mainIdentity?.accountId, mainIdentity?.userId); const main: CodexAuthAccountDto = { id: MAIN_CODEX_ACCOUNT_ID, + slotId: MAIN_CODEX_ACCOUNT_ID, + slotKind: "native-main", + ...(mainAccountKey ? { upstreamAccountKey: mainAccountKey } : {}), email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login", plan: mainInfo.plan, ...(liveQuotaRefresh ? { quotaRefresh: liveQuotaRefresh } : {}), diff --git a/src/codex/collision-identity.ts b/src/codex/collision-identity.ts index 69124e0df9e..478a184bcc3 100644 --- a/src/codex/collision-identity.ts +++ b/src/codex/collision-identity.ts @@ -4,9 +4,11 @@ import { getCodexAccountCredential } from "./account-store"; import type { CodexTokens } from "./auth-collision"; import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "./main-account-cache"; import { codexPlanKey, extractChatgptPlanType } from "./plan"; +import { nativeUserIdClaims } from "./reserve-availability"; export interface CodexCollisionIdentity { accountId: string; + userId?: string; email?: string | null; plan?: unknown; } @@ -34,19 +36,37 @@ function homeScope(): string { let observed: { identity: CodexCollisionIdentity; homeScope: string; generation: number } | undefined; +function suppressObservedUserId(): void { + if (!observed || observed.homeScope !== homeScope() + || !isMainAccountIdentityGenerationLive(observed.generation)) return; + observed = { ...observed, identity: { ...observed.identity, userId: undefined } }; +} + /** Only existing owned lifecycle observations may publish; never call with a request bearer. */ export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void { if (!tokens) { observed = undefined; return; } if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" - || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return; + || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) { + suppressObservedUserId(); + return; + } const claims = [extractAccountIdClaims(tokens.id_token), extractAccountIdClaims(tokens.access_token)]; - if (claims.some(claim => claim.conflict)) return; + if (claims.some(claim => claim.conflict)) { suppressObservedUserId(); return; } const accountId = tokens.account_id || claims.find(claim => claim.accountId)?.accountId; - if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) return; + if (!accountId || claims.some(claim => claim.accountId && claim.accountId !== accountId)) { + suppressObservedUserId(); + return; + } const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] .flatMap(email => normalizedEmail(email) ? [normalizedEmail(email)!] : [])); - if (emails.size > 1) return; + if (emails.size > 1) { suppressObservedUserId(); return; } + const accessUser = nativeUserIdClaims(tokens.access_token); + const idUser = tokens.id_token ? nativeUserIdClaims(tokens.id_token) : undefined; + const userId = !accessUser.conflict && !idUser?.conflict + && (!idUser?.userId || idUser.userId === accessUser.userId) + ? accessUser.userId : undefined; observed = { identity: { accountId, email: [...emails][0], + ...(userId ? { userId } : {}), plan: extractChatgptPlanType(tokens.id_token, tokens.access_token) }, homeScope: homeScope(), generation: captureMainAccountIdentityGeneration() }; } diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 6f41e009937..4b34e81f76a 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -37,6 +37,7 @@ preserving a stale one would block every later migration. ## Model and wire identity +Account-list rows retain each registered `slotId` and `slotKind`; optional `upstreamAccountKey` is a versioned HMAC of the ChatGPT account ID and user claim under the local administrator token. Native identity comes from the owned observation without passive credential reads; missing or conflicting claims omit the key, and token rotation changes it. No raw identity or credential is serialized. The optional [native reasoning retention policy](../transports/native-reasoning-retention.md#runtime-boundary) can permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT destination. It does not change account selection, entitlement, cache isolation, continuation-id ownership or uploaded-file retention; cross-account acceptance is not guaranteed. Native Spark membership and its model-specific request/tool exceptions are removed; the shared diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 96c72a95266..9d1e10c42b6 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -762,6 +762,24 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(new Set(parsed.accounts.map(row => row.type))).toEqual(new Set(["codex", "oauth", "api-key"])); }); + test("official --all --json output carries the upstream join contract", async () => { + const key = `uai1_${"a".repeat(64)}`; + codexAccounts = [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key, + email: "main@example.test", plan: "pro" }, + { id: "chatgpt_1", slotId: "chatgpt_1", slotKind: "pool", upstreamAccountKey: key, + email: "pool@example.test", plan: "pro" }, + ]; + const result = await run(["list", "--all", "--json"]); + const rows = (JSON.parse(result.stdout) as { accounts: Array> }).accounts + .filter(row => row.provider === "openai"); + expect(result.code).toBe(0); + expect(rows.map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["chatgpt_1", "pool", key], + ]); + }); + test("3: empty providers are skipped by default and shown with --all", async () => { const normal = await run(["list"]); const withAll = await run(["list", "--all"]); diff --git a/tests/cli/cli-dto-fidelity.test.ts b/tests/cli/cli-dto-fidelity.test.ts index bae0bff7d36..468df021518 100644 --- a/tests/cli/cli-dto-fidelity.test.ts +++ b/tests/cli/cli-dto-fidelity.test.ts @@ -193,6 +193,35 @@ describe("#2703 the projection does not strip the 5h window", () => { }); }); +describe("Codex account identity projection", () => { + test("keeps validated join keys and slot identities while accepting older servers", async () => { + const key = `uai1_${"a".repeat(64)}`; + const fetchImpl = (async (url: string | URL | Request) => Response.json( + String(url).includes("/api/codex-auth/active") + ? { activeCodexAccountId: "__main__" } + : { accounts: [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key }, + { id: "pool-a", slotId: "pool-a", slotKind: "pool", upstreamAccountKey: key }, + { id: "pool-b", slotId: "other-slot", upstreamAccountKey: key }, + { id: "pool-legacy" }, + { id: "pool-object", slotId: "pool-object", upstreamAccountKey: { toString: null, valueOf: null } }, + { id: "pool-array", slotId: "pool-array", upstreamAccountKey: [key] }, + ] }, + )) as unknown as typeof fetch; + const { fetchCodexRows } = await import("../../src/cli/account-api"); + const result = await fetchCodexRows({ baseUrl: "http://127.0.0.1:10100", fetchImpl }, + "http://127.0.0.1:10100"); + expect(result.rows.map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["pool-a", "pool", key], + ["pool-b", "pool", undefined], + ["pool-legacy", "pool", undefined], + ["pool-object", "pool", undefined], + ["pool-array", "pool", undefined], + ]); + }); +}); + describe("#2705 access key usage columns", () => { async function listOutput(payload: Record): Promise { const { handleAccessCommand } = await import("../../src/cli/access"); diff --git a/tests/codex-integration/codex-stored-main-duplicate.test.ts b/tests/codex-integration/codex-stored-main-duplicate.test.ts index 4082e770537..9f272f575a3 100644 --- a/tests/codex-integration/codex-stored-main-duplicate.test.ts +++ b/tests/codex-integration/codex-stored-main-duplicate.test.ts @@ -14,6 +14,7 @@ import { clearThreadAccountMap, clearCodexUpstreamHealth } from "../../src/codex import { clearAccountQuota } from "../../src/codex/quota"; import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; import { setMainAccountPlan } from "../../src/codex/main-account"; +import { observeMainCodexCollisionIdentity } from "../../src/codex/collision-identity"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -23,16 +24,18 @@ let previousOcxHome: string | undefined; let config: OcxConfig; const poolId = "stored-main-duplicate"; const email = "member@example.test"; -function jwt(plan: string, member?: string): string { +function jwt(plan: string, member?: string, userId?: string, accountId = "shared-account"): string { return `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, - "https://api.openai.com/auth": { chatgpt_plan_type: plan }, + chatgpt_account_id: accountId, + "https://api.openai.com/auth": { chatgpt_plan_type: plan, ...(userId ? { chatgpt_user_id: userId } : {}) }, ...(member ? { "https://api.openai.com/profile": { email: member } } : {}), })).toString("base64url")}.fake`; } -function native(accountId = "shared-account", plan = "plus", member: string | undefined = email, observe = true): void { +function native(accountId = "shared-account", plan = "plus", member: string | undefined = email, + observe = true, userId?: string): void { writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ - tokens: { access_token: jwt(plan, member), account_id: accountId }, + tokens: { access_token: jwt(plan, member, userId, accountId), account_id: accountId }, })); if (observe) reconcileMainCodexAccountRuntimeState(); } @@ -83,6 +86,62 @@ afterEach(() => { }); describe("stored Pool duplicate of native main", () => { + test("official rows join the same upstream user without exposing identity claims", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "same-user"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const rows = await runPassiveRead(() => listCodexAuthAccounts(config)); + const main = rows.find(row => row.id === "__main__")!; + const pool = rows.find(row => row.id === poolId)!; + expect(main).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(pool).toMatchObject({ slotId: poolId, slotKind: "pool" }); + expect(main.upstreamAccountKey).toMatch(/^uai1_[0-9a-f]{64}$/); + expect(pool.upstreamAccountKey).toBe(main.upstreamAccountKey); + expect(JSON.stringify(rows)).not.toContain("same-user"); + expect(JSON.stringify(rows)).not.toContain("shared-account"); + + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "other-user"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const changed = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(changed.find(row => row.id === poolId)?.upstreamAccountKey).not.toBe(main.upstreamAccountKey); + expect(changed.find(row => row.id === "__main__")?.upstreamAccountKey).toBe(main.upstreamAccountKey); + }); + + test("a missing user claim or local secret preserves separate registered slots", async () => { + native(); + const noSecret = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(noSecret.map(row => row.slotKind)).toEqual(["native-main", "pool"]); + expect(noSecret.every(row => row.upstreamAccountKey === undefined)).toBe(true); + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + const noClaim = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(noClaim.every(row => row.upstreamAccountKey === undefined)).toBe(true); + }); + test("conflicting native user claims and pool account claims omit join keys", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ tokens: { + access_token: jwt("plus", email, "access-user"), + id_token: jwt("plus", email, "different-user"), + account_id: "shared-account", + } })); + reconcileMainCodexAccountRuntimeState(); + saveCodexAccountCredential(poolId, { accessToken: jwt("plus", email, "access-user", "other-account"), + refreshToken: "fake-refresh", chatgptAccountId: "shared-account", expiresAt: Date.now() + 3600_000 }); + const rows = await runPassiveRead(() => listCodexAuthAccounts(config)); + expect(rows).toHaveLength(2); + expect(rows.every(row => row.upstreamAccountKey === undefined)).toBe(true); + expect(rows.map(row => row.slotId)).toEqual(["__main__", poolId]); + }); + test("conflicting replacement native claims remove a previously observed join key", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toMatch(/^uai1_/); + observeMainCodexCollisionIdentity({ + access_token: jwt("plus", email, "same-user", "other-account"), + account_id: "shared-account", + }); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); + }); test("existing duplicate is ineligible without deleting its credential or config row", () => { native(); expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main"); From e653fc812328380b45dc5469534e3d6c18ffa666 Mon Sep 17 00:00:00 2001 From: Plamyth Date: Thu, 22 Oct 2026 07:57:46 +0900 Subject: [PATCH 08/11] fix(codex): suppress join key for malformed ID token --- src/codex/collision-identity.ts | 7 +++++-- .../codex-stored-main-duplicate.test.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/codex/collision-identity.ts b/src/codex/collision-identity.ts index 478a184bcc3..e32bb3ed65a 100644 --- a/src/codex/collision-identity.ts +++ b/src/codex/collision-identity.ts @@ -1,4 +1,4 @@ -import { extractAccountIdClaims, extractEmail } from "../oauth/chatgpt"; +import { decodeJwtPayload, extractAccountIdClaims, extractEmail } from "../oauth/chatgpt"; import type { CodexAccount, OcxConfig } from "../types"; import { getCodexAccountCredential } from "./account-store"; import type { CodexTokens } from "./auth-collision"; @@ -60,9 +60,12 @@ export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void { const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] .flatMap(email => normalizedEmail(email) ? [normalizedEmail(email)!] : [])); if (emails.size > 1) { suppressObservedUserId(); return; } + const idPayload: unknown = tokens.id_token === undefined ? undefined : decodeJwtPayload(tokens.id_token); + const idTokenMalformed = tokens.id_token !== undefined + && (idPayload === null || typeof idPayload !== "object" || Array.isArray(idPayload)); const accessUser = nativeUserIdClaims(tokens.access_token); const idUser = tokens.id_token ? nativeUserIdClaims(tokens.id_token) : undefined; - const userId = !accessUser.conflict && !idUser?.conflict + const userId = !idTokenMalformed && !accessUser.conflict && !idUser?.conflict && (!idUser?.userId || idUser.userId === accessUser.userId) ? accessUser.userId : undefined; observed = { identity: { accountId, email: [...emails][0], diff --git a/tests/codex-integration/codex-stored-main-duplicate.test.ts b/tests/codex-integration/codex-stored-main-duplicate.test.ts index 9f272f575a3..daba4f87eaf 100644 --- a/tests/codex-integration/codex-stored-main-duplicate.test.ts +++ b/tests/codex-integration/codex-stored-main-duplicate.test.ts @@ -142,6 +142,18 @@ describe("stored Pool duplicate of native main", () => { }); expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); }); + test("malformed present native ID token suppresses a previously observed join key", async () => { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `ocx_admin_${"x".repeat(43)}\n`); + native("shared-account", "plus", email, true, "same-user"); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toMatch(/^uai1_/); + observeMainCodexCollisionIdentity({ + access_token: jwt("plus", email, "same-user"), + id_token: "malformed", + account_id: "shared-account", + }); + expect((await runPassiveRead(() => listCodexAuthAccounts(config)))[0]?.upstreamAccountKey).toBeUndefined(); + expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main"); + }); test("existing duplicate is ineligible without deleting its credential or config row", () => { native(); expect(codexAccountUnusableReason(config, poolId)).toBe("duplicate_main"); From 27f8feb2d955a5e5eee118805c6668fa62cb5e76 Mon Sep 17 00:00:00 2001 From: chlwnsgurg <97290748+chlwnsgurg@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:22:15 +0900 Subject: [PATCH 09/11] fix(codex): require a token account claim for the native join key The native slot emitted upstreamAccountKey when auth.json carried an account_id that no token claim backed, while Pool rows require a matching access-token account claim. Require at least one matching token claim before retaining the native user ID; the unverified account ID still feeds duplicate detection. Add a route-level proof that the account-list response serializes no upstream account ID, user ID, access/ID/refresh token, or administrator token, and that the key changes when the administrator token rotates. Move the carried CLI JSON case and the headless layout entry out of files at their size caps. Co-authored-by: Plamyth <145187149+Plamyth@users.noreply.github.com> Co-authored-by: alexalok <5950085+alexalok@users.noreply.github.com> --- .../test-layout/layout-codex-collision.json | 1 + scripts/test-layout/layout.json | 5 +- src/codex/collision-identity.ts | 2 + structure/providers/openai-accounts.md | 2 +- tests/cli/cli-account-slot-identity.test.ts | 56 +++++++ tests/cli/cli-account.test.ts | 18 --- .../codex-account-slot-identity.test.ts | 150 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + 8 files changed, 214 insertions(+), 22 deletions(-) create mode 100644 tests/cli/cli-account-slot-identity.test.ts create mode 100644 tests/codex-integration/codex-account-slot-identity.test.ts diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index cba9043fe1b..d2f263fcabd 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -1,4 +1,5 @@ { "codex-stored-main-duplicate.test.ts": "codex-integration", + "codex-account-slot-identity.test.ts": "codex-integration", "codex-pool-caller-fallback-policy.test.ts": "codex-integration" } diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f7f6db92563..7419d3fd248 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -65,8 +65,7 @@ "jev-decision-scope.test.ts": "server", "server-combo-plan-model-refusal.test.ts": "server", "codex-main-token-invalidated.test.ts": "codex-integration", - "codex-main-grant-refusal.test.ts": "codex-integration", - "codex-auth-headless-accounts.test.ts": "codex-integration", + "codex-main-grant-refusal.test.ts": "codex-integration", "codex-auth-headless-accounts.test.ts": "codex-integration", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", @@ -467,7 +466,7 @@ "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", - "cli-account-alias-target.test.ts": "cli", + "cli-account-alias-target.test.ts": "cli", "cli-account-slot-identity.test.ts": "cli", "cli-account-cancel-flow.test.ts": "cli", "cli-account-kiro-device.test.ts": "cli", "cli-account-orca-import.test.ts": "cli", "cli-account-pin-drain.test.ts": "cli", diff --git a/src/codex/collision-identity.ts b/src/codex/collision-identity.ts index e32bb3ed65a..5000d5504e0 100644 --- a/src/codex/collision-identity.ts +++ b/src/codex/collision-identity.ts @@ -65,7 +65,9 @@ export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void { && (idPayload === null || typeof idPayload !== "object" || Array.isArray(idPayload)); const accessUser = nativeUserIdClaims(tokens.access_token); const idUser = tokens.id_token ? nativeUserIdClaims(tokens.id_token) : undefined; + // Like Pool rows, a join key needs a token-backed account claim; auth.json account_id alone is unverified. const userId = !idTokenMalformed && !accessUser.conflict && !idUser?.conflict + && claims.some(claim => claim.accountId === accountId) && (!idUser?.userId || idUser.userId === accessUser.userId) ? accessUser.userId : undefined; observed = { identity: { accountId, email: [...emails][0], diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 4b34e81f76a..e1e4cbd2205 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -37,7 +37,7 @@ preserving a stale one would block every later migration. ## Model and wire identity -Account-list rows retain each registered `slotId` and `slotKind`; optional `upstreamAccountKey` is a versioned HMAC of the ChatGPT account ID and user claim under the local administrator token. Native identity comes from the owned observation without passive credential reads; missing or conflicting claims omit the key, and token rotation changes it. No raw identity or credential is serialized. +Account-list rows retain each registered `slotId` and `slotKind`; optional `upstreamAccountKey` is a versioned HMAC of the ChatGPT account ID and user claim under the local administrator token. Native identity comes from the owned observation without passive credential reads; missing or conflicting claims omit the key, an `auth.json` `account_id` without a matching token account claim omits it as Pool rows do, and token rotation changes it. No raw identity or credential is serialized. The optional [native reasoning retention policy](../transports/native-reasoning-retention.md#runtime-boundary) can permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT destination. It does not change account selection, entitlement, cache isolation, continuation-id ownership or uploaded-file retention; cross-account acceptance is not guaranteed. Native Spark membership and its model-specific request/tool exceptions are removed; the shared diff --git a/tests/cli/cli-account-slot-identity.test.ts b/tests/cli/cli-account-slot-identity.test.ts new file mode 100644 index 00000000000..3ae3544d861 --- /dev/null +++ b/tests/cli/cli-account-slot-identity.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { cmdAccount } from "../../src/cli/account"; +import type { AccountDeps } from "../../src/cli/account-api"; +import type { OcxConfig } from "../../src/types"; + +/** + * `ocx account list --json` carries the slot identity contract from the account-list API. + * Kept out of the #180 matrix file, which sits at its file-size ratchet cap. + */ +function deps(accounts: Array>): AccountDeps { + return { + baseUrl: "http://127.0.0.1:10100", + loadConfigImpl: () => ({ + port: 10100, + defaultProvider: "openai", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", codexAccountMode: "pool" } }, + }) as unknown as OcxConfig, + fetchImpl: (async (url: string | URL | Request) => { + const path = new URL(String(url)).pathname; + if (path === "/api/codex-auth/accounts") return Response.json({ accounts }); + if (path === "/api/codex-auth/active") return Response.json({ activeCodexAccountId: "__main__" }); + return Response.json({}); + }) as unknown as typeof fetch, + }; +} + +async function runJson(args: string[], accounts: Array>) { + const lines: string[] = []; + const log = console.log; + console.log = (...parts: unknown[]) => { lines.push(parts.map(String).join(" ")); }; + try { + const code = await cmdAccount(args, deps(accounts)); + return { code, rows: (JSON.parse(lines.join("\n")) as { accounts: Array> }).accounts }; + } finally { + console.log = log; + } +} + +describe("ocx account list: slot identity", () => { + test("official --json output carries the upstream join contract", async () => { + const key = `uai1_${"a".repeat(64)}`; + const result = await runJson(["list", "openai", "--json"], [ + { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key, + email: "main@example.test", plan: "pro" }, + { id: "chatgpt_1", slotId: "chatgpt_1", slotKind: "pool", upstreamAccountKey: key, + email: "pool@example.test", plan: "pro" }, + ]); + expect(result.code).toBe(0); + expect(result.rows.filter(row => row.provider === "openai") + .map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ + ["__main__", "native-main", key], + ["chatgpt_1", "pool", key], + ]); + }); +}); diff --git a/tests/cli/cli-account.test.ts b/tests/cli/cli-account.test.ts index 9d1e10c42b6..96c72a95266 100644 --- a/tests/cli/cli-account.test.ts +++ b/tests/cli/cli-account.test.ts @@ -762,24 +762,6 @@ describe("ocx account CLI (issue #180 matrix)", () => { expect(new Set(parsed.accounts.map(row => row.type))).toEqual(new Set(["codex", "oauth", "api-key"])); }); - test("official --all --json output carries the upstream join contract", async () => { - const key = `uai1_${"a".repeat(64)}`; - codexAccounts = [ - { id: "__main__", slotId: "__main__", slotKind: "native-main", upstreamAccountKey: key, - email: "main@example.test", plan: "pro" }, - { id: "chatgpt_1", slotId: "chatgpt_1", slotKind: "pool", upstreamAccountKey: key, - email: "pool@example.test", plan: "pro" }, - ]; - const result = await run(["list", "--all", "--json"]); - const rows = (JSON.parse(result.stdout) as { accounts: Array> }).accounts - .filter(row => row.provider === "openai"); - expect(result.code).toBe(0); - expect(rows.map(row => [row.slotId, row.slotKind, row.upstreamAccountKey])).toEqual([ - ["__main__", "native-main", key], - ["chatgpt_1", "pool", key], - ]); - }); - test("3: empty providers are skipped by default and shown with --all", async () => { const normal = await run(["list"]); const withAll = await run(["list", "--all"]); diff --git a/tests/codex-integration/codex-account-slot-identity.test.ts b/tests/codex-integration/codex-account-slot-identity.test.ts new file mode 100644 index 00000000000..062d9957969 --- /dev/null +++ b/tests/codex-integration/codex-account-slot-identity.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { codexAccountUnusableReason } from "../../src/codex/account-usability"; +import { reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import type { CodexAuthAccountDto } from "../../src/codex/auth-api/account-list"; +import { handleCodexAuthAPI } from "../../src/codex/auth-api/routes"; +import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { clearAccountQuota } from "../../src/codex/quota"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import { runPassiveRead } from "../../src/lib/passive-read"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * Security proof for the slot identity fields on the account-list API: `slotId`, `slotKind` + * and the opaque `upstreamAccountKey` must carry no upstream account ID, user ID, token, or the + * local admin secret that keys the join. Every sentinel below is distinctive so a substring + * search over the serialized response is a meaningful check. + */ +const ACCOUNT = "acct-sentinel-0f3a9d"; +const USER = "user-sentinel-8c1d42"; +const EMAIL = "slot-proof@example.test"; +const POOL_ID = "slot-proof-pool"; +const NATIVE_REFRESH = "native-refresh-sentinel-5b7e"; +const POOL_REFRESH = "pool-refresh-sentinel-2e9c"; +const ADMIN_TOKEN = `ocx_admin_${"Q7".repeat(21)}Z`; +const ROTATED_ADMIN_TOKEN = `ocx_admin_${"R4".repeat(21)}Y`; +const KEY_FORMAT = /^uai1_[0-9a-f]{64}$/; + +let home: string; +let saved: Record<"CODEX_HOME" | "OPENCODEX_HOME" | "OPENCODEX_ADMIN_AUTH_TOKEN", string | undefined>; +let originalFetch: typeof fetch; +let config: OcxConfig; + +function jwt(options: { account?: string; user?: string; label: string }): string { + return `header.${Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + 3600, + ...(options.account ? { chatgpt_account_id: options.account } : {}), + "https://api.openai.com/auth": { chatgpt_plan_type: "plus", ...(options.user ? { chatgpt_user_id: options.user } : {}) }, + "https://api.openai.com/profile": { email: EMAIL }, + jti: options.label, + })).toString("base64url")}.sig-${options.label}`; +} + +function writeAdminToken(token: string): void { + writeFileSync(join(process.env.OPENCODEX_HOME!, "admin-api-token"), `${token}\n`); +} + +function writeNative(tokens: Record): void { + writeFileSync(join(process.env.CODEX_HOME!, "auth.json"), JSON.stringify({ tokens })); + reconcileMainCodexAccountRuntimeState(); +} + +function savePool(accessToken: string): void { + saveCodexAccountCredential(POOL_ID, { accessToken, refreshToken: POOL_REFRESH, + chatgptAccountId: ACCOUNT, expiresAt: Date.now() + 3_600_000 }); +} + +async function listOverHttp(): Promise<{ body: string; accounts: CodexAuthAccountDto[] }> { + const url = new URL("http://localhost/api/codex-auth/accounts"); + const response = await runPassiveRead(() => handleCodexAuthAPI(new Request(url), url, config)); + expect(response?.status).toBe(200); + const body = await response!.text(); + return { body, accounts: (JSON.parse(body) as { accounts: CodexAuthAccountDto[] }).accounts }; +} + +function resetRuntime(): void { + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + clearMainAccountInfoCache(); + setMainAccountPlan(null); + resetMainCodexAccountIdentityTrackingForTests(); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-slot-identity-")); + saved = { CODEX_HOME: process.env.CODEX_HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, + OPENCODEX_ADMIN_AUTH_TOKEN: process.env.OPENCODEX_ADMIN_AUTH_TOKEN }; + process.env.CODEX_HOME = join(home, "codex"); + process.env.OPENCODEX_HOME = join(home, "ocx"); + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + mkdirSync(process.env.OPENCODEX_HOME, { recursive: true }); + originalFetch = globalThis.fetch; + // A passive read must not probe upstream; any request here is a test failure. + globalThis.fetch = (async () => { throw new Error("unexpected network request"); }) as unknown as typeof fetch; + config = { port: 10100, providers: {}, defaultProvider: "openai", codexMainAccountHardLock: false, + codexAccounts: [{ id: POOL_ID, email: EMAIL, plan: "plus", isMain: false }] }; + resetRuntime(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetRuntime(); + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + removeTreeWithRetry(home); +}); + +describe("Codex account slot identity metadata", () => { + test("the account-list response carries no upstream identifiers, tokens, or admin secret", async () => { + writeAdminToken(ADMIN_TOKEN); + const nativeAccess = jwt({ account: ACCOUNT, user: USER, label: "native-access" }); + const nativeId = jwt({ account: ACCOUNT, user: USER, label: "native-id" }); + const poolAccess = jwt({ account: ACCOUNT, user: USER, label: "pool-access" }); + writeNative({ access_token: nativeAccess, id_token: nativeId, refresh_token: NATIVE_REFRESH, account_id: ACCOUNT }); + savePool(poolAccess); + + const { body, accounts } = await listOverHttp(); + const main = accounts.find(row => row.id === "__main__")!; + const pool = accounts.find(row => row.id === POOL_ID)!; + expect(main).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(pool).toMatchObject({ slotId: POOL_ID, slotKind: "pool" }); + expect(main.upstreamAccountKey).toMatch(KEY_FORMAT); + expect(pool.upstreamAccountKey).toBe(main.upstreamAccountKey); + + const secrets = [ACCOUNT, USER, NATIVE_REFRESH, POOL_REFRESH, ADMIN_TOKEN, nativeAccess, nativeId, poolAccess, + ...[nativeAccess, nativeId, poolAccess].flatMap(token => token.split(".").slice(1))]; + for (const secret of secrets) expect(body).not.toContain(secret); + + // The key is keyed by the local admin secret: rotating it changes every key, so a holder of + // the identifiers alone cannot compute or confirm one. + writeAdminToken(ROTATED_ADMIN_TOKEN); + const rotated = await listOverHttp(); + const rotatedMain = rotated.accounts.find(row => row.id === "__main__")!; + expect(rotatedMain.upstreamAccountKey).toMatch(KEY_FORMAT); + expect(rotatedMain.upstreamAccountKey).not.toBe(main.upstreamAccountKey); + expect(rotated.body).not.toContain(ROTATED_ADMIN_TOKEN); + }); + + test("a native account_id without any token account claim yields no join key", async () => { + writeAdminToken(ADMIN_TOKEN); + writeNative({ access_token: jwt({ user: USER, label: "native-unclaimed" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + expect(accounts.find(row => row.id === "__main__")).toMatchObject({ slotId: "__main__", slotKind: "native-main" }); + expect(accounts.find(row => row.id === "__main__")?.upstreamAccountKey).toBeUndefined(); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toMatch(KEY_FORMAT); + // The unverified account ID still guards against storing the native login as a Pool duplicate. + expect(codexAccountUnusableReason(config, POOL_ID)).toBe("duplicate_main"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d46e3137dc9..bbf2ac8b93d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -145,6 +145,8 @@ "codex-main-token-invalidated.test.ts": "codex-integration", "codex-main-grant-refusal.test.ts": "codex-integration", "codex-auth-headless-accounts.test.ts": "codex-integration", + "codex-account-slot-identity.test.ts": "codex-integration", + "cli-account-slot-identity.test.ts": "cli", "responses-alternate-main-cancellation.test.ts": "responses", "codex-shim-path-readiness.test.ts": "codex-integration", "codex-shim-fnm.test.ts": "codex-integration", From 88595951f1e63cbbd08e50062ab4e5cf49f1768f Mon Sep 17 00:00:00 2001 From: chlwnsgurg <97290748+chlwnsgurg@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:06:05 +0900 Subject: [PATCH 10/11] fix(codex): bind native join key to one token claiming account and user --- src/codex/collision-identity.ts | 14 +++++------ structure/providers/openai-accounts.md | 2 +- .../codex-account-slot-identity.test.ts | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/codex/collision-identity.ts b/src/codex/collision-identity.ts index 5000d5504e0..9d899f945e8 100644 --- a/src/codex/collision-identity.ts +++ b/src/codex/collision-identity.ts @@ -63,13 +63,13 @@ export function observeMainCodexCollisionIdentity(tokens?: CodexTokens): void { const idPayload: unknown = tokens.id_token === undefined ? undefined : decodeJwtPayload(tokens.id_token); const idTokenMalformed = tokens.id_token !== undefined && (idPayload === null || typeof idPayload !== "object" || Array.isArray(idPayload)); - const accessUser = nativeUserIdClaims(tokens.access_token); - const idUser = tokens.id_token ? nativeUserIdClaims(tokens.id_token) : undefined; - // Like Pool rows, a join key needs a token-backed account claim; auth.json account_id alone is unverified. - const userId = !idTokenMalformed && !accessUser.conflict && !idUser?.conflict - && claims.some(claim => claim.accountId === accountId) - && (!idUser?.userId || idUser.userId === accessUser.userId) - ? accessUser.userId : undefined; + const users = [tokens.id_token ? nativeUserIdClaims(tokens.id_token) : undefined, nativeUserIdClaims(tokens.access_token)]; + const userIds = new Set(users.flatMap(user => user?.userId ? [user.userId] : [])); + // A join key needs one token claiming both the account and the user, as a Pool access token does; + // auth.json account_id alone, or claims split across a mixed token set, prove no pairing. + const bound = claims.some((claim, index) => claim.accountId === accountId && users[index]?.userId); + const userId = !idTokenMalformed && !users.some(user => user?.conflict) && bound && userIds.size === 1 + ? [...userIds][0] : undefined; observed = { identity: { accountId, email: [...emails][0], ...(userId ? { userId } : {}), plan: extractChatgptPlanType(tokens.id_token, tokens.access_token) }, diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index e1e4cbd2205..fd7d145a8fc 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -37,7 +37,7 @@ preserving a stale one would block every later migration. ## Model and wire identity -Account-list rows retain each registered `slotId` and `slotKind`; optional `upstreamAccountKey` is a versioned HMAC of the ChatGPT account ID and user claim under the local administrator token. Native identity comes from the owned observation without passive credential reads; missing or conflicting claims omit the key, an `auth.json` `account_id` without a matching token account claim omits it as Pool rows do, and token rotation changes it. No raw identity or credential is serialized. +Account-list rows retain each registered `slotId` and `slotKind`; optional `upstreamAccountKey` is a versioned HMAC of the ChatGPT account ID and user claim under the local administrator token. Native identity comes from the owned observation without passive credential reads; missing or conflicting claims omit the key, the native key needs one token that claims both the matching account and the user (an `auth.json` `account_id` alone, or account and user claims split across the ID and access tokens, omit it), Pool rows need the access token's matching account claim, and token rotation changes it. No raw identity or credential is serialized. The optional [native reasoning retention policy](../transports/native-reasoning-retention.md#runtime-boundary) can permit reasoning ciphertext forwarding across a known model or account change on the same canonical ChatGPT destination. It does not change account selection, entitlement, cache isolation, continuation-id ownership or uploaded-file retention; cross-account acceptance is not guaranteed. Native Spark membership and its model-specific request/tool exceptions are removed; the shared diff --git a/tests/codex-integration/codex-account-slot-identity.test.ts b/tests/codex-integration/codex-account-slot-identity.test.ts index 062d9957969..e62a1f0f807 100644 --- a/tests/codex-integration/codex-account-slot-identity.test.ts +++ b/tests/codex-integration/codex-account-slot-identity.test.ts @@ -147,4 +147,28 @@ describe("Codex account slot identity metadata", () => { // The unverified account ID still guards against storing the native login as a Pool duplicate. expect(codexAccountUnusableReason(config, POOL_ID)).toBe("duplicate_main"); }); + + test("account and user claims split across native tokens yield no join key", async () => { + writeAdminToken(ADMIN_TOKEN); + // A stale or mixed token set: the ID token proves only the account, the access token only the user. + writeNative({ id_token: jwt({ account: ACCOUNT, label: "native-id-account-only" }), + access_token: jwt({ user: USER, label: "native-access-user-only" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + expect(accounts.find(row => row.id === "__main__")?.upstreamAccountKey).toBeUndefined(); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toMatch(KEY_FORMAT); + }); + + test("one native token claiming both account and user still joins the Pool row", async () => { + writeAdminToken(ADMIN_TOKEN); + writeNative({ id_token: jwt({ account: ACCOUNT, user: USER, label: "native-id-bound" }), + access_token: jwt({ user: USER, label: "native-access-user" }), account_id: ACCOUNT }); + savePool(jwt({ account: ACCOUNT, user: USER, label: "pool-claimed" })); + + const { accounts } = await listOverHttp(); + const mainKey = accounts.find(row => row.id === "__main__")?.upstreamAccountKey; + expect(mainKey).toMatch(KEY_FORMAT); + expect(accounts.find(row => row.id === POOL_ID)?.upstreamAccountKey).toBe(mainKey); + }); }); From 8c901935cc1f5fcf5ed2424c485e48a24f53e877 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 24 Oct 2026 10:59:25 +0900 Subject: [PATCH 11/11] docs(structure): keep openai-accounts.md within the 600-line budget --- structure/providers/openai-accounts.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index e5c1f45724e..961cee4f8ac 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -251,8 +251,7 @@ restored from the Accounts picker or Codex Auth through gated recovery: missing from the canonical preset, disabled canonical rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` rows never receive that recovery path. -Account listing and explicit refresh return stored pool accounts without creating a missing native `CODEX_HOME`; an unchecked main observation leaves credential/reauth state unchanged. -When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 responses with a known terminal code, unrecognized HTTP 401 responses when the access token is not verifiably live, and allowlisted terminal 403 responses as `needsReauth`; successful refresh clears the runtime mark. +Account listing and explicit refresh return stored pool accounts without creating a missing native `CODEX_HOME`; an unchecked main observation leaves credential/reauth state unchanged. When the native home is accessible, `GET /api/codex-auth/accounts?refresh=1` treats missing main credentials, HTTP 401 responses with a known terminal code, unrecognized HTTP 401 responses when the access token is not verifiably live, and allowlisted terminal 403 responses as `needsReauth`; successful refresh clears the runtime mark. Generic quota permission failures remain non-terminal; missing-home fallback requires proven absence, so non-directory homes, dangling links, and parent directories that cannot be traversed still fail closed for both default and explicit home paths. Native-profile context resolution validates OpenCodex storage before the native home, so unsafe storage fails closed even when that home is absent; credential operations retain their strict claim requirements. `src/codex/chatgpt-refresh-failure.ts` classifies token-endpoint failures for both stored-pool