codex-multi-auth 2.21.0 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +8 -5
  3. package/config/schema/config.schema.json +8 -0
  4. package/dist/lib/accounts.d.ts +5 -0
  5. package/dist/lib/accounts.js +33 -4
  6. package/dist/lib/api-route-store.d.ts +7 -0
  7. package/dist/lib/api-route-store.js +100 -31
  8. package/dist/lib/auth/account-access.js +2 -1
  9. package/dist/lib/auth/auth.js +3 -2
  10. package/dist/lib/auth/device-auth.js +2 -1
  11. package/dist/lib/auth/http.d.ts +1 -0
  12. package/dist/lib/auth/http.js +1 -0
  13. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  14. package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
  15. package/dist/lib/codex-manager/api-login-menu.js +4 -6
  16. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  17. package/dist/lib/codex-manager/commands/locale.js +25 -0
  18. package/dist/lib/codex-manager/commands/status.d.ts +5 -1
  19. package/dist/lib/codex-manager/commands/status.js +501 -13
  20. package/dist/lib/codex-manager/help.js +3 -1
  21. package/dist/lib/codex-manager.js +98 -23
  22. package/dist/lib/config.js +3 -0
  23. package/dist/lib/constants.d.ts +1 -0
  24. package/dist/lib/constants.js +1 -0
  25. package/dist/lib/locale.d.ts +7 -0
  26. package/dist/lib/locale.js +35 -0
  27. package/dist/lib/model-route-policy.d.ts +1 -0
  28. package/dist/lib/model-route-policy.js +2 -1
  29. package/dist/lib/quota-probe.js +2 -1
  30. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  31. package/dist/lib/request/proxy-fetch.js +37 -0
  32. package/dist/lib/request/url-rewriting.js +17 -0
  33. package/dist/lib/runtime/account-model-catalog.d.ts +3 -0
  34. package/dist/lib/runtime/account-model-catalog.js +7 -2
  35. package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
  36. package/dist/lib/runtime/api-model-capabilities.js +155 -24
  37. package/dist/lib/runtime/app-bind.d.ts +7 -0
  38. package/dist/lib/runtime/app-bind.js +92 -1
  39. package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
  40. package/dist/lib/runtime/automatic-api-checks.js +188 -0
  41. package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
  42. package/dist/lib/runtime/model-discovery-status.js +64 -49
  43. package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
  44. package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
  45. package/dist/lib/runtime/responses-websocket.d.ts +48 -5
  46. package/dist/lib/runtime/responses-websocket.js +275 -39
  47. package/dist/lib/runtime/rotation-account-selection.d.ts +25 -5
  48. package/dist/lib/runtime/rotation-account-selection.js +94 -4
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
  50. package/dist/lib/runtime/rotation-proxy-state.js +376 -4
  51. package/dist/lib/runtime/rotation-server-types.d.ts +6 -0
  52. package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
  53. package/dist/lib/runtime/rotation-token-refresh.js +218 -27
  54. package/dist/lib/runtime-rotation-proxy.js +601 -105
  55. package/dist/lib/schemas.d.ts +5 -0
  56. package/dist/lib/schemas.js +1 -0
  57. package/dist/lib/storage/credential-sidecars.js +34 -2
  58. package/dist/lib/storage/file-lock.d.ts +2 -0
  59. package/dist/lib/storage/file-lock.js +44 -30
  60. package/dist/lib/usage/ledger.js +1 -0
  61. package/dist/lib/usage/redaction.js +1 -0
  62. package/dist/lib/usage/types.d.ts +1 -1
  63. package/dist/lib/web-dashboard-html.d.ts +1 -0
  64. package/dist/lib/web-dashboard-html.js +66 -0
  65. package/dist/lib/web-dashboard.d.ts +36 -0
  66. package/dist/lib/web-dashboard.js +322 -0
  67. package/package.json +3 -1
  68. package/scripts/codex-app-router.js +86 -14
  69. package/scripts/codex-routing.js +1 -0
  70. package/scripts/codex.js +150 -13
@@ -1,8 +1,27 @@
1
1
  import { type SubscriptionQuotaPreference } from "./subscription-quota-order.js";
2
- import type { AccountManager, ManagedAccount } from "../accounts.js";
2
+ import { type AccountManager, type ManagedAccount } from "../accounts.js";
3
3
  import type { ModelFamily } from "../prompts/codex.js";
4
4
  import type { RuntimePolicyDecision } from "../policy/runtime-policy.js";
5
5
  import type { SessionAffinityStore } from "../session-affinity.js";
