codex-multi-auth 2.20.0 → 2.22.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 (44) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +5 -4
  3. package/dist/lib/accounts.d.ts +5 -0
  4. package/dist/lib/accounts.js +33 -4
  5. package/dist/lib/api-route-store.d.ts +6 -0
  6. package/dist/lib/api-route-store.js +98 -30
  7. package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
  8. package/dist/lib/codex-manager/api-login-menu.js +4 -6
  9. package/dist/lib/codex-manager/commands/status.d.ts +1 -1
  10. package/dist/lib/codex-manager/commands/status.js +26 -9
  11. package/dist/lib/codex-manager.js +7 -1
  12. package/dist/lib/constants.d.ts +1 -0
  13. package/dist/lib/constants.js +1 -0
  14. package/dist/lib/model-route-policy.d.ts +1 -0
  15. package/dist/lib/model-route-policy.js +2 -1
  16. package/dist/lib/runtime/account-model-catalog.d.ts +1 -0
  17. package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
  18. package/dist/lib/runtime/api-model-capabilities.js +155 -24
  19. package/dist/lib/runtime/app-bind.d.ts +4 -0
  20. package/dist/lib/runtime/app-bind.js +32 -1
  21. package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
  22. package/dist/lib/runtime/automatic-api-checks.js +188 -0
  23. package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
  24. package/dist/lib/runtime/model-discovery-status.js +64 -49
  25. package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
  26. package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
  27. package/dist/lib/runtime/responses-websocket.d.ts +18 -5
  28. package/dist/lib/runtime/responses-websocket.js +33 -11
  29. package/dist/lib/runtime/rotation-account-selection.d.ts +20 -1
  30. package/dist/lib/runtime/rotation-account-selection.js +94 -4
  31. package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
  32. package/dist/lib/runtime/rotation-proxy-state.js +376 -4
  33. package/dist/lib/runtime/rotation-server-types.d.ts +3 -0
  34. package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
  35. package/dist/lib/runtime/rotation-token-refresh.js +218 -27
  36. package/dist/lib/runtime-rotation-proxy.js +571 -96
  37. package/dist/lib/storage/credential-sidecars.js +34 -2
  38. package/dist/lib/storage/file-lock.d.ts +2 -0
  39. package/dist/lib/storage/file-lock.js +44 -30
  40. package/dist/lib/usage/ledger.js +1 -0
  41. package/dist/lib/usage/redaction.js +1 -0
  42. package/dist/lib/usage/types.d.ts +1 -1
  43. package/package.json +1 -1
  44. package/scripts/codex-app-router.js +42 -14
@@ -1,3 +1,4 @@
1
+ import { sanitizeEmail } from "../accounts.js";
1
2
  import { queuedRefresh } from "../refresh-queue.js";
2
3
  import { isTokenInvalidationError, isTokenRefreshRetryable, } from "../request/rate-limit-decision.js";
3
4
  /** @internal */
@@ -24,49 +25,232 @@ function hasUsableAccessToken(account, now, skewMs) {
24
25
  typeof account.expires === "number" &&
25
26
  account.expires > now + Math.max(0, skewMs));
26
27
  }
