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 4c7dcb2c1bd..c98ac1c098d 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -874,12 +874,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 7b3f7a32720..0fffb323c6f 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -84,6 +84,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 743dd27ef85..c71bea71d29 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -765,6 +765,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` (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 | | `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 9a21a1f3e05..7cd7c85e5ad 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-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", + "explicit": { "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "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", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index ef01e117654..63329af9470 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) | 41 | +| [accounts](01_surface_accounts.md) | 42 | | [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) @@ -1397,6 +1401,6 @@ Original invocation order. These headings preserve links to the previous single- ## Counts -- declared capabilities: 337 -- of those, state-changing: 206 +- declared capabilities: 338 +- of those, state-changing: 207 - head-resolved invocations: 2 diff --git a/skills/ocx/references/01_surface_accounts.md b/skills/ocx/references/01_surface_accounts.md index de86c7746cc..8a658e0a1c9 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: 41. +Declared capabilities: 42. ### `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..dbbb6904f17 --- /dev/null +++ b/src/cli/account-preservation.ts @@ -0,0 +1,44 @@ +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"}`); + 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."); + } + 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 a743347a7ad..925512acfe8 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] @@ -477,6 +478,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 8f86d4fef19..bf9b7804f9c 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 5c5c1be9451..8e6e51f9ca2 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 { setCodexAccountUseRemainingQuota } from "./account-use-remaining"; @@ -67,7 +68,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; @@ -111,6 +116,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; } @@ -123,6 +129,7 @@ export function applyConfirmedMainCodexAccountTransition( fromAccountId: string, toAccountId: string, ): boolean { + observeSignedInCodexIdentity(); if (!fromAccountId || !toAccountId || fromAccountId === toAccountId) { if (toAccountId) { observedMainChatgptAccountId = toAccountId; @@ -138,6 +145,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..79108fec524 --- /dev/null +++ b/src/codex/auth-api/account-preservation.ts @@ -0,0 +1,57 @@ +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: nextAccountId !== null && affectedAccountIds.includes(nextAccountId), + pinnedAccountId: pinnedCodexAccountId(runtime) ?? null, + bindings: update?.applyToBound ? "released-for-next-request" : "retained", + releasedPin, + priorityFailbackEnabled: runtime.codexAccountPriorityFailback === true, + fallbackConsumesSignedInQuota: true, + }); + }); +} diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts index 179f1e5d578..fe65cfe1bb9 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 { handleUseRemainingQuotaRoute } from "./use-remaining-route"; @@ -35,6 +36,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/use-remaining" && (req.method === "GET" || req.method === "PUT")) { return handleUseRemainingQuotaRoute(req, url, config); } 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..ec595a79384 --- /dev/null +++ b/src/codex/signed-in-identity.ts @@ -0,0 +1,43 @@ +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 { + 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. */ +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 a842aec5b54..aca8261db5e 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -407,12 +407,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; } /** @@ -720,7 +723,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 3e8be8685e7..e53f721e76a 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -314,6 +314,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 b9839b219df..b46cdffe238 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/accounts/use-remaining", module: "codex/auth-api/routes", mutates: false }, diff --git a/src/types/config.ts b/src/types/config.ts index bad89c22bd5..c5d86ac2e89 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1222,6 +1222,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/INDEX.md b/structure/INDEX.md index 0aadb16fc81..f71e9bcfa45 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -77,6 +77,7 @@ Per-vendor contracts and the adapter authority that constructs them. | [`providers/oauth-code-display-login.md`](providers/oauth-code-display-login.md) | Headless code-display OAuth login: flow ownership, expiry, unsupported providers, single-operator store. | | [`providers/openai-tiers.md`](providers/openai-tiers.md) | Pool/Direct account modes, API-key separation, and the public provider and quota contract. | | [`providers/openai-accounts.md`](providers/openai-accounts.md) | Migration and restore, wire identity, store concurrency, pool ordering and exclusions, quota observations, and account-bound retention. | +| [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | Opt-in effective priority bands that spend other pool accounts before the signed-in identity, its identity observation and management API. | | [`providers/cursor.md`](providers/cursor.md) | Cursor native exec, parameterized models, checkpoints, and active-context usage. | | [`providers/google.md`](providers/google.md) | Gemini thought-text, response parts, thought-signature replay, and adjacency repair. | | [`providers/kiro.md`](providers/kiro.md) | Kiro parallel-tool hints, Responses text controls, and reasoning round-trip. | @@ -160,16 +161,20 @@ A source area can be described by more than one doc, because these docs are orga | `src/claude/` | [`runtime.md`](runtime.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/cli/models-runtime.ts` | [`pricing.md`](pricing.md) | | `src/client/` | [`runtime.md`](runtime.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md) | | `src/clients/` | [`clients/integrations.md`](clients/integrations.md)
[`clients/qoder.md`](clients/qoder.md) | | `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`subagent-capacity.md`](subagent-capacity.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`providers/openai-accounts.md`](providers/openai-accounts.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `src/codex/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | +| `src/codex/auth-api/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/codex/catalog/effort.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md)
[`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/catalog/model-hints.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/parsing.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/routed-gather.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/vendor-context.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/runtime.ts` | [`catalog-effort-clamp.md`](catalog-effort-clamp.md) | +| `src/codex/signed-in-identity.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md) | | `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`companion.md`](companion.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | diff --git a/structure/cli-management.md b/structure/cli-management.md index 6b59efcd821..0e539b2e1e5 100644 --- a/structure/cli-management.md +++ b/structure/cli-management.md @@ -127,3 +127,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 73568bafc5f..e6a777f0b45 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 3fd0a9ed511..3de7e7e8146 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,6 +1,6 @@ # GUI And Management API -`src/server/management/combo-routes.ts` retains omitted `metered: true` metadata by trimmed provider/model identity during dashboard-shaped saves. Explicit false clears the flag; false defaults are omitted from persisted targets. Omitted combo `allowMeteredTargets` survives saves and renames; explicit false clears it and stays sparse. The CLI exposes `--allow-metered-targets on|off`, and receipts preserve the boolean. Key and loopback operator consent follow the [runtime contract](runtime.md#request-local-target-compatibility). Model-price APIs and receipt validation follow the [pricing contract](pricing.md). 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-signed-in-preservation.md); strict boolean writes can explicitly release affected bindings/pins for the next request after persistence. The GUI retains its existing account controls. `src/server/management/combo-routes.ts` retains omitted `metered: true` metadata by trimmed provider/model identity during dashboard-shaped saves. Explicit false clears the flag; false defaults are omitted from persisted targets. Omitted combo `allowMeteredTargets` survives saves and renames; explicit false clears it and stays sparse. The CLI exposes `--allow-metered-targets on|off`, and receipts preserve the boolean. Key and loopback operator consent follow the [runtime contract](runtime.md#request-local-target-compatibility). Model-price APIs and receipt validation follow the [pricing contract](pricing.md). 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/manifest.json b/structure/manifest.json index c4c57b40009..5ae90f7e07a 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -471,6 +471,13 @@ "src/config.ts" ] }, + { + "path": "providers/openai-signed-in-preservation.md", + "tier": 4, + "title": "Preserve Signed-In Codex Quota", + "scope": "Opt-in effective priority bands that spend other pool accounts before the signed-in identity, its identity observation and management API.", + "documents": ["src/codex/account-preservation.ts", "src/codex/signed-in-identity.ts", "src/codex/auth-api/account-preservation.ts", "src/cli/account-preservation.ts"] + }, { "path": "providers/cursor.md", "tier": 4, diff --git a/structure/providers/openai-accounts.md b/structure/providers/openai-accounts.md index 9af8ea3bf38..1a199f651b5 100644 --- a/structure/providers/openai-accounts.md +++ b/structure/providers/openai-accounts.md @@ -316,6 +316,11 @@ 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 + +The opt-in preference that spends other pool accounts before the signed-in identity is +described in [`openai-signed-in-preservation.md`](openai-signed-in-preservation.md). + ## Manual account pause and resume Manual pause/resume in `src/codex/auth-api/account-pause-group.ts` resolves existing native-main diff --git a/structure/providers/openai-signed-in-preservation.md b/structure/providers/openai-signed-in-preservation.md new file mode 100644 index 00000000000..b98a8ba16f4 --- /dev/null +++ b/structure/providers/openai-signed-in-preservation.md @@ -0,0 +1,24 @@ +# Preserve Signed-In Codex 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 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. + +`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 (`src/cli/account-preservation.ts`) drives this API; linked pause remains the absolute +exclusion alternative. 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..5a658ee3e05 --- /dev/null +++ b/tests/codex-integration/codex-preserve-signed-in.test.ts @@ -0,0 +1,225 @@ +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(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 () => { + 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(); } +}); + +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"]); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f72dcdfb17c..674f4756f6d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "messages-native-empty-inline.test.ts": "claude-integration",