6
+ /**
7
+ * Select a managed credential for transcription without consulting or mutating
8
+ * Codex inference health, quota, cooldown, circuit-breaker, affinity, or cursor
9
+ * state. Transcription shares account authentication and workspace policy with
10
+ * inference, but it is a separately admitted operation and must not inherit
11
+ * model-runtime backpressure.
12
+ */
13
+ export declare function chooseTranscriptionAccount(params: {
14
+ accountManager: AccountManager;
15
+ attemptedIndexes: ReadonlySet<number>;
16
+ policy: RuntimePolicyDecision | null;
17
+ pinnedIndex: number | null;
18
+ now: number;
19
+ transcriptionRateLimitUntilByRecordId?: ReadonlyMap<string, number>;
20
+ startIndex?: number | null;
21
+ preferredIndex?: number | null;
22
+ fallbackPinnedIndex?: number | null;
23
+ skipReasons?: Map<number, string>;
24
+ }): ManagedAccount | null;
6
25
  /**
7
26
  * `chooseAccount` is a SYNC selector that internally advances the rotation
8
27
  * cursor (the session-affinity-preferred branch and the round-robin fallback
@@ -43,16 +62,17 @@ export declare function chooseAccount(params: {
43
62
  /**
44
63
  * Admit a pinned account that is only blocked by its own cooldown.
45
64
  *
46
- * Set by the proxy for the RETRY passes of one pinned request, never for
47
- * the first pass. A cooldown exists to keep OTHER requests off a flaky
48
- * account; a retry budget exists to give THIS request another attempt.
65
+ * Set by the proxy for RETRY passes, or the first pass when a pin only
66
+ * has a shared network-error cooldown. Independent concurrent work must
67
+ * not fail solely because another request encountered a transport error.
68
+ * Other cooldown reasons still protect the pin from new requests.
49
69
  * Conflating them meant every transient branch cooled the pin down before
50
70
  * the next selection pass and the budget could never be spent — a pinned
51
71
  * request got exactly one upstream attempt under shipped defaults.
52
72
  *
53
73
  * Only the cooldown is waived. Rate limits, an open circuit, a disabled
54
74
  * account, workspace and policy blocks all still reject the pin, and the
55
- * cooldown itself stays on the account for everyone else and for the 503's
75
+ * cooldown itself stays on the account for unpinned selection and the 503's
56
76
  * `retry_after_ms`.
57
77
  *
58
78
  * One ordering note: `getAccountRuntimeSkipReason` reports the FIRST
@@ -1,4 +1,5 @@
1
1
  import { compareSubscriptionQuota, usesSubscriptionReserve } from "./subscription-quota-order.js";
2
+ import { AUTH_INVALIDATION_MARKER } from "../accounts.js";
2
3
  /**
3
4
  * Whether a runtime skip reason is a cooldown, the one blocker a pinned
4
5
  * retry may waive. `getManagedAccountRuntimeSkipReason` emits either the
@@ -7,6 +8,84 @@ import { compareSubscriptionQuota, usesSubscriptionReserve } from "./subscriptio
7
8
  function isCooldownReason(reason) {
8
9
  return reason === "cooling-down" || reason.startsWith("cooling-down:");
9
10
  }
11
+ /**
12
+ * Select a managed credential for transcription without consulting or mutating
13
+ * Codex inference health, quota, cooldown, circuit-breaker, affinity, or cursor
14
+ * state. Transcription shares account authentication and workspace policy with
15
+ * inference, but it is a separately admitted operation and must not inherit
16
+ * model-runtime backpressure.
17
+ */
18
+ export function chooseTranscriptionAccount(params) {
19
+ const { accountManager, attemptedIndexes, policy, pinnedIndex, now, transcriptionRateLimitUntilByRecordId, startIndex, preferredIndex, fallbackPinnedIndex, skipReasons, } = params;
20
+ /** Return only credential-level blockers; inference runtime health is intentionally ignored. */
21
+ const credentialReason = (index) => {
22
+ const account = accountManager.getAccountByIndex(index);
23
+ if (!account)
24
+ return "missing";
25
+ if (account.enabled === false)
26
+ return "disabled";
27
+ if (!accountManager.hasEnabledWorkspaces(account))
28
+ return "workspace-disabled";
29
+ if (accountManager.isAccountAuthInvalidated(account))
30
+ return AUTH_INVALIDATION_MARKER;
31
+ if (account.cooldownReason === "auth-failure" &&
32
+ typeof account.coolingDownUntil === "number" &&
33
+ account.coolingDownUntil > now)
34
+ return "cooling-down:auth-failure";
35
+ const transcriptionRateLimitUntil = account.recordId
36
+ ? transcriptionRateLimitUntilByRecordId?.get(account.recordId)
37
+ : undefined;
38
+ if (typeof transcriptionRateLimitUntil === "number" && transcriptionRateLimitUntil > now) {
39
+ return "transcription-rate-limited";
40
+ }
41
+ if (policy?.blockedAccountIndexes.has(index))
42
+ return "policy-blocked";
43
+ if (attemptedIndexes.has(index))
44
+ return "already-attempted";
45
+ return null;
46
+ };
47
+ /** Resolve one candidate while preserving a diagnostic skip reason for rejected credentials. */
48
+ const eligible = (index) => {
49
+ const reason = credentialReason(index);
50
+ if (reason) {
51
+ skipReasons?.set(index, reason);
52
+ return null;
53
+ }
54
+ return accountManager.getAccountByIndex(index);
55
+ };
56
+ if (typeof pinnedIndex === "number")
57
+ return eligible(pinnedIndex);
58
+ if (typeof fallbackPinnedIndex === "number") {
59
+ const fallback = eligible(fallbackPinnedIndex);
60
+ if (fallback)
61
+ return fallback;
62
+ }
63
+ let candidates = accountManager.getAccountsSnapshot().filter((account) => credentialReason(account.index) === null);
64
+ if (!candidates.length) {
65
+ for (const account of accountManager.getAccountsSnapshot()) {
66
+ const reason = credentialReason(account.index);
67
+ if (reason)
68
+ skipReasons?.set(account.index, reason);
69
+ }
70
+ return null;
71
+ }
72
+ if (policy?.priorityByAccount) {
73
+ const tier = Math.min(...candidates.map((account) => policy.priorityByAccount?.[account.index] ?? 1));
74
+ candidates = candidates.filter((account) => (policy.priorityByAccount?.[account.index] ?? 1) === tier);
75
+ }
76
+ if (typeof preferredIndex === "number") {
77
+ const preferred = candidates.find((account) => account.index === preferredIndex);
78
+ if (preferred)
79
+ return accountManager.getAccountByIndex(preferred.index);
80
+ }
81
+ if (typeof startIndex === "number" && candidates.length > 1) {
82
+ const after = candidates.filter((account) => account.index >= startIndex);
83
+ const before = candidates.filter((account) => account.index < startIndex);
84
+ candidates = [...after, ...before];
85
+ }
86
+ const first = candidates[0];
87
+ return first ? accountManager.getAccountByIndex(first.index) : null;
88
+ }
10
89
  /**
11
90
  * `chooseAccount` is a SYNC selector that internally advances the rotation
12
91
  * cursor (the session-affinity-preferred branch and the round-robin fallback
@@ -58,8 +137,13 @@ export function chooseAccount(params) {
58
137
  }
59
138
  const tier = Math.min(...candidates.map(a => priorities[a.index] ?? 1));
60
139
  const tierIndexes = new Set(candidates.filter(a => (priorities[a.index] ?? 1) === tier).map(a => a.index));
61
- let preferred = params.preferredIndex;
62
- if (quotaByAccount) {
140
+ // Runtime policy fills default priority 1 even without a configured order.
141
+ const useSavedOrder = schedulingStrategy === "sequential" &&
142
+ Object.values(priorities).some(priority => priority !== 1);
143
+ let preferred = useSavedOrder
144
+ ? candidates.find(account => tierIndexes.has(account.index))?.index
145
+ : params.preferredIndex;
146
+ if (quotaByAccount && schedulingStrategy !== "sequential") {
63
147
  // Rank only measured accounts: "no data" compares equal to everything, which
64
148
  // is not transitive and would make the winner depend on storage order.
65
149
  const ranked = candidates.flatMap(a => {
@@ -75,19 +159,25 @@ export function chooseAccount(params) {
75
159
  }
76
160
  }
77
161
  // Re-evaluate tiers on every attempt. Never persist a temporary tier block.
78
- return chooseAccount({
162
+ const selected = chooseAccount({
79
163
  ...params,
80
164
  policy: {
81
165
  ...policy,
82
166
  priorityByAccount: undefined,
83
167
  blockedAccountIndexes: new Set([
84
168
  ...policy.blockedAccountIndexes,
85
- ...accountManager.getAccountsSnapshot().filter(a => !tierIndexes.has(a.index)).map(a => a.index),
169
+ // A retry alone must not make a healthy sequential primary unusable.
170
+ ...accountManager.getAccountsSnapshot().filter(a => !tierIndexes.has(a.index) && (schedulingStrategy !== "sequential" || !attemptedIndexes.has(a.index) ||
171
+ quotaByAccount?.[a.index]?.exhausted ||
172
+ (hasNonReserve && usesSubscriptionReserve(quotaByAccount?.[a.index])))).map(a => a.index),
86
173
  ]),
87
174
  },
88
175
  fallbackPinnedIndex: null,
89
176
  pinnedIndex: typeof preferred === "number" && tierIndexes.has(preferred) ? preferred : null,
90
177
  });
178
+ if (selected && schedulingStrategy === "sequential")
179
+ selected.lastUsed = now;
180
+ return selected;
91
181
  }
92
182
  // Manual pin (from `codex-multi-auth switch <n>`) overrides every other
93
183
  // selection signal. We do NOT call markSwitched here — the proxy must not
@@ -32,6 +32,13 @@ export interface RotationProxyStateInit {
32
32
  routingMutexMode: "enabled" | "legacy";
33
33
  schedulingStrategy: "hybrid" | "sequential";
34
34
  fetchImpl: typeof fetch;
35
+ /**
36
+ * The embedder's configured fetcher, captured before `state.fetchImpl` is
37
+ * rebound to the WebSocket gateway. Capability/doc probes must use this —
38
+ * the gateway fetch exists for Responses transport upgrades, and bare
39
+ * `fetch` would bypass a custom fetcher's instrumentation entirely.
40
+ */
41
+ upstreamFetchImpl: typeof fetch;
35
42
  upstreamBaseUrl: string;
