codex-multi-auth 2.22.0 → 2.24.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 (117) hide show
  1. package/.codex-plugin/plugin.json +9 -9
  2. package/LICENSE +21 -21
  3. package/README.md +159 -150
  4. package/assets/codex-multi-auth-icon.svg +39 -39
  5. package/assets/readme-hero.svg +31 -31
  6. package/config/README.md +31 -31
  7. package/config/codex-legacy.json +1021 -1021
  8. package/config/codex-modern.json +347 -347
  9. package/config/minimal-codex.json +15 -15
  10. package/config/schema/config.schema.json +8 -0
  11. package/dist/index.js +33 -10
  12. package/dist/lib/account-policy.d.ts +2 -0
  13. package/dist/lib/account-policy.js +3 -1
  14. package/dist/lib/accounts/rate-limits.d.ts +6 -0
  15. package/dist/lib/accounts/rate-limits.js +14 -1
  16. package/dist/lib/accounts.d.ts +6 -1
  17. package/dist/lib/accounts.js +33 -8
  18. package/dist/lib/api-route-store.d.ts +1 -0
  19. package/dist/lib/api-route-store.js +2 -1
  20. package/dist/lib/auth/account-access.js +2 -1
  21. package/dist/lib/auth/auth.js +3 -2
  22. package/dist/lib/auth/device-auth.js +2 -1
  23. package/dist/lib/auth/http.d.ts +1 -0
  24. package/dist/lib/auth/http.js +1 -0
  25. package/dist/lib/auth/token-utils.d.ts +5 -0
  26. package/dist/lib/auth/token-utils.js +10 -0
  27. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  28. package/dist/lib/codex-manager/commands/account.js +15 -0
  29. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  30. package/dist/lib/codex-manager/commands/locale.js +25 -0
  31. package/dist/lib/codex-manager/commands/resets.js +18 -5
  32. package/dist/lib/codex-manager/commands/status.d.ts +4 -0
  33. package/dist/lib/codex-manager/commands/status.js +476 -5
  34. package/dist/lib/codex-manager/commands/workspace.d.ts +4 -2
  35. package/dist/lib/codex-manager/commands/workspace.js +33 -2
  36. package/dist/lib/codex-manager/help.js +5 -3
  37. package/dist/lib/codex-manager.js +112 -30
  38. package/dist/lib/config.js +3 -0
  39. package/dist/lib/constants.d.ts +2 -0
  40. package/dist/lib/constants.js +2 -0
  41. package/dist/lib/context-budget-response.js +8 -8
  42. package/dist/lib/context-overflow.js +10 -10
  43. package/dist/lib/locale.d.ts +7 -0
  44. package/dist/lib/locale.js +35 -0
  45. package/dist/lib/oauth-success.html +125 -125
  46. package/dist/lib/policy/runtime-policy-cache.js +1 -1
  47. package/dist/lib/policy/runtime-policy.d.ts +1 -0
  48. package/dist/lib/policy/runtime-policy.js +4 -0
  49. package/dist/lib/preemptive-quota-scheduler.js +12 -3
  50. package/dist/lib/prompts/codex-host-bridge.js +138 -138
  51. package/dist/lib/prompts/codex.js +79 -79
  52. package/dist/lib/quota-cache.d.ts +4 -0
  53. package/dist/lib/quota-probe.js +2 -1
  54. package/dist/lib/quota-readiness.d.ts +1 -1
  55. package/dist/lib/quota-readiness.js +9 -2
  56. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  57. package/dist/lib/request/proxy-fetch.js +37 -0
  58. package/dist/lib/request/rate-limit-decision.js +1 -1
  59. package/dist/lib/request/url-rewriting.js +17 -0
  60. package/dist/lib/runtime/account-model-catalog.d.ts +2 -0
  61. package/dist/lib/runtime/account-model-catalog.js +7 -2
  62. package/dist/lib/runtime/account-reset-credits.d.ts +11 -2
  63. package/dist/lib/runtime/account-reset-credits.js +35 -12
  64. package/dist/lib/runtime/app-bind.d.ts +4 -1
  65. package/dist/lib/runtime/app-bind.js +63 -3
  66. package/dist/lib/runtime/automatic-account-checks.js +6 -5
  67. package/dist/lib/runtime/automatic-subscription-checks.js +4 -3
  68. package/dist/lib/runtime/model-discovery-status.js +2 -2
  69. package/dist/lib/runtime/reset-credit-routing.d.ts +11 -3
  70. package/dist/lib/runtime/reset-credit-routing.js +66 -21
  71. package/dist/lib/runtime/reset-credits.d.ts +31 -7
  72. package/dist/lib/runtime/reset-credits.js +135 -28
  73. package/dist/lib/runtime/responses-websocket.d.ts +30 -0
  74. package/dist/lib/runtime/responses-websocket.js +243 -29
  75. package/dist/lib/runtime/rotation-account-selection.d.ts +5 -4
  76. package/dist/lib/runtime/rotation-proxy-state.d.ts +3 -0
  77. package/dist/lib/runtime/rotation-server-types.d.ts +4 -1
  78. package/dist/lib/runtime/subscription-quota-order.d.ts +1 -0
  79. package/dist/lib/runtime/subscription-quota-order.js +7 -3
  80. package/dist/lib/runtime/workspace-model-scopes.d.ts +2 -0
  81. package/dist/lib/runtime/workspace-model-scopes.js +14 -4
  82. package/dist/lib/runtime-rotation-proxy.js +138 -38
  83. package/dist/lib/schemas.d.ts +5 -0
  84. package/dist/lib/schemas.js +1 -0
  85. package/dist/lib/storage/snapshot-merge.js +15 -4
  86. package/dist/lib/web-dashboard-html.d.ts +1 -0
  87. package/dist/lib/web-dashboard-html.js +66 -0
  88. package/dist/lib/web-dashboard.d.ts +36 -0
  89. package/dist/lib/web-dashboard.js +322 -0
  90. package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -2
  91. package/node_modules/@codex-ai/plugin/dist/index.js +2 -2
  92. package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -42
  93. package/node_modules/@codex-ai/plugin/dist/tool.js +29 -29
  94. package/node_modules/@codex-ai/plugin/package.json +9 -9
  95. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -4
  96. package/node_modules/@codex-ai/sdk/dist/index.js +2 -2
  97. package/node_modules/@codex-ai/sdk/package.json +8 -8
  98. package/package.json +209 -207
  99. package/scripts/codex-app-launcher.js +653 -653
  100. package/scripts/codex-app-router.js +446 -402
  101. package/scripts/codex-bin-resolver.js +332 -332
  102. package/scripts/codex-multi-auth.js +65 -65
  103. package/scripts/codex-routing.js +63 -62
  104. package/scripts/codex.js +7143 -7006
  105. package/scripts/install-codex-auth-utils.js +213 -213
  106. package/scripts/mcodex.js +367 -367
  107. package/scripts/postinstall.js +114 -114
  108. package/scripts/preuninstall.js +265 -265
  109. package/skills/codex-auth-setup/SKILL.md +73 -73
  110. package/vendor/codex-ai-plugin/dist/index.d.ts +2 -2
  111. package/vendor/codex-ai-plugin/dist/index.js +2 -2
  112. package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -42
  113. package/vendor/codex-ai-plugin/dist/tool.js +29 -29
  114. package/vendor/codex-ai-plugin/package.json +9 -9
  115. package/vendor/codex-ai-sdk/dist/index.d.ts +4 -4
  116. package/vendor/codex-ai-sdk/dist/index.js +2 -2
  117. package/vendor/codex-ai-sdk/package.json +8 -8
