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
@@ -1,15 +1,15 @@
1
- {
2
- "$schema": "./schema/config.schema.json",
3
- "plugin": ["codex-multi-auth"],
4
-
5
- "provider": {
6
- "openai": {
7
- "options": {
8
- "store": false
9
- }
10
- }
11
- },
12
- "model": "openai/gpt-5.6-sol"
13
- }
14
-
15
-
1
+ {
2
+ "$schema": "./schema/config.schema.json",
3
+ "plugin": ["codex-multi-auth"],
4
+
5
+ "provider": {
6
+ "openai": {
7
+ "options": {
8
+ "store": false
9
+ }
10
+ }
11
+ },
12
+ "model": "openai/gpt-5.6-sol"
13
+ }
14
+
15
+
package/dist/index.js CHANGED
@@ -672,12 +672,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
672
672
  capabilityPolicy: capabilityPolicyStore,
673
673
  });
674
674
  }
675
- catch (policyError) {
676
- logWarn("Runtime policy evaluation failed open.", {
677
- error: policyError instanceof Error
678
- ? policyError.message
679
- : String(policyError),
680
- });
675
+ catch {
676
+ logWarn("Runtime policy evaluation failed; request blocked.");
681
677
  }
682
678
  usageRecorder = createRuntimeUsageRecorder({
683
679
  source: "plugin-host",
@@ -692,6 +688,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
692
688
  statusCode: null,
693
689
  errorCode: "plugin_host_request_failed",
694
690
  };
691
+ if (!runtimePolicyDecision) {
692
+ runtimeMetrics.failedRequests += 1;
693
+ runtimeMetrics.lastError = "Runtime policy unavailable";
694
+ syncRuntimeObservability(requestTraceId);
695
+ usageCompletion = {
696
+ outcome: "blocked",
697
+ statusCode: 503,
698
+ errorCode: "runtime_policy_unavailable",
699
+ };
700
+ return new Response(JSON.stringify({
701
+ error: {
702
+ message: "Runtime policy could not be loaded for this local request.",
703
+ code: "runtime_policy_unavailable",
704
+ },
705
+ }), {
706
+ status: 503,
707
+ headers: { "content-type": "application/json; charset=utf-8" },
708
+ });
709
+ }
695
710
  if (runtimePolicyDecision && !runtimePolicyDecision.allowed) {
696
711
  runtimeMetrics.failedRequests += 1;
697
712
  runtimeMetrics.lastError = "Runtime policy blocked request";
@@ -715,6 +730,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
715
730
  },
716
731
  });
717
732
  }
733
+ // This host cannot prove ordinary capacity and permitted reset
734
+ // recovery are exhausted, so emergency accounts stay reserved.
735
+ const blockedAccountIndexes = new Set([
736
+ ...(runtimePolicyDecision?.blockedAccountIndexes ?? []),
737
+ ...(runtimePolicyDecision?.emergencyAccountIndexes ?? []),
738
+ ]);
718
739
  const abortSignal = requestInit?.signal ?? init?.signal ?? null;
719
740
  const sleep = createAbortableSleep(abortSignal);
720
741
  const poolCooldownRemainingMs = getPoolExhaustionCooldownRemaining();
