codex-multi-auth 2.21.0 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +8 -5
  3. package/config/schema/config.schema.json +8 -0
  4. package/dist/lib/accounts.d.ts +5 -0
  5. package/dist/lib/accounts.js +33 -4
  6. package/dist/lib/api-route-store.d.ts +7 -0
  7. package/dist/lib/api-route-store.js +100 -31
  8. package/dist/lib/auth/account-access.js +2 -1
  9. package/dist/lib/auth/auth.js +3 -2
  10. package/dist/lib/auth/device-auth.js +2 -1
  11. package/dist/lib/auth/http.d.ts +1 -0
  12. package/dist/lib/auth/http.js +1 -0
  13. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  14. package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
  15. package/dist/lib/codex-manager/api-login-menu.js +4 -6
  16. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  17. package/dist/lib/codex-manager/commands/locale.js +25 -0
  18. package/dist/lib/codex-manager/commands/status.d.ts +5 -1
  19. package/dist/lib/codex-manager/commands/status.js +501 -13
  20. package/dist/lib/codex-manager/help.js +3 -1
  21. package/dist/lib/codex-manager.js +98 -23
  22. package/dist/lib/config.js +3 -0
  23. package/dist/lib/constants.d.ts +1 -0
  24. package/dist/lib/constants.js +1 -0
  25. package/dist/lib/locale.d.ts +7 -0
  26. package/dist/lib/locale.js +35 -0
  27. package/dist/lib/model-route-policy.d.ts +1 -0
  28. package/dist/lib/model-route-policy.js +2 -1
  29. package/dist/lib/quota-probe.js +2 -1
  30. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  31. package/dist/lib/request/proxy-fetch.js +37 -0
  32. package/dist/lib/request/url-rewriting.js +17 -0
  33. package/dist/lib/runtime/account-model-catalog.d.ts +3 -0
  34. package/dist/lib/runtime/account-model-catalog.js +7 -2
  35. package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
  36. package/dist/lib/runtime/api-model-capabilities.js +155 -24
  37. package/dist/lib/runtime/app-bind.d.ts +7 -0
  38. package/dist/lib/runtime/app-bind.js +92 -1
  39. package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
  40. package/dist/lib/runtime/automatic-api-checks.js +188 -0
  41. package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
  42. package/dist/lib/runtime/model-discovery-status.js +64 -49
  43. package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
  44. package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
  45. package/dist/lib/runtime/responses-websocket.d.ts +48 -5
  46. package/dist/lib/runtime/responses-websocket.js +275 -39
  47. package/dist/lib/runtime/rotation-account-selection.d.ts +25 -5
  48. package/dist/lib/runtime/rotation-account-selection.js +94 -4
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
  50. package/dist/lib/runtime/rotation-proxy-state.js +376 -4
  51. package/dist/lib/runtime/rotation-server-types.d.ts +6 -0
  52. package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
  53. package/dist/lib/runtime/rotation-token-refresh.js +218 -27
  54. package/dist/lib/runtime-rotation-proxy.js +601 -105
  55. package/dist/lib/schemas.d.ts +5 -0
  56. package/dist/lib/schemas.js +1 -0
  57. package/dist/lib/storage/credential-sidecars.js +34 -2
  58. package/dist/lib/storage/file-lock.d.ts +2 -0
  59. package/dist/lib/storage/file-lock.js +44 -30
  60. package/dist/lib/usage/ledger.js +1 -0
  61. package/dist/lib/usage/redaction.js +1 -0
  62. package/dist/lib/usage/types.d.ts +1 -1
  63. package/dist/lib/web-dashboard-html.d.ts +1 -0
  64. package/dist/lib/web-dashboard-html.js +66 -0
  65. package/dist/lib/web-dashboard.d.ts +36 -0
  66. package/dist/lib/web-dashboard.js +322 -0
  67. package/package.json +3 -1
  68. package/scripts/codex-app-router.js +86 -14
  69. package/scripts/codex-routing.js +1 -0
  70. package/scripts/codex.js +150 -13
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.21.0",
3
+ "version": "2.23.0",
4
4
  "description": "Install and operate codex-multi-auth for the official @openai/codex CLI with multi-account OAuth rotation, switching, health checks, and recovery tools.",