@@ -98,11 +98,28 @@ function shouldBypassProxyForUrl(url, noProxyValue) {
98
98
  }
99
99
  return false;
100
100
  }
101
+ /**
102
+ * Loopback destinations never traverse a configured proxy: routing them through
103
+ * HTTP_PROXY would leak managed bearer tokens to the proxy and break the
104
+ * loopback-only runtime proxy contract. This bypass applies even when NO_PROXY
105
+ * is unset, matching curl/git proxy conventions.
106
+ */
107
+ function isLoopbackRequestHost(url) {
108
+ const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase();
109
+ return (hostname === "localhost" ||
110
+ hostname.endsWith(".localhost") ||
111
+ hostname === "::1" ||
112
+ hostname.startsWith("127.") ||
113
+ hostname.startsWith("::ffff:127."));
114
+ }
101
115
  export function resolveProxyUrlForRequest(url, env = process.env) {
102
116
  const parsed = new URL(url);
103
117
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
104
118
  return undefined;
105
119
  }
120
+ if (isLoopbackRequestHost(parsed)) {
121
+ return undefined;
122
+ }
106
123
  const httpProxy = resolveProxyEnvValue(env, "http_proxy", "HTTP_PROXY");
107
124
  const httpsProxy = resolveProxyEnvValue(env, "https_proxy", "HTTPS_PROXY");
