codex-multi-auth 2.23.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 (84) hide show
  1. package/.codex-plugin/plugin.json +9 -9
  2. package/LICENSE +21 -21
  3. package/README.md +159 -152
  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/dist/index.js +33 -10
  11. package/dist/lib/account-policy.d.ts +2 -0
  12. package/dist/lib/account-policy.js +3 -1
  13. package/dist/lib/accounts/rate-limits.d.ts +6 -0
  14. package/dist/lib/accounts/rate-limits.js +14 -1
  15. package/dist/lib/accounts.d.ts +6 -1
  16. package/dist/lib/accounts.js +33 -8
  17. package/dist/lib/auth/token-utils.d.ts +5 -0
  18. package/dist/lib/auth/token-utils.js +10 -0
  19. package/dist/lib/codex-manager/commands/account.js +15 -0
  20. package/dist/lib/codex-manager/commands/resets.js +18 -5
  21. package/dist/lib/codex-manager/commands/workspace.d.ts +4 -2
  22. package/dist/lib/codex-manager/commands/workspace.js +33 -2
  23. package/dist/lib/codex-manager/help.js +2 -2
  24. package/dist/lib/codex-manager.js +14 -1
  25. package/dist/lib/constants.d.ts +2 -0
  26. package/dist/lib/constants.js +2 -0
  27. package/dist/lib/context-budget-response.js +8 -8
  28. package/dist/lib/context-overflow.js +10 -10
  29. package/dist/lib/oauth-success.html +125 -125
  30. package/dist/lib/policy/runtime-policy-cache.js +1 -1
  31. package/dist/lib/policy/runtime-policy.d.ts +1 -0
  32. package/dist/lib/policy/runtime-policy.js +4 -0
  33. package/dist/lib/preemptive-quota-scheduler.js +12 -3
  34. package/dist/lib/prompts/codex-host-bridge.js +138 -138
  35. package/dist/lib/prompts/codex.js +79 -79
  36. package/dist/lib/quota-cache.d.ts +4 -0
  37. package/dist/lib/quota-readiness.d.ts +1 -1
  38. package/dist/lib/quota-readiness.js +9 -2
  39. package/dist/lib/request/rate-limit-decision.js +1 -1
  40. package/dist/lib/runtime/account-reset-credits.d.ts +11 -2
  41. package/dist/lib/runtime/account-reset-credits.js +35 -12
  42. package/dist/lib/runtime/automatic-account-checks.js +6 -5
  43. package/dist/lib/runtime/automatic-subscription-checks.js +4 -3
  44. package/dist/lib/runtime/model-discovery-status.js +2 -2
  45. package/dist/lib/runtime/reset-credit-routing.d.ts +11 -3
  46. package/dist/lib/runtime/reset-credit-routing.js +66 -21
  47. package/dist/lib/runtime/reset-credits.d.ts +31 -7
  48. package/dist/lib/runtime/reset-credits.js +135 -28
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +3 -0
  50. package/dist/lib/runtime/subscription-quota-order.d.ts +1 -0
  51. package/dist/lib/runtime/subscription-quota-order.js +7 -3
  52. package/dist/lib/runtime/workspace-model-scopes.d.ts +2 -0
  53. package/dist/lib/runtime/workspace-model-scopes.js +14 -4
  54. package/dist/lib/runtime-rotation-proxy.js +108 -29
  55. package/dist/lib/storage/snapshot-merge.js +15 -4
  56. package/dist/lib/web-dashboard-html.js +53 -53
  57. package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -2
  58. package/node_modules/@codex-ai/plugin/dist/index.js +2 -2
  59. package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -42
  60. package/node_modules/@codex-ai/plugin/dist/tool.js +29 -29
  61. package/node_modules/@codex-ai/plugin/package.json +9 -9
  62. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -4
  63. package/node_modules/@codex-ai/sdk/dist/index.js +2 -2
  64. package/node_modules/@codex-ai/sdk/package.json +8 -8
  65. package/package.json +209 -209
  66. package/scripts/codex-app-launcher.js +653 -653
  67. package/scripts/codex-app-router.js +446 -446
  68. package/scripts/codex-bin-resolver.js +332 -332
  69. package/scripts/codex-multi-auth.js +65 -65
  70. package/scripts/codex-routing.js +63 -63
  71. package/scripts/codex.js +7143 -7143
  72. package/scripts/install-codex-auth-utils.js +213 -213
  73. package/scripts/mcodex.js +367 -367
  74. package/scripts/postinstall.js +114 -114
  75. package/scripts/preuninstall.js +265 -265
  76. package/skills/codex-auth-setup/SKILL.md +73 -73
  77. package/vendor/codex-ai-plugin/dist/index.d.ts +2 -2
  78. package/vendor/codex-ai-plugin/dist/index.js +2 -2
  79. package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -42
  80. package/vendor/codex-ai-plugin/dist/tool.js +29 -29
  81. package/vendor/codex-ai-plugin/package.json +9 -9
  82. package/vendor/codex-ai-sdk/dist/index.d.ts +4 -4
  83. package/vendor/codex-ai-sdk/dist/index.js +2 -2
  84. package/vendor/codex-ai-sdk/package.json +8 -8