36
43
  clientApiKey: string;
37
44
  now: () => number;
@@ -96,8 +103,27 @@ export interface RotationProxyState extends RotationProxyStateInit {
96
103
  lastGlobalSwitchAt: number;
97
104
  staleRuntimeReloadPromise: Promise<AccountManager | null> | null;
98
105
  lastStaleRuntimeReloadAt: number;
106
+ credentialReloadPromise: Promise<AccountManager | null> | null;
107
+ lastCredentialReloadAt: number;
108
+ transcriptionNextAccountIndex: number;
109
+ readonly transcriptionRateLimitUntilByRecordId: Map<string, number>;
99
110
  }
100
111
  /** @internal */
101
112
  export declare function createRotationProxyState(init: RotationProxyStateInit): RotationProxyState;
113
+ /**
114
+ * Reload account credentials without resetting inference runtime state.
115
+ *
116
+ * This is intentionally narrower than {@link recoverStaleRuntimeState}: it is
117
+ * used when a non-inference operation discovers that its in-memory credential
118
+ * inventory is stale after an external login/enable action. Fresh credential,
119
+ * workspace, enabled and auth-invalidation fields come from disk, while live
120
+ * inference rate-limit windows and non-auth cooldowns are carried forward by
121
+ * stable account identity. An auth-failure cooldown is carried only while the
122
+ * access token is unchanged, so a genuine re-login can repair the credential.
123
+ * Process-global health/token trackers and circuit breakers are never reset.
124
+ *
125
+ * @internal
126
+ */
127
+ export declare function reloadRuntimeCredentials(state: RotationProxyState): Promise<AccountManager | null>;
102
128
  /** @internal */