@@ -843,7 +864,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
843
864
  typeof preferredSessionAccountIndex === "number") {
844
865
  usedPreferredSessionAccount = true;
845
866
  if (accountManager.isAccountAvailableForFamily(preferredSessionAccountIndex, modelFamily, model) &&
846
- !runtimePolicyDecision?.blockedAccountIndexes.has(preferredSessionAccountIndex)) {
867
+ !blockedAccountIndexes.has(preferredSessionAccountIndex)) {
847
868
  account = accountManager.getAccountByIndex(preferredSessionAccountIndex);
848
869
  if (account) {
849
870
  account.lastUsed = Date.now();
@@ -858,13 +879,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
858
879
  account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, {
859
880
  pidOffsetEnabled,
860
881
  scoreBoostByAccount: capabilityBoostByAccount,
882
+ blockedAccountIndexes,
861
883
  });
862
884
  }
863
885
  if (!account || attempted.has(account.index)) {
864
886
  break;
865
887
  }
866
888
  attempted.add(account.index);
867
- if (runtimePolicyDecision?.blockedAccountIndexes.has(account.index)) {
889
+ if (blockedAccountIndexes.has(account.index)) {
868
890
  continue;
869
891
  }
870
892
  // Log account selection for debugging rotation
@@ -1451,7 +1473,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1451
1473
  runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs;
1452
1474
  let responseForSuccess = response;
1453
1475
  if (isStreaming) {
1454
- const streamFallbackCandidateOrder = buildAdaptiveStreamFailoverCandidateOrder(account.index, accountSnapshotList);
1476
+ const streamFallbackCandidateOrder = buildAdaptiveStreamFailoverCandidateOrder(account.index, accountSnapshotList.filter((candidate) => !blockedAccountIndexes.has(candidate.index)));
1455
1477
  runtimeMetrics.lastStreamFailoverCandidateCount =
1456
1478
  streamFallbackCandidateOrder.length;
1457
1479
  runtimeMetrics.streamFailoverCandidatesConsidered +=
@@ -1472,7 +1494,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1472
1494
  if (abortSignal?.aborted) {
1473
1495
  return null;
1474
1496
  }
1475
- if (!accountManager.isAccountAvailableForFamily(candidateIndex, modelFamily, model)) {
1497
+ if (blockedAccountIndexes.has(candidateIndex) ||
1498
+ !accountManager.isAccountAvailableForFamily(candidateIndex, modelFamily, model)) {
1476
1499
  continue;
1477
1500
  }
1478
1501
  const fallbackAccount = accountManager.getAccountByIndex(candidateIndex);
@@ -7,6 +7,8 @@ export interface AccountPolicy {
7
7
  priority?: number;
8
8
  /** Opt in to periodic first-use completion for unused personal subscriptions. */
9
9
  autoPrime?: boolean;
10
+ /** Pool backup only, after primary subscriptions and reset credits are exhausted. */
11
+ emergencyOnly?: boolean;
10
12
  paused: boolean;
11
13
  drained: boolean;
12
14
  note: string | null;
@@ -37,6 +37,7 @@ function normalizePolicy(key, value) {
37
37
  weight: normalizeWeight(record.weight),
38
38
  priority: normalizePriority(record.priority),
39
39
  autoPrime: record.autoPrime === true,
40
+ emergencyOnly: record.emergencyOnly === true,
40
41
  paused: record.paused === true,
41
42
  drained: record.drained === true,
42
43
  note: note.length > 0 ? note.slice(0, 500) : null,
@@ -112,7 +113,7 @@ export async function loadAccountPolicyStore(options = {}) {
112
113
  if (!key.startsWith("sha256:"))
113
114
  continue;
114
115
  // Legacy rows may omit flags; explicit malformed values are not OFF.
115
- if (!isRecord(row) || ["autoPrime", "paused", "drained"].some(flag => flag in row && typeof row[flag] !== "boolean")) {
116
+ if (!isRecord(row) || ["autoPrime", "emergencyOnly", "paused", "drained"].some(flag => flag in row && typeof row[flag] !== "boolean")) {
116
117
  throw new Error("Invalid account policy row");
117
118
  }
118
119
  }
@@ -241,6 +242,7 @@ export function upsertAccountPolicy(store, accountKey, mutate, now = Date.now())
241
242
  next.weight = normalizeWeight(next.weight);
242
243
  next.priority = normalizePriority(next.priority);
243
244
  next.autoPrime = next.autoPrime === true;
245
+ next.emergencyOnly = next.emergencyOnly === true;
244
246
  next.updatedAt = stampUpdatedAt(now, store.accounts[accountKey]?.updatedAt);
245
247
  store.accounts[accountKey] = next;
246
248
  return next;
@@ -15,6 +15,12 @@ export interface RateLimitState {
15
15
  export interface RateLimitedEntity {
16
16
  rateLimitResetTimes: RateLimitState;
17
17
  }
18
+ /**
19
+ * Bound legacy persisted routing deadlines once, when loading the account.
20
+ * These deadlines schedule revalidation; quota snapshots retain upstream resets.
21
+ * Never call this on every selection: that would slide the deadline indefinitely.
22
+ */
23
+ export declare function boundRateLimitCache(times: RateLimitState, now?: number): RateLimitState;
18
24
  export declare function clearExpiredRateLimits(entity: RateLimitedEntity): void;
19
25
  /**
20
26
  * Remove every rate-limit reset entry from `entity`, regardless of whether the
@@ -2,7 +2,7 @@
2
2
  * Rate limiting utilities for account management.
3
3
  * Extracted from accounts.ts to reduce module size and improve cohesion.
4
4
  */
5
- import { LUNA_RESERVE_MODEL } from "../constants.js";
5
+ import { LUNA_RESERVE_MODEL, RATE_LIMIT_CACHE_TTL_MS } from "../constants.js";
6
6
  import { nowMs } from "../utils.js";
7
7
  export function parseRateLimitReason(code) {
8
8
  if (!code)
@@ -28,6 +28,19 @@ export function clampNonNegativeInt(value, fallback) {
28
28
  }
29
29
  return value < 0 ? 0 : Math.floor(value);
30
30
  }
31
+ /**
32
+ * Bound legacy persisted routing deadlines once, when loading the account.
33
+ * These deadlines schedule revalidation; quota snapshots retain upstream resets.
34
+ * Never call this on every selection: that would slide the deadline indefinitely.
35
+ */
36
+ export function boundRateLimitCache(times, now = nowMs()) {
37
+ return Object.fromEntries(Object.entries(times).map(([key, until]) => [
38
+ key,
39
+ typeof until === "number"
40
+ ? Math.min(until, now + RATE_LIMIT_CACHE_TTL_MS)
41
+ : until,
42
+ ]));
43
+ }
31
44
  export function clearExpiredRateLimits(entity) {
32
45
  const now = nowMs();
33
46
  const keys = Object.keys(entity.rateLimitResetTimes);
@@ -70,6 +70,7 @@ export declare class AccountManager {
70
70
  private readonly persistedWorkspaceSelections;
71
71
  private readonly persistedWorkspaces;
72
72
  private hadPersistedStorage;
73
+ private hasUnpersistedRateLimitBounds;
73
74
  private readonly loadedAccountStorageSnapshot;
74
75
  private cursorByFamily;
75
76
  private currentAccountIndexByFamily;
@@ -334,7 +335,11 @@ export declare class AccountManager {
334
335
  commitRefreshedAuth(source: Pick<ManagedAccount, "index" | "accountId" | "email" | "refreshToken">, auth: OAuthAuthDetails): Promise<ManagedAccount | null>;
335
336
  toAuthDetails(account: ManagedAccount): Auth;
336
337
  getMinWaitTime(): number;
337
- getMinWaitTimeForFamily(family: ModelFamily, model?: string | null): number;
338
+ /**
339
+ * Accounts in `excludedIndexes` (policy-blocked or reserved for this request)
340
+ * are not capacity the caller can retry into, so they never shorten the wait.
341
+ */
342
+ getMinWaitTimeForFamily(family: ModelFamily, model?: string | null, excludedIndexes?: ReadonlySet<number>): number;
338
343
  /**
339
344
  * Walks forward from `start` (inclusive) looking for the next enabled
340
345
  * account, wrapping around at most once. Returns -1 when every account in
@@ -9,7 +9,7 @@ import { MODEL_FAMILIES } from "./request/helpers/model-map.js";
9
9
  import { getHealthTracker, getTokenTracker, resetTrackers, selectHybridAccount, } from "./rotation.js";
10
10
  import { withRoutingMutex, } from "./routing-mutex.js";
11
11
  import { nowMs } from "./utils.js";
12
- import { ERROR_MESSAGES, HTTP_STATUS, LUNA_RESERVE_MODEL, MAX_RATE_LIMIT_DELAY_MS } from "./constants.js";
12
+ import { ERROR_MESSAGES, HTTP_STATUS, LUNA_RESERVE_MODEL, RATE_LIMIT_CACHE_TTL_MS } from "./constants.js";
13
13
  import { CodexAuthError } from "./errors.js";
14
14
  import { loadCodexCliState, } from "./codex-cli/state.js";
15
15
  import { syncAccountStorageFromCodexCli } from "./codex-cli/sync.js";
@@ -21,7 +21,7 @@ export { extractAccountId, extractAccountEmail, getAccountIdCandidates, selectBe
21
21
  export { parseRateLimitReason, getQuotaKey, clampNonNegativeInt, clearExpiredRateLimits, clearAllRateLimits, isRateLimitedForQuotaKey, isRateLimitedForFamily, formatWaitTime, } from "./accounts/rate-limits.js";
22
22
  export { lookupCodexCliTokensByEmail, isCodexCliSyncEnabled, } from "./codex-cli/state.js";
23
23
  import { codexCliAccountIdFor, extractAccountId, extractAccountEmail, shouldUpdateAccountIdFromToken, sanitizeEmail, } from "./auth/token-utils.js";
24
- import { clampNonNegativeInt, getQuotaKey, clearExpiredRateLimits, clearAllRateLimits, isRateLimitedForFamily, formatWaitTime, } from "./accounts/rate-limits.js";
24
+ import { boundRateLimitCache, clampNonNegativeInt, getQuotaKey, clearExpiredRateLimits, clearAllRateLimits, isRateLimitedForFamily, formatWaitTime, } from "./accounts/rate-limits.js";
25
25
  const log = createLogger("accounts");
26
26
  let nextRuntimeCircuitKeyId = 0;
27
27
  function getAccountCircuitKey(account) {
@@ -142,6 +142,7 @@ export class AccountManager {
142
142
  persistedWorkspaceSelections = new WeakMap();
143
143
  persistedWorkspaces = new WeakMap();
144
144
  hadPersistedStorage = false;
145
+ hasUnpersistedRateLimitBounds = false;
145
146
  loadedAccountStorageSnapshot;
146
147
  cursorByFamily = initFamilyState(0);
147
148
  currentAccountIndexByFamily = initFamilyState(-1);
@@ -201,6 +202,19 @@ export class AccountManager {
201
202
  }
202
203
  const manager = new AccountManager(authFallback, sourceOfTruthStorage);
203
204
  await manager.hydrateFromCodexCli();
205
+ if (manager.hasUnpersistedRateLimitBounds) {
206
+ // Short-lived wrappers may exit without another save; persisting the
207
+ // bound now keeps each restart from re-bounding from its own load time.
208
+ try {
209
+ await manager.saveToDisk(true);
210
+ manager.hasUnpersistedRateLimitBounds = false;
211
+ }
212
+ catch (error) {
213
+ log.debug("Failed to persist bounded rate-limit deadlines", {
214
+ error: String(error),
215
+ });
216
+ }
217
+ }
204
218
  return manager;
205
219
  }
206
220
  /** @internal True when construction received a parsed/recovered storage snapshot. */
@@ -337,6 +351,11 @@ export class AccountManager {
337
351
  return null;
338
352
  }
339
353
  const matchesFallback = !!authFallback && fallbackMatchedRowIndex === index;
354
+ const storedRateLimits = account.rateLimitResetTimes ?? {};
355
+ const rateLimitResetTimes = boundRateLimitCache(storedRateLimits, baseNow);
356
+ if (Object.entries(rateLimitResetTimes).some(([key, until]) => until !== storedRateLimits[key])) {
357
+ this.hasUnpersistedRateLimitBounds = true;
358
+ }
340
359
  const refreshToken = matchesFallback && authFallback
341
360
  ? authFallback.refresh
342
361
  : account.refreshToken;
@@ -362,7 +381,7 @@ export class AccountManager {
362
381
  addedAt: clampNonNegativeInt(account.addedAt, baseNow),
363
382
  lastUsed: clampNonNegativeInt(account.lastUsed, 0),
364
383
  lastSwitchReason: account.lastSwitchReason,
365
- rateLimitResetTimes: account.rateLimitResetTimes ?? {},
384
+ rateLimitResetTimes,
366
385
  coolingDownUntil: account.coolingDownUntil,
367
386
  cooldownReason: account.cooldownReason,
368
387
  authInvalidatedAt: account.authInvalidatedAt,
@@ -1062,9 +1081,9 @@ export class AccountManager {
1062
1081
  // Fail closed: retain this new limit if the selection cannot be read.
1063
1082
  log.warn("Rate limit recorded while account selection metadata is unavailable");
1064
1083
  }
1065
- // Clamp to MAX_RATE_LIMIT_DELAY_MS so a bogus upstream retry-after value
1066
- // cannot wedge this account unavailable for years (see stress audit H1).
1067
- const retryMs = Math.min(Math.max(0, Math.floor(retryAfterMs)), MAX_RATE_LIMIT_DELAY_MS);
1084
+ // A local rejection is authoritative for at most an hour. The next request
1085
+ // then revalidates upstream, including after an out-of-band quota reset.
1086
+ const retryMs = Math.min(Math.max(0, Math.floor(retryAfterMs)), RATE_LIMIT_CACHE_TTL_MS);
1068
1087
  const observedAtMs = nowMs();
1069
1088
  const resetAt = observedAtMs + retryMs;
1070
1089
  // Only carry forward an unsequenced entry that is still replayable.
@@ -1561,9 +1580,15 @@ export class AccountManager {
1561
1580
  getMinWaitTime() {
1562
1581
  return this.getMinWaitTimeForFamily("codex");
1563
1582
  }
1564
- getMinWaitTimeForFamily(family, model) {
1583
+ /**
1584
+ * Accounts in `excludedIndexes` (policy-blocked or reserved for this request)
1585
+ * are not capacity the caller can retry into, so they never shorten the wait.
1586
+ */
1587
+ getMinWaitTimeForFamily(family, model, excludedIndexes) {
1565
1588
  const now = nowMs();
1566
- const enabledAccounts = this.accounts.filter((account) => account.enabled !== false && !this.isAccountAuthInvalidated(account));
1589
+ const enabledAccounts = this.accounts.filter((account) => account.enabled !== false &&
1590
+ !this.isAccountAuthInvalidated(account) &&
1591
+ !excludedIndexes?.has(account.index));
1567
1592
  const available = enabledAccounts.filter((account) => {
1568
1593
  clearExpiredRateLimits(account);
1569
1594
  return (!isRateLimitedForFamily(account, family, model) &&
@@ -45,6 +45,11 @@ export declare function extractAccountId(accessToken?: string): string | undefin
45
45
  */
46
46
  /** Whether an id is an OpenAI platform org id ("org-..."), never a ChatGPT workspace. */
47
47
  export declare function isOpenAiOrgId(accountId: string | undefined): boolean;
48
+ /** Preserve the native client's org-alias mapping; this is not workspace fallback. */
49
+ export declare function nativeWorkspaceIdentity(account: {
50
+ access?: string;
51
+ codexCliMirror?: CodexCliMirror;
52
+ }, storedId: string | undefined): string | undefined;
48
53
  export declare function resolveCodexAuthAccountId(accountId: string | undefined, accessToken?: string, idToken?: string): string | undefined;
49
54
  /**
50
55
  * The account id to hand the ~/.codex/auth.json writer for a saved account,
@@ -261,6 +261,16 @@ export function extractAccountId(accessToken) {
261
261
  export function isOpenAiOrgId(accountId) {
262
262
  return /^org-/i.test(accountId?.trim() ?? "");
263
263
  }
264
+ /** Preserve the native client's org-alias mapping; this is not workspace fallback. */
265
+ export function nativeWorkspaceIdentity(account, storedId) {
266
+ const id = storedId?.trim();
267
+ if (!id)
268
+ return undefined;
269
+ if (!isOpenAiOrgId(id))
270
+ return id;
271
+ const native = codexCliAccountIdFor({ accountId: id, codexCliMirror: account.codexCliMirror }, account.access);
272
+ return native && !isOpenAiOrgId(native) ? native : undefined;
273
+ }
264
274
  export function resolveCodexAuthAccountId(accountId, accessToken, idToken) {
265
275
  const trimmed = accountId?.trim();
266
276
  if (!trimmed)
@@ -7,6 +7,7 @@ function printAccountUsage(logInfo) {
7
7
  " codex-multi-auth account weight <index> <0..10>",
8
8
  " codex-multi-auth account priority <index> <0..9>",
9
9
  " codex-multi-auth account auto-prime <index> on|off",
10
+ " codex-multi-auth account emergency-only <index> on|off",
10
11
  " codex-multi-auth account pause|unpause|drain|undrain <index>",
11
12
  " codex-multi-auth account note <index> <text>",
12
13
  " codex-multi-auth account policy list [--json]",
@@ -37,6 +38,7 @@ function policySummary(store, storage) {
37
38
  weight: policy?.weight ?? 1,
38
39
  priority: policy?.priority ?? 1,
39
40
  autoPrime: policy?.autoPrime ?? false,
41
+ emergencyOnly: policy?.emergencyOnly ?? false,
40
42
  paused: policy?.paused ?? false,
41
43
  drained: policy?.drained ?? false,
42
44
  note: policy?.note ?? null,
@@ -84,6 +86,7 @@ export async function runAccountCommand(args, deps) {
84
86
  `weight=${entry.weight}`,
85
87
  `priority=${entry.priority}`,
86
88
  `auto-prime=${entry.autoPrime ? "on" : "off"}`,
89
+ entry.emergencyOnly ? "emergency-only" : null,
87
90
  entry.paused ? "paused" : null,
88
91
  entry.drained ? "drained" : null,
89
92
  entry.tags.length > 0 ? `tags=${entry.tags.join(",")}` : null,
@@ -120,6 +123,18 @@ export async function runAccountCommand(args, deps) {
120
123
  logInfo(`${command === "tag" ? "Tagged" : "Removed tag from"} account ${resolved.index + 1}: ${policy.tags.join(",") || "none"}`);
121
124
  return 0;
122
125
  }
126
+ if (command === "emergency-only") {
127
+ if (rest.length !== 2 || !["on", "off"].includes(rest[1] ?? "")) {
128
+ logError("emergency-only requires on or off.");
129
+ return 1;
130
+ }
131
+ await updateStore(store => ({
132
+ result: upsertAccountPolicy(store, accountKey, next => { next.emergencyOnly = rest[1] === "on"; }, now),
133
+ dirty: true,
134
+ }));
135
+ logInfo(`Emergency-only ${rest[1]} for account ${resolved.index + 1}.`);
136
+ return 0;
137
+ }
123
138
  if (command === "auto-prime") {
124
139
  if (rest.length !== 2 || !["on", "off"].includes(rest[1] ?? "")) {
125
140
  logError("auto-prime requires on or off.");
@@ -1,8 +1,14 @@
1
1
  import { AccountManager } from '../../accounts.js';
2
2
  import { loadAccounts } from '../../storage.js';
3
3
  import { runWithGlobalStoragePath } from '../../storage/path-state.js';
4
- import { createResetCreditService, resetTargetForStoredAccount, resetAccountLabel } from '../../runtime/account-reset-credits.js';
4
+ import { createResetCreditService, resetTargetForStoredAccount, resetAccountLabel, formatResetCreditLine } from '../../runtime/account-reset-credits.js';
5
5
  import { withCheckProgress } from '../../ui/check-progress.js';
6
+ // A pending result may belong to a workspace that was since disabled or deselected.
7
+ function ownsPendingKey(account, key) {
8
+ const workspaces = account.workspaces?.map(workspace => ({ ...workspace, enabled: true }));
9
+ const selections = [undefined, ...(workspaces ?? []).map((_, index) => index)];
10
+ return selections.some(index => resetTargetForStoredAccount({ ...account, enabled: true, workspaces: index === undefined ? undefined : workspaces, currentWorkspaceIndex: index ?? account.currentWorkspaceIndex })?.key === key);
11
+ }
6
12
  const usage = 'Usage: codex-multi-auth resets list [--refresh] | redeem <account-number> | auto manual|last-resort';
7
13
  export async function runResetsCommand(args) {
8
14
  const [command = 'list', value, ...extra] = args;
@@ -47,7 +53,13 @@ export async function runResetsCommand(args) {
47
53
  const index = (storage?.accounts ?? []).findIndex(a => resetTargetForStoredAccount(a)?.key === state.lastRedemption?.key);
48
54
  console.log(`Last confirmed reset: ${index >= 0 ? `account ${index + 1}` : "removed account"}; ${state.lastRedemption.outcome}; ${state.lastRedemption.automatic ? "automatic" : "explicit"}`);
49
55
  }
50
- (storage?.accounts ?? []).forEach((a, i) => { const target = resetTargetForStoredAccount(a); const ambiguous = target && (keyCounts.get(target.key) ?? 0) > 1; const snapshot = target && !ambiguous ? (refreshed ?? state.snapshots)[target.key] : undefined; console.log(`${resetAccountLabel(a, i)}: ${snapshot?.availableCount ?? 'unknown'} reset credits${snapshot ? ` (checked ${Math.max(0, Math.floor((Date.now() - snapshot.updatedAt) / 1000))}s ago)` : ''}${ambiguous ? ' [ambiguous account identity; repair duplicate records]' : ''}${target?.key === state.pending?.key ? ' [redemption pending; retry this account]' : ''}`); });
56
+ (storage?.accounts ?? []).forEach((a, i) => {
57
+ const target = resetTargetForStoredAccount(a);
58
+ const ambiguous = target && (keyCounts.get(target.key) ?? 0) > 1;
59
+ const snapshot = target && !ambiguous ? (refreshed ?? state.snapshots)[target.key] : undefined;
60
+ console.log(`${resetAccountLabel(a, i)}: ${snapshot?.availableCount ?? 'unknown'} reset credits${snapshot ? ` (checked ${Math.max(0, Math.floor((Date.now() - snapshot.updatedAt) / 1000))}s ago)` : ''}${ambiguous ? ' [ambiguous account identity; repair duplicate records]' : ''}${target?.key === state.pending?.key ? ' [redemption pending; retry this account]' : ''}`);
61
+ snapshot?.credits?.forEach((c, idx) => console.log(formatResetCreditLine(c, idx)));
62
+ });
51
63
  const failed = refreshed ? targets.filter(target => (keyCounts.get(target.key) ?? 0) > 1 || !refreshed[target.key]).length : 0;
52
64
  if (failed) {
53
65
  console.error(`${failed} of ${targets.length} reset-credit reads failed. Check account authentication and the native Codex backend (CODEX_MULTI_AUTH_USAGE_CODEX_BIN selects an executable). No credits were redeemed.`);
@@ -61,10 +73,11 @@ export async function runResetsCommand(args) {
61
73
  const pending = command === 'redeem' ? await service.status().then(s => s.pending ?? null, () => ({ key: undefined })) : null;
62
74
  // The pending record can belong to another account (a redeem is refused while
63
75
  // any result is pending); point at the account that actually needs the retry.
64
- const owner = pending?.key ? (storage?.accounts ?? []).findIndex(a => resetTargetForStoredAccount({ ...a, enabled: true, workspaces: undefined })?.key === pending.key) : -1;
76
+ const pendingKey = pending?.key;
77
+ const owner = pendingKey ? (storage?.accounts ?? []).findIndex(a => ownsPendingKey(a, pendingKey)) : -1;
65
78
  const pendingAccount = owner >= 0 ? storage?.accounts[owner] : undefined;
66
- const disabled = pendingAccount ? resetTargetForStoredAccount(pendingAccount) === null : false;
67
- const retry = disabled ? `account ${owner + 1} or its workspace is disabled; re-enable it before retrying the pending result` : pending?.key && owner < 0 ? 'check it there (the pending account was removed)' : owner >= 0 && owner !== Number(value) - 1 ? `retry account ${owner + 1}, which holds the pending result` : 'retry the same account';
79
+ const disabled = pendingAccount ? resetTargetForStoredAccount(pendingAccount)?.key !== pendingKey : false;
80
+ const retry = disabled ? `account ${owner + 1} or its pending workspace is disabled or no longer selected; re-enable and select it before retrying the pending result` : pending?.key && owner < 0 ? 'check it there (the pending account was removed)' : owner >= 0 && owner !== Number(value) - 1 ? `retry account ${owner + 1}, which holds the pending result` : 'retry the same account';
68
81
  console.error(command === 'list' ? 'Reset-credit availability could not be refreshed. No credits were redeemed.' : command === 'auto' ? 'Reset-credit settings could not be updated.' : pending ? `Reset operation could not be confirmed. No new automatic redemption will be attempted while a result is pending; use resets list and ${retry}.` : 'No reset credit was redeemed. Availability could not be confirmed or a reset was just redeemed; run resets list --refresh and try again.');
69
82
  return 1;
70
83
  }
@@ -1,18 +1,20 @@
1
- import type { AccountStorageV3 } from "../../storage.js";
1
+ import type { AccountStorageV3, withAccountStorageTransaction } from "../../storage.js";
2
2
  type LoadedStorage = AccountStorageV3 | null;
3
3
  export interface WorkspaceCommandDeps {
4
4
  setStoragePath: (path: string | null) => void;
5
5
  loadAccounts: () => Promise<LoadedStorage>;
6
6
  saveAccounts: (storage: AccountStorageV3) => Promise<void>;
7
+ withAccountStorageTransaction: typeof withAccountStorageTransaction;
7
8
  logError?: (message: string) => void;
8
9
  logInfo?: (message: string) => void;
9
10
  }
10
11
  /**
11
12
  * `codex-multi-auth workspace <account> [workspace]`
12
13
  *
13
- * With only an account index, lists the workspaces that account can rotate
14
+ * With only an account index, lists the workspaces that account can select
14
15
  * between (personal Plus vs business/team under one email, issue #491). With a
15
16
  * workspace index too, sets it as the active workspace for that account.
17
+ * A final enable/disable action changes availability without changing selection.
16
18
  */
17
19
  export declare function runWorkspaceCommand(args: string[], deps: WorkspaceCommandDeps): Promise<number>;
18
20
  export {};
@@ -1,11 +1,12 @@
1
- import { formatAccountLabel, formatWorkspaceLines, } from "../../accounts.js";
1
+ import { formatAccountLabel, formatWorkspaceLines, resolveAccountRecordId, } from "../../accounts.js";
2
2
  import { saveAccountsWithRetry } from "../forecast-report-shared.js";
3
3
  /**
4
4
  * `codex-multi-auth workspace <account> [workspace]`
5
5
  *
6
- * With only an account index, lists the workspaces that account can rotate
6
+ * With only an account index, lists the workspaces that account can select
7
7
  * between (personal Plus vs business/team under one email, issue #491). With a
8
8
  * workspace index too, sets it as the active workspace for that account.
9
+ * A final enable/disable action changes availability without changing selection.
9
10
  */
10
11
  export async function runWorkspaceCommand(args, deps) {
11
12
  deps.setStoragePath(null);
@@ -76,6 +77,36 @@ export async function runWorkspaceCommand(args, deps) {
76
77
  return 1;
77
78
  }
78
79
  const targetName = target.name?.trim() || "(unnamed)";
80
+ const action = args[2];
81
+ if (args.length > 3 || (action && action !== "enable" && action !== "disable")) {
82
+ logError("Invalid workspace action. Use enable or disable.");
83
+ return 1;
84
+ }
85
+ if (action) {
86
+ const recordId = resolveAccountRecordId(account);
87
+ const workspaceId = target.id;
88
+ return deps.withAccountStorageTransaction(async (current, persist) => {
89
+ // An explicit command is an operation, even if its initial snapshot already
90
+ // had that value. Apply it to the latest row under the storage lock.
91
+ const matches = current?.accounts.filter(row => resolveAccountRecordId(row) === recordId) ?? [];
92
+ const latest = matches.length === 1 ? matches[0] : undefined;
93
+ const latestWorkspaces = latest?.workspaces ?? [];
94
+ const workspaceMatches = latestWorkspaces.filter(row => row.id === workspaceId);
95
+ const workspace = workspaceMatches.length === 1 ? workspaceMatches[0] : undefined;
96
+ if (!current || !latest || !workspace) {
97
+ logError("Account or workspace changed concurrently; reload before changing workspace availability.");
98
+ return 1;
99
+ }
100
+ workspace.enabled = action === "enable";
101
+ // Absence of disabledAt distinguishes an operator exclusion from runtime health.
102
+ delete workspace.disabledAt;
103
+ await saveAccountsWithRetry(current, persist);
104
+ const currentIndex = latestWorkspaces.indexOf(workspace) + 1;
105
+ const currentName = workspace.name?.trim() || "(unnamed)";
106
+ logInfo(`Workspace ${currentIndex} [${currentName}] ${workspace.enabled ? "enabled" : "disabled"}. Selected workspace unchanged.`);
107
+ return 0;
108
+ });
109
+ }
79
110
  if (target.enabled === false) {
80
111
  logError(`Workspace ${parsedWorkspace} ([${targetName}]) is disabled and cannot be selected.`);
81
112
  return 1;
@@ -17,10 +17,10 @@ export function printUsage() {
17
17
  " codex-multi-auth switch <index> (pins the account for runtime routing)",
18
18
  " codex-multi-auth resets list [--refresh] | redeem <account> | auto manual|last-resort",
19
19
  " codex-multi-auth unpin (clears the manual pin set by switch)",
20
- " codex-multi-auth workspace <account> [workspace] (list or switch an account's workspaces)",
20
+ " codex-multi-auth workspace <account> [workspace] (list or select; add enable/disable to change availability)",
21
21
  " codex-multi-auth best [--live] [--json] [--model <model>] (clears any manual pin set by switch)",
22
22
  " codex-multi-auth forecast [--live] [--json] [--explain] [--model <model>] [--no-runtime-overlay]",
23
- " codex-multi-auth account tag|untag|weight|priority|auto-prime|pause|unpause|drain|undrain|note|policy list ...",
23
+ " codex-multi-auth account tag|untag|weight|priority|auto-prime|emergency-only|pause|unpause|drain|undrain|note|policy list ...",
24
24
  "",
25
25
  "Repair:",
26
26
  " codex-multi-auth uninstall [--dry-run] [--json] [--clear-accounts]",
@@ -63,7 +63,7 @@ import { loadQuotaCache, saveQuotaCache } from "./quota-cache.js";
63
63
  import { readAppRuntimeHelperAccountSignal } from "./runtime/runtime-current-account.js";
64
64
  import { fetchCodexQuotaSnapshot, formatQuotaSnapshotLine, } from "./quota-probe.js";
65
65
  import { queuedRefresh } from "./refresh-queue.js";
66
- import { clearAccounts, inspectStorageHealth, getLastAccountsSaveTimestamp, getStoragePath, loadAccounts, loadFlaggedAccounts, saveAccounts, setStoragePath, } from "./storage.js";
66
+ import { clearAccounts, inspectStorageHealth, getLastAccountsSaveTimestamp, getStoragePath, loadAccounts, loadFlaggedAccounts, saveAccounts, setStoragePath, withAccountStorageTransaction, } from "./storage.js";
67
67
  // Formatter implementations moved to lib/codex-manager/formatters/ (audit
68
68
  // roadmap §4.1.1 phase 1). Re-exported here so existing consumers importing
69
69
  // from lib/codex-manager.js keep working unchanged.
@@ -256,6 +256,18 @@ export function buildSelectAccountTraced() {
256
256
  blockedReasonByAccount[index] = AUTH_INVALIDATION_MARKER;
257
257
  }
258
258
  }
259
+ // This offline diagnostic cannot establish ordinary-pool exhaustion or
260
+ // perform permitted reset recovery. Leave emergency capacity reserved;
261
+ // keep any more specific policy, authentication, or runtime exclusion.
262
+ for (const account of accountsWithMetrics) {
263
+ if (policy.emergencyAccountIndexes?.has(account.index) &&
264
+ account.isAvailable &&
265
+ !blockedAccountIndexes.has(account.index)) {
266
+ blockedAccountIndexes.add(account.index);
267
+ blockedReasonByAccount[account.index] =
268
+ "emergency-only: reserved until runtime confirms ordinary capacity and permitted resets are exhausted";
269
+ }
270
+ }
259
271
  return selectHybridAccountTraced({
260
272
  accounts: accountsWithMetrics,
261
273
  healthTracker,
@@ -368,6 +380,7 @@ const CLI_COMMAND_HANDLERS = new Map([
368
380
  setStoragePath,
369
381
  loadAccounts,
370
382
  saveAccounts,
383
+ withAccountStorageTransaction,
371
384
  }),
372
385
  ],
373
386
  ["check", (rest) => runCheckCommand({
@@ -116,3 +116,5 @@ export type WireReasoningEffort = Exclude<ModelReasoningEffort, "ultra">;
116
116
  * untouched.
117
117
  */
118
118
  export declare function stripModelEffortSuffix(modelId: string): string;
119
+ /** Maximum lifetime of a local quota rejection before upstream revalidation. */
120
+ export declare const RATE_LIMIT_CACHE_TTL_MS: number;
@@ -136,3 +136,5 @@ export function stripModelEffortSuffix(modelId) {
136
136
  return modelId;
137
137
  return modelId.replace(EFFORT_SUFFIX_PATTERN, "");
138
138
  }
139
+ /** Maximum lifetime of a local quota rejection before upstream revalidation. */
140
+ export const RATE_LIMIT_CACHE_TTL_MS = 60 * 60_000;
@@ -17,14 +17,14 @@ function buildPauseMessage(advisory) {
17
17
  const windowNote = advisory.windowSource === "override"
18
18
  ? "your configured window size"
19
19
  : "an estimated window size (unverified — set `contextBudgetGuardModelWindowOverrides` if you know the real limit)";
20
- return `[Plugin Notice] Context budget guard paused this session at ${formatPercent(advisory.percent)}% of ${windowNote} for \`${advisory.model}\`, before hitting the model's actual limit.
21
-
22
- Please use one of these commands to reduce context size:
23
-
24
- • **/compact** - Compress conversation history (recommended)
25
- • **/clear** - Start fresh with empty context
26
- • **/undo** - Remove recent messages
27
-
20
+ return `[Plugin Notice] Context budget guard paused this session at ${formatPercent(advisory.percent)}% of ${windowNote} for \`${advisory.model}\`, before hitting the model's actual limit.
21
+
22
+ Please use one of these commands to reduce context size:
23
+
24
+ • **/compact** - Compress conversation history (recommended)
25
+ • **/clear** - Start fresh with empty context
26
+ • **/undo** - Remove recent messages
27
+
28
28
  Then retry your request. This pause clears itself automatically once your context usage drops back below the threshold.`;
29
29
  }
30
30
  /**