@@ -381,83 +381,83 @@ export function prewarmCodexInstructions(models = []) {
381
381
  /**
382
382
  * Tool remapping instructions for host runtime tools
383
383
  */
384
- export const TOOL_REMAP_MESSAGE = `<user_instructions priority="0">
385
- <environment_override priority="0">
386
- YOU ARE IN A DIFFERENT ENVIRONMENT. These instructions override ALL previous tool references.
387
- </environment_override>
388
-
389
- <tool_replacements priority="0">
390
- <critical_rule priority="0">
391
- apply_patch/applyPatch are Codex names, but host tool names vary by version.
392
- - Inspect the actual tool list before editing.
393
- - If edit exists: use edit for precise in-place string replacements and hashline edits.
394
- - If edit is absent and apply_patch exists: use apply_patch for those precise/hashline edits in this plugin.
395
- - For diff-style or multi-line structural edits: use patch if available, otherwise use apply_patch.
396
- - In this plugin, edit/apply_patch support hashline refs (lineRef, endLineRef, operation, content)
397
- </critical_rule>
398
-
399
- <critical_rule priority="0">
400
- UPDATE_PLAN DOES NOT EXIST -> USE "todowrite" INSTEAD
401
- - NEVER use: update_plan, updatePlan
402
- - ALWAYS use: todowrite for ALL task/plan operations
403
- - Use todoread to read current plan
404
- - Before plan operations: Verify you're using "todowrite", NOT "update_plan"
405
- </critical_rule>
406
- </tool_replacements>
407
-
408
- <available_tools priority="0">
409
- File Operations:
410
- - write - Create new files (if available)
411
- - edit - Modify existing files with string replacement (version-dependent)
412
- - oldString must be literal text from the current file; never pass unresolved placeholders like \${TARGET_SNIPPET}
413
- - apply_patch - May be the edit/patch tool name in newer host builds (version-dependent)
414
- - hashline_read - Read lines with hashline refs (L<line>#<hash>) for deterministic edits
415
- - patch - Apply diff patches (version-dependent)
416
- - read - Read file contents
417
-
418
- Search/Discovery:
419
- - grep - Search file contents
420
- - glob - Find files by pattern
421
- - list - List directories (if available)
422
-
423
- Execution:
424
- - bash - Run shell commands
425
-
426
- Network:
427
- - webfetch - Fetch web content
428
-
429
- Task Management:
430
- - todowrite - Manage tasks/plans (REPLACES update_plan)
431
- - todoread - Read current plan
432
- </available_tools>
433
-
434
- <substitution_rules priority="0">
435
- Base instruction says: You MUST use instead:
436
- apply_patch -> patch (preferred if available), otherwise edit/apply_patch based on actual tool list
437
- update_plan -> todowrite
438
- read_plan -> todoread
439
- absolute paths -> relative paths
440
- </substitution_rules>
441
-
442
- <verification_checklist priority="0">
443
- Before file/plan modifications:
444
- 1. Am I using the actual available edit tool name (edit, patch, or apply_patch)?
445
- 2. Am I using "todowrite" NOT "update_plan"?
446
- 3. Is this tool in the approved list above?
447
- 4. Am I using relative paths?
448
-
449
- If ANY answer is NO -> STOP and correct before proceeding.
450
- </verification_checklist>
451
-
452
- <hashline_workflow priority="0">
453
- When hashline_read is available:
454
- 1. call hashline_read on the file
455
- 2. edit/apply_patch using lineRef/endLineRef + operation + content
456
- 3. use oldString/newString only as fallback, and only with literal file text (no template placeholders)
457
- </hashline_workflow>
458
-
459
- <safety_rules priority="0">
460
- - Never run destructive git commands (\`git reset --hard\`, \`git checkout --\`) unless explicitly requested by the user.
461
- - Never call \`request_user_input\` unless collaboration mode is explicitly Plan mode.
462
- </safety_rules>
384
+ export const TOOL_REMAP_MESSAGE = `<user_instructions priority="0">
385
+ <environment_override priority="0">
386
+ YOU ARE IN A DIFFERENT ENVIRONMENT. These instructions override ALL previous tool references.
387
+ </environment_override>
388
+
389
+ <tool_replacements priority="0">
390
+ <critical_rule priority="0">
391
+ apply_patch/applyPatch are Codex names, but host tool names vary by version.
392
+ - Inspect the actual tool list before editing.
393
+ - If edit exists: use edit for precise in-place string replacements and hashline edits.
394
+ - If edit is absent and apply_patch exists: use apply_patch for those precise/hashline edits in this plugin.
395
+ - For diff-style or multi-line structural edits: use patch if available, otherwise use apply_patch.
396
+ - In this plugin, edit/apply_patch support hashline refs (lineRef, endLineRef, operation, content)
397
+ </critical_rule>
398
+
399
+ <critical_rule priority="0">
400
+ UPDATE_PLAN DOES NOT EXIST -> USE "todowrite" INSTEAD
401
+ - NEVER use: update_plan, updatePlan
402
+ - ALWAYS use: todowrite for ALL task/plan operations
403
+ - Use todoread to read current plan
404
+ - Before plan operations: Verify you're using "todowrite", NOT "update_plan"
405
+ </critical_rule>
406
+ </tool_replacements>
407
+
408
+ <available_tools priority="0">
409
+ File Operations:
410
+ - write - Create new files (if available)
411
+ - edit - Modify existing files with string replacement (version-dependent)
412
+ - oldString must be literal text from the current file; never pass unresolved placeholders like \${TARGET_SNIPPET}
413
+ - apply_patch - May be the edit/patch tool name in newer host builds (version-dependent)
414
+ - hashline_read - Read lines with hashline refs (L<line>#<hash>) for deterministic edits
415
+ - patch - Apply diff patches (version-dependent)
416
+ - read - Read file contents
417
+
418
+ Search/Discovery:
419
+ - grep - Search file contents
420
+ - glob - Find files by pattern
421
+ - list - List directories (if available)
422
+
423
+ Execution:
424
+ - bash - Run shell commands
425
+
426
+ Network:
427
+ - webfetch - Fetch web content
428
+
429
+ Task Management:
430
+ - todowrite - Manage tasks/plans (REPLACES update_plan)
431
+ - todoread - Read current plan
432
+ </available_tools>
433
+
434
+ <substitution_rules priority="0">
435
+ Base instruction says: You MUST use instead:
436
+ apply_patch -> patch (preferred if available), otherwise edit/apply_patch based on actual tool list
437
+ update_plan -> todowrite
438
+ read_plan -> todoread
439
+ absolute paths -> relative paths
440
+ </substitution_rules>
441
+
442
+ <verification_checklist priority="0">
443
+ Before file/plan modifications:
444
+ 1. Am I using the actual available edit tool name (edit, patch, or apply_patch)?
445
+ 2. Am I using "todowrite" NOT "update_plan"?
446
+ 3. Is this tool in the approved list above?
447
+ 4. Am I using relative paths?
448
+
449
+ If ANY answer is NO -> STOP and correct before proceeding.
450
+ </verification_checklist>
451
+
452
+ <hashline_workflow priority="0">
453
+ When hashline_read is available:
454
+ 1. call hashline_read on the file
455
+ 2. edit/apply_patch using lineRef/endLineRef + operation + content
456
+ 3. use oldString/newString only as fallback, and only with literal file text (no template placeholders)
457
+ </hashline_workflow>
458
+
459
+ <safety_rules priority="0">
460
+ - Never run destructive git commands (\`git reset --hard\`, \`git checkout --\`) unless explicitly requested by the user.
461
+ - Never call \`request_user_input\` unless collaboration mode is explicitly Plan mode.
462
+ </safety_rules>
463
463
  </user_instructions>`;
@@ -4,6 +4,10 @@ export interface QuotaCacheWindow {
4
4
  resetAtMs?: number;
5
5
  }
6
6
  export interface QuotaCacheEntry {
7
+ /** Account-validated native usage permission; never inferred from percentages. */
8
+ ordinaryUsageAllowed?: boolean | null;
9
+ /** Native permission timestamp; header refreshes must not extend its lifetime. */
10
+ ordinaryUsageCheckedAt?: number;
7
11
  primingCompleted?: boolean;
8
12
  /** Completion/failure time, independent of the quota header observation. */
9
13
  primingObservedAt?: number;
@@ -1,6 +1,6 @@
1
1
  import type { QuotaCacheData, QuotaCacheEntry } from "./quota-cache.js";
2
2
  import type { AccountMetadataV3 } from "./storage.js";
3
- export type QuotaCacheAccountRef = Pick<AccountMetadataV3, "accountId" | "email" | "recordId" | "workspaces" | "currentWorkspaceIndex"> & Partial<Pick<AccountMetadataV3, "refreshToken" | "addedAt" | "accessToken">>;
3
+ export type QuotaCacheAccountRef = Pick<AccountMetadataV3, "accountId" | "email" | "recordId" | "workspaces" | "currentWorkspaceIndex"> & Partial<Pick<AccountMetadataV3, "refreshToken" | "addedAt" | "accessToken" | "codexCliMirror">>;
4
4
  export declare function normalizeQuotaAccountId(value: string | undefined): string | null;
5
5
  export declare function normalizeQuotaEmail(value: string | undefined): string | null;
6
6
  export declare function hasUniqueQuotaAccountId(accounts: readonly QuotaCacheAccountRef[], account: QuotaCacheAccountRef): boolean;
@@ -1,4 +1,5 @@
1
- import { extractAccountId } from "./auth/token-utils.js";
1
+ import { RATE_LIMIT_CACHE_TTL_MS } from "./constants.js";
2
+ import { extractAccountId, nativeWorkspaceIdentity } from "./auth/token-utils.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { resolveAccountRecordId } from "./storage/record-identity.js";
4
5
  export function normalizeQuotaAccountId(value) {
@@ -89,6 +90,11 @@ export function isQuotaCacheEntryExhausted(entry, now = Date.now()) {
89
90
  // Codex quota windows are cumulative gates: a 0% remaining active window blocks use
90
91
  // even if another window still has quota left.
91
92
  const updatedAt = entry?.updatedAt;
93
+ // Stale usage is unknown, not proof that the server still rejects requests.
94
+ if (typeof updatedAt === "number" &&
95
+ Number.isFinite(updatedAt) &&
96
+ now >= updatedAt + RATE_LIMIT_CACHE_TTL_MS)
97
+ return false;
92
98
  return (quotaWindowIsExhausted(entry?.primary, now, updatedAt) ||
93
99
  quotaWindowIsExhausted(entry?.secondary, now, updatedAt));
94
100
  }
@@ -100,9 +106,10 @@ export function quotaWorkspaceKey(account, workspaceId) {
100
106
  }
101
107
  /** Display the selected workspace; callers routing another scope must pass that exact workspace. */
102
108
  export function quotaWorkspaceId(account) {
103
- return account.workspaces?.length
109
+ const selectedId = account.workspaces?.length
104
110
  ? account.workspaces[account.currentWorkspaceIndex ?? 0]?.id.trim()
105
111
  : account.accountId?.trim() || extractAccountId(account.accessToken);
112
+ return nativeWorkspaceIdentity({ access: account.accessToken, codexCliMirror: account.codexCliMirror }, selectedId);
106
113
  }
107
114
  /** Read exact-workspace quota, using legacy binding entries only for that binding. */
108
115
  export function findQuotaCacheEntryForAccount(cache, account, accounts, emailFallbackState = buildQuotaEmailFallbackState(accounts), workspaceId = quotaWorkspaceId(account)) {
@@ -181,7 +181,7 @@ const DEADLINE_SENTENCES = {
181
181
  "available-again": (resetAt) => `the account is expected to be available again at ${resetAt}`,
182
182
  "next-attempt": (resetAt) => `the next attempt is allowed at ${resetAt}`,
183
183
  "cooldown-ends": (resetAt) => `the cooldown ends at ${resetAt}`,
184
- "rate-limit-reset": (resetAt) => `the rate limit resets at ${resetAt}`,
184
+ "rate-limit-reset": (resetAt) => `the cached rate limit will be rechecked at ${resetAt}`,
185
185
  };
186
186
  /**
187
187
  * Every cooldown variant is bounded by the same `coolingDownUntil` field, so
@@ -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();
@@ -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
+ }