103
129
  export declare function recoverStaleRuntimeState(state: RotationProxyState): Promise<AccountManager | null>;
@@ -1,6 +1,61 @@
1
- import { AccountManager } from "../accounts.js";
1
+ import { AccountManager, sanitizeEmail } from "../accounts.js";
2
2
  import { recordRuntimeReload, recordRuntimeReset, } from "./runtime-observability.js";
3
+ import { redirectRuntimeRefreshCommits, waitForRuntimeRefreshCommits } from "./rotation-token-refresh.js";
3
4
  const STALE_RUNTIME_RELOAD_DEDUPE_MS = 1_000;
5
+ /**
6
+ * Copy credentials that rotated on `previousManager` since `authBeforeLoad`
7
+ * into the matching rows of `reloaded`. A replacement snapshot read before a
8
+ * refresh commit persisted still holds the spent single-use refresh token;
9
+ * without this carry the swap reintroduces it and the next refresh fails with
10
+ * token reuse. Matching is deliberately conservative — persisted record id,
11
+ * then the refresh token on either side of the rotation, then accountId+email —
12
+ * so a credential never lands on a different account.
13
+ */
14
+ function carryRotatedCredentials(previousManager, authBeforeLoad, reloaded) {
15
+ const candidates = reloaded.getAccountsSnapshot();
16
+ for (const previous of previousManager.getAccountsSnapshot()) {
17
+ const baseline = authBeforeLoad.find((account) => (account.recordId && account.recordId === previous.recordId) ||
18
+ account.index === previous.index);
19
+ if (!baseline ||
20
+ (baseline.access === previous.access &&
21
+ baseline.refreshToken === previous.refreshToken &&
22
+ baseline.expires === previous.expires)) {
23
+ continue;
24
+ }
25
+ const rotatedTokens = new Set([previous.refreshToken, baseline.refreshToken]);
26
+ const sourceEmail = sanitizeEmail(previous.email);
27
+ const matches = candidates.filter((candidate) => (previousManager.hasPersistedRecordId(previous) &&
28
+ candidate.recordId === previous.recordId) ||
29
+ rotatedTokens.has(candidate.refreshToken) ||
30
+ (previous.accountId !== undefined &&
31
+ candidate.accountId === previous.accountId &&
32
+ sanitizeEmail(candidate.email) === sourceEmail));
33
+ const [matched] = matches;
34
+ if (matches.length !== 1 || !matched)
35
+ continue;
36
+ const live = reloaded.getAccountByIndex(matched.index);
37
+ if (!live)
38
+ continue;
39
+ live.access = previous.access;
40
+ live.refreshToken = previous.refreshToken;
41
+ live.expires = previous.expires;
42
+ // Token-bound identity (accountId/accountIdSource/email) rides along with
43
+ // the rotated credential — `commitRefreshedAuth` can refresh it during the
44
+ // same window, and without it the row keeps a workspace binding issued for
45
+ // the spent token. Explicit manual/org bindings stay authoritative.
46
+ const tokenBoundIdentityChanged = baseline.accountId !== previous.accountId ||
47
+ baseline.accountIdSource !== previous.accountIdSource;
48
+ const liveHasExplicitBinding = live.accountIdSource === "manual" || live.accountIdSource === "org";
49
+ const previousHasTokenBinding = previous.accountIdSource === "token" || previous.accountIdSource === "id_token";
50
+ if (tokenBoundIdentityChanged && previousHasTokenBinding && !liveHasExplicitBinding) {
51
+ live.accountId = previous.accountId;
52
+ live.accountIdSource = previous.accountIdSource;
53
+ }
54
+ if (baseline.email !== previous.email) {
55
+ live.email = previous.email;
56
+ }
57
+ }
58
+ }
4
59
  /** @internal */