27
- const runtimeRefreshCommitQueues = new WeakMap();
28
+ let runtimeRefreshCommitQueues = new WeakMap();
29
+ let runtimeRefreshManagerRedirects = new WeakMap();
30
+ function resolveRuntimeRefreshCommitManager(accountManager) {
31
+ let current = accountManager;
32
+ const seen = new Set();
33
+ while (!seen.has(current)) {
34
+ seen.add(current);
35
+ const next = runtimeRefreshManagerRedirects.get(current);
36
+ if (!next)
37
+ return current;
38
+ current = next;
39
+ }
40
+ return current;
41
+ }
42
+ function uniqueMatch(accounts, predicate) {
43
+ const matches = accounts.filter(predicate);
44
+ return matches.length === 1 ? matches[0] ?? null : null;
45
+ }
46
+ function workspaceIds(account) {
47
+ return new Set([
48
+ account.accountId?.trim(),
49
+ ...(account.workspaces ?? []).map((workspace) => workspace.id.trim()),
50
+ ].filter((value) => Boolean(value)));
51
+ }
52
+ function hasWorkspaceConflict(source, candidate) {
53
+ const sourceIds = workspaceIds(source);
54
+ const candidateIds = workspaceIds(candidate);
55
+ return (sourceIds.size > 0 &&
56
+ candidateIds.size > 0 &&
57
+ ![...sourceIds].some((id) => candidateIds.has(id)));
58
+ }
59
+ /** @internal Resolve an account onto the latest runtime manager replacement. */
60
+ export function resolveRuntimeRefreshTarget(accountManager, account) {
61
+ const targetManager = resolveRuntimeRefreshCommitManager(accountManager);
62
+ if (targetManager === accountManager)
63
+ return { accountManager, account };
64
+ const accounts = targetManager.getAccountsSnapshot();
65
+ // A persisted record id is the strongest row identity we have. If that row
66
+ // vanished in the replacement manager, never fall through to a same-email
67
+ // sibling workspace: the in-flight refresh belongs to the removed row.
68
+ // Derived in-memory ids hash the refresh token, so they are only strict
69
+ // when the row actually persisted one — otherwise rotation retires the id.
70
+ if (account.recordId && accountManager.hasPersistedRecordId(account)) {
71
+ const exact = uniqueMatch(accounts, (candidate) => candidate.recordId === account.recordId);
72
+ if (!exact)
73
+ return null;
74
+ const live = targetManager.getAccountByIndex(exact.index);
75
+ return live ? { accountManager: targetManager, account: live } : null;
76
+ }
77
+ const compatibleAccounts = accounts.filter((candidate) => !hasWorkspaceConflict(account, candidate));
78
+ const sourceEmail = sanitizeEmail(account.email);
79
+ const snapshot = uniqueMatch(compatibleAccounts, (candidate) => candidate.refreshToken === account.refreshToken) ??
80
+ (account.accountId && sourceEmail
81
+ ? uniqueMatch(compatibleAccounts, (candidate) => candidate.accountId === account.accountId && sanitizeEmail(candidate.email) === sourceEmail)
82
+ : null) ??
83
+ // accountId alone cannot merge logins: two members of one workspace share
84
+ // it. When both rows carry known-but-different emails they are different
85
+ // logins, and the in-flight refresh belongs to the removed one — let the
86
+ // caller restart selection instead of adopting the sibling's credentials.
87
+ (account.accountId
88
+ ? uniqueMatch(compatibleAccounts, (candidate) => candidate.accountId === account.accountId &&
89
+ (!sourceEmail ||
90
+ !sanitizeEmail(candidate.email) ||
91
+ sanitizeEmail(candidate.email) === sourceEmail))
92
+ : null) ??
93
+ (sourceEmail
94
+ ? uniqueMatch(compatibleAccounts, (candidate) => sanitizeEmail(candidate.email) === sourceEmail)
95
+ : null);
96
+ if (!snapshot)
97
+ return null;
98
+ const live = targetManager.getAccountByIndex(snapshot.index);
99
+ return live ? { accountManager: targetManager, account: live } : null;
100
+ }
101
+ /** @internal Test-only reset for process-global refresh commit/redirect state. */
102
+ export function resetRuntimeRefreshStateForTests() {
103
+ runtimeRefreshCommitQueues = new WeakMap();
104
+ runtimeRefreshManagerRedirects = new WeakMap();
105
+ }
106
+ /** @internal Wait until every refresh-auth commit already targeting this manager settles. */
107
+ export async function waitForRuntimeRefreshCommits(accountManager) {
108
+ const target = resolveRuntimeRefreshCommitManager(accountManager);
109
+ while (true) {
110
+ const queue = runtimeRefreshCommitQueues.get(target);
111
+ const pending = queue ? [...queue.values()] : [];
112
+ if (pending.length === 0)
113
+ return;
114
+ await Promise.allSettled(pending);
115
+ }
116
+ }
117
+ /** @internal Redirect late refresh state from requests that captured a superseded manager. */
118
+ export function redirectRuntimeRefreshCommits(previousManager, replacementManager) {
119
+ if (previousManager === replacementManager)
120
+ return;
121
+ runtimeRefreshManagerRedirects.set(previousManager, resolveRuntimeRefreshCommitManager(replacementManager));
122
+ }
28
123
  async function commitRefreshedAuthOnce(accountManager, account, auth) {
124
+ const commitManager = resolveRuntimeRefreshCommitManager(accountManager);
29
125
  const key = [
30
- account.index,
126
+ account.recordId ?? "",
31
127
  account.accountId ?? "",
32
128
  account.email ?? "",
33
129
  account.refreshToken,
34
130
  ].join("\0");
35
- let queue = runtimeRefreshCommitQueues.get(accountManager);
131
+ let queue = runtimeRefreshCommitQueues.get(commitManager);
36
132
  if (!queue) {
37
133
  queue = new Map();
38
- runtimeRefreshCommitQueues.set(accountManager, queue);
134
+ runtimeRefreshCommitQueues.set(commitManager, queue);
39
135
  }
40
136
  const existing = queue.get(key);
41
137
  if (existing)
42
138
  return existing;
43
- const pending = accountManager
139
+ const pending = commitManager
44
140
  .commitRefreshedAuth(account, auth)
141
+ .then((updatedAccount) => ({ accountManager: commitManager, account: updatedAccount }))
45
142
  .finally(() => queue?.delete(key));
46
143
  queue.set(key, pending);
47
144
  return pending;
48
145
  }