5
5
  "interface": {
6
6
  "composerIcon": "./assets/codex-multi-auth-icon.svg"
package/README.md CHANGED
@@ -16,8 +16,9 @@ Using OpenCode rather than the Codex CLI? The sibling project [`oc-codex-multi-a
16
16
  ## What it does
17
17
 
18
18
  - **Account pool** — OAuth login for multiple ChatGPT accounts, stored locally under `~/.codex/multi-auth` (files `0600`, directories `0700`), with per-project pools under `projects/<project-key>/`.
19
- - **Switching and selection** — pin an account, pick the forecast-best one, or let health- and quota-aware hybrid selection rotate for you.
20
- - **Runtime rotation** — a default-on, loopback-only proxy that forwards Responses API and model-discovery traffic through the best available account during wrapper-launched Codex sessions.
19
+ - **Switching and selection** — pin an account, pick the forecast-best one, follow saved account order within customized priority tiers with sequential scheduling, or let health- and quota-aware hybrid selection rotate for you.
20
+ - **Runtime rotation** — a default-on, loopback-only proxy that forwards Responses API, model-discovery, image, thread-goal, and Codex dictation/transcription traffic through managed accounts during wrapper-launched Codex sessions.
21
+ - **API capability evidence** — opted-in API/ZDR routes receive daily billable checks while the router runs; status distinguishes configured access programs from verified responses. See [check behavior and controls](docs/reference/commands.md#codex-multi-auth-check).
21
22
  - **Diagnostics and repair** — status, live health checks, quota forecasts, JSON reports, and `fix`/`doctor`/`verify` commands that recover stale or damaged local state.
22
23
  - **Luna Reserve fallback** — treats `gpt-reserve` as a separately metered Luna allowance, automatically falls back after a genuine Luna quota 429, and exposes the Reserve percentage through `limits --json --refresh`.
23
24
  - **Local governance** — file-backed usage ledger, budget guards, account tags/weights/pause/drain policies, routing profiles, and an optional loopback bridge with bearer tokens for local OpenAI-compatible clients.
@@ -27,7 +28,8 @@ Using OpenCode rather than the Codex CLI? The sibling project [`oc-codex-multi-a
27
28
 
28
29
  | Binary | Role |
29
30
  | --- | --- |
30
- | `codex-multi-auth` | The account manager. Runs all 31 management subcommands locally (`login`, `status`, `switch`, `forecast`, `rotation`, `fix`, ...). The `auth` prefix is optional: `codex-multi-auth login` ≡ `codex-multi-auth auth login`. |
31
+ | `codex-multi-auth` | The account manager. Runs all management subcommands locally (`login`, `status`, `web`, `switch`, `forecast`, `rotation`, `fix`, ...). The `auth` prefix is optional: `codex-multi-auth login` ≡ `codex-multi-auth auth login`. |
32
+ | `codex-ma` | Short alias for `codex-multi-auth`, including the local real-time web dashboard. |
31
33
  | `codex-multi-auth-codex` | Opt-in wrapper. Handles `auth ...` locally and forwards every other command to the official Codex CLI, enabling the rotation proxy for request-bearing commands. |
32
34
  | `mcodex` | Convenience launcher over the wrapper, with optional `--monitor` and `--tmux` modes. No account logic of its own. |
33
35
  | `codex-multi-auth-app-launcher` | User-level OS launcher routing helper (Windows shortcut, macOS wrapper app, Linux `.desktop`). |
@@ -51,6 +53,7 @@ codex-multi-auth login
51
53
 
52
54
  # 2. Repeat login to add more accounts, then inspect the pool
53
55
  codex-multi-auth status
56
+ codex-ma web --no-open
54
57
 
55
58
  # 3. Probe account health and preview which account would serve next
56
59
  codex-multi-auth check
@@ -94,7 +97,7 @@ codex-multi-auth rotation status # is live rotation enabled?
94
97
 
95
98
  ## Runtime rotation, in one paragraph
96
99
 
97
- With rotation on (the default), request-bearing wrapper commands — `exec`, `review`, `resume`, `fork`, `app`, and the bare TUI — route through a local proxy that picks the healthiest account per request, refreshes tokens as needed, and fails over across the pool on rate limits within a bounded retry budget. The proxy listens on loopback only, authenticates its own clients with a per-process token, forwards only Responses API, model-discovery, image, and thread-goal calls, and never writes account emails or tokens to client headers or logs. Plain `codex` is never touched: rotation exists only inside `codex-multi-auth-codex`/`mcodex` sessions, or inside the packaged desktop app after an opt-in, reversible bind. Turn it off with `codex-multi-auth rotation disable` or `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`.
100
+ With rotation on (the default), request-bearing wrapper commands — `exec`, `review`, `resume`, `fork`, `app`, and the bare TUI — route through a local proxy that picks the healthiest account per request, refreshes tokens as needed, and fails over across the pool on rate limits within a bounded retry budget. The proxy listens on loopback only, authenticates its own clients with a per-process token, forwards only Responses API, model-discovery, image, thread-goal, and `POST /transcribe` calls, and never writes account emails or tokens to client headers or logs. Transcription preserves the incoming multipart audio body and boundary, uses managed ChatGPT OAuth, and does not replay ambiguous audio uploads across accounts. Plain `codex` is never touched: rotation exists only inside `codex-multi-auth-codex`/`mcodex` sessions, or inside the packaged desktop app after an opt-in, reversible bind. Turn it off with `codex-multi-auth rotation disable` or `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`.
98
101
 
99
102
  ## Where state lives
100
103
 
@@ -138,7 +141,7 @@ Common symptoms — wrong active account, OAuth callback port `1455` already bou
138
141
 
139
142
  ## Release notes
140
143
 
141
- - Current stable: [v2.21.0](docs/releases/v2.21.0.md) — `npm i -g codex-multi-auth`
144
+ - Current stable: [v2.23.0](docs/releases/v2.23.0.md) — `npm i -g codex-multi-auth`
142
145
  - Earlier stable lines: [v2.6.0](docs/releases/v2.6.0.md), [v2.5.0](docs/releases/v2.5.0.md)
143
146
  - Full release archive: [docs/README.md#release-history](docs/README.md#release-history)
144
147
 
@@ -31,6 +31,14 @@
31
31
  "pluginConfig": {
32
32
  "type": "object",
33
33
  "properties": {
34
+ "locale": {
35
+ "type": "string",
36
+ "enum": [
37
+ "auto",
38
+ "en",
39
+ "zh"
40
+ ]
41
+ },
34
42
  "codexMode": {
35
43
  "type": "boolean"
36
44
  },
@@ -70,6 +70,7 @@ export declare class AccountManager {
70
70
  private readonly persistedWorkspaceSelections;
71
71
  private readonly persistedWorkspaces;
72
72
  private hadPersistedStorage;
73
+ private readonly loadedAccountStorageSnapshot;
73
74
  private cursorByFamily;
74
75
  private currentAccountIndexByFamily;
75
76
  private lastToastAccountIndex;
@@ -103,6 +104,10 @@ export declare class AccountManager {
103
104
  */
104
105
  private routingMutexMode;
105
106
  static loadFromDisk(authFallback?: OAuthAuthDetails): Promise<AccountManager>;
107
+ /** @internal True when construction received a parsed/recovered storage snapshot. */
108
+ hasLoadedAccountStorageSnapshot(): boolean;
109
+ /** @internal True only when this account identity was explicitly persisted, not legacy-derived in memory. */
110
+ hasPersistedRecordId(account: ManagedAccount): boolean;
106
111
  hasRefreshToken(refreshToken: string): boolean;
107
112
  private hydrateFromCodexCli;
108
113
  constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV3 | null);
@@ -142,6 +142,7 @@ export class AccountManager {
142
142
  persistedWorkspaceSelections = new WeakMap();
143
143
  persistedWorkspaces = new WeakMap();
144
144
  hadPersistedStorage = false;
145
+ loadedAccountStorageSnapshot;
145
146
  cursorByFamily = initFamilyState(0);
146
147
  currentAccountIndexByFamily = initFamilyState(-1);
147
148
  lastToastAccountIndex = -1;
@@ -202,6 +203,18 @@ export class AccountManager {
202
203
  await manager.hydrateFromCodexCli();
203
204
  return manager;
204
205
  }
206
+ /** @internal True when construction received a parsed/recovered storage snapshot. */
207
+ hasLoadedAccountStorageSnapshot() {
208
+ return this.loadedAccountStorageSnapshot;
209
+ }
210
+ /** @internal True only when this account identity was explicitly persisted, not legacy-derived in memory. */
211
+ hasPersistedRecordId(account) {
212
+ const recordId = account.recordId?.trim();
213
+ if (!recordId || !this.persistenceBaseline)
214
+ return false;
215
+ const baseline = this.persistenceBaseline.accounts.find((row) => resolveAccountRecordId(row) === recordId);
216
+ return typeof baseline?.recordId === "string" && baseline.recordId.trim().length > 0;
217
+ }
205
218
  hasRefreshToken(refreshToken) {
206
219
  return this.accounts.some((account) => account.refreshToken === refreshToken);
207
220
  }
@@ -266,6 +279,7 @@ export class AccountManager {
266
279
  }
267
280
  constructor(authFallback, stored) {
268
281
  this.storagePathState = { ...getStoragePathState() };
282
+ this.loadedAccountStorageSnapshot = stored !== undefined && stored !== null;
269
283
  this.hadPersistedStorage = existsSync(this.resolveSelectionStoragePath());
270
284
  const fallbackAccountId = extractAccountId(authFallback?.access)?.trim() || undefined;
271
285
  const fallbackAccountEmail = sanitizeEmail(extractAccountEmail(authFallback?.access));
@@ -522,6 +536,7 @@ export class AccountManager {
522
536
  _runtimeTrackerKey: trackerKey,
523
537
  circuitKeyId,
524
538
  rateLimitResetTimes: { ...account.rateLimitResetTimes },
539
+ workspaces: account.workspaces?.map((workspace) => ({ ...workspace })),
525
540
  };
526
541
  });
527
542
  }
@@ -1377,6 +1392,14 @@ export class AccountManager {
1377
1392
  if (!storedAccount) {
1378
1393
  return null;
1379
1394
  }
1395
+ const liveAccount = this.getAccountByIdentity(source, auth);
1396
+ // Credential rotation proves auth health only. It must not erase an
1397
+ // unrelated inference network/server/rate-limit backoff that happens
1398
+ // to be live on the same account.
1399
+ const preserveNonAuthCooldown = liveAccount?.cooldownReason !== undefined &&
1400
+ liveAccount.cooldownReason !== "auth-failure" &&
1401
+ typeof liveAccount.coolingDownUntil === "number" &&
1402
+ liveAccount.coolingDownUntil > nowMs();
1380
1403
  storedAccount.refreshToken = auth.refresh;
1381
1404
  storedAccount.accessToken = auth.access;
1382
1405
  storedAccount.expiresAt = auth.expires;
@@ -1391,9 +1414,14 @@ export class AccountManager {
1391
1414
  storedAccount.enabled = undefined;
1392
1415
  delete storedAccount.authInvalidatedAt;
1393
1416
  delete storedAccount.authInvalidationErrorCode;
1394
- delete storedAccount.coolingDownUntil;
1395
- delete storedAccount.cooldownReason;
1396
- const liveAccount = this.getAccountByIdentity(source, auth);
1417
+ if (preserveNonAuthCooldown && liveAccount) {
1418
+ storedAccount.coolingDownUntil = liveAccount.coolingDownUntil;
1419
+ storedAccount.cooldownReason = liveAccount.cooldownReason;
1420
+ }
1421
+ else {
1422
+ delete storedAccount.coolingDownUntil;
1423
+ delete storedAccount.cooldownReason;
1424
+ }
1397
1425
  if (liveAccount) {
1398
1426
  const previousLiveAccountState = {
1399
1427
  access: liveAccount.access,
@@ -1411,7 +1439,8 @@ export class AccountManager {
1411
1439
  };
1412
1440
  this.updateFromAuth(liveAccount, auth);
1413
1441
  liveAccount.enabled = true;
1414
- this.clearAccountCooldown(liveAccount);
1442
+ if (!preserveNonAuthCooldown)
1443
+ this.clearAccountCooldown(liveAccount);
1415
1444
  this.clearAuthFailures(liveAccount);
1416
1445
  try {
1417
1446
  await this.persistSnapshot(_current, nextStorage, persist, storedAccount);
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  declare const routeSchema: z.ZodObject<{
3
+ capabilityEpoch: z.ZodOptional<z.ZodString>;
4
+ legacyCapabilityCache: z.ZodOptional<z.ZodBoolean>;
3
5
  id: z.ZodString;
4
6
  label: z.ZodString;
5
7
  kind: z.ZodEnum<{
@@ -15,9 +17,14 @@ declare const routeSchema: z.ZodObject<{
15
17
  accessPrograms: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
16
18
  modelAccessPrograms: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>>;
17
19
  }, z.core.$strip>;
20
+ export declare const MAX_API_ROUTES = 100;
18
21
  export type ApiRouteCredential = z.infer<typeof routeSchema>;
19
22
  export declare function getApiRoutesPath(): string;
20
23
  export declare function loadApiRoutes(path?: string): Promise<ApiRouteCredential[]>;
21
24
  export declare function saveApiRoutes(routes: ApiRouteCredential[], path?: string, expected?: ApiRouteCredential[]): Promise<void>;
25
+ /** Return our committed snapshot, never a later writer's configuration. */
26
+ export declare function saveApiRoutesSnapshot(routes: ApiRouteCredential[], path?: string, expected?: ApiRouteCredential[]): Promise<ApiRouteCredential[]>;
22
27
  export declare function updateApiModelDiscovery(route: ApiRouteCredential, models: string[], path?: string): Promise<ApiRouteCredential>;
28
+ /** Migrate legacy ownership before taking any capability lease or cache lock. */
29
+ export declare function ensureApiRouteCapabilityEpoch(route: ApiRouteCredential, signal?: AbortSignal): Promise<ApiRouteCredential | undefined>;
23
30
  export {};
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { promises as fs } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { z } from "zod";
@@ -15,6 +16,9 @@ const programId = z.string().min(1).max(80).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/
15
16
  const accessPrograms = z.record(programId, z.array(programId).max(32))
16
17
  .refine(value => Object.keys(value).length <= 16);
17
18
  const routeSchema = z.object({
19
+ /** Internal lifetime token. Store-owned; never trust an incoming token. */
20
+ capabilityEpoch: z.string().uuid().optional(),
21
+ legacyCapabilityCache: z.boolean().optional(),
18
22
  id: z
19
23
  .string()
20
24
  .min(1)
@@ -37,9 +41,10 @@ const routeSchema = z.object({
37
41
  modelAccessPrograms: z.record(modelId, accessPrograms)
38
42
  .refine(value => Object.keys(value).length <= 1000).optional(),
39
43
  });
44
+ export const MAX_API_ROUTES = 100;
40
45
  const schema = z.object({
41
46
  version: z.literal(1),
42
- routes: z.array(routeSchema).max(100),
47
+ routes: z.array(routeSchema).max(MAX_API_ROUTES),
43
48
  });
44
49
  export function getApiRoutesPath() {
45
50
  return join(getCodexMultiAuthDir(), "api-routes.json");
@@ -52,6 +57,9 @@ function validate(value) {
52
57
  throw new Error("Invalid API route configuration");
53
58
  return parsed.data.routes;
54
59
  }
60
+ class InvalidApiRouteContentsError extends Error {
61
+ constructor() { super("Invalid API route configuration"); }
62
+ }
55
63
  export async function loadApiRoutes(path = getApiRoutesPath()) {
56
64
  let raw;
57
65
  try {
@@ -78,39 +86,80 @@ export async function loadApiRoutes(path = getApiRoutesPath()) {
78
86
  return validate(JSON.parse(raw));
79
87
  }
80
88
  catch {
81
- throw new Error("Invalid API route configuration");
89
+ throw new InvalidApiRouteContentsError();
82
90
  }
83
91
  }
84
- export async function saveApiRoutes(routes, path = getApiRoutesPath(), expected) {
92
+ export function saveApiRoutes(routes, path = getApiRoutesPath(), expected) {
93
+ return saveApiRoutesInternal(routes, path, expected);
94
+ }
95
+ /** Return our committed snapshot, never a later writer's configuration. */
96
+ export async function saveApiRoutesSnapshot(routes, path = getApiRoutesPath(), expected) {
97
+ let saved = [];
98
+ await saveApiRoutesInternal(routes, path, expected, false, undefined, value => { saved = value; });
99
+ return saved;
100
+ }
101
+ async function saveApiRoutesInternal(routes, path = getApiRoutesPath(), expected, discoveryExpansion = false, signal, onSaved) {
102
+ await withStorageLock(() => withFileTransactionLock(path, () => saveApiRoutesLocked(routes, path, expected, discoveryExpansion, signal, onSaved), { signal }));
103
+ }
104
+ async function saveApiRoutesLocked(routes, path = getApiRoutesPath(), expected, discoveryExpansion = false, signal, onSaved) {
85
105
  const validated = validate({ version: 1, routes });
86
- const serialized = JSON.stringify({ version: 1, routes: validated }) + "\n";
106
+ let serialized = JSON.stringify({ version: 1, routes: validated }) + "\n";
87
107
  if (Buffer.byteLength(serialized) > 1024 * 1024)
88
108
  throw new Error("API route configuration is too large");
89
- await withStorageLock(() => withFileTransactionLock(path, async () => {
90
- await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
91
- const temp = tempPathFor(path);
92
- try {
93
- if (expected &&
94
- JSON.stringify(await loadApiRoutes(path)) !==
95
- JSON.stringify(validate({ version: 1, routes: expected }))) {
96
- throw new Error("API route configuration changed; reopen the menu before saving.");
97
- }
98
- await fs.writeFile(temp, serialized, { mode: 0o600, flag: "wx" });
99
- await withRetry(() => fs.rename(temp, path), {
100
- maxAttempts: 6,
101
- backoffMs: 25,
102
- });
103
- }
104
- finally {
105
- await withRetry(() => fs.unlink(temp), {
106
- maxAttempts: 6,
107
- backoffMs: 25,
108
- }).catch((error) => {
109
- if (error.code !== "ENOENT")
110
- throw error;
111
- });
109
+ signal?.throwIfAborted();
110
+ let current;
111
+ try {
112
+ current = await loadApiRoutes(path);
113
+ }
114
+ catch (error) {
115
+ // Only explicit replacement may repair parsed-content corruption. CAS,
116
+ // oversized reads and I/O failures must not be treated as an empty store.
117
+ if (expected !== undefined || !(error instanceof InvalidApiRouteContentsError))
118
+ throw error;
119
+ current = [];
120
+ }
121
+ await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
122
+ const temp = tempPathFor(path);
123
+ try {
124
+ if (expected &&
125
+ JSON.stringify(current) !==
126
+ JSON.stringify(validate({ version: 1, routes: expected }))) {
127
+ throw new Error("API route configuration changed; reopen the menu before saving.");
112
128
  }
113
- }));
129
+ const owned = validated.map(route => {
130
+ const old = current.find(value => value.id === route.id);
131
+ const policy = (value) => JSON.stringify([
132
+ value.kind, value.apiKey, value.enabled, value.probeCapabilities,
133
+ value.accessPrograms, value.modelAccessPrograms,
134
+ // Visibility is a selection set; menu ordering is not a policy edit.
135
+ ...(discoveryExpansion ? [] : [[...new Set(value.visibleModels)].sort()]),
136
+ ]);
137
+ const sameLifetime = old && policy(old) === policy(route);
138
+ const withoutLegacy = { ...route };
139
+ delete withoutLegacy.legacyCapabilityCache;
140
+ return { ...withoutLegacy, capabilityEpoch: sameLifetime ? old.capabilityEpoch ?? randomUUID() : randomUUID(),
141
+ ...(sameLifetime && (!old.capabilityEpoch || old.legacyCapabilityCache === true) ? { legacyCapabilityCache: true } : {}) };
142
+ });
143
+ serialized = JSON.stringify({ version: 1, routes: owned }) + "\n";
144
+ if (Buffer.byteLength(serialized) > 1024 * 1024)
145
+ throw Error("API route configuration is too large");
146
+ await fs.writeFile(temp, serialized, { mode: 0o600, flag: "wx" });
147
+ signal?.throwIfAborted();
148
+ await withRetry(() => fs.rename(temp, path), {
149
+ maxAttempts: 6,
150
+ backoffMs: 25,
151
+ });
152
+ onSaved?.(owned);
153
+ }
154
+ finally {
155
+ await withRetry(() => fs.unlink(temp), {
156
+ maxAttempts: 6,
157
+ backoffMs: 25,
158
+ }).catch((error) => {
159
+ if (error.code !== "ENOENT")
160
+ throw error;
161
+ });
162
+ }
114
163
  }
115
164
  /** Persist a discovery baseline and new GPT text IDs without undoing menu edits.
116
165
  * Callers supply IDs already filtered for the Responses endpoint family. */
@@ -122,7 +171,8 @@ export function updateApiModelDiscovery(route, models, path = getApiRoutesPath()
122
171
  }
123
172
  async function updateApiModelDiscoverySerial(route, models, path = getApiRoutesPath()) {
124
173
  const routes = await loadApiRoutes(path);
125
- const current = routes.find((r) => r.id === route.id && r.apiKey === route.apiKey && r.kind === route.kind);
174
+ const current = routes.find((r) => r.id === route.id && r.apiKey === route.apiKey && r.kind === route.kind &&
175
+ (!route.capabilityEpoch || route.capabilityEpoch === r.capabilityEpoch));
126
176
  if (!current)
127
177
  return { ...route, enabled: false, visibleModels: [] };
128
178
  if (!current.enabled)
@@ -139,7 +189,26 @@ async function updateApiModelDiscoverySerial(route, models, path = getApiRoutesP
139
189
  knownModels: [...known],
140
190
  visibleModels: [...visible],
141
191
  };
192
+ let persisted = updated;
142
193
  if (JSON.stringify(updated) !== JSON.stringify(current))
143
- await saveApiRoutes(routes.map((r) => (r.id === current.id ? updated : r)), path, routes);
144
- return updated;
194
+ await saveApiRoutesInternal(routes.map((r) => (r.id === current.id ? updated : r)), path, routes, true, undefined, values => { const saved = values.find(value => value.id === updated.id); if (saved)
195
+ persisted = saved; });
196
+ return persisted;
197
+ }
198
+ /** Migrate legacy ownership before taking any capability lease or cache lock. */
199
+ export async function ensureApiRouteCapabilityEpoch(route, signal) {
200
+ return withStorageLock(() => withFileTransactionLock(getApiRoutesPath(), async () => {
201
+ signal?.throwIfAborted();
202
+ const current = await loadApiRoutes();
203
+ const match = current.find(value => value.id === route.id && value.kind === route.kind && value.apiKey === route.apiKey);
204
+ if (!match || (route.capabilityEpoch && route.capabilityEpoch !== match.capabilityEpoch))
205
+ return undefined;
206
+ if (match.capabilityEpoch)
207
+ return match;
208
+ let migrated;
209
+ await saveApiRoutesLocked(current, getApiRoutesPath(), current, false, signal, values => {
210
+ migrated = values.find(value => value.id === route.id);
211
+ });
212
+ return migrated;
213
+ }, { signal }));
145
214
  }
@@ -1,5 +1,6 @@
1
1
  import { CODEX_BASE_URL } from "../constants.js";
2
2
  import { combineSignals } from "../utils.js";
3
+ import { fetchAuth } from "./http.js";
3
4
  const ACCOUNTS_CHECK_PATH = "/wham/accounts/check";
4
5
  const ACCOUNTS_CHECK_TIMEOUT_MS = 10_000;
5
6
  function readString(value) {
@@ -49,7 +50,7 @@ export async function fetchAuthorizedAccounts(accessToken, options = {}) {
49
50
  const token = readString(accessToken);
50
51
  if (!token)
51
52
  return null;
52
- const doFetch = options.fetch ?? globalThis.fetch;
53
+ const doFetch = options.fetch ?? fetchAuth;
53
54
  const timeout = AbortSignal.timeout(ACCOUNTS_CHECK_TIMEOUT_MS);
54
55
  // combineSignals wraps AbortSignal.any (available on every Node the
55
56
  // engines floor, `>=22.19`, permits) with null-tolerance; the native composite also detaches
@@ -2,6 +2,7 @@ import { randomBytes, webcrypto } from "node:crypto";
2
2
  import { logError } from "../logger.js";
3
3
  import { safeParseOAuthTokenResponse } from "../schemas.js";
4
4
  import { isAbortError } from "../utils.js";
5
+ import { fetchAuth } from "./http.js";
5
6
  // OAuth constants (from openai/codex)
6
7
  export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
7
8
  export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
@@ -396,7 +397,7 @@ export function parseAuthorizationInput(input) {
396
397
  * @returns Token result
397
398
  */
398
399
  export async function exchangeAuthorizationCode(code, verifier, redirectUri = REDIRECT_URI) {
399
- const res = await fetch(TOKEN_URL, {
400
+ const res = await fetchAuth(TOKEN_URL, {
400
401
  method: "POST",
401
402
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
402
403
  body: new URLSearchParams({
@@ -468,7 +469,7 @@ export function decodeJWT(token) {
468
469
  }
469
470
  export async function refreshAccessToken(refreshToken, options = {}) {
470
471
  try {
471
- const response = await fetch(TOKEN_URL, {
472
+ const response = await fetchAuth(TOKEN_URL, {
472
473
  method: "POST",
473
474
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
474
475
  signal: options?.signal,
@@ -1,4 +1,5 @@
1
1
  import { CLIENT_ID, exchangeAuthorizationCode, sanitizeOAuthResponseBodyForLog, } from "./auth.js";
2
+ import { fetchAuth } from "./http.js";
2
3
  const DEVICE_AUTH_BASE_URL = "https://auth.openai.com";
3
4
  const DEVICE_AUTH_API_BASE_URL = `${DEVICE_AUTH_BASE_URL}/api/accounts`;
4
5
  export const DEVICE_AUTH_VERIFICATION_URL = `${DEVICE_AUTH_BASE_URL}/codex/device`;
@@ -14,7 +15,7 @@ const DEVICE_AUTH_TRANSIENT_PENDING_STATUSES = new Set([
14
15
  504,
15
16
  ]);
16
17
  function getFetch(options) {
17
- return options.fetchImpl ?? fetch;
18
+ return options.fetchImpl ?? fetchAuth;
18
19
  }
19
20
  function getNow(options) {
20
21
  return options.now ?? Date.now;
@@ -0,0 +1 @@
1
+ export { fetchWithProxy as fetchAuth } from "../request/proxy-fetch.js";
@@ -0,0 +1 @@
1
+ export { fetchWithProxy as fetchAuth } from "../request/proxy-fetch.js";
@@ -14,6 +14,7 @@ export const ACCOUNT_MANAGER_COMMANDS = new Set([
14
14
  "login",
15
15
  "list",
16
16
  "status",
17
+ "web",
17
18
  "limits",
18
19
  "switch",
19
20
  "unpin",
@@ -2,7 +2,7 @@ import { type ApiRouteCredential } from "../api-route-store.js";
2
2
  import { type MenuItem } from "../ui/select.js";
3
3
  interface MenuDeps {
4
4
  load: () => Promise<ApiRouteCredential[]>;
5
- save: (routes: ApiRouteCredential[], expected: ApiRouteCredential[]) => Promise<void>;
5
+ save: (routes: ApiRouteCredential[], expected: ApiRouteCredential[]) => Promise<ApiRouteCredential[]>;
6
6
  select: (items: MenuItem<string>[], message: string) => Promise<string | null>;
7
7
  text: (message: string) => Promise<string>;
8
8
  secret: () => Promise<string>;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { Writable } from "node:stream";
4
- import { loadApiRoutes, saveApiRoutes, } from "../api-route-store.js";
4
+ import { loadApiRoutes, saveApiRoutesSnapshot, } from "../api-route-store.js";
5
5
  import { ApiModelRuntime } from "../runtime/api-model-runtime.js";
6
6
  import { select } from "../ui/select.js";
7
7
  async function ask(message, secret = false) {
@@ -35,7 +35,7 @@ async function ask(message, secret = false) {
35
35
  }
36
36
  const defaults = {
37
37
  load: loadApiRoutes,
38
- save: (routes, expected) => saveApiRoutes(routes, undefined, expected),
38
+ save: (routes, expected) => saveApiRoutesSnapshot(routes, undefined, expected),
39
39
  select: (items, message) => select(items, { message, allowEscape: true }),
40
40
  text: (message) => ask(message),
41
41
  secret: () => ask("API key (hidden): ", true),
@@ -189,8 +189,7 @@ export async function runApiLoginMenu(overrides = {}) {
189
189
  }
190
190
  if (action !== "models") {
191
191
  const next = routes.map((r) => (r.id === route?.id ? route : r));
192
- await d.save(next, routes);
193
- routes = next;
192
+ routes = await d.save(next, routes);
194
193
  continue;
195
194
  }
196
195
  }
@@ -203,8 +202,7 @@ export async function runApiLoginMenu(overrides = {}) {
203
202
  const next = routes.some((r) => r.id === edited.id)
204
203
  ? routes.map((r) => (r.id === edited.id ? edited : r))
205
204
  : [...routes, edited];
206
- await d.save(next, routes);
207
- routes = next;
205
+ routes = await d.save(next, routes);
208
206
  d.log("Saved. Choose an API or ZDR model explicitly in Codex. Desktop login is unchanged.");
209
207
  }
210
208
  catch (error) {
@@ -0,0 +1,12 @@
1
+ import { type OutputLocaleSetting } from "../../locale.js";
2
+ export interface LocaleCommandDeps {
3
+ loadConfig: () => {
4
+ locale?: OutputLocaleSetting;
5
+ };
6
+ saveConfig: (patch: {
7
+ locale: OutputLocaleSetting;
8
+ }) => Promise<void>;
9
+ logInfo?: (message: string) => void;
10
+ logError?: (message: string) => void;
11
+ }
12
+ export declare function runLocaleCommand(args: string[], deps: LocaleCommandDeps): Promise<number>;
@@ -0,0 +1,25 @@
1
+ import { localeName, normalizeOutputLocaleSetting, resolveOutputLocale, } from "../../locale.js";
2
+ export async function runLocaleCommand(args, deps) {
3
+ const logInfo = deps.logInfo ?? console.log;
4
+ const logError = deps.logError ?? console.error;
5
+ const requested = args[0]?.trim();
6
+ if (args.length > 1 || requested === "--help" || requested === "-h") {
7
+ logInfo("Usage: codex-multi-auth config locale [auto|en|zh]");
8
+ return requested === "--help" || requested === "-h" ? 0 : 1;
9
+ }
10
+ if (!requested) {
11
+ const configured = deps.loadConfig().locale ?? "auto";
12
+ const effective = resolveOutputLocale(configured);
13
+ logInfo(`Locale: ${configured} (effective: ${localeName(effective)})`);
14
+ return 0;
15
+ }
16
+ const locale = normalizeOutputLocaleSetting(requested);
17
+ if (!locale) {
18
+ logError(`Unsupported locale: ${requested}. Choose auto, en, or zh.`);
19
+ return 1;
20
+ }
21
+ await deps.saveConfig({ locale });
22
+ const effective = resolveOutputLocale(locale);
23
+ logInfo(`Locale saved: ${locale} (effective: ${localeName(effective)})`);
24
+ return 0;
25
+ }
@@ -4,9 +4,10 @@ import { loadAccountPolicyStore } from "../../account-policy.js";
4
4
  import { type ModelInventory } from "../../runtime/model-discovery-status.js";
5
5
  import type { ModelFamily } from "../../prompts/codex.js";
6
6
  import type { QuotaCacheData } from "../../quota-cache.js";
7
- import type { AppBindRouterStatus } from "../../runtime/app-bind.js";
7
+ import { type AppBindRouterStatus } from "../../runtime/app-bind.js";
8
8
  import { type RuntimeAccountSignal } from "../../runtime/runtime-current-account.js";
9
9
  import type { RuntimeObservabilitySnapshot } from "../../runtime/runtime-observability.js";
10
+ import { type OutputLocale } from "../../locale.js";
10
11
  import type { AccountStorageV3, StorageHealthSummary } from "../../storage.js";
11
12
  type LoadedStorage = AccountStorageV3 | null;
12
13
  export interface StatusCommandDeps {
@@ -31,6 +32,9 @@ export interface StatusCommandDeps {
31
32
  logInfo?: (message: string) => void;
32
33
  /** When true, emit a single machine-readable JSON object instead of text (cli-manager-03). */
33
34
  json?: boolean;
35
+ /** Compact human-facing summary. Undefined keeps the legacy detailed view for API callers. */
36
+ compact?: boolean;
37
+ locale?: OutputLocale;
34
38
  }
35
39
  export declare function runStatusCommand(deps: StatusCommandDeps): Promise<number>;
36
40
  export interface FeaturesCommandDeps {