5
60
  export function createRotationProxyState(init) {
6
61
  return {
@@ -24,21 +79,325 @@ export function createRotationProxyState(init) {
24
79
  lastGlobalSwitchAt: 0,
25
80
  staleRuntimeReloadPromise: null,
26
81
  lastStaleRuntimeReloadAt: 0,
82
+ credentialReloadPromise: null,
83
+ lastCredentialReloadAt: 0,
84
+ transcriptionNextAccountIndex: 0,
85
+ transcriptionRateLimitUntilByRecordId: new Map(),
27
86
  };
28
87
  }
88
+ /**
89
+ * Reload account credentials without resetting inference runtime state.
90
+ *
91
+ * This is intentionally narrower than {@link recoverStaleRuntimeState}: it is
92
+ * used when a non-inference operation discovers that its in-memory credential
93
+ * inventory is stale after an external login/enable action. Fresh credential,
94
+ * workspace, enabled and auth-invalidation fields come from disk, while live
95
+ * inference rate-limit windows and non-auth cooldowns are carried forward by
96
+ * stable account identity. An auth-failure cooldown is carried only while the
97
+ * access token is unchanged, so a genuine re-login can repair the credential.
98
+ * Process-global health/token trackers and circuit breakers are never reset.
99
+ *
100
+ * @internal
101
+ */
102
+ export async function reloadRuntimeCredentials(state) {
103
+ // Inference stale recovery is destructive by design and takes precedence over
104
+ // the credential-only refresh. Sharing the in-flight result prevents two
105
+ // asynchronous loaders from racing to replace activeAccountManager.
106
+ if (state.staleRuntimeReloadPromise)
107
+ return state.staleRuntimeReloadPromise;
108
+ if (Date.now() - state.lastCredentialReloadAt <= STALE_RUNTIME_RELOAD_DEDUPE_MS) {
109
+ return state.activeAccountManager;
110
+ }
111
+ if (state.credentialReloadPromise)
112
+ return state.credentialReloadPromise;
113
+ state.credentialReloadPromise = (async () => {
114
+ const previousManager = state.activeAccountManager;
115
+ /** Stable matching key for carrying only runtime observations between credential snapshots. */
116
+ const identityOf = (account) => {
117
+ const accountId = account.accountId?.trim();
118
+ const email = sanitizeEmail(account.email);
119
+ return accountId || email
120
+ ? JSON.stringify([accountId ?? null, email ?? null])
121
+ : account.refreshToken?.trim()
122
+ ? `refresh:${account.refreshToken.trim()}`
123
+ : null;
124
+ };
125
+ // Capture auth before I/O so a token rotation that lands while loadFromDisk
126
+ // is suspended can be distinguished from credentials intentionally changed
127
+ // on disk by another login. Only the former is carried into the replacement.
128
+ const authBeforeLoad = previousManager.getAccountsSnapshot();
129
+ const reloaded = await AccountManager.loadFromDisk();
130
+ if (state.activeAccountManager !== previousManager) {
131
+ // Another path (native storage sync, stale recovery) already swapped
132
+ // the manager while disk I/O was pending; never clobber its redirect.
133
+ state.lastCredentialReloadAt = Date.now();
134
+ return state.activeAccountManager;
135
+ }
136
+ reloaded.setRoutingMutexMode(state.routingMutexMode);
137
+ const freshAccounts = reloaded.getAccountsSnapshot();
138
+ // loadFromDisk intentionally converts an unreadable/corrupt source with no
139
+ // usable WAL/backup into an empty manager. Do not let a transient read
140
+ // failure erase a healthy resident pool; a parsed empty/reset snapshot is
141
+ // still authoritative and may intentionally remove every account.
142
+ if (authBeforeLoad.length > 0 &&
143
+ freshAccounts.length === 0 &&
144
+ !reloaded.hasLoadedAccountStorageSnapshot()) {
145
+ state.lastCredentialReloadAt = Date.now();
146
+ return previousManager;
147
+ }
148
+ // A refresh can finish upstream while disk I/O is pending and then queue
149
+ // its auth commit against the resident manager. Let every commit already
150
+ // targeting that manager settle before taking the final live snapshot.
151
+ await waitForRuntimeRefreshCommits(previousManager);
152
+ if (state.activeAccountManager !== previousManager) {
153
+ state.lastCredentialReloadAt = Date.now();
154
+ return state.activeAccountManager;
155
+ }
156
+ const previousAccounts = previousManager.getAccountsSnapshot();
157
+ const credentialInventoryFingerprint = (accounts) => JSON.stringify(accounts.map((account) => ({
158
+ recordId: account.recordId ?? null,
159
+ accountId: account.accountId?.trim() ?? null,
160
+ accountIdSource: account.accountIdSource ?? null,
161
+ email: sanitizeEmail(account.email) ?? null,
162
+ refreshToken: account.refreshToken,
163
+ access: account.access ?? null,
164
+ expires: account.expires ?? null,
165
+ enabled: account.enabled !== false,
166
+ authInvalidatedAt: account.authInvalidatedAt ?? null,
167
+ authInvalidationErrorCode: account.authInvalidationErrorCode ?? null,
168
+ workspaces: (account.workspaces ?? []).map((workspace) => ({
169
+ id: workspace.id.trim(),
170
+ name: workspace.name ?? null,
171
+ enabled: workspace.enabled !== false,
172
+ disabledAt: workspace.disabledAt ?? null,
173
+ isDefault: workspace.isDefault ?? null,
174
+ })),
175
+ currentWorkspaceIndex: account.currentWorkspaceIndex ?? null,
176
+ })));
177
+ // A credential probe that finds the exact same inventory must not swap the
178
+ // live manager. Returning the resident object preserves inference cursors,
179
+ // lastUsed state, and in-flight runtime observations while still bounding
180
+ // disk checks through lastCredentialReloadAt.
181
+ if (credentialInventoryFingerprint(authBeforeLoad) === credentialInventoryFingerprint(freshAccounts)) {
182
+ // Cooldown fields are deliberately excluded from the inventory fingerprint
183
+ // so a disk probe cannot reset inference selector state. Still absorb a
184
+ // newer persisted auth-failure cooldown from another proxy, but only when
185
+ // the credential itself is unchanged; a re-login/rotation repairs that wait.
186
+ for (const fresh of freshAccounts) {
187
+ const resident = previousManager.getAccountByIndex(fresh.index);
188
+ if (resident &&
189
+ fresh.cooldownReason === "auth-failure" &&
190
+ typeof fresh.coolingDownUntil === "number" &&
191
+ fresh.coolingDownUntil > state.now() &&
192
+ resident.access === fresh.access &&
193
+ resident.refreshToken === fresh.refreshToken &&
194
+ fresh.coolingDownUntil > (resident.coolingDownUntil ?? 0)) {
195
+ resident.coolingDownUntil = fresh.coolingDownUntil;
196
+ resident.cooldownReason = "auth-failure";
197
+ }
198
+ }
199
+ state.lastCredentialReloadAt = Date.now();
200
+ return previousManager;
201
+ }
202
+ const baselineFor = (previous) => authBeforeLoad.find((account) => (account.recordId && account.recordId === previous.recordId) ||
203
+ account.index === previous.index);
204
+ const workspaceIds = (account) => new Set([
205
+ account.accountId?.trim(),
206
+ ...(account.workspaces ?? []).map((workspace) => workspace.id.trim()),
207
+ ].filter((value) => Boolean(value)));
208
+ const hasWorkspaceConflict = (previous, baseline, candidate) => {
209
+ const sourceIds = workspaceIds(previous);
210
+ if (baseline) {
211
+ for (const id of workspaceIds(baseline))
212
+ sourceIds.add(id);
213
+ }
214
+ const candidateIds = workspaceIds(candidate);
215
+ return sourceIds.size > 0 && candidateIds.size > 0 &&
216
+ ![...sourceIds].some((id) => candidateIds.has(id));
217
+ };
218
+ const claimed = new Set();
219
+ const matchedFreshIndexByPrevious = new Map();
220
+ const reserve = (previous, candidate) => {
221
+ if (!candidate || claimed.has(candidate.index))
222
+ return false;
223
+ claimed.add(candidate.index);
224
+ matchedFreshIndexByPrevious.set(previous.index, candidate.index);
225
+ return true;
226
+ };
227
+ // Reserve every strong match for every old row before considering fuzzy
228
+ // fallbacks. This prevents an earlier deleted row from stealing the only
229
+ // surviving same-email workspace before that survivor can claim itself.
230
+ for (const previous of previousAccounts) {
231
+ if (!previous.recordId)
232
+ continue;
233
+ const matches = freshAccounts.filter((account) => !claimed.has(account.index) && account.recordId === previous.recordId);
234
+ if (matches.length === 1)
235
+ reserve(previous, matches[0]);
236
+ }
237
+ for (const previous of previousAccounts) {
238
+ if (matchedFreshIndexByPrevious.has(previous.index))
239
+ continue;
240
+ const baseline = baselineFor(previous);
241
+ const identities = new Set([identityOf(previous), baseline ? identityOf(baseline) : null].filter((identity) => identity !== null));
242
+ const matches = freshAccounts.filter((account) => {
243
+ if (claimed.has(account.index))
244
+ return false;
245
+ const identity = identityOf(account);
246
+ return identity !== null && identities.has(identity);
247
+ });
248
+ if (matches.length === 1)
249
+ reserve(previous, matches[0]);
250
+ }
251
+ for (const previous of previousAccounts) {
252
+ if (matchedFreshIndexByPrevious.has(previous.index))
253
+ continue;
254
+ const baseline = baselineFor(previous);
255
+ const refreshTokens = new Set([previous.refreshToken, baseline?.refreshToken].filter((value) => Boolean(value)));
256
+ const matches = freshAccounts.filter((account) => !claimed.has(account.index) && refreshTokens.has(account.refreshToken));
257
+ if (matches.length === 1)
258
+ reserve(previous, matches[0]);
259
+ }
260
+ for (const previous of previousAccounts) {
261
+ if (matchedFreshIndexByPrevious.has(previous.index))
262
+ continue;
263
+ const baseline = baselineFor(previous);
264
+ const stableEmail = sanitizeEmail(previous.email) ?? sanitizeEmail(baseline?.email);
265
+ if (!stableEmail)
266
+ continue;
267
+ const matches = freshAccounts.filter((account) => !claimed.has(account.index) &&
268
+ sanitizeEmail(account.email) === stableEmail &&
269
+ !hasWorkspaceConflict(previous, baseline, account));
270
+ if (matches.length === 1)
271
+ reserve(previous, matches[0]);
272
+ }
273
+ for (const previous of previousAccounts) {
274
+ const matchedIndex = matchedFreshIndexByPrevious.get(previous.index);
275
+ if (matchedIndex === undefined)
276
+ continue;
277
+ const baseline = baselineFor(previous);
278
+ const live = reloaded.getAccountByIndex(matchedIndex);
279
+ if (!live)
280
+ continue;
281
+ const authRotatedDuringLoad = baseline !== undefined && (baseline.access !== previous.access ||
282
+ baseline.refreshToken !== previous.refreshToken ||
283
+ baseline.expires !== previous.expires);
284
+ if (authRotatedDuringLoad) {
285
+ live.access = previous.access;
286
+ live.refreshToken = previous.refreshToken;
287
+ live.expires = previous.expires;
288
+ const tokenBoundIdentityChanged = baseline !== undefined && (baseline.accountId !== previous.accountId ||
289
+ baseline.accountIdSource !== previous.accountIdSource);
290
+ const liveHasExplicitBinding = live.accountIdSource === "manual" || live.accountIdSource === "org";
291
+ const previousHasTokenBinding = previous.accountIdSource === "token" || previous.accountIdSource === "id_token";
292
+ if (tokenBoundIdentityChanged && previousHasTokenBinding && !liveHasExplicitBinding) {
293
+ live.accountId = previous.accountId;
294
+ live.accountIdSource = previous.accountIdSource;
295
+ }
296
+ if (baseline !== undefined && baseline.email !== previous.email) {
297
+ live.email = previous.email;
298
+ }
299
+ }
300
+ for (const [key, until] of Object.entries(previous.rateLimitResetTimes)) {
301
+ if (typeof until === "number" && until > state.now()) {
302
+ live.rateLimitResetTimes[key] = Math.max(live.rateLimitResetTimes[key] ?? 0, until);
303
+ }
304
+ }
305
+ if ((previous.coolingDownUntil ?? 0) > (live.coolingDownUntil ?? 0)) {
306
+ const repairedAuth = previous.cooldownReason === "auth-failure" && previous.access !== live.access;
307
+ if (!repairedAuth) {
308
+ live.coolingDownUntil = previous.coolingDownUntil;
309
+ live.cooldownReason = previous.cooldownReason;
310
+ }
311
+ }
312
+ const sameCredential = live.access === previous.access && live.refreshToken === previous.refreshToken;
313
+ // Auth-invalidation and workspace-disable markers are written in memory
314
+ // first and only reach disk through the debounced save. Carry only a
315
+ // marker that landed during the load window (absent from the baseline):
316
+ // a pre-existing marker may have been legitimately cleared on disk by
317
+ // another process, which is then authoritative. A re-login (rotated
318
+ // credential) legitimately clears them either way.
319
+ const baselineInvalidated = baseline !== undefined && previousManager.isAccountAuthInvalidated(baseline);
320
+ if (sameCredential &&
321
+ !baselineInvalidated &&
322
+ previousManager.isAccountAuthInvalidated(previous) &&
323
+ !reloaded.isAccountAuthInvalidated(live)) {
324
+ live.authInvalidatedAt = previous.authInvalidatedAt;
325
+ live.authInvalidationErrorCode = previous.authInvalidationErrorCode;
326
+ }
327
+ if (sameCredential) {
328
+ for (const workspace of previous.workspaces ?? []) {
329
+ const baselineWorkspace = baseline?.workspaces?.find((item) => item.id === workspace.id);
330
+ // A disable that predates the load is already in the baseline —
331
+ // disk may have re-enabled the workspace, and that wins.
332
+ if (baselineWorkspace?.enabled === false)
333
+ continue;
334
+ const target = live.workspaces?.find((item) => item.id === workspace.id);
335
+ if (target &&
336
+ workspace.enabled === false &&
337
+ workspace.disabledAt !== undefined &&
338
+ target.enabled !== false) {
339
+ target.enabled = false;
340
+ target.disabledAt = workspace.disabledAt;
341
+ }
342
+ }
343
+ }
344
+ }
345
+ // Requests that captured the resident manager before this swap may finish
346
+ // refreshing afterward. Redirect those late auth commits to the replacement
347
+ // so rotated single-use refresh tokens cannot be stranded on the old object.
348
+ redirectRuntimeRefreshCommits(previousManager, reloaded);
349
+ state.activeAccountManager = reloaded;
350
+ state.knownAccountManagers.add(reloaded);
351
+ state.lastCredentialReloadAt = Date.now();
352
+ recordRuntimeReload("credential-inventory-refresh");
353
+ return reloaded;
354
+ })()
355
+ .catch((error) => {
356
+ state.status.lastError = error instanceof Error ? error.message : String(error);
357
+ return null;
358
+ })
359
+ .finally(() => {
360
+ state.credentialReloadPromise = null;
361
+ });
362
+ return state.credentialReloadPromise;
363
+ }
29
364
  /** @internal */
30
365
  export async function recoverStaleRuntimeState(state) {
366
+ if (state.staleRuntimeReloadPromise)
367
+ return state.staleRuntimeReloadPromise;
368
+ // If a non-destructive credential refresh started first, let it publish its
369
+ // inventory before performing the authoritative stale-state reset. Re-check
370
+ // the stale promise afterward so concurrent recovery callers still dedupe.
371
+ if (state.credentialReloadPromise)
372
+ await state.credentialReloadPromise;
373
+ if (state.staleRuntimeReloadPromise)
374
+ return state.staleRuntimeReloadPromise;
31
375
  if (Date.now() - state.lastStaleRuntimeReloadAt <= STALE_RUNTIME_RELOAD_DEDUPE_MS) {
32
376
  return state.activeAccountManager;
33
377
  }
34
- if (state.staleRuntimeReloadPromise) {
35
- return state.staleRuntimeReloadPromise;
36
- }
37
378
  state.staleRuntimeReloadPromise = (async () => {
379
+ const previousManager = state.activeAccountManager;
380
+ const authBeforeLoad = previousManager.getAccountsSnapshot();
38
381
  AccountManager.resetVolatileRuntimeState();
39
382
  recordRuntimeReset("pool-exhausted-no-account");
40
383
  const reloaded = await AccountManager.loadFromDisk();
384
+ if (state.activeAccountManager !== previousManager) {
385
+ // A concurrent swap (native storage sync, credential reload) already
386
+ // published a newer manager; discarding this result keeps its redirect
387
+ // chain intact instead of clobbering previous → live with stale.
388
+ state.lastStaleRuntimeReloadAt = Date.now();
389
+ return state.activeAccountManager;
390
+ }
41
391
  reloaded.setRoutingMutexMode(state.routingMutexMode);
392
+ // Drain refresh commits that queued against the resident manager while
393
+ // disk I/O was pending, then carry the rotated credentials: the loaded
394
+ // snapshot predates their persist and still holds the spent token.
395
+ await waitForRuntimeRefreshCommits(previousManager);
396
+ if (state.activeAccountManager !== previousManager) {
397
+ state.lastStaleRuntimeReloadAt = Date.now();
398
+ return state.activeAccountManager;
399
+ }
400
+ carryRotatedCredentials(previousManager, authBeforeLoad, reloaded);
42
401
  // Wipe per-account cooldowns and rate-limit windows on the freshly
43
402
  // reloaded pool. `resetVolatileRuntimeState` above only cleared global
44
403
  // singletons (trackers, circuit breakers); the per-account transient
@@ -63,6 +422,19 @@ export async function recoverStaleRuntimeState(state) {
63
422
  // durable across a restart, not just best-effort. Recovery is rare
64
423
  // (full-pool exhaustion), so the extra synchronous write is cheap.
65
424
  await reloaded.flushPendingSave();
425
+ // The flush opened another window in which a late commit could queue on
426
+ // the superseded manager. Drain it, then carry + redirect + swap in one
427
+ // synchronous block so no commit can slip between the drain and the swap.
428
+ await waitForRuntimeRefreshCommits(previousManager);
429
+ if (state.activeAccountManager !== previousManager) {
430
+ state.lastStaleRuntimeReloadAt = Date.now();
431
+ return state.activeAccountManager;
432
+ }
433
+ carryRotatedCredentials(previousManager, authBeforeLoad, reloaded);
434
+ // Keep refresh ownership chains current across destructive recovery too.
435
+ // Requests that captured the previous manager before this swap must never
436
+ // persist a late refresh/cooldown into an object that is no longer active.
437
+ redirectRuntimeRefreshCommits(previousManager, reloaded);
66
438
  state.activeAccountManager = reloaded;
67
439
  state.knownAccountManagers.add(reloaded);
68
440
  state.lastStaleRuntimeReloadAt = Date.now();
@@ -1,3 +1,4 @@
1
+ import type { WebSocketDisconnectDiagnostic, WebSocketUpgradeRejectionDiagnostic } from "./responses-websocket.js";
1
2
  import type { QuotaCacheData } from "../quota-cache.js";
2
3
  import type { ApiRouteCredential } from "../api-route-store.js";
3
4
  import type { AccountStorageV3 } from "../storage.js";
@@ -18,10 +19,15 @@ export interface RuntimeRotationProxyServer {
18
19
  getOpenConnectionCount?: () => number;
19
20
  }
20
21
  export interface RuntimeRotationProxyStatus {
22
+ schedulingStrategy?: "hybrid" | "sequential";
21
23
  streamQuotaUpdates?: number;
22
24
  lastStreamQuotaUpdateAt?: number;
23
25
  websocketConnections?: number;
24
26
  websocketUpstreamRequests?: number;
27
+ websocketActiveConnections?: number;
28
+ websocketCapacityReclaims?: number;
29
+ lastWebsocketUpgradeRejection?: WebSocketUpgradeRejectionDiagnostic;
30
+ lastWebsocketDisconnect?: WebSocketDisconnectDiagnostic;
25
31
  startedAt: number;
26
32
  totalRequests: number;
27
33
  upstreamRequests: number;