From 461d823036093dfb273f3e1483a0c1e537178139 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 15 Oct 2026 16:34:01 +0900 Subject: [PATCH 1/3] feat(codex): preserve the signed-in identity as pool fallback --- .../docs/reference/cli/providers-accounts.md | 40 +++- .../docs/reference/configuration/providers.md | 1 + .../content/docs/reference/management-api.md | 2 + scripts/test-layout/layout.json | 2 +- .../ocx/references/01_management_surface.md | 10 +- skills/ocx/references/01_surface_accounts.md | 24 +- src/cli/account-preservation.ts | 43 ++++ src/cli/account.ts | 5 + src/cli/capabilities-accounts.ts | 8 + src/codex/account-identity.ts | 29 +++ src/codex/account-lifecycle.ts | 14 +- src/codex/account-preservation.ts | 28 +++ src/codex/auth-api/account-pause-group.ts | 56 +---- src/codex/auth-api/account-preservation.ts | 56 +++++ src/codex/auth-api/routes.ts | 4 + src/codex/routing/selection.ts | 7 +- src/codex/signed-in-identity.ts | 40 ++++ src/config/diagnostics.ts | 15 +- src/config/schema/config-schema.ts | 1 + src/server/management/route-registry.ts | 2 + src/types/config.ts | 2 + structure/cli-management.md | 2 + structure/config.md | 2 +- structure/gui-and-management-api.md | 2 +- structure/providers/openai-accounts.md | 24 ++ tests/cli/cli-account-preservation.test.ts | 36 +++ .../codex-preserve-signed-in.test.ts | 217 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 + 28 files changed, 604 insertions(+), 70 deletions(-) create mode 100644 src/cli/account-preservation.ts create mode 100644 src/codex/account-identity.ts create mode 100644 src/codex/account-preservation.ts create mode 100644 src/codex/auth-api/account-preservation.ts create mode 100644 src/codex/signed-in-identity.ts create mode 100644 tests/cli/cli-account-preservation.test.ts create mode 100644 tests/codex-integration/codex-preserve-signed-in.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 27387b52951..ab2ee95434d 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -815,12 +815,50 @@ openai: { provider, autoSwitchThreshold: number, enabled: boolean } generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` +### `ocx account preserve-signed-in openai [on|off|status] [--apply-to-bound] [--json]` + +Opt in to **use other eligible accounts first and keep the signed-in ChatGPT/Codex Desktop +identity as fallback**. A bare invocation or `status` reads the running proxy's policy, affected +entry IDs, manual pin and next unbound choice. `on` persists `codexPreserveSignedInAccount: true`; +`off` restores your existing custom account order without rewriting any priorities. + +The reserved group includes native `__main__` and ordinary pool entries with the same selected +ChatGPT account/workspace **and** normalized member email. Sharing a workspace alone is insufficient. +The group follows the latest physical login observed by the proxy's native-login lifecycle; +no incoming request credential can define it. If member/workspace evidence is unavailable, +`identityKnown: false` reports that only main can be identified. Status obtains a fresh owned +observation; a busy or unreadable native profile returns 503 without applying the change. + +All four Codex strategies prefer other identities with quota headroom, preserving custom order +within each group. Reserved entries remain fallback when preferred tiers drain. **Fallback can +consume the signed-in quota**, subject to existing model access, pause, cooldown, reauthentication, +hard-lock and paid-credit consent checks. This policy does not grant paid-credit consent. + +Healthy existing bindings stay in place, and manual pins and exact account-qualified model routes +override the preference. To apply it to ongoing work, run: + +```bash +ocx account preserve-signed-in openai on --apply-to-bound +``` + +This explicitly releases only affected bindings and any affected manual pin for the **next request**; +in-flight requests keep their captured account. Unrelated bindings and pins remain. Moving work may +lose its warm prompt cache, and account-scoped uploads or continuation state can prevent a move. +`codexAccountPriorityFailback` remains a separate opt-in, with its existing quota-strategy, +positive-threshold and fresh-observation conditions; it can also move work toward a preferred group. + +For **never use this identity through the pool**, use `ocx account pause openai main` instead: +linked pause excludes main and matching pool entries without changing the desktop login file. +Only requests that reach OpenCodex are controlled. This does not unlock an already-disabled +desktop composer or promise to fix client-side quota gating. + ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` Reads or sets one Codex pool account's selection order: **higher is used earlier**, the default is `0`, and the range is `-100` through `100`. Only the `openai` Codex pool is ordered, so other providers exit 1. `main` targets the Codex Desktop login, which is ordered like any other pool -account — `ocx account priority openai main last` is how you keep it as the reserve. +account. `ocx account priority openai main last` lowers only that entry; use +`preserve-signed-in` above to reserve its matching ordinary pool entries too. Preset words stand in for small integers: `first` is `+2`, `earlier` is `+1`, `normal` is `0`, `later` is `-1`, and `last` is `-2`. `reset` returns the account to the default and drops its stored diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 50c3520b0df..49b32ee055c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -76,6 +76,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is at or above its own nonzero effective usage threshold (account override or global default), cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and by default, never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | | `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). | +| `codexPreserveSignedInAccount?` | `boolean` | `false` | Prefer other eligible Codex Pool identities before native main and ordinary entries matching its selected workspace and member email. Existing custom priorities are preserved inside each group. Healthy bindings and manual pins remain; exact account routes and Direct mode are unchanged. Fallback may consume signed-in quota under all existing guards. Use [account preserve-signed-in](/reference/cli/providers-accounts/#ocx-account-preserve-signed-in-openai-onoffstatus---apply-to-bound---json) for status or an explicit next-request binding transition. | | `codexAccountPriorityFailback?` | `boolean` | `false` | Opt-in return of ongoing Codex Pool tasks to a usable higher-priority account under the `quota` strategy. Requests trigger a background quota refresh at most once every five minutes, including failed refresh attempts. When recent quota observations show headroom, the next request may rebind even if its current account is below the threshold. This explicit preference can discard a warm prompt cache; it does not change default cache affinity. No requests means no polling. Manual pins, model access, cooldowns, pauses and reauthentication exclusions still apply; in-flight requests keep their captured account. Requires the current source account's effective threshold to be positive (an override of 0 disables it; a positive override works with global 0); exact account routes and Direct mode are unchanged. | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for placing new/unbound work. `quota` can re-evaluate unbound tasks on their next request once usage crosses this threshold. Bound tasks keep their account past the threshold by default (`pool.cacheAffinity`); they leave only when that account is exhausted or otherwise cannot serve, and then only for an account with genuine quota headroom and strictly lower usage. Set `pool.cacheAffinity: false` to re-evaluate bound tasks at this threshold, still only onto such a destination. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | | `codexAccountAutoSwitchThresholds?` | `Record` | — | Per-account overrides for `autoSwitchThreshold`: account id → integer from `0` to `100`. An absent entry inherits the global value; `0` disables usage-driven switching only when that account is the source. `quota` uses the current source account's effective threshold for bound-task re-evaluation and unbound switching. Fill-first drain and selection-order tier headroom are evaluated against each account's own effective threshold; main-account pins and subagent fallback also use the relevant account's effective threshold. Supports the main `__main__` account. A malformed map is ignored with a warning. Managed by each account card on the Codex Auth page; disabling an override removes its entry. Enabling the override copies the current global threshold into a fixed account value. The override, including `0`, takes precedence over later global changes. Disabling it sends `threshold: null`, removes the entry, and restores inheritance of the current global threshold and future global changes. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 19c55968fa8..a267d00fffb 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -742,6 +742,8 @@ manager. Its routes are: | `POST /api/codex-auth/accounts/clear-cooldown` | Clear runtime cooldown for one account or all accounts | 400 invalid id | | `GET, PUT /api/codex-auth/active` | Read or select the active account | 400 invalid or missing account; 409 paused/legacy-row conflict or `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | Set the global quota threshold with `{ threshold }`, or an account override with `{ id, threshold }`; `null` restores global inheritance, and `__main__` selects the Desktop login | 400 invalid id/threshold; 404 missing account | +| `GET /api/codex-auth/preserve-signed-in` | Read policy, current `affectedAccountIds`, `identityKnown`, `nextAccountId`, `nextUsesSignedInQuota`, `pinnedAccountId`, `bindings`, and `fallbackConsumesSignedInQuota` | 503 native profile busy/unreadable | +| `PUT /api/codex-auth/preserve-signed-in` | Strict `{ enabled: boolean, applyToBound?: boolean }`; explicit `applyToBound: true` requires enabled, releases affected bindings/pin for next request; reports `releasedPin` | 400 invalid body; 503 native profile busy/unreadable | | `PUT, PATCH /api/codex-auth/pool-strategy` | Update Codex account-pool selection strategy | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | | `GET /api/codex-auth/quota` | Read cached quota state by account | — | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..78718ee25e0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "service-log.test.ts": "service", + "explicit": { "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-log.test.ts": "service", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", "anthropic-revoked-token.test.ts": "adapters/anthropic", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 901cb97f388..529cbd7c7ce 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -27,7 +27,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin |---|---| | [lifecycle](01_surface_lifecycle.md) | 12 | | [providers-models](01_surface_providers-models.md) | 47 | -| [accounts](01_surface_accounts.md) | 40 | +| [accounts](01_surface_accounts.md) | 41 | | [agents-routing](01_surface_agents-routing.md) | 51 | | [integrations](01_surface_integrations.md) | 41 | | [observe-system](01_surface_observe-system.md) | 92 | @@ -519,6 +519,10 @@ Original invocation order. These headings preserve links to the previous single- [State-changing task](01_surface_providers-models.md#ocx-models-order-reset) +### `ocx account preserve-signed-in` + +[State-changing task](01_surface_accounts.md#ocx-account-preserve-signed-in) + ### `ocx logout` [State-changing task](01_surface_accounts.md#ocx-logout) @@ -1393,6 +1397,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 336 -- of those, state-changing: 205 +- declared capabilities: 337 +- of those, state-changing: 206 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_accounts.md b/skills/ocx/references/01_surface_accounts.md index 68d05ed225d..7a695eb1165 100644 --- a/skills/ocx/references/01_surface_accounts.md +++ b/skills/ocx/references/01_surface_accounts.md @@ -8,7 +8,7 @@ Use these declarations to choose a task, then check its flags and authority before execution. Non-mutating probes may still contact providers, consume quota or refresh caches. -Declared capabilities: 40. +Declared capabilities: 41. ### `ocx account login` @@ -355,6 +355,28 @@ JSON mode: `envelope`. - Without --account the existing pool-wide operation remains. OpenAI per-account status reads override plus effective threshold; on writes80, off0, inherit null, threshold a validated0–100 integer. - New OpenAI policy writes require successful unambiguous roster evidence and never fall back to a raw unresolved selector. These are routing thresholds, not pool enablement or paid-credit permission. +### `ocx account preserve-signed-in` + +Usage: `ocx account preserve-signed-in openai [on|off|status] [--apply-to-bound] [--json]` + +Prefer other Codex identities and reserve the native sign-in as fallback. + +State-changing: yes. + +| Method | Route | +|---|---| +| GET | `/api/codex-auth/preserve-signed-in` | +| PUT | `/api/codex-auth/preserve-signed-in` | + +| Flag | Value | Meaning | +|---|---|---| +| `--apply-to-bound` | boolean | With on, release protected bindings and pins for the next request. | +| `--json` | boolean | Emit affected entries, next unbound choice and pin/binding semantics. | + +JSON mode: `payload`. + +- Default/status reads. Custom priorities are preserved. Bound threads and manual pins remain unless explicitly released. Exact-account models override the preference. Fallback can spend signed-in quota; linked pause excludes it instead. + ### `ocx logout` Usage: `ocx logout [--live] [--json]` diff --git a/src/cli/account-preservation.ts b/src/cli/account-preservation.ts new file mode 100644 index 00000000000..6484a07f1ec --- /dev/null +++ b/src/cli/account-preservation.ts @@ -0,0 +1,43 @@ +import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api"; + +/** This operation follows the selected runtime; never read a local account/config roster. */ +export async function cmdPreserveSignedIn(args: string[], deps: AccountDeps): Promise { + const provider = args.shift(); + let mode = "status"; + let hasMode = false; + let json = false; + let applyToBound = false; + for (const arg of args) { + if (arg === "--json" && !json) json = true; + else if (arg === "--apply-to-bound" && !applyToBound) applyToBound = true; + else if (["on", "off", "status"].includes(arg) && !hasMode) { mode = arg; hasMode = true; } + else return usage(); + } + if (provider !== "openai" || (applyToBound && mode !== "on")) return usage(); + const baseUrl = await resolveBaseUrl(deps); + if (!baseUrl) return proxyUnreachable(); + const result = await apiJson(deps, baseUrl, mode === "status" ? "GET" : "PUT", "/api/codex-auth/preserve-signed-in", + mode === "status" ? undefined : { enabled: mode === "on", applyToBound }); + if (result.status === 0) return proxyUnreachable(result.transportError); + if (result.status !== 200) return apiError(result.json, "Signed-in quota policy unavailable", result.status); + if (typeof result.json.enabled !== "boolean" || !Array.isArray(result.json.affectedAccountIds) + || !result.json.affectedAccountIds.every(id => typeof id === "string") + || (result.json.nextAccountId !== null && typeof result.json.nextAccountId !== "string")) { + return apiError({}, "Invalid signed-in quota policy response", 502); + } + if (json) console.log(JSON.stringify(result.json, null, 2)); + else { + console.log(`Preserve signed-in quota: ${result.json.enabled ? "on" : "off"}`); + console.log(`Affected pool entries: ${result.json.affectedAccountIds.join(", ")}`); + console.log(`Next unbound choice: ${result.json.nextAccountId ?? "none"}`); + console.log(`Bindings: ${result.json.bindings}; manual pin: ${result.json.pinnedAccountId ?? "none"}`); + console.log("Fallback can consume signed-in quota. Exact-account models override this preference."); + console.log("Use on --apply-to-bound to release affected bindings and pins for the next request."); + } + return 0; +} + +function usage(): number { + console.error("Usage: ocx account preserve-signed-in openai [on|off|status] [--apply-to-bound] [--json]"); + return 1; +} diff --git a/src/cli/account.ts b/src/cli/account.ts index 09ddaf81c40..04eb4a356ee 100644 --- a/src/cli/account.ts +++ b/src/cli/account.ts @@ -54,6 +54,7 @@ const ACCOUNT_USAGE = `Usage: ocx account auto-switch > [--json] ocx account auto-switch > --account [--json] ocx account alias [--json] + ocx account preserve-signed-in openai [on|off|status] [--apply-to-bound] [--json] ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json] ocx account pause [--json] ocx account resume [--json] @@ -476,6 +477,10 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis if (sub === "refresh") return await cmdRefresh(rest, deps); if (sub === "auto-switch") return await cmdAutoSwitch(rest, deps); if (sub === "alias" || sub === "rename") return await cmdAlias(rest, deps); + if (sub === "preserve-signed-in") { + const { cmdPreserveSignedIn } = await import("./account-preservation"); + return cmdPreserveSignedIn(rest, deps); + } if (sub === "priority") return await cmdPriority(rest, deps); // #2702: the server routes existed and only the CLI caller was missing, so these were // dashboard-only capabilities. diff --git a/src/cli/capabilities-accounts.ts b/src/cli/capabilities-accounts.ts index 24726f9d4da..86a3f173eb8 100644 --- a/src/cli/capabilities-accounts.ts +++ b/src/cli/capabilities-accounts.ts @@ -2,6 +2,14 @@ import type { Capability } from "./capability-types"; /** Account pool operations and physical native-login profiles have separate owners. */ export const ACCOUNT_CAPABILITIES: readonly Capability[] = [ + { + command: ["account", "preserve-signed-in"], summary: "Prefer other Codex identities and reserve the native sign-in as fallback.", + usage: "ocx account preserve-signed-in openai [on|off|status] [--apply-to-bound] [--json]", + routes: [{ method: "GET", path: "/api/codex-auth/preserve-signed-in" }, { method: "PUT", path: "/api/codex-auth/preserve-signed-in" }], + flags: [{ name: "--apply-to-bound", value: "boolean", summary: "With on, release protected bindings and pins for the next request." }, { name: "--json", value: "boolean", summary: "Emit affected entries, next unbound choice and pin/binding semantics." }], + mutates: true, json: "payload", + details: ["Default/status reads. Custom priorities are preserved. Bound threads and manual pins remain unless explicitly released. Exact-account models override the preference. Fallback can spend signed-in quota; linked pause excludes it instead."], + }, { command: ["logout"], summary: "Remove the active stored OAuth credential for a provider locally.", diff --git a/src/codex/account-identity.ts b/src/codex/account-identity.ts new file mode 100644 index 00000000000..f6c937a372d --- /dev/null +++ b/src/codex/account-identity.ts @@ -0,0 +1,29 @@ +import type { OcxConfig } from "../types"; +import { codexLoginIdentity as identity, codexTokenIdentity, type CodexLoginIdentity } from "./signed-in-identity"; +import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "./account-id"; +import type { CodexTokenReadResult } from "./auth-collision"; +import { getCodexAccountCredential } from "./account-store"; + +/** Match existing entries from an already-owned main observation, without exposing identity fields. */ +export function linkedCodexAccountIds(config: OcxConfig, selectedId: string, main: CodexTokenReadResult | { status: "api-key-only" }): string[] | undefined { + // Unknown main identity must not produce a successful but incomplete pause. + if (main.status === "unreadable" || main.status === "invalid") return undefined; + const identities = new Map(); + if (main.status === "ok") { + const selected = codexTokenIdentity(main.tokens); + if (selected === null) return undefined; + identities.set(MAIN_CODEX_ACCOUNT_ID, selected); + } + for (const account of config.codexAccounts ?? []) { + if (!isSelectableCodexPoolAccount(account)) continue; + const credential = getCodexAccountCredential(account.id); + identities.set(account.id, identity(credential?.chatgptAccountId, account.email)); + } + const selected = identities.get(selectedId); + // Missing identity evidence never links unrelated logins, including members of one workspace. + if (!selected) return [selectedId]; + return [selectedId, ...[...identities].flatMap(([id, candidate]) => ( + id !== selectedId && candidate?.accountId === selected.accountId && candidate.email === selected.email + ? [id] : [] + ))]; +} diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 93771f26d9e..fba75b6313b 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -1,3 +1,4 @@ +import { observeSignedInCodexIdentity } from "./signed-in-identity"; import { existsSync, readFileSync } from "node:fs"; import { deleteConfigTopLevelKey, @@ -8,7 +9,7 @@ import { import { captureConfigTopLevelRollback } from "../config/rebase-provenance"; import { removeCodexAccountCredential } from "./account-store"; import { clearAccountNeedsReauth } from "./account-runtime-state"; -import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision"; +import { readCodexTokensResult } from "./auth-collision"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account"; import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; @@ -16,7 +17,7 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } f import { clearCodexPoolRefreshFailure } from "./pool-refresh-backoff"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountCredentialPresence, clearMainAccountInfoCache, observeMainQuotaCredential, observeMainQuotaIdentity } from "./main-account-cache"; -import { extractAccountIdClaims } from "../oauth/chatgpt"; +import { extractAccountId, extractAccountIdClaims } from "../oauth/chatgpt"; import { forgetCodexAccountPause } from "./account-pause"; import { forgetCodexAccountCreditUse } from "./account-credit-use"; import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority"; @@ -66,7 +67,11 @@ function purgeMainCodexAccountRuntimeState(): void { * cooldown, quota, reauth, and thread affinity do not leak across accounts. */ export function reconcileMainCodexAccountRuntimeState(): boolean { - const currentAccountId = getMainChatgptAccountId(); + const read = readCodexTokensResult(); + const currentAccountId = read.status === "ok" + ? extractAccountId(read.tokens.id_token, read.tokens.access_token) ?? (read.tokens.account_id || null) + : null; + if (read.status === "ok") observeSignedInCodexIdentity(read.tokens); // A missing/malformed auth.json is an unknown identity, not a confirmed account switch. Keep the // prior observation and its safety state until a real account id can be read again. if (currentAccountId === null) return false; @@ -110,6 +115,7 @@ export function initializeMainAccountPolicyBinding(authPath: string): boolean { const previousAccountId = observedMainChatgptAccountId; observedMainChatgptAccountId = accountId; if (previousAccountId !== undefined && previousAccountId !== accountId) purgeMainCodexAccountRuntimeState(); + observeSignedInCodexIdentity(tokens); observeMainQuotaIdentity(accountId); return observeMainQuotaCredential(tokens.access_token, accountId) !== undefined; } @@ -122,6 +128,7 @@ export function applyConfirmedMainCodexAccountTransition( fromAccountId: string, toAccountId: string, ): boolean { + observeSignedInCodexIdentity(); if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) { if (toAccountId) { observedMainChatgptAccountId = toAccountId; @@ -137,6 +144,7 @@ export function applyConfirmedMainCodexAccountTransition( export function resetMainCodexAccountIdentityTrackingForTests(): void { observedMainChatgptAccountId = undefined; + observeSignedInCodexIdentity(); clearMainAccountCredentialPresence(); } diff --git a/src/codex/account-preservation.ts b/src/codex/account-preservation.ts new file mode 100644 index 00000000000..b1e03e1f0d0 --- /dev/null +++ b/src/codex/account-preservation.ts @@ -0,0 +1,28 @@ +import type { OcxConfig } from "../types"; +import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "./account-id"; +import { getCodexAccountCredential } from "./account-store"; +import { codexAccountPriorityLookup } from "./account-priority"; +import { MIN_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY } from "./pool-rotation"; +import { codexLoginIdentity, getSignedInCodexIdentity, sameCodexLogin } from "./signed-in-identity"; + +/** Resolve current rows afresh: a pool replacement must not inherit the prior login's role. */ +export function signedInCodexAccountIds(config: OcxConfig): ReadonlySet { + const ids = new Set([MAIN_CODEX_ACCOUNT_ID]); + const main = getSignedInCodexIdentity(); + if (!main) return ids; + for (const account of config.codexAccounts ?? []) { + if (!isSelectableCodexPoolAccount(account)) continue; + const credential = getCodexAccountCredential(account.id); + if (sameCodexLogin(main, codexLoginIdentity(credential?.chatgptAccountId, account.email))) ids.add(account.id); + } + return ids; +} + +/** Two disjoint bands, preserving custom order inside each; never writes the stored priorities. */ +export function codexSelectionPriorityLookup(config: OcxConfig): (id: string) => number { + const priority = codexAccountPriorityLookup(config); + if (config.codexPreserveSignedInAccount !== true) return priority; + const reserved = signedInCodexAccountIds(config); + const band = MAX_ACCOUNT_PRIORITY - MIN_ACCOUNT_PRIORITY + 1; + return id => priority(id) - (reserved.has(id) ? band : 0); +} diff --git a/src/codex/auth-api/account-pause-group.ts b/src/codex/auth-api/account-pause-group.ts index 878f009dc72..9a43b18f3a9 100644 --- a/src/codex/auth-api/account-pause-group.ts +++ b/src/codex/auth-api/account-pause-group.ts @@ -1,10 +1,9 @@ +import { observeSignedInCodexIdentity } from "../signed-in-identity"; import { lstatSync } from "node:fs"; import type { OcxConfig } from "../../types"; -import { inspectChatGptDomainClaim, extractEmail } from "../../oauth/chatgpt"; -import { MAIN_CODEX_ACCOUNT_ID, isSelectableCodexPoolAccount } from "../account-id"; import { readCodexTokensResult } from "../auth-collision"; import type { CodexTokenReadResult } from "../auth-collision"; -import { getCodexAccountCredential } from "../account-store"; +import { linkedCodexAccountIds } from "../account-identity"; import { resolveCodexHomeDir } from "../home"; import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; import { withNativeMainSharedClaim } from "../native-main-claim"; @@ -12,7 +11,6 @@ import { MAX_AUTH_BYTES, readBounded, resolveNativeProfileContext } from "../nat import { NativeProfileError } from "../native-profile-types"; import { isNativeMainClaimUnavailable, nativeMainProfileBusyResponse } from "./http"; -type AccountIdentity = { accountId: string; email: string }; type MainAuth = CodexTokenReadResult | { status: "api-key-only" }; /** A valid API-key login has no ChatGPT identity; anything else unreadable stays unknown. */ @@ -32,50 +30,6 @@ function readMainAuth(authPath: string): MainAuth { } } -function identity(accountId: unknown, email: unknown): AccountIdentity | undefined { - const normalizedEmail = typeof email === "string" ? email.trim().toLowerCase() : undefined; - return typeof accountId === "string" && accountId.trim() && normalizedEmail - ? { accountId: accountId.trim(), email: normalizedEmail } - : undefined; -} - -/** Resolve only existing entries, under native-main ownership, never on the request path. */ -function linkedAccountIds(config: OcxConfig, selectedId: string, main: MainAuth): string[] | undefined { - // Unknown main identity must not produce a successful but incomplete pause. - if (main.status === "unreadable" || main.status === "invalid") return undefined; - const identities = new Map(); - if (main.status === "ok") { - const tokens = main.tokens; - if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" - || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return undefined; - const claims = [tokens.id_token, tokens.access_token] - .filter((token): token is string => typeof token === "string") - .map(inspectChatGptDomainClaim); - if (claims.some(claim => claim.kind === "invalid")) return undefined; - const claimIds = claims.flatMap(claim => claim.kind === "valid" ? [claim.accountId] : []); - // organizations[] describes memberships, not the selected workspace. Never use its first row. - const accountId = tokens.account_id.trim() || claimIds[0]; - if (claimIds.some(claim => claim !== accountId)) return undefined; - // Both tokens must name the same member; otherwise the bearer's identity is unknown. - const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] - .flatMap(email => email?.trim() ? [email.trim().toLowerCase()] : [])); - if (emails.size > 1) return undefined; - identities.set(MAIN_CODEX_ACCOUNT_ID, identity(accountId, [...emails][0])); - } - for (const account of config.codexAccounts ?? []) { - if (!isSelectableCodexPoolAccount(account)) continue; - const credential = getCodexAccountCredential(account.id); - identities.set(account.id, identity(credential?.chatgptAccountId, account.email)); - } - const selected = identities.get(selectedId); - // Missing identity evidence never links unrelated logins, including members of one workspace. - if (!selected) return [selectedId]; - return [selectedId, ...[...identities].flatMap(([id, candidate]) => ( - id !== selectedId && candidate?.accountId === selected.accountId && candidate.email === selected.email - ? [id] : [] - ))]; -} - /** Hold admission throughout; claim the physical main whenever its home exists. */ export async function withCodexAccountPauseGroup( config: OcxConfig, @@ -92,13 +46,15 @@ export async function withCodexAccountPauseGroup( // Only positive absence permits Pool-only publication. A dangling symlink or access // failure is not absence; do not create a home just to coordinate an unused main login. if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") return nativeMainProfileBusyResponse(); - const ids = linkedAccountIds(config, selectedId, { status: "missing" }); + const ids = linkedCodexAccountIds(config, selectedId, { status: "missing" }); + if (ids) observeSignedInCodexIdentity(); return ids ? publish(ids) : nativeMainProfileBusyResponse(); } const context = resolveNativeProfileContext({ codexHome }); return await withNativeMainSharedClaim(context, async () => { const main = readMainAuth(context.authPath); - const ids = linkedAccountIds(config, selectedId, main); + const ids = linkedCodexAccountIds(config, selectedId, main); + if (ids) observeSignedInCodexIdentity(main.status === "ok" ? main.tokens : undefined); return ids ? publish(ids) : nativeMainProfileBusyResponse(); }); } catch (error) { diff --git a/src/codex/auth-api/account-preservation.ts b/src/codex/auth-api/account-preservation.ts new file mode 100644 index 00000000000..3f45508e943 --- /dev/null +++ b/src/codex/auth-api/account-preservation.ts @@ -0,0 +1,56 @@ +import { getSignedInCodexIdentity } from "../signed-in-identity"; +import type { OcxConfig } from "../../types"; +import { captureConfigTopLevelRollback } from "../../config/rebase-provenance"; +import { clearCodexAccountPin, pinnedCodexAccountId } from "../account-priority"; +import { clearThreadAccountMapForAccount, previewCodexAccountForRequest } from "../routing"; +import { forgetManualPreference } from "../routing/active-account"; +import { withCodexAccountPauseGroup } from "./account-pause-group"; +import { MAIN_CODEX_ACCOUNT_ID } from "../account-id"; +import { getRuntimeConfig, saveRuntimeConfig } from "./runtime-config"; +import { jsonResponse } from "./http"; + +/** Management owns the native-main claim; publish before releasing any runtime bindings. */ +export async function handleCodexAccountPreservation(req: Request, config: OcxConfig): Promise { + let update: { enabled: boolean; applyToBound: boolean } | undefined; + if (req.method === "PUT") { + const body: unknown = await req.json().catch(() => null); + if (!body || typeof body !== "object" || Array.isArray(body) + || !("enabled" in body) || typeof body.enabled !== "boolean" + || Object.keys(body).some(key => key !== "enabled" && key !== "applyToBound") + || ("applyToBound" in body && typeof body.applyToBound !== "boolean")) { + return jsonResponse({ error: "enabled and optional applyToBound must be booleans" }, 400); + } + const applyToBound = "applyToBound" in body && body.applyToBound === true; + if (applyToBound && !body.enabled) return jsonResponse({ error: "applyToBound requires enabled: true" }, 400); + update = { enabled: body.enabled, applyToBound }; + } + const runtime = getRuntimeConfig(config); + return withCodexAccountPauseGroup(runtime, MAIN_CODEX_ACCOUNT_ID, affectedAccountIds => { + const releasedPin = update?.applyToBound === true && affectedAccountIds.includes(pinnedCodexAccountId(runtime) ?? ""); + if (update) { + const rollback = captureConfigTopLevelRollback(runtime, ["codexPreserveSignedInAccount", "activeCodexAccountPinned"]); + try { + runtime.codexPreserveSignedInAccount = update.enabled; + if (releasedPin) clearCodexAccountPin(runtime); + saveRuntimeConfig(config, runtime); + } catch (error) { rollback(); throw error; } + if (update.applyToBound) { + for (const id of affectedAccountIds) { + clearThreadAccountMapForAccount(id); + forgetManualPreference(id); + } + } + } + const enabled = runtime.codexPreserveSignedInAccount === true; + const nextAccountId = previewCodexAccountForRequest(null, runtime); + return jsonResponse({ + ok: true, enabled, affectedAccountIds, identityKnown: getSignedInCodexIdentity() !== undefined, + nextAccountId, + nextUsesSignedInQuota: enabled && nextAccountId !== null && affectedAccountIds.includes(nextAccountId), + pinnedAccountId: pinnedCodexAccountId(runtime) ?? null, + bindings: update?.applyToBound ? "released-for-next-request" : "retained", + releasedPin, + fallbackConsumesSignedInQuota: true, + }); + }); +} diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index 55553921fef..0944ffae790 100644 --- a/src/codex/auth-api/routes.ts +++ b/src/codex/auth-api/routes.ts @@ -1,3 +1,4 @@ +import { handleCodexAccountPreservation } from "./account-preservation"; import { persistCodexAccountSelection } from "./account-selection"; import { CODEX_ACCOUNT_LOG_LABEL_RE, codexAccountLogLabel } from "../account-label"; import { poolQuotaHistoryIdentity, readCodexAccountRecord } from "../account-store"; @@ -34,6 +35,9 @@ export async function handleCodexAuthAPI( convergeCodexCatalog?: CodexAuthCatalogConvergence, principal?: import("../../server/management-auth").ManagementPrincipal, ): Promise { + if (url.pathname === "/api/codex-auth/preserve-signed-in" && (req.method === "GET" || req.method === "PUT")) { + return handleCodexAccountPreservation(req, config); + } if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") { const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) }); diff --git a/src/codex/routing/selection.ts b/src/codex/routing/selection.ts index 941783e421d..049ab1a5a9e 100644 --- a/src/codex/routing/selection.ts +++ b/src/codex/routing/selection.ts @@ -1,7 +1,8 @@ import { getEffectiveCodexAutoSwitchThreshold } from "../account-auto-switch"; import { codexAccountUsesCreditsAfterLimit, isCodexAccountHeldForCredits } from "../account-credit-use"; import { isCodexAccountPaused } from "../account-pause"; -import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority"; +import { pinnedCodexAccountId } from "../account-priority"; +import { codexSelectionPriorityLookup } from "../account-preservation"; import { isSelectableCodexPoolAccount } from "../account-id"; import { isAccountNeedsReauth } from "../account-runtime-state"; import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "../account-usability"; @@ -248,7 +249,7 @@ export function getEligiblePoolAccounts( const pinned = pinnedCodexAccountId(config); return selectPriorityTier( withoutModelDeniedAccounts(ids, selectionOptions?.deniedModelAccountIds, pinned), - codexAccountPriorityLookup(config), + codexSelectionPriorityLookup(config), id => hasCodexQuotaHeadroom(config, id, selectionOptions, now), pinned, ); @@ -767,7 +768,7 @@ export function pickPriorityPreemption( && eligible.includes(pinned) && hasCodexQuotaHeadroom(config, pinned, selectionOptions, now) ) return null; - const priorityOf = codexAccountPriorityLookup(config); + const priorityOf = codexSelectionPriorityLookup(config); if (priorityOf(eligible[0]!) <= priorityOf(active)) return null; // Members without headroom are in the tier only because a sibling has some; // picking one would hand the request straight back to a drained account. diff --git a/src/codex/signed-in-identity.ts b/src/codex/signed-in-identity.ts new file mode 100644 index 00000000000..fa4878ec961 --- /dev/null +++ b/src/codex/signed-in-identity.ts @@ -0,0 +1,40 @@ +import { extractEmail, inspectChatGptDomainClaim } from "../oauth/chatgpt"; +import type { CodexTokens } from "./auth-collision"; + +export type CodexLoginIdentity = Readonly<{ accountId: string; email: string }>; +let observed: CodexLoginIdentity | undefined; + +export function codexLoginIdentity(accountId: unknown, email: unknown): CodexLoginIdentity | undefined { + const member = typeof email === "string" ? email.trim().toLowerCase() : ""; + const workspace = typeof accountId === "string" ? accountId.trim() : ""; + return workspace && member ? { accountId: workspace, email: member } : undefined; +} + +/** Undefined is insufficient evidence; null means conflicting or malformed token evidence. */ +export function codexTokenIdentity(tokens: CodexTokens): CodexLoginIdentity | undefined | null { + if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" + || (tokens.id_token !== undefined && typeof tokens.id_token !== "string")) return null; + const claims = [tokens.id_token, tokens.access_token] + .filter((token): token is string => typeof token === "string") + .map(inspectChatGptDomainClaim); + if (claims.some(claim => claim.kind === "invalid")) return null; + const claimIds = claims.flatMap(claim => claim.kind === "valid" ? [claim.accountId] : []); + const accountId = tokens.account_id.trim() || claimIds[0]; + if (claimIds.some(claim => claim !== accountId)) return null; + const emails = new Set([extractEmail(tokens.id_token), extractEmail(undefined, tokens.access_token)] + .flatMap(email => email?.trim() ? [email.trim().toLowerCase()] : [])); + if (emails.size > 1) return null; + return codexLoginIdentity(accountId, [...emails][0]); +} + +/** Publish only from an existing physical-login observation, never an incoming bearer. */ +export function observeSignedInCodexIdentity(tokens?: CodexTokens): void { + observed = tokens ? codexTokenIdentity(tokens) ?? undefined : undefined; +} + +/** Routing reads memory only, including while the native profile is fenced. */ +export function getSignedInCodexIdentity(): CodexLoginIdentity | undefined { return observed; } + +export function sameCodexLogin(a: CodexLoginIdentity, b: CodexLoginIdentity | undefined): boolean { + return a.accountId === b?.accountId && a.email === b.email; +} diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index d91f8bd602b..63dc7a0e6bc 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -404,12 +404,15 @@ function codexAccountPrioritiesError(value: unknown): string | null { return null; } -function codexAccountPriorityFailbackError(value: unknown): string | null { +function codexAccountSelectionPreferencesError(value: unknown): string | null { const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "codexAccountPriorityFailback")) return null; - const enabled = raw.codexAccountPriorityFailback; - if (enabled === undefined || typeof enabled === "boolean") return null; - return "schema_invalid: codexAccountPriorityFailback: must be a boolean or omitted"; + if (!raw) return null; + for (const key of ["codexAccountPriorityFailback", "codexPreserveSignedInAccount"]) { + if (raw[key] !== undefined && typeof raw[key] !== "boolean") { + return `schema_invalid: ${key}: must be a boolean or omitted`; + } + } + return null; } /** @@ -710,7 +713,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? codexPoolError(value) ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) - ?? codexAccountPriorityFailbackError(value) + ?? codexAccountSelectionPreferencesError(value) ?? poolCredentialGroupsError(value) ?? codexQuotaAutoRefreshError(value) ?? codexAccountPickerEnabledError(value) diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 36a09ba78f9..bd6282d8624 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -307,6 +307,7 @@ export const configSchema = z.object({ codexAccountAutoSwitchThresholds: z.unknown().optional().transform(salvageCodexAccountAutoSwitchThresholds), // An invalid optional preference must not discard providers or credential rows. codexAccountPriorityFailback: z.boolean().optional().catch(false), + codexPreserveSignedInAccount: z.boolean().optional().catch(false), // Absent or invalid keeps the sliding window on. Explicit false restores consecutive-only steering. codexFailureWindow: z.boolean().optional().catch(undefined), codexPinnedTransientPolicy: z.enum(["hold", "detour-new-threads"]).optional().catch("hold"), diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 3a24f7138fe..54caff58d0e 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -97,6 +97,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management-api { method: "POST", path: "/api/stop", module: "server/management-api", mutates: true }, // codex/auth-api + { method: "GET", path: "/api/codex-auth/preserve-signed-in", module: "codex/auth-api/routes", mutates: false }, + { method: "PUT", path: "/api/codex-auth/preserve-signed-in", module: "codex/auth-api/routes", mutates: true }, { method: "DELETE", path: "/api/codex-auth/accounts", module: "codex/auth-api/routes", mutates: true }, { method: "GET", path: "/api/codex-auth/accounts", module: "codex/auth-api/routes", mutates: false }, { method: "GET", path: "/api/codex-auth/active", module: "codex/auth-api/routes", mutates: false }, diff --git a/src/types/config.ts b/src/types/config.ts index 9b56063c760..20e9c5720d0 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1207,6 +1207,8 @@ export interface OcxConfig { autoSwitchThreshold?: number; /** Opt-in: return bound quota-strategy tasks to recovered higher-priority accounts. */ codexAccountPriorityFailback?: boolean; + /** Prefer other identities, reserving the current native sign-in as pool fallback. */ + codexPreserveSignedInAccount?: boolean; /** New-session account rotation strategy for the Codex pool. Default quota (today's behaviour). */ accountPoolStrategy?: OcxAccountPoolRotationStrategy | "reset-first"; /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ diff --git a/structure/cli-management.md b/structure/cli-management.md index ec0a2af8f04..162241f5022 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -125,3 +125,5 @@ Internal version observations in `src/service/managing-cli.ts` (`probeVersionOnc `src/cli/account-auth.ts` creates a Grok coupon operation UUID before every explicitly confirmed consume request unless the caller supplies one. Uncertain delivery and unknown-outcome API responses, including `operation_state_changed`, print guidance to preserve that same `--operation-id`; the CLI performs no automatic redemption retry. Ledger and reconciliation semantics are owned by [Grok reset coupons](providers/xai-grok.md#grok-reset-coupons-billing-api-parity). Remote Link enrollment is shared with the dashboard through `src/server/management/link-enrollment.ts` and `src/cli/link-enrollment.ts`. `ocx link probe ` returns a temporary SSH fingerprint/key type/version offer. `ocx link add --expect-fingerprint SHA256:…` confirms exact trust and applies under loopback admin authority, with TTY explicit confirmation when the flag is omitted. Non-TTY omission fails before discovery. One Home URL and a 360-second deadline cover all requests and bodies; redirects are refused, credentials are never printed, and post-connect residual failures retain fixed recovery. `tests/cli/cli-link-enrollment.test.ts` verifies these boundaries. Child `link status` fallback uses the shared projector with persisted client connection and `clientLinkTunnelStatus`, plus validated client-owned sidecar/marker initiation identity; ambiguous evidence never invents direction. An unobserved Home-owned tunnel is conservatively idle. Link-mode `status` reads supported Hub projections via the local Child without sending a credential; a standalone Home role-based 404 explains absent Hub state rather than recommending an upgrade. + +`ocx account preserve-signed-in openai` reads or updates the runtime-owned [signed-in quota policy](providers/openai-accounts.md#preserve-signed-in-quota), with affected entries, next choice and explicit next-request binding release. It never reads a local account roster. diff --git a/structure/config.md b/structure/config.md index f803d408c5c..df3f6ae5b15 100644 --- a/structure/config.md +++ b/structure/config.md @@ -500,7 +500,7 @@ Client connection metadata stores a stable `apiKeyId` and a non-secret rotation Codex display-cache expiry, retained blocking main-policy evidence, and reset history follow the [quota cache contract](providers/openai-tiers.md#quota-cache-and-short-window-history); automatic account decisions also use [spendable credit evidence](providers/openai-tiers.md#spendable-codex-credits). -`codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-accounts.md#automatic-pool-plan-exclusions). Optional `codexPool.startIdleWindows` defaults off and follows the [idle-window steering contract](providers/openai-accounts.md#idle-window-steering), using real new requests to start observed idle 5-hour windows. +`codexPreserveSignedInAccount` is an optional boolean defaulting off; malformed loads fall back to false and malformed writes are rejected. Its [identity-aware ordering contract](providers/openai-accounts.md#preserve-signed-in-quota) preserves custom priorities. `codexPool.excludedPlans` is interpreted only by automatic selection; its all-excluded and explicit-route behavior follows the [plan exclusion contract](providers/openai-accounts.md#automatic-pool-plan-exclusions). Optional `codexPool.startIdleWindows` defaults off and follows the [idle-window steering contract](providers/openai-accounts.md#idle-window-steering), using real new requests to start observed idle 5-hour windows. Connected CLI usage follows the [client-scoped hub usage contract](dashboard-and-usage.md#usage-accounting); local management and account data remain separate. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 961a55f07ed..ef1c8c5f505 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,6 +1,6 @@ # GUI And Management API -Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null), +`GET/PUT /api/codex-auth/preserve-signed-in` and CLI `account preserve-signed-in` expose the [signed-in quota policy](providers/openai-accounts.md#preserve-signed-in-quota); strict boolean writes can explicitly release affected bindings/pins for the next request after persistence. The GUI retains its existing account controls. Anthropic OAuth account DTOs include `autoSwitchThresholdOverride` (integer or null), `autoSwitchThreshold` (pool default) and `effectiveAutoSwitchThreshold`. The dedicated `PUT /api/oauth/accounts/auto-switch` accepts `{ provider: "anthropic", accountId, threshold }`; explicit null restores inheritance and missing/invalid values fail. Other providers are rejected. diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index af23cc1d3a3..1794b6d0f27 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -316,6 +316,30 @@ as the writer. `"opencodex"`, a missing binary, or an older binary keeps the guarded writer above. While the test home guard is armed, auto resolution never spawns Codex. `ocx doctor` prints the active mode and its reason. +## Preserve signed-in quota + +`codexPreserveSignedInAccount` defaults off. `src/codex/account-preservation.ts` gives native main +and matching ordinary pool entries a lower effective priority band; stored per-ID priorities +remain unchanged inside each band. Eligibility, model evidence, quota headroom, manual pin ceilings +and every strategy still use the shared selection path. Unbound quota preemption compares these +same effective priorities; healthy bindings remain unless existing priority-failback rules apply. +Exact account routes bypass the preference. Fallback may spend reserved quota, never bypassing +pause, reauth, model access, hard lock or paid-credit consent. + +`src/codex/signed-in-identity.ts` requires matching selected workspace and normalized member email. +Its process-local observation is published by existing physical-login lifecycle reads and the +owned linked-group read; request routing reads only memory, even during native profile drain. +Unknown/conflicting evidence never links an ordinary row. Startup seeds the observation; later +owned observations follow login changes, and confirmed transitions clear stale observations. +Pool replacements are matched from their current credentials. Existing lifecycle transitions +purge main bindings. No credential or member identity is persisted by the policy. + +`src/codex/auth-api/account-preservation.ts` exposes GET/PUT policy status under native-main +ownership and reports affected entry IDs and the actual unbound preview. A validated PUT saves +before releasing runtime state. Explicit application releases affected affinity scopes and manual +preference/pin only for subsequent requests; already-materialized requests retain their account. +The CLI drives this API; linked pause remains the absolute exclusion alternative. + ## Manual account pause and resume Manual pause/resume in `src/codex/auth-api/account-pause-group.ts` resolves existing native-main diff --git a/tests/cli/cli-account-preservation.test.ts b/tests/cli/cli-account-preservation.test.ts new file mode 100644 index 00000000000..3f3db41d799 --- /dev/null +++ b/tests/cli/cli-account-preservation.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { cmdPreserveSignedIn } from "../../src/cli/account-preservation"; +import type { AccountDeps } from "../../src/cli/account-api"; +let log: ReturnType, error: ReturnType; +beforeEach(() => { log = spyOn(console, "log").mockImplementation(() => {}); error = spyOn(console, "error").mockImplementation(() => {}); }); +afterEach(() => { log.mockRestore(); error.mockRestore(); }); +function fixture() { + const calls: { method: string; body: unknown }[] = []; + const deps: AccountDeps = { baseUrl: "http://127.0.0.1:10100", loadConfigImpl: () => { throw new Error("No local config reads"); }, + fetchImpl: (async (url, init) => { + expect(new URL(String(url)).pathname).toBe("/api/codex-auth/preserve-signed-in"); + calls.push({ method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); + return Response.json({ enabled: true, affectedAccountIds: ["__main__", "copy"], nextAccountId: "other", pinnedAccountId: null, bindings: "retained" }); + }) as typeof fetch, + }; + return { deps, calls }; +} +test("bare status only reads runtime and prints affected entries and next choice", async () => { + const f = fixture(); expect(await cmdPreserveSignedIn(["openai"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ method: "GET", body: undefined }]); + expect(log.mock.calls.flat().join("\n")).toContain("Next unbound choice: other"); +}); +test("on and explicit transition send exact API contract", async () => { + const f = fixture(); expect(await cmdPreserveSignedIn(["openai", "on", "--apply-to-bound", "--json"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ method: "PUT", body: { enabled: true, applyToBound: true } }]); + expect(JSON.parse(String(log.mock.calls[0]![0])).affectedAccountIds).toEqual(["__main__", "copy"]); +}); +test("off leaves custom ordering to the runtime", async () => { + const f = fixture(); expect(await cmdPreserveSignedIn(["openai", "off"], f.deps)).toBe(0); + expect(f.calls).toEqual([{ method: "PUT", body: { enabled: false, applyToBound: false } }]); +}); +for (const args of [["anthropic", "on"], ["openai", "off", "--apply-to-bound"], ["openai", "status", "--apply-to-bound"], ["openai", "on", "off"], ["openai", "on", "--unknown"], ["openai", "--json", "--json"]]) { + test(`invalid operands ${args.join(" ")} never reach runtime`, async () => { + const f = fixture(); expect(await cmdPreserveSignedIn(args, f.deps)).toBe(1); expect(f.calls).toEqual([]); + }); +} diff --git a/tests/codex-integration/codex-preserve-signed-in.test.ts b/tests/codex-integration/codex-preserve-signed-in.test.ts new file mode 100644 index 00000000000..bc8ae3edaab --- /dev/null +++ b/tests/codex-integration/codex-preserve-signed-in.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { OcxConfig } from "../../src/types"; +import { getDefaultConfig } from "../../src/config/proxy-env"; +import { loadConfig } from "../../src/config"; +import { configSchema } from "../../src/config/schema/config-schema"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; +import { handleCodexAuthAPI, clearAccountQuota, updateAccountQuota } from "../../src/codex/auth-api"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import * as runtimeConfig from "../../src/codex/auth-api/runtime-config"; +import * as authRead from "../../src/codex/auth-collision"; +import { setMainAccountPlan } from "../../src/codex/main-account"; +import { clearCodexUpstreamHealth, clearThreadAccountMap, previewCodexAccountForRequest, resolveCodexAccountForThread } from "../../src/codex/routing"; +import { clearPoolRotationState } from "../../src/codex/pool-rotation"; +import { setCodexAccountPin } from "../../src/codex/account-priority"; +import { signedInCodexAccountIds } from "../../src/codex/account-preservation"; +import { getEligiblePoolAccounts } from "../../src/codex/routing/selection"; +import { rememberActiveCodexAccount } from "../../src/codex/routing/active-account"; +import { initializeMainAccountPolicyBinding, reconcileMainCodexAccountRuntimeState, resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { acquireNativeMainProfileDrain, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests } from "../../src/lib/windows-secret-acl"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let scratch: string; +let authPath: string; +let previousHome: string | undefined, previousCodex: string | undefined; +const aclOk = { success: true, exitCode: 0, timedOut: false, stdout: "", stderr: "" }; +function mainLogin(email = "member@example.test", accountId = "workspace-a") { + const id_token = `h.${Buffer.from(JSON.stringify({ email, chatgpt_account_id: accountId })).toString("base64url")}.s`; + writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-main", id_token, account_id: accountId } })); +} +function config(strategy: OcxConfig["accountPoolStrategy"] = "quota"): OcxConfig { + const c: OcxConfig = { ...getDefaultConfig(), autoSwitchThreshold: 80, + accountPoolStrategy: strategy, activeCodexAccountId: "copy", codexPreserveSignedInAccount: true, + codexAccounts: [{ id: "copy", email: " MEMBER@example.test ", isMain: false }, + { id: "other", email: "other@example.test", isMain: false }], + }; + for (const id of ["copy", "other"]) saveCodexAccountCredential(id, { + accessToken: `synthetic-${id}`, refreshToken: `synthetic-refresh-${id}`, + expiresAt: Date.now() + 300_000, chatgptAccountId: id === "copy" ? "workspace-a" : "workspace-b", + }); + mainLogin(); + initializeMainAccountPolicyBinding(authPath); + updateAccountQuota("copy", 1); + updateAccountQuota("__main__", 2); + updateAccountQuota("other", 20); + return c; +} +async function api(c: OcxConfig, body?: unknown) { + const req = new Request("http://localhost/api/codex-auth/preserve-signed-in", body === undefined ? {} : { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }); + return (await handleCodexAuthAPI(req, new URL(req.url), c))!; +} +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; previousCodex = process.env.CODEX_HOME; + scratch = mkdtempSync(join(tmpdir(), "ocx-preserve-")); + const codex = join(scratch, "codex"); mkdirSync(codex); authPath = join(codex, "auth.json"); + process.env.OPENCODEX_HOME = scratch; process.env.CODEX_HOME = codex; + setIcaclsRunnerForTests(() => aclOk); setAsyncIcaclsRunnerForTests(async () => aclOk); + resetLifecycleDrainStateForTests(); resetMainCodexAccountIdentityTrackingForTests(); + clearThreadAccountMap(); clearCodexUpstreamHealth(); clearPoolRotationState(); clearAccountQuota(); setMainAccountPlan(null); +}); +afterEach(async () => { + resetLifecycleDrainStateForTests(); resetMainCodexAccountIdentityTrackingForTests(); + clearThreadAccountMap(); clearCodexUpstreamHealth(); clearPoolRotationState(); clearAccountQuota(); setMainAccountPlan(null); + await flushConfigDirHardeningForTests(); setIcaclsRunnerForTests(null); setAsyncIcaclsRunnerForTests(null); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousCodex === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodex; + removeTreeWithRetry(scratch); +}); + +for (const strategy of ["quota", "round-robin", "fill-first", "reset-first"] as const) { + test(`${strategy}: other identities outrank native main and its active duplicate`, () => { + const c = config(strategy); + c.codexAccountPriorities = { copy: 100, __main__: 100, other: -100 }; + rememberActiveCodexAccount(c, "copy"); + expect(previewCodexAccountForRequest(null, c)).toBe("other"); + expect(resolveCodexAccountForThread("new-thread", c)).toBe("other"); + expect(c.codexAccountPriorities).toEqual({ copy: 100, __main__: 100, other: -100 }); + }); + test(`${strategy}: guarded fallback can spend the signed-in quota`, () => { + const c = config(strategy); c.pausedCodexAccountIds = ["other"]; + expect(["copy", "__main__"]).toContain(resolveCodexAccountForThread(null, c)); + c.pausedCodexAccountIds.push("copy", "__main__"); + expect(getEligiblePoolAccounts(c)).toEqual([]); + }); +} +test("healthy bound thread and manual pin survive policy enable; explicit apply releases both", async () => { + const c = config(); c.codexPreserveSignedInAccount = false; + setCodexAccountPin(c, "copy"); + expect(resolveCodexAccountForThread("existing", c)).toBe("copy"); + const auth = readFileSync(authPath, "utf8"); + expect((await api(c, { enabled: true })).status).toBe(200); + expect(c.activeCodexAccountPinned).toBe("copy"); + expect(resolveCodexAccountForThread("existing", c)).toBe("copy"); + expect(resolveCodexAccountForThread(null, c)).toBe("copy"); + const result = await (await api(c, { enabled: true, applyToBound: true })).json(); + expect(result.bindings).toBe("released-for-next-request"); expect(result.releasedPin).toBe(true); + expect(result.nextAccountId).toBe("other"); + expect(c.activeCodexAccountPinned).toBeUndefined(); + expect(resolveCodexAccountForThread("existing", c)).toBe("other"); + expect(readFileSync(authPath, "utf8")).toBe(auth); + expect(loadConfig().codexPreserveSignedInAccount).toBe(true); +}); +test("unrelated pin and binding remain intact during explicit application", async () => { + const c = config(); c.activeCodexAccountId = "other"; setCodexAccountPin(c, "other"); + expect(resolveCodexAccountForThread("unrelated", c)).toBe("other"); + const result = await (await api(c, { enabled: true, applyToBound: true })).json(); + expect(result.releasedPin).toBe(false); expect(c.activeCodexAccountPinned).toBe("other"); + expect(resolveCodexAccountForThread("unrelated", c)).toBe("other"); +}); +test("same workspace different member and same member different workspace are independent", () => { + const c = config(); + c.codexAccounts![1]!.email = "another@example.test"; + saveCodexAccountCredential("other", { accessToken: "synthetic", refreshToken: "synthetic-r", expiresAt: Date.now() + 300_000, chatgptAccountId: "workspace-a" }); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "copy"]); + mainLogin("member@example.test", "workspace-b"); reconcileMainCodexAccountRuntimeState(); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__"]); +}); +test("owned login replacement and pool credential replacement update protected entries", async () => { + const c = config(); + expect((await (await api(c)).json()).affectedAccountIds).toEqual(["__main__", "copy"]); + mainLogin("other@example.test", "workspace-b"); reconcileMainCodexAccountRuntimeState(); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "other"]); + saveCodexAccountCredential("other", { accessToken: "replacement", refreshToken: "replacement-r", expiresAt: Date.now() + 300_000, chatgptAccountId: "workspace-c" }); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__"]); +}); +test("drain selection protects duplicates using memory without reading native credentials", async () => { + const c = config(); const drain = acquireNativeMainProfileDrain("preservation-test"); + const read = spyOn(authRead, "readCodexTokensResult").mockImplementation(() => { throw new Error("fenced read"); }); + try { + expect(getEligiblePoolAccounts(c, undefined, Date.now(), undefined, { nativeMainSelectionOnly: true })).toEqual(["other"]); + expect((await api(c, { enabled: true, applyToBound: true })).status).toBe(503); + expect(read).not.toHaveBeenCalled(); + } finally { read.mockRestore(); drain.release(); } +}); +test("quota threshold, credit hold and model availability still control fallback", () => { + const c = config(); updateAccountQuota("other", 90); + expect(getEligiblePoolAccounts(c)).toEqual(["__main__", "copy"]); + c.creditCodexAccountIds = []; updateAccountQuota("copy", 100, Date.now() + 60_000); updateAccountQuota("__main__", 100, Date.now() + 60_000); + expect(getEligiblePoolAccounts(c)).toEqual(["other"]); + expect(getEligiblePoolAccounts(c, undefined, Date.now(), undefined, { modelEligibleAccountIds: new Set(["copy"]) })).toEqual([]); +}); +test("status is observational; disabling preserves and restores custom priorities", async () => { + const c = config(); c.codexAccountPriorities = { copy: 2, other: -2 }; + const result = await (await api(c)).json(); + expect(result.nextAccountId).toBe("other"); expect(c.activeCodexAccountId).toBe("copy"); + const off = await (await api(c, { enabled: false })).json(); + expect(off.nextAccountId).toBe("copy"); expect(c.codexAccountPriorities).toEqual({ copy: 2, other: -2 }); +}); +for (const body of [null, [], {}, { enabled: "true" }, { enabled: true, applyToBound: 1 }, { enabled: false, applyToBound: true }, { enabled: true, extra: true }]) { + test(`invalid management policy body ${JSON.stringify(body)} is rejected`, async () => { + const c = config(); expect((await api(c, body)).status).toBe(400); expect(c.codexPreserveSignedInAccount).toBe(true); + }); +} +test("config loads malformed policy safely without discarding providers", () => { + expect(configSchema.parse({ ...getDefaultConfig(), codexPreserveSignedInAccount: "true" }).codexPreserveSignedInAccount).toBe(false); +}); + +test("missing member or conflicting tokens never link an ordinary pool entry", async () => { + const c = config(); + writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-opaque", account_id: "workspace-a" } })); + reconcileMainCodexAccountRuntimeState(); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__"]); + expect((await (await api(c)).json()).identityKnown).toBe(false); + const conflicting = `h.${Buffer.from(JSON.stringify({ email: "member@example.test", chatgpt_account_id: "workspace-x" })).toString("base64url")}.s`; + writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic-opaque", id_token: conflicting, account_id: "workspace-a" } })); + reconcileMainCodexAccountRuntimeState(); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__"]); + expect((await api(c, { enabled: false })).status).toBe(503); + expect(c.codexPreserveSignedInAccount).toBe(true); +}); +test("confirmed main replacement retires main bindings before selecting new work", () => { + const c = config(); c.codexPreserveSignedInAccount = false; c.activeCodexAccountId = "__main__"; + setCodexAccountPin(c, "__main__"); reconcileMainCodexAccountRuntimeState(); + expect(resolveCodexAccountForThread("native-existing", c)).toBe("__main__"); + mainLogin("other@example.test", "workspace-b"); reconcileMainCodexAccountRuntimeState(); + c.codexPreserveSignedInAccount = true; delete c.activeCodexAccountPinned; + expect(resolveCodexAccountForThread("native-existing", c)).toBe("copy"); +}); +test("request-owned credentials cannot change the protected physical login", () => { + const c = config(); + expect(getEligiblePoolAccounts(c, undefined, Date.now(), undefined, { + requestOwnedMainCredential: true, isMainAccountTokenLive: () => true, + })).toEqual(["other"]); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "copy"]); +}); +test("healthy binding without a manual pin stays on its account until explicit transition", async () => { + const c = config(); c.codexPreserveSignedInAccount = false; + expect(resolveCodexAccountForThread("bound-only", c)).toBe("copy"); + await api(c, { enabled: true }); + expect(resolveCodexAccountForThread("bound-only", c)).toBe("copy"); + expect(resolveCodexAccountForThread(null, c)).toBe("other"); + await api(c, { enabled: true, applyToBound: true }); + expect(resolveCodexAccountForThread("bound-only", c)).toBe("other"); +}); + +test("transient malformed native-login reads retain the last owned identity preference", () => { + const c = config(); writeFileSync(authPath, "{"); + reconcileMainCodexAccountRuntimeState(); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "copy"]); + expect(getEligiblePoolAccounts(c, undefined, Date.now(), undefined, { nativeMainSelectionOnly: true })).toEqual(["other"]); +}); + +test("failed persistence keeps the previous policy, manual pin and live binding", async () => { + const c = config(); c.codexPreserveSignedInAccount = false; setCodexAccountPin(c, "copy"); + expect(resolveCodexAccountForThread("save-failure", c)).toBe("copy"); + const save = spyOn(runtimeConfig, "saveRuntimeConfig").mockImplementation(() => { throw new Error("synthetic save failure"); }); + try { + await expect(api(c, { enabled: true, applyToBound: true })).rejects.toThrow("synthetic save failure"); + expect(c.codexPreserveSignedInAccount).toBe(false); expect(c.activeCodexAccountPinned).toBe("copy"); + expect(resolveCodexAccountForThread("save-failure", c)).toBe("copy"); + } finally { save.mockRestore(); } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..9b1dacab5cd 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ { + "codex-preserve-signed-in.test.ts": "codex-integration", + "cli-account-preservation.test.ts": "cli", "codex-history-pending-wal.test.ts": "codex-integration", "service-log.test.ts": "service", "service-health-sentinel-policy.test.ts": "service", From 7588fa1844894310a710655655762bac08e4b6d2 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 15 Oct 2026 16:34:55 +0900 Subject: [PATCH 2/3] fix(codex): retain preservation evidence across conflicting observations --- docs-site/src/content/docs/reference/management-api.md | 2 +- src/cli/account-preservation.ts | 1 + src/codex/auth-api/account-preservation.ts | 1 + src/codex/signed-in-identity.ts | 5 ++++- structure/providers/openai-accounts.md | 2 +- tests/codex-integration/codex-preserve-signed-in.test.ts | 8 ++++++++ 6 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index a267d00fffb..ca94b5d780b 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -742,7 +742,7 @@ manager. Its routes are: | `POST /api/codex-auth/accounts/clear-cooldown` | Clear runtime cooldown for one account or all accounts | 400 invalid id | | `GET, PUT /api/codex-auth/active` | Read or select the active account | 400 invalid or missing account; 409 paused/legacy-row conflict or `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | Set the global quota threshold with `{ threshold }`, or an account override with `{ id, threshold }`; `null` restores global inheritance, and `__main__` selects the Desktop login | 400 invalid id/threshold; 404 missing account | -| `GET /api/codex-auth/preserve-signed-in` | Read policy, current `affectedAccountIds`, `identityKnown`, `nextAccountId`, `nextUsesSignedInQuota`, `pinnedAccountId`, `bindings`, and `fallbackConsumesSignedInQuota` | 503 native profile busy/unreadable | +| `GET /api/codex-auth/preserve-signed-in` | Read policy, current `affectedAccountIds`, `identityKnown`, `nextAccountId`, `nextUsesSignedInQuota`, `pinnedAccountId`, `bindings`, `priorityFailbackEnabled`, and `fallbackConsumesSignedInQuota` | 503 native profile busy/unreadable | | `PUT /api/codex-auth/preserve-signed-in` | Strict `{ enabled: boolean, applyToBound?: boolean }`; explicit `applyToBound: true` requires enabled, releases affected bindings/pin for next request; reports `releasedPin` | 400 invalid body; 503 native profile busy/unreadable | | `PUT, PATCH /api/codex-auth/pool-strategy` | Update Codex account-pool selection strategy | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | diff --git a/src/cli/account-preservation.ts b/src/cli/account-preservation.ts index 6484a07f1ec..dbbb6904f17 100644 --- a/src/cli/account-preservation.ts +++ b/src/cli/account-preservation.ts @@ -31,6 +31,7 @@ export async function cmdPreserveSignedIn(args: string[], deps: AccountDeps): Pr console.log(`Affected pool entries: ${result.json.affectedAccountIds.join(", ")}`); console.log(`Next unbound choice: ${result.json.nextAccountId ?? "none"}`); console.log(`Bindings: ${result.json.bindings}; manual pin: ${result.json.pinnedAccountId ?? "none"}`); + if (result.json.priorityFailbackEnabled === true) console.log("Existing priority failback can move bound work when its quota and freshness conditions hold."); console.log("Fallback can consume signed-in quota. Exact-account models override this preference."); console.log("Use on --apply-to-bound to release affected bindings and pins for the next request."); } diff --git a/src/codex/auth-api/account-preservation.ts b/src/codex/auth-api/account-preservation.ts index 3f45508e943..ac8f7d11b15 100644 --- a/src/codex/auth-api/account-preservation.ts +++ b/src/codex/auth-api/account-preservation.ts @@ -50,6 +50,7 @@ export async function handleCodexAccountPreservation(req: Request, config: OcxCo pinnedAccountId: pinnedCodexAccountId(runtime) ?? null, bindings: update?.applyToBound ? "released-for-next-request" : "retained", releasedPin, + priorityFailbackEnabled: runtime.codexAccountPriorityFailback === true, fallbackConsumesSignedInQuota: true, }); }); diff --git a/src/codex/signed-in-identity.ts b/src/codex/signed-in-identity.ts index fa4878ec961..ec595a79384 100644 --- a/src/codex/signed-in-identity.ts +++ b/src/codex/signed-in-identity.ts @@ -29,7 +29,10 @@ export function codexTokenIdentity(tokens: CodexTokens): CodexLoginIdentity | un /** Publish only from an existing physical-login observation, never an incoming bearer. */ export function observeSignedInCodexIdentity(tokens?: CodexTokens): void { - observed = tokens ? codexTokenIdentity(tokens) ?? undefined : undefined; + if (!tokens) { observed = undefined; return; } + const next = codexTokenIdentity(tokens); + // Conflicting claims can be a partial external write, never proof of a new login. + if (next !== null) observed = next; } /** Routing reads memory only, including while the native profile is fenced. */ diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 1794b6d0f27..130b529323e 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -329,7 +329,7 @@ pause, reauth, model access, hard lock or paid-credit consent. `src/codex/signed-in-identity.ts` requires matching selected workspace and normalized member email. Its process-local observation is published by existing physical-login lifecycle reads and the owned linked-group read; request routing reads only memory, even during native profile drain. -Unknown/conflicting evidence never links an ordinary row. Startup seeds the observation; later +Unknown/conflicting evidence never links a new ordinary row; transient failures and conflicting claims retain the last verified observation. Startup seeds the observation; later owned observations follow login changes, and confirmed transitions clear stale observations. Pool replacements are matched from their current credentials. Existing lifecycle transitions purge main bindings. No credential or member identity is persisted by the policy. diff --git a/tests/codex-integration/codex-preserve-signed-in.test.ts b/tests/codex-integration/codex-preserve-signed-in.test.ts index bc8ae3edaab..411369608b3 100644 --- a/tests/codex-integration/codex-preserve-signed-in.test.ts +++ b/tests/codex-integration/codex-preserve-signed-in.test.ts @@ -215,3 +215,11 @@ test("failed persistence keeps the previous policy, manual pin and live binding" expect(resolveCodexAccountForThread("save-failure", c)).toBe("copy"); } finally { save.mockRestore(); } }); + +test("conflicting token claims cannot retract an existing owned identity observation", () => { + const c = config(); + const id_token = `h.${Buffer.from(JSON.stringify({ email: "member@example.test", chatgpt_account_id: "workspace-x" })).toString("base64url")}.s`; + writeFileSync(authPath, JSON.stringify({ tokens: { access_token: "synthetic", id_token, account_id: "workspace-a" } })); + reconcileMainCodexAccountRuntimeState(); + expect([...signedInCodexAccountIds(c)]).toEqual(["__main__", "copy"]); +}); From 6ce501cc4eed059cc99e9ffa97407a21057a342b Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 15 Oct 2026 16:52:53 +0900 Subject: [PATCH 3/3] fix(codex): report signed-in quota use independently of policy state --- docs-site/src/content/docs/reference/management-api.md | 2 +- src/codex/auth-api/account-preservation.ts | 2 +- tests/codex-integration/codex-preserve-signed-in.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index ca94b5d780b..c3c09679e4e 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -742,7 +742,7 @@ manager. Its routes are: | `POST /api/codex-auth/accounts/clear-cooldown` | Clear runtime cooldown for one account or all accounts | 400 invalid id | | `GET, PUT /api/codex-auth/active` | Read or select the active account | 400 invalid or missing account; 409 paused/legacy-row conflict or `account_selection_unavailable` | | `PUT /api/codex-auth/auto-switch` | Set the global quota threshold with `{ threshold }`, or an account override with `{ id, threshold }`; `null` restores global inheritance, and `__main__` selects the Desktop login | 400 invalid id/threshold; 404 missing account | -| `GET /api/codex-auth/preserve-signed-in` | Read policy, current `affectedAccountIds`, `identityKnown`, `nextAccountId`, `nextUsesSignedInQuota`, `pinnedAccountId`, `bindings`, `priorityFailbackEnabled`, and `fallbackConsumesSignedInQuota` | 503 native profile busy/unreadable | +| `GET /api/codex-auth/preserve-signed-in` | Read policy, current `affectedAccountIds`, `identityKnown`, `nextAccountId`, `nextUsesSignedInQuota` (actual next choice, even when disabled), `pinnedAccountId`, `bindings`, `priorityFailbackEnabled`, and `fallbackConsumesSignedInQuota` | 503 native profile busy/unreadable | | `PUT /api/codex-auth/preserve-signed-in` | Strict `{ enabled: boolean, applyToBound?: boolean }`; explicit `applyToBound: true` requires enabled, releases affected bindings/pin for next request; reports `releasedPin` | 400 invalid body; 503 native profile busy/unreadable | | `PUT, PATCH /api/codex-auth/pool-strategy` | Update Codex account-pool selection strategy | 400 invalid strategy/config | | `PUT /api/codex-auth/failover` | Set the account failover threshold | 400 invalid threshold | diff --git a/src/codex/auth-api/account-preservation.ts b/src/codex/auth-api/account-preservation.ts index ac8f7d11b15..79108fec524 100644 --- a/src/codex/auth-api/account-preservation.ts +++ b/src/codex/auth-api/account-preservation.ts @@ -46,7 +46,7 @@ export async function handleCodexAccountPreservation(req: Request, config: OcxCo return jsonResponse({ ok: true, enabled, affectedAccountIds, identityKnown: getSignedInCodexIdentity() !== undefined, nextAccountId, - nextUsesSignedInQuota: enabled && nextAccountId !== null && affectedAccountIds.includes(nextAccountId), + nextUsesSignedInQuota: nextAccountId !== null && affectedAccountIds.includes(nextAccountId), pinnedAccountId: pinnedCodexAccountId(runtime) ?? null, bindings: update?.applyToBound ? "released-for-next-request" : "retained", releasedPin, diff --git a/tests/codex-integration/codex-preserve-signed-in.test.ts b/tests/codex-integration/codex-preserve-signed-in.test.ts index 411369608b3..5a658ee3e05 100644 --- a/tests/codex-integration/codex-preserve-signed-in.test.ts +++ b/tests/codex-integration/codex-preserve-signed-in.test.ts @@ -149,7 +149,7 @@ test("status is observational; disabling preserves and restores custom prioritie const result = await (await api(c)).json(); expect(result.nextAccountId).toBe("other"); expect(c.activeCodexAccountId).toBe("copy"); const off = await (await api(c, { enabled: false })).json(); - expect(off.nextAccountId).toBe("copy"); expect(c.codexAccountPriorities).toEqual({ copy: 2, other: -2 }); + expect(off.nextAccountId).toBe("copy"); expect(off.nextUsesSignedInQuota).toBe(true); expect(c.codexAccountPriorities).toEqual({ copy: 2, other: -2 }); }); for (const body of [null, [], {}, { enabled: "true" }, { enabled: true, applyToBound: 1 }, { enabled: false, applyToBound: true }, { enabled: true, extra: true }]) { test(`invalid management policy body ${JSON.stringify(body)} is rejected`, async () => {