108
125
  if (!httpProxy && !httpsProxy) {
@@ -23,6 +23,8 @@ export declare class AccountModelCatalog {
23
23
  private readonly pending;
24
24
  constructor(fetchCatalog: (accountKey: string) => Promise<unknown>, now?: () => number, ttlMs?: number);
25
25
  private read;
26
+ /** Includes successful empty catalogs; stale or failed discovery remains unknown. */
27
+ hasUsablePickerSnapshot(key: string): boolean;
26
28
  /** Picker-only stale-while-refresh view; routing still awaits fresh discovery. */
27
29
  cachedList(accountKeys: string[]): CatalogModel[];
28
30
  invalidate(): void;
@@ -83,12 +83,17 @@ export class AccountModelCatalog {
83
83
  this.pending.delete(key);
84
84
  }
85
85
  }
86
+ /** Includes successful empty catalogs; stale or failed discovery remains unknown. */
87
+ hasUsablePickerSnapshot(key) {
88
+ const entry = this.cache.get(key);
89
+ return Boolean(entry && !entry.error && entry.checkedAt <= this.now() && this.now() - entry.checkedAt < 15 * 60_000);
90
+ }
86
91
  /** Picker-only stale-while-refresh view; routing still awaits fresh discovery. */
87
92
  cachedList(accountKeys) {
88
93
  return buildVisibleModelUnion(accountKeys.map((key, index) => {
89
94
  const entry = this.cache.get(key);
90
- const usable = entry && !entry.error && entry.checkedAt <= this.now() && this.now() - entry.checkedAt < 15 * 60_000;
91
- return { id: key, kind: "oauth", priority: index, enabled: true, models: usable ? entry.models ?? [] : [], visibleModels: null };
95
+ const usable = this.hasUsablePickerSnapshot(key);
96
+ return { id: key, kind: "oauth", priority: index, enabled: true, models: usable ? entry?.models ?? [] : [], visibleModels: null };
92
97
  }));
93
98
  }
94
99
  invalidate() { this.cache.clear(); }
@@ -1,15 +1,19 @@
1
1
  import { AccountManager } from '../accounts.js';
2
2
  import { type AccountMetadataV3 } from '../storage.js';
3
- import { ResetCreditService, type ResetTarget } from './reset-credits.js';
3
+ import { ResetCreditService, type ResetCreditDetail, type ResetTarget } from './reset-credits.js';
4
4
  export declare function resetTargetForStoredAccount(account: AccountMetadataV3): ResetTarget | null;
5
5
  export declare function isResetTargetEnabled(account: AccountMetadataV3, target: ResetTarget): boolean;
6
6
  export declare function createResetCreditService(manager?: AccountManager): ResetCreditService;
7
7
  export declare function loadResetCreditState(): Promise<{
8
- version: 1;
8
+ version: 1 | 2;
9
9
  policy: "last-resort" | "manual";
10
10
  snapshots: Record<string, {
11
11
  updatedAt: number;
12
12
  availableCount: number | null;
13
+ credits?: {
14
+ expiresAt?: number | null | undefined;
15
+ title?: string | null | undefined;
16
+ }[] | null | undefined;
13
17
  ordinaryUsageAllowed: boolean | null;
14
18
  planType: string | null;
15
19
  primary: {
@@ -26,6 +30,7 @@ export declare function loadResetCreditState(): Promise<{
26
30
  pending?: {
27
31
  key: string;
28
32
  idempotencyKey: string;
33
+ creditId?: string | undefined;
29
34
  } | undefined;
30
35
  lastAutomaticCheckAt?: number | undefined;
31
36
  lastRedemptionAt?: number | undefined;
@@ -38,4 +43,8 @@ export declare function loadResetCreditState(): Promise<{
38
43
  }>;
39
44
  /** Local terminal labels only; never used in provider requests or public artifacts. */
40
45
  export declare function resetAccountLabel(account: Pick<AccountMetadataV3, "email">, index: number): string;
46
+ /** Strips terminal control codes, invisible formatters, and whitespace before printing credit titles. */
47
+ export declare function sanitizeCreditTitle(title: string | null | undefined): string | null;
48
+ /** One local terminal line per credit row; expiresAt is Unix seconds and null means it never expires. */
49
+ export declare function formatResetCreditLine(credit: ResetCreditDetail, index: number): string;
41
50
  export declare function refreshAndPrintResetCredits(log: (message: string) => void): Promise<void>;
@@ -1,9 +1,9 @@
1
1
  import { join } from 'node:path';
2
- import { AccountManager, extractAccountId, resolveAccountRecordId, sanitizeEmail } from '../accounts.js';
2
+ import { AccountManager, resolveAccountRecordId, sanitizeEmail } from '../accounts.js';
3
3
  import { loadAccounts } from '../storage.js';
4
4
  import { getCodexMultiAuthDir } from '../runtime-paths.js';
5
5
  import { ensureFreshAccessToken } from './rotation-token-refresh.js';
6
- import { modelScopeId, workspaceModelScopes } from './workspace-model-scopes.js';
6
+ import { modelScopeId, workspaceModelScopes, nativeWorkspaceIdentity } from './workspace-model-scopes.js';
7
7
  import { nativeRateLimitsRpc } from './native-rate-limits.js';
8
8
  import { codexCliAccountIdFor } from '../auth/token-utils.js';
9
9
  import { isRecord } from '../utils.js';
@@ -11,10 +11,13 @@ import { ResetCreditService } from './reset-credits.js';
11
11
  import { withCheckProgress } from '../ui/check-progress.js';
12
12
  export function resetTargetForStoredAccount(account) {
13
13
  // Organization display aliases are not native ChatGPT workspace identities.
14
- const stored = account.accountId?.trim();
15
- const id = stored?.startsWith("org-") ? extractAccountId(account.accessToken)?.trim() : stored;
14
+ const selected = account.workspaces?.length ? account.workspaces[account.currentWorkspaceIndex ?? 0] : undefined;
15
+ if (account.workspaces?.length && (!selected || selected.enabled === false))
16
+ return null;
17
+ const stored = (selected?.id ?? account.accountId)?.trim();
18
+ const id = nativeWorkspaceIdentity({ access: account.accessToken, codexCliMirror: account.codexCliMirror }, stored);
16
19
  const workspace = account.workspaces?.find(w => w.id === id);
17
- if (!id || id.startsWith("org-") || account.enabled === false || workspace?.enabled === false)
20
+ if (!id || id.startsWith("org-") || account.enabled === false || (!selected && workspace?.enabled === false))
18
21
  return null;
19
22
  return { key: modelScopeId('oauth', resolveAccountRecordId(account), id), accountId: id };
20
23
  }
@@ -24,8 +27,7 @@ export function isResetTargetEnabled(account, target) {
24
27
  const canonical = resetTargetForStoredAccount(account);
25
28
  if (canonical?.key === target.key && canonical.accountId === target.accountId)
26
29
  return true;
27
- const scopeKey = modelScopeId('oauth', resolveAccountRecordId(account), target.accountId);
28
- return !target.accountId.startsWith('org-') && scopeKey === target.key && Boolean(account.workspaces?.some(w => w.id === target.accountId && w.enabled !== false));
30
+ return false;
29
31
  }
30
32
  export function createResetCreditService(manager) {
31
33
  const auth = async (target) => {
@@ -43,17 +45,17 @@ export function createResetCreditService(manager) {
43
45
  return { accessToken: fresh.accessToken, accountId: target.accountId, expiresAt: fresh.account.expires ?? 0, codexCliMirror: current.codexCliMirror };
44
46
  };
45
47
  return new ResetCreditService(join(getCodexMultiAuthDir(), 'reset-credits.json'), {
46
- read: async (target) => {
48
+ read: async (target, { creditDetails }) => {
47
49
  // The backend answers for the id written into its auth.json (the mirror id when one
48
50
  // applies). Verify the reply against that id; the snapshot stays keyed on the target.
49
51
  const freshAuth = await auth(target);
50
52
  const sent = codexCliAccountIdFor(freshAuth, freshAuth.accessToken) ?? freshAuth.accountId;
51
- const reply = await nativeRateLimitsRpc(freshAuth, 'account/rateLimits/read', { excludeResetCreditDetails: true });
53
+ const reply = await nativeRateLimitsRpc(freshAuth, 'account/rateLimits/read', { excludeResetCreditDetails: !creditDetails });
52
54
  if (!isRecord(reply) || reply.accountId !== sent)
53
55
  throw Error('Reset credit workspace identity mismatch');
54
56
  return { ...reply, accountId: target.accountId };
55
57
  },
56
- consume: async (target, idempotencyKey) => {
58
+ consume: async (target, idempotencyKey, creditId) => {
57
59
  const freshAuth = await auth(target);
58
60
  const disk = await loadAccounts();
59
61
  const matches = disk?.accounts.filter(a => isResetTargetEnabled(a, target)) ?? [];
@@ -61,7 +63,7 @@ export function createResetCreditService(manager) {
61
63
  throw Error('Reset account identity is ambiguous; no credit consumed');
62
64
  if (matches.length !== 1)
63
65
  throw Error('Reset target was removed, disabled, or changed; no credit consumed');
64
- return nativeRateLimitsRpc(freshAuth, 'account/rateLimitResetCredit/consume', { idempotencyKey });
66
+ return nativeRateLimitsRpc(freshAuth, 'account/rateLimitResetCredit/consume', { idempotencyKey, ...(creditId === undefined ? {} : { creditId }) });
65
67
  },
66
68
  });
67
69
  }
@@ -72,6 +74,22 @@ export function resetAccountLabel(account, index) {
72
74
  const printable = email && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(email);
73
75
  return `Account ${index + 1}${printable ? ` (${email})` : ""}`;
74
76
  }
77
+ /** Strips terminal control codes, invisible formatters, and whitespace before printing credit titles. */
78
+ export function sanitizeCreditTitle(title) {
79
+ if (typeof title !== "string")
80
+ return null;
81
+ const trimmed = title.trim();
82
+ if (!trimmed || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(trimmed))
83
+ return null;
84
+ return trimmed;
85
+ }
86
+ /** One local terminal line per credit row; expiresAt is Unix seconds and null means it never expires. */
87
+ export function formatResetCreditLine(credit, index) {
88
+ const date = new Date(typeof credit.expiresAt === 'number' && credit.expiresAt > 0 ? credit.expiresAt * 1000 : Number.NaN);
89
+ const expiry = credit.expiresAt === null ? 'no expiry' : Number.isNaN(date.getTime()) ? 'expires unknown' : `expires ${date.toLocaleString()}`;
90
+ const title = sanitizeCreditTitle(credit.title);
91
+ return ` - credit #${index + 1}: ${expiry}${title ? ` (${title})` : ''}`;
92
+ }
75
93
  export async function refreshAndPrintResetCredits(log) {
76
94
  const storage = await loadAccounts();
77
95
  if (!storage?.accounts.length)
@@ -80,7 +98,12 @@ export async function refreshAndPrintResetCredits(log) {
80
98
  const targets = storage.accounts.map(resetTargetForStoredAccount).filter((t) => Boolean(t));
81
99
  try {
82
100
  const snapshots = await withCheckProgress(`Checking reset credits for ${targets.length} accounts`, () => createResetCreditService(manager).refresh(targets), log);
83
- storage.accounts.forEach((a, i) => { const target = resetTargetForStoredAccount(a); const count = target ? snapshots[target.key]?.availableCount : undefined; log(`${resetAccountLabel(a, i)}: available subscription resets: ${count ?? 'unknown (not verified)'}`); });
101
+ storage.accounts.forEach((a, i) => {
102
+ const target = resetTargetForStoredAccount(a);
103
+ const snap = target ? snapshots[target.key] : undefined;
104
+ log(`${resetAccountLabel(a, i)}: available subscription resets: ${snap?.availableCount ?? 'unknown (not verified)'}`);
105
+ snap?.credits?.forEach((c, idx) => log(formatResetCreditLine(c, idx)));
106
+ });
84
107
  }
85
108
  finally {
86
109
  await manager.flushPendingSave();
@@ -1,4 +1,4 @@
1
- import type { WebSocketDisconnectDiagnostic } from "./responses-websocket.js";
1
+ import type { WebSocketDisconnectDiagnostic, WebSocketUpgradeRejectionDiagnostic } from "./responses-websocket.js";
2
2
  export interface AppBindPaths {
3
3
  codexHome: string;
4
4
  configPath: string;
@@ -39,6 +39,9 @@ export interface AppBindState {
39
39
  }
40
40
  export interface AppBindRouterStatus {
41
41
  lastWebsocketDisconnect?: WebSocketDisconnectDiagnostic | null;
42
+ websocketActiveConnections?: number | null;
43
+ websocketCapacityReclaims?: number | null;
44
+ lastWebsocketUpgradeRejection?: WebSocketUpgradeRejectionDiagnostic | null;
42
45
  schedulingStrategy?: "hybrid" | "sequential";
43
46
  state: string | null;
44
47
  pid: number | null;
@@ -232,6 +232,29 @@ function readWebsocketDisconnect(value) {
232
232
  if (!isRecord(value) || typeof value.at !== "number" || !Number.isFinite(value.at) || value.at < 0)
233
233
  return null;
234
234
  const { cause, stage, closeCode, routeKind, connectionAgeMs, eventCount } = value;
235
+ const transport = {};
236
+ for (const key of ["requestBytes", "inputItemCount"]) {
237
+ const field = value[key];
238
+ if (field !== undefined) {
239
+ if (typeof field !== "number" || !Number.isSafeInteger(field) || field < 0)
240
+ return null;
241
+ transport[key] = field;
242
+ }
243
+ }
244
+ for (const key of ["isPrewarm", "hasPreviousResponse", "reusedConnection", "sendCompleted", "compressionNegotiated", "tcpEndReceived", "tcpCloseHadError"]) {
245
+ const field = value[key];
246
+ if (field !== undefined) {
247
+ if (typeof field !== "boolean")
248
+ return null;
249
+ transport[key] = field;
250
+ }
251
+ }
252
+ const errorCode = value.transportErrorCode;
253
+ if (errorCode !== undefined) {
254
+ if (errorCode !== "ECONNRESET" && errorCode !== "EPIPE" && errorCode !== "ETIMEDOUT" && errorCode !== "OTHER")
255
+ return null;
256
+ transport.transportErrorCode = errorCode;
257
+ }
235
258
  if (routeKind !== undefined && routeKind !== "oauth" && routeKind !== "api" && routeKind !== "zdr")
236
259
  return null;
237
260
  for (const count of [connectionAgeMs, eventCount])
@@ -246,7 +269,24 @@ function readWebsocketDisconnect(value) {
246
269
  return { at: value.at, cause, stage, ...(typeof closeCode === "number" ? { closeCode } : {}),
247
270
  ...(routeKind === "oauth" || routeKind === "api" || routeKind === "zdr" ? { routeKind } : {}),
248
271
  ...(typeof connectionAgeMs === "number" ? { connectionAgeMs } : {}),
249
- ...(typeof eventCount === "number" ? { eventCount } : {}) };
272
+ ...(typeof eventCount === "number" ? { eventCount } : {}), ...transport };
273
+ }
274
+ function readWebsocketCounter(value, maximum = Number.MAX_SAFE_INTEGER) {
275
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum
276
+ ? value : null;
277
+ }
278
+ function readWebsocketUpgradeRejection(value) {
279
+ if (!isRecord(value))
280
+ return null;
281
+ const at = readWebsocketCounter(value.at, 8.64e15);
282
+ const activeConnections = readWebsocketCounter(value.activeConnections, 64);
283
+ const { reason, status } = value;
284
+ if (at === null || activeConnections === null)
285
+ return null;
286
+ if (!((reason === "origin" && status === 403) ||
287
+ ((reason === "capacity" || reason === "repeated_disconnect") && status === 426)))
288
+ return null;
289
+ return { at, reason, status, activeConnections };
250
290
  }
251
291
  async function readRouterStatus(path) {
252
292
  const record = await readJsonFile(path);
@@ -254,6 +294,9 @@ async function readRouterStatus(path) {
254
294
  return null;
255
295
  return {
256
296
  lastWebsocketDisconnect: readWebsocketDisconnect(record.lastWebsocketDisconnect),
297
+ websocketActiveConnections: readWebsocketCounter(record.websocketActiveConnections, 64),
298
+ websocketCapacityReclaims: readWebsocketCounter(record.websocketCapacityReclaims),
299
+ lastWebsocketUpgradeRejection: readWebsocketUpgradeRejection(record.lastWebsocketUpgradeRejection),
257
300
  schedulingStrategy: record.schedulingStrategy === "hybrid" || record.schedulingStrategy === "sequential"
258
301
  ? record.schedulingStrategy : undefined,
259
302
  state: readString(record, "state"),
@@ -1407,7 +1450,22 @@ export function formatWebsocketDisconnect(value) {
1407
1450
  const diagnostic = readWebsocketDisconnect(value);
1408
1451
  if (!diagnostic || diagnostic.at > 8.64e15)
1409
1452
  return null;
1410
- return `Last WebSocket disconnect: ${new Date(diagnostic.at).toISOString()}; cause=${diagnostic.cause}; stage=${diagnostic.stage}${diagnostic.closeCode !== undefined ? `; closeCode=${diagnostic.closeCode}` : ""}${diagnostic.routeKind !== undefined ? `; route=${diagnostic.routeKind}` : ""}${diagnostic.connectionAgeMs !== undefined ? `; connectionAgeMs=${diagnostic.connectionAgeMs}` : ""}${diagnostic.eventCount !== undefined ? `; events=${diagnostic.eventCount}` : ""}`;
1453
+ const transport = ["requestBytes", "inputItemCount", "isPrewarm", "hasPreviousResponse", "reusedConnection", "sendCompleted", "compressionNegotiated", "transportErrorCode", "tcpEndReceived", "tcpCloseHadError"]
1454
+ .filter(key => diagnostic[key] !== undefined).map(key => `; ${key}=${diagnostic[key]}`).join("");
1455
+ return `Last WebSocket disconnect: ${new Date(diagnostic.at).toISOString()}; cause=${diagnostic.cause}; stage=${diagnostic.stage}${diagnostic.closeCode !== undefined ? `; closeCode=${diagnostic.closeCode}` : ""}${diagnostic.routeKind !== undefined ? `; route=${diagnostic.routeKind}` : ""}${diagnostic.connectionAgeMs !== undefined ? `; connectionAgeMs=${diagnostic.connectionAgeMs}` : ""}${diagnostic.eventCount !== undefined ? `; events=${diagnostic.eventCount}` : ""}${transport}`;
1456
+ }
1457
+ function formatWebsocketAdmission(status) {
1458
+ const active = readWebsocketCounter(status?.websocketActiveConnections, 64);
1459
+ const reclaimed = readWebsocketCounter(status?.websocketCapacityReclaims);
1460
+ const counters = [
1461
+ ...(active !== null ? [`active=${active}`] : []),
1462
+ ...(reclaimed !== null ? [`idleReclaimed=${reclaimed}`] : []),
1463
+ ];
1464
+ const rejection = readWebsocketUpgradeRejection(status?.lastWebsocketUpgradeRejection);
1465
+ return [
1466
+ ...(counters.length ? [`WebSocket connections: ${counters.join("; ")}`] : []),
1467
+ ...(rejection ? [`Last WebSocket upgrade rejection: ${new Date(rejection.at).toISOString()}; reason=${rejection.reason}; status=${rejection.status}; active=${rejection.activeConnections}`] : []),
1468
+ ];
1411
1469
  }
1412
1470
  export function formatAppBindStatus(status) {
1413
1471
  if (status.unmanagedBind && !status.state) {
@@ -1434,11 +1492,13 @@ export function formatAppBindStatus(status) {
1434
1492
  parts.push(`lastAccount=Account ${status.router.lastAccountIndex + 1}`);
1435
1493
  }
1436
1494
  const diagnostic = formatWebsocketDisconnect(status.router?.lastWebsocketDisconnect);
1495
+ const admission = formatWebsocketAdmission(status.router);
1437
1496
  if (status.state.nativeOpenai)
1438
- return [`Codex app bind: ${parts.join(", ")}, native OpenAI, live account catalogs`, ...(diagnostic ? [diagnostic] : [])].join("\n");
1497
+ return [`Codex app bind: ${parts.join(", ")}, native OpenAI, live account catalogs`, ...(diagnostic ? [diagnostic] : []), ...admission].join("\n");
1439
1498
  return [
1440
1499
  `Codex app bind: ${parts.join(", ")}`,
1441
1500
  ...(diagnostic ? [diagnostic] : []),
1501
+ ...admission,
1442
1502
  [
1443
1503
  "Note: Codex Desktop may hide history while the app bind selects the",
1444
1504
  "codex-multi-auth-runtime-proxy provider; use `codex-multi-auth rotation",
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { promises as fs } from "node:fs";
3
3
  import { z } from "zod";
4
4
  import { resolveAccountRecordId } from "../accounts.js";
5
- import { extractAccountId } from "../auth/token-utils.js";
5
+ import { extractAccountId, nativeWorkspaceIdentity } from "../auth/token-utils.js";
6
6
  import { getAccountPolicyKey } from "../account-policy.js";
7
7
  import { withFileTransactionLock } from "../storage/file-lock.js";
8
8
  import { withRetry } from "../fs-retry.js";
@@ -17,11 +17,12 @@ const schema = z.record(z.string().regex(/^sha256:[a-f0-9]{64}$/), z.number().fi
17
17
  const retry = { maxAttempts: 6, backoffMs: 25 };
18
18
  /** Honor the saved selection; a disabled/invalid selection must not fall back to a sibling. */
19
19
  export function automaticCheckWorkspaceId(account) {
20
+ const identity = { access: account.accessToken, codexCliMirror: account.codexCliMirror };
20
21
  if (account.workspaces?.length) {
21
22
  const selected = account.workspaces[account.currentWorkspaceIndex ?? 0];
22
- return selected?.enabled === false ? undefined : selected?.id.trim() || undefined;
23
+ return selected?.enabled === false ? undefined : nativeWorkspaceIdentity(identity, selected?.id);
23
24
  }
24
- return account.accountId?.trim() || extractAccountId(account.accessToken) || undefined;
25
+ return nativeWorkspaceIdentity(identity, account.accountId?.trim() || extractAccountId(account.accessToken));
25
26
  }
26
27
  /** Policy keys can be shared by organization members; attempt limits must never be. */
27
28
  export function automaticAccountCheckKey(account) {
@@ -72,7 +73,7 @@ export async function runAutomaticAccountChecks(options) {
72
73
  // Keep every still-saved workspace's throttle, including temporarily disabled selections.
73
74
  const keys = new Set(storage.accounts.flatMap(account => [
74
75
  automaticAccountCheckKey(account),
75
- ...(account.workspaces ?? []).map(workspace => workspaceAttemptKey(account, workspace.id.trim())),
76
+ ...(account.workspaces ?? []).map(workspace => workspaceAttemptKey(account, nativeWorkspaceIdentity({ access: account.accessToken, codexCliMirror: account.codexCliMirror }, workspace.id) ?? "")),
76
77
  ]));
77
78
  for (const key of Object.keys(attempts))
78
79
  if (!keys.has(key))
@@ -84,7 +85,7 @@ export async function runAutomaticAccountChecks(options) {
84
85
  if (!account)
85
86
  continue;
86
87
  const key = automaticAccountCheckKey(account), policy = policies.accounts[getAccountPolicyKey(account)], now = options.now?.() ?? Date.now();
87
- if (!policy?.autoPrime || policy.paused || policy.drained || account.enabled === false || account.authInvalidatedAt || (account.coolingDownUntil ?? 0) > now)
88
+ if (!policy?.autoPrime || policy.paused || policy.drained || policy.emergencyOnly || account.enabled === false || account.authInvalidatedAt || (account.coolingDownUntil ?? 0) > now)
88
89
  continue;
89
90
  const lastAttempt = attempts[key];
90
91
  if (lastAttempt !== undefined && lastAttempt <= now + FUTURE_SKEW_MS && now - lastAttempt < AUTOMATIC_CHECK_INTERVAL_MS)
@@ -20,7 +20,8 @@ export function createAutomaticSubscriptionCheck(observe) {
20
20
  await saveQuotaCache(cache, baseline);
21
21
  });
22
22
  return (signal) => runWithStoragePathState(storageState, () => runAutomaticAccountChecks({
23
- path: `${path}.automatic-checks.json`, signal, loadPolicies: loadAccountPolicyStore,
23
+ // Priming may consume quota: malformed permissions must never normalize to OFF.
24
+ path: `${path}.automatic-checks.json`, signal, loadPolicies: () => loadAccountPolicyStore({ strict: true }),
24
25
  loadAccounts: async () => { const snapshot = await read(); return snapshot.verified ? snapshot.storage : null; },
25
26
  check: async (storage, index) => {
26
27
  const manager = new AccountManager(undefined, storage);
@@ -36,8 +37,8 @@ export function createAutomaticSubscriptionCheck(observe) {
36
37
  // A refresh is an I/O boundary: revalidate removal, policy, auth and workspace changes.
37
38
  const disk = await read();
38
39
  const current = disk.verified ? disk.storage?.accounts.find(row => resolveAccountRecordId(row) === resolveAccountRecordId(fresh.account)) : undefined;
39
- const policy = current ? (await loadAccountPolicyStore()).accounts[getAccountPolicyKey(current)] : undefined;
40
- if (!disk.storage || !current || current.refreshToken !== fresh.account.refreshToken || current.accessToken !== fresh.accessToken || !policy?.autoPrime || policy.paused || policy.drained || current.enabled === false || current.authInvalidatedAt || (current.coolingDownUntil ?? 0) > Date.now())
40
+ const policy = current ? (await loadAccountPolicyStore({ strict: true })).accounts[getAccountPolicyKey(current)] : undefined;
41
+ if (!disk.storage || !current || current.refreshToken !== fresh.account.refreshToken || current.accessToken !== fresh.accessToken || !policy?.autoPrime || policy.paused || policy.drained || policy.emergencyOnly || current.enabled === false || current.authInvalidatedAt || (current.coolingDownUntil ?? 0) > Date.now())
41
42
  return;
42
43
  if (automaticCheckWorkspaceId(current) !== targetId || signal.aborted)
43
44
  return;
@@ -68,7 +68,7 @@ export async function discoverModelInventory(storage, routes, options = {}) {
68
68
  const scopes = manager.getAccountsSnapshot().flatMap(workspaceModelScopes);
69
69
  const catalog = new AccountModelCatalog(async (key) => {
70
70
  const scope = scopes.find(item => item.id === key);
71
- const account = scope?.enabled ? manager.getAccountByIndex(scope.accountIndex) : null;
71
+ const account = scope?.routable ? manager.getAccountByIndex(scope.accountIndex) : null;
72
72
  if (!account || account.enabled === false)
73
73
  throw Error("Disabled");
74
74
  const fresh = await ensureFreshAccessToken({
@@ -105,7 +105,7 @@ export async function discoverModelInventory(storage, routes, options = {}) {
105
105
  }, now);
106
106
  try {
107
107
  entries.push(...await mapWithConcurrency(scopes, 3, async (scope) => {
108
- if (scope.enabled)
108
+ if (scope.routable)
109
109
  await catalog.list([scope.id]);
110
110
  const snapshot = catalog.snapshot(scope.id);
111
111
  return { id: scope.id, label: scope.label, kind: "oauth", enabled: scope.enabled, routable: scope.routable, bound: scope.bound, selected: scope.selected,
@@ -1,8 +1,8 @@
1
- import { type AccountManager, type ManagedAccount } from '../accounts.js';
1
+ import type { AccountManager, ManagedAccount } from '../accounts.js';
2
2
  import type { ModelFamily } from '../prompts/codex.js';
3
3
  import type { QuotaCacheEntry } from '../quota-cache.js';
4
- import { type ResetCreditService } from './reset-credits.js';
5
- import type { workspaceModelScopes } from './workspace-model-scopes.js';
4
+ import { type ResetSnapshot, type ResetCreditService, type ResetExhaustionEvidence } from './reset-credits.js';
5
+ import { workspaceModelScopes } from './workspace-model-scopes.js';
6
6
  type Scope = ReturnType<typeof workspaceModelScopes>[number];
7
7
  export declare function recoverResetQuota(options: {
8
8
  model: string;
@@ -19,6 +19,7 @@ export declare function recoverResetQuota(options: {
19
19
  snapshots: Awaited<ReturnType<ResetCreditService['status']>>['snapshots'];
20
20
  }>;
21
21
  };
22
+ quotaRejections?: ReadonlyMap<string, ResetExhaustionEvidence>;
22
23
  observations: Map<string, QuotaCacheEntry>;
23
24
  clearQuotaScheduler: (account: ManagedAccount) => void;
24
25
  now: () => number;
@@ -37,4 +38,11 @@ export declare function applyConfirmedReset(options: {
37
38
  clearQuotaScheduler: (account: ManagedAccount) => void;
38
39
  now: number;
39
40
  }): boolean;
41
+ /** Unknown, stale, or pending reset state must never authorize backup traffic. */
42
+ export declare function primaryPoolFullyExhausted(scopes: Map<number, Scope[]>, state: {
43
+ snapshots: Record<string, ResetSnapshot>;
44
+ pending?: {
45
+ key: string;
46
+ };
47
+ } | null, now: number, latestQuota?: (index: number, scope: Scope) => QuotaCacheEntry | null | undefined): boolean;
40
48
  export {};
@@ -1,8 +1,21 @@
1
- import { extractAccountId } from '../accounts.js';
2
1
  import { LUNA_RESERVE_MODEL } from '../constants.js';
3
2
  import { parseModelRoute } from '../model-route-policy.js';
4
3
  import { subscriptionQuotaPreference } from './subscription-quota-order.js';
5
- import { resetSnapshotQuota } from './reset-credits.js';
4
+ import { resetSnapshotQuota, isResetSubscription } from './reset-credits.js';
5
+ import { workspaceModelScopes } from './workspace-model-scopes.js';
6
+ /** Account-level quota blockers belong to the currently routed workspace. */
7
+ function clearRecoveredScopeQuota(options) {
8
+ const { account, scope, family, model, manager, clearQuotaScheduler } = options;
9
+ // Recheck selection and enablement: a reset read may outlive a workspace change.
10
+ if (account.lastRateLimitReason !== 'quota' || !workspaceModelScopes(account).some(current => current.routable && current.id === scope.id && current.accountId === scope.accountId))
11
+ return false;
12
+ delete account.rateLimitResetTimes[family];
13
+ delete account.rateLimitResetTimes[`${family}:${model}`];
14
+ if (account.cooldownReason === 'rate-limit')
15
+ manager.clearAccountCooldown(account);
16
+ clearQuotaScheduler(account);
17
+ return true;
18
+ }
6
19
  export async function recoverResetQuota(options) {
7
20
  if (options.model === LUNA_RESERVE_MODEL || !options.native || options.pinned || parseModelRoute(options.model).kind !== 'oauth')
8
21
  return false;
@@ -10,39 +23,62 @@ export async function recoverResetQuota(options) {
10
23
  const candidates = [...options.scopes].flatMap(([index, scopes]) => { const account = options.manager.getAccountByIndex(index); return account && !account.authInvalidatedAt ? scopes.map(scope => ({ account, scope })) : []; });
11
24
  if (!candidates.length)
12
25
  return false;
26
+ const currentScope = (account, scope) => workspaceModelScopes(account).some(current => current.routable && current.id === scope.id && current.accountId === scope.accountId);
27
+ const rejectionForScope = (account, scope) => {
28
+ const proof = options.quotaRejections?.get(JSON.stringify([scope.id, options.model]));
29
+ const latest = options.quotaForScope(account, scope), now = options.now();
30
+ // An older in-flight response can deliver newer percentage headers. Only a
31
+ // later authoritative native permission check supersedes a body rejection.
32
+ const checkedAt = typeof latest?.ordinaryUsageAllowed === 'boolean'
33
+ ? latest.ordinaryUsageCheckedAt ?? latest.updatedAt : 0;
34
+ return proof && proof.key === scope.id && proof.accountId === scope.accountId &&
35
+ Number.isFinite(proof.observedAt) && proof.observedAt <= now && now - proof.observedAt <= 60000 &&
36
+ proof.observedAt > checkedAt ? proof : undefined;
37
+ };
13
38
  // Any usable capacity (including the reserve), unknown quota, or non-quota
14
39
  // failure prevents an automatic credit from being spent.
15
40
  if (candidates.some(({ account, scope }) => {
16
- if (account.enabled === false || account.authInvalidatedAt || !scope.routable)
41
+ if (account.enabled === false || account.authInvalidatedAt || !scope.routable || !currentScope(account, scope))
17
42
  return true;
18
43
  const skip = options.manager.getAccountRuntimeSkipReason(account.index, family, options.model);
19
44
  if (skip && !['rate-limited', 'cooling-down:rate-limit'].includes(skip))
20
45
  return true;
21
46
  const quota = subscriptionQuotaPreference(options.quotaForScope(account, scope), options.now());
22
- return !quota.exhausted && !['rate-limited', 'cooling-down:rate-limit'].includes(skip ?? '');
47
+ return !quota.exhausted && !rejectionForScope(account, scope) && !['rate-limited', 'cooling-down:rate-limit'].includes(skip ?? '');
23
48
  }))
24
49
  return false;
25
50
  candidates.sort((a, b) => Number(b.account.index === options.preferredIndex) - Number(a.account.index === options.preferredIndex) || (options.priorityByAccount?.[a.account.index] ?? 1) - (options.priorityByAccount?.[b.account.index] ?? 1));
26
51
  const targets = candidates.filter(({ scope }) => !scope.accountId.startsWith("org-")).map(({ scope }) => ({ key: scope.id, accountId: scope.accountId }));
27
- await options.service.automatic(targets);
52
+ const evidence = candidates.flatMap(({ account, scope }) => {
53
+ if (scope.accountId.startsWith("org-"))
54
+ return [];
55
+ const rejection = rejectionForScope(account, scope);
56
+ if (rejection)
57
+ return [rejection];
58
+ const quota = subscriptionQuotaPreference(options.quotaForScope(account, scope), options.now());
59
+ return quota.exhausted && quota.observedAt !== null
60
+ ? [{ key: scope.id, accountId: scope.accountId, observedAt: quota.observedAt }] : [];
61
+ });
62
+ await options.service.automatic(targets, evidence);
28
63
  const state = await options.service.status();
29
64
  let recovered = false;
30
65
  for (const { account, scope } of candidates) {
31
66
  const snapshot = state.snapshots[scope.id];
32
67
  const previous = options.quotaForScope(account, scope);
33
- if (!snapshot || snapshot.updatedAt < (previous?.updatedAt ?? 0) || options.now() - snapshot.updatedAt > 60000 || snapshot.updatedAt > options.now())
68
+ const rejectedAt = rejectionForScope(account, scope)?.observedAt ?? 0;
69
+ // Healthy headers may arrive late from an older in-flight request. Native
70
+ // permission chronology wins there; unknown, exhausted, and failed reads do not.
71
+ const healthyHeaders = snapshot?.ordinaryUsageAllowed === true && previous?.status === 200 &&
72
+ (subscriptionQuotaPreference(previous, options.now()).remainingPercent ?? 0) > 0;
73
+ const previousAt = healthyHeaders
74
+ ? typeof previous?.ordinaryUsageAllowed === 'boolean' ? previous.ordinaryUsageCheckedAt ?? previous.updatedAt : 0
75
+ : previous?.updatedAt ?? 0;
76
+ if (!snapshot || snapshot.updatedAt < Math.max(previousAt, rejectedAt) || options.now() - snapshot.updatedAt > 60000 || snapshot.updatedAt > options.now())
34
77
  continue;
35
78
  options.observations.set(JSON.stringify([scope.id, options.model]), resetSnapshotQuota(snapshot));
36
79
  if (snapshot.ordinaryUsageAllowed !== true)
37
80
  continue;
38
- // Do not clear sibling workspace/model throttles or authentication failures.
39
- if ((scope.bound || (account.accountId?.startsWith('org-') && scope.accountId === extractAccountId(account.access))) && account.lastRateLimitReason === 'quota') {
40
- delete account.rateLimitResetTimes[family];
41
- delete account.rateLimitResetTimes[`${family}:${options.model}`];
42
- if (account.cooldownReason === 'rate-limit')
43
- options.manager.clearAccountCooldown(account);
44
- options.clearQuotaScheduler(account);
45
- }
81
+ clearRecoveredScopeQuota({ ...options, account, scope, family });
46
82
  recovered = true;
47
83
  }
48
84
  if (recovered)
@@ -57,13 +93,22 @@ export function applyConfirmedReset(options) {
57
93
  if (!s || options.lastRedemptionAt === undefined || s.updatedAt < options.lastRedemptionAt || s.ordinaryUsageAllowed !== true || s.updatedAt <= (options.previous?.updatedAt ?? 0) || options.now - s.updatedAt > 60000 || s.updatedAt > options.now)
58
94
  return false;
59
95
  options.observations.set(JSON.stringify([options.scope.id, options.model]), resetSnapshotQuota(s));
60
- if ((options.scope.bound || (options.account.accountId?.startsWith('org-') && options.scope.accountId === extractAccountId(options.account.access))) && options.account.lastRateLimitReason === 'quota') {
61
- delete options.account.rateLimitResetTimes[options.family];
62
- delete options.account.rateLimitResetTimes[`${options.family}:${options.model}`];
63
- if (options.account.cooldownReason === 'rate-limit')
64
- options.manager.clearAccountCooldown(options.account);
65
- options.clearQuotaScheduler(options.account);
96
+ if (clearRecoveredScopeQuota(options))
66
97
  options.manager.saveToDiskDebounced();
67
- }
68
98
  return true;
69
99
  }
100
+ /** Unknown, stale, or pending reset state must never authorize backup traffic. */
101
+ export function primaryPoolFullyExhausted(scopes, state, now, latestQuota) {
102
+ const primary = [...scopes].flatMap(([index, rows]) => rows.map(scope => ({ index, scope })));
103
+ return primary.length > 0 && !!state && !state.pending && primary.every(({ index, scope }) => {
104
+ const snapshot = state.snapshots[scope.id];
105
+ const latest = latestQuota?.(index, scope);
106
+ if (snapshot && latest && latest.updatedAt >= snapshot.updatedAt &&
107
+ !subscriptionQuotaPreference(latest, now).exhausted)
108
+ return false;
109
+ return scope.routable && !!snapshot && Number.isFinite(snapshot.updatedAt) &&
110
+ snapshot.updatedAt <= now && now - snapshot.updatedAt <= 60_000 &&
111
+ isResetSubscription(snapshot) && snapshot.ordinaryUsageAllowed === false &&
112
+ snapshot.availableCount === 0;
113
+ });
114
+ }