49
146
  /** @internal */
50
147
  export async function ensureFreshAccessToken(params) {
51
- const { accountManager, account, family, model, now, tokenRefreshSkewMs, tokenInvalidationCooldownMs } = params;
52
- if (hasUsableAccessToken(account, now, tokenRefreshSkewMs)) {
53
- return { ok: true, accessToken: account.access ?? "", account };
148
+ const { accountManager, account, family, model, now, tokenRefreshSkewMs, tokenInvalidationCooldownMs, } = params;
149
+ const redirectedInitialManager = resolveRuntimeRefreshCommitManager(accountManager);
150
+ const resolvedInitialTarget = resolveRuntimeRefreshTarget(accountManager, account);
151
+ if (redirectedInitialManager !== accountManager && !resolvedInitialTarget) {
152
+ // The request captured a manager that has since been replaced, and the
153
+ // selected row no longer exists there. Even a still-fresh old token must not
154
+ // bypass that removal; restart selection on the replacement pool.
155
+ return {
156
+ ok: false,
157
+ retryable: true,
158
+ selectionRestart: true,
159
+ accountManager: redirectedInitialManager,
160
+ };
161
+ }
162
+ const initialTarget = resolvedInitialTarget ?? {
163
+ accountManager,
164
+ account,
165
+ };
166
+ if (initialTarget.account.enabled === false) {
167
+ return {
168
+ ok: false,
169
+ retryable: true,
170
+ selectionRestart: true,
171
+ accountManager: initialTarget.accountManager,
172
+ };
173
+ }
174
+ if (hasUsableAccessToken(initialTarget.account, now, tokenRefreshSkewMs)) {
175
+ return {
176
+ ok: true,
177
+ accessToken: initialTarget.account.access ?? "",
178
+ account: initialTarget.account,
179
+ accountManager: initialTarget.accountManager,
180
+ };
181
+ }
182
+ const refreshCredential = {
183
+ access: initialTarget.account.access,
184
+ refreshToken: initialTarget.account.refreshToken,
185
+ expires: initialTarget.account.expires,
186
+ };
187
+ const refreshResult = await queuedRefresh(refreshCredential.refreshToken);
188
+ const redirectedManager = resolveRuntimeRefreshCommitManager(initialTarget.accountManager);
189
+ const resolvedLatestTarget = resolveRuntimeRefreshTarget(initialTarget.accountManager, initialTarget.account);
190
+ if (redirectedManager !== initialTarget.accountManager && !resolvedLatestTarget) {
191
+ // The row that initiated this refresh disappeared while OAuth was in flight.
192
+ // Ignore the stale result rather than guessing another same-email workspace,
193
+ // but hand the caller the replacement manager so it can restart selection and
194
+ // discover a different healthy login from the current pool.
195
+ return {
196
+ ok: false,
197
+ retryable: true,
198
+ selectionRestart: true,
199
+ accountManager: redirectedManager,
200
+ };
201
+ }
202
+ const latestTarget = resolvedLatestTarget ?? initialTarget;
203
+ if (latestTarget.account.enabled === false) {
204
+ // An operator/runtime inventory change disabled this row while OAuth was in
205
+ // flight. Never let a successful refresh re-enable it in commitRefreshedAuth.
206
+ return {
207
+ ok: false,
208
+ retryable: true,
209
+ selectionRestart: true,
210
+ accountManager: latestTarget.accountManager,
211
+ };
212
+ }
213
+ const credentialChangedWhileRefreshing = latestTarget.account.access !== refreshCredential.access ||
214
+ latestTarget.account.refreshToken !== refreshCredential.refreshToken ||
215
+ latestTarget.account.expires !== refreshCredential.expires;
216
+ if (credentialChangedWhileRefreshing) {
217
+ // A concurrent refresh or external re-login repaired/replaced this credential.
218
+ // The result for the old OAuth tuple must never invalidate or overwrite it.
219
+ if (hasUsableAccessToken(latestTarget.account, now, tokenRefreshSkewMs)) {
220
+ return {
221
+ ok: true,
222
+ accessToken: latestTarget.account.access ?? "",
223
+ account: latestTarget.account,
224
+ accountManager: latestTarget.accountManager,
225
+ };
226
+ }
227
+ return {
228
+ ok: false,
229
+ retryable: true,
230
+ account: latestTarget.account,
231
+ accountManager: latestTarget.accountManager,
232
+ };
54
233
  }
55
- const refreshResult = await queuedRefresh(account.refreshToken);
56
234
  if (refreshResult.type === "failed") {
57
- accountManager.recordFailure(account, family, model);
58
- accountManager.incrementAuthFailures(account);
235
+ latestTarget.accountManager.recordFailure(latestTarget.account, family, model);
236
+ latestTarget.accountManager.incrementAuthFailures(latestTarget.account);
59
237
  // If the refresh endpoint itself returns an explicit invalidation message
60
238
  // (e.g. Microsoft/Outlook SSO revokes the refresh token server-side), apply
61
239
  // the long cooldown and signal to the caller to stop rotating rather than
62
240
  // presenting other accounts' tokens from the same IP.
63
241
  const invalidated = isTokenInvalidationError(refreshResult.message ?? "");
64
242
  if (invalidated) {
65
- accountManager.markAuthInvalidated(account);
243
+ latestTarget.accountManager.markAuthInvalidated(latestTarget.account);
66
244
  }
67
- applyMonotonicAuthCooldown(accountManager, account, invalidated ? tokenInvalidationCooldownMs : DEFAULT_AUTH_FAILURE_COOLDOWN_MS);
68
- accountManager.saveToDiskDebounced();
69
- return { ok: false, retryable: isTokenRefreshRetryable(refreshResult), invalidated };
245
+ applyMonotonicAuthCooldown(latestTarget.accountManager, latestTarget.account, invalidated ? tokenInvalidationCooldownMs : DEFAULT_AUTH_FAILURE_COOLDOWN_MS);
246
+ latestTarget.accountManager.saveToDiskDebounced();
247
+ return {
248
+ ok: false,
249
+ retryable: isTokenRefreshRetryable(refreshResult),
250
+ invalidated,
251
+ account: latestTarget.account,
252
+ accountManager: latestTarget.accountManager,
253
+ };
70
254
  }
71
255
  const auth = {
72
256
  type: "oauth",
@@ -75,28 +259,35 @@ export async function ensureFreshAccessToken(params) {
75
259
  expires: refreshResult.expires,
76
260
  };
77
261
  try {
78
- const updatedAccount = await commitRefreshedAuthOnce(accountManager, account, auth);
262
+ const committed = await commitRefreshedAuthOnce(latestTarget.accountManager, latestTarget.account, auth);
79
263
  // If commit succeeded, use the stored access token from the committed
80
- // account — this also handles the dedup case where two concurrent callers
264
+ // account ? this also handles the dedup case where two concurrent callers
81
265
  // share one commit and both end up with the same committed token.
82
266
  // If commit returned null (account missing from storage after persist),
83
- // fall back to the original account object but always use refreshResult.access:
84
- // the original account may carry an expired token, and using that would
85
- // cause a downstream 401 and incorrectly trigger invalidation-cooldown logic.
86
- const resolvedAccount = updatedAccount ?? account;
87
- const accessToken = updatedAccount !== null
88
- ? (updatedAccount.access ?? refreshResult.access)
267
+ // fall back to the account that initiated the commit but always use
268
+ // refreshResult.access; that account may still carry an expired token.
269
+ const resolvedAccount = committed.account ?? latestTarget.account;
270
+ const resolvedManager = committed.account ? committed.accountManager : latestTarget.accountManager;
271
+ const accessToken = committed.account !== null
272
+ ? (committed.account.access ?? refreshResult.access)
89
273
  : refreshResult.access;
90
274
  return {
91
275
  ok: true,
92
276
  accessToken,
93
277
  account: resolvedAccount,
278
+ accountManager: resolvedManager,
94
279
  };
95
280
  }
96
281
  catch {
97
- accountManager.recordFailure(account, family, model);
98
- accountManager.markAccountCoolingDown(account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
99
- accountManager.saveToDiskDebounced();
100
- return { ok: false, retryable: true };
282
+ const failureTarget = resolveRuntimeRefreshTarget(latestTarget.accountManager, latestTarget.account) ?? latestTarget;
283
+ failureTarget.accountManager.recordFailure(failureTarget.account, family, model);
284
+ failureTarget.accountManager.markAccountCoolingDown(failureTarget.account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
285
+ failureTarget.accountManager.saveToDiskDebounced();
286
+ return {
287
+ ok: false,
288
+ retryable: true,
289
+ account: failureTarget.account,
290
+ accountManager: failureTarget.accountManager,
291
+ };
101
292
  }
102
293
  }