oc-codex-multi-auth 6.2.0 → 6.3.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 (95) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +192 -27
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/account-display.d.ts +31 -0
  5. package/dist/lib/account-display.d.ts.map +1 -0
  6. package/dist/lib/account-display.js +46 -0
  7. package/dist/lib/account-display.js.map +1 -0
  8. package/dist/lib/accounts/recovery.d.ts +13 -0
  9. package/dist/lib/accounts/recovery.d.ts.map +1 -1
  10. package/dist/lib/accounts/recovery.js +32 -0
  11. package/dist/lib/accounts/recovery.js.map +1 -1
  12. package/dist/lib/accounts/state.d.ts +6 -0
  13. package/dist/lib/accounts/state.d.ts.map +1 -1
  14. package/dist/lib/accounts/state.js +37 -1
  15. package/dist/lib/accounts/state.js.map +1 -1
  16. package/dist/lib/accounts.d.ts +4 -1
  17. package/dist/lib/accounts.d.ts.map +1 -1
  18. package/dist/lib/accounts.js +6 -2
  19. package/dist/lib/accounts.js.map +1 -1
  20. package/dist/lib/cli.d.ts +1 -0
  21. package/dist/lib/cli.d.ts.map +1 -1
  22. package/dist/lib/cli.js +9 -6
  23. package/dist/lib/cli.js.map +1 -1
  24. package/dist/lib/errors.d.ts +11 -0
  25. package/dist/lib/errors.d.ts.map +1 -1
  26. package/dist/lib/errors.js +4 -0
  27. package/dist/lib/errors.js.map +1 -1
  28. package/dist/lib/logger.d.ts.map +1 -1
  29. package/dist/lib/logger.js +14 -1
  30. package/dist/lib/logger.js.map +1 -1
  31. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  32. package/dist/lib/request/fetch-helpers.js +37 -16
  33. package/dist/lib/request/fetch-helpers.js.map +1 -1
  34. package/dist/lib/request/rate-limit-backoff.d.ts +7 -0
  35. package/dist/lib/request/rate-limit-backoff.d.ts.map +1 -1
  36. package/dist/lib/request/rate-limit-backoff.js +24 -0
  37. package/dist/lib/request/rate-limit-backoff.js.map +1 -1
  38. package/dist/lib/rotation.d.ts +23 -0
  39. package/dist/lib/rotation.d.ts.map +1 -1
  40. package/dist/lib/rotation.js +47 -0
  41. package/dist/lib/rotation.js.map +1 -1
  42. package/dist/lib/storage/flagged.d.ts.map +1 -1
  43. package/dist/lib/storage/flagged.js +48 -2
  44. package/dist/lib/storage/flagged.js.map +1 -1
  45. package/dist/lib/storage/keychain.d.ts +14 -0
  46. package/dist/lib/storage/keychain.d.ts.map +1 -1
  47. package/dist/lib/storage/keychain.js +42 -0
  48. package/dist/lib/storage/keychain.js.map +1 -1
  49. package/dist/lib/storage/load-save.d.ts.map +1 -1
  50. package/dist/lib/storage/load-save.js +22 -4
  51. package/dist/lib/storage/load-save.js.map +1 -1
  52. package/dist/lib/tools/codex-dashboard.d.ts.map +1 -1
  53. package/dist/lib/tools/codex-dashboard.js +4 -3
  54. package/dist/lib/tools/codex-dashboard.js.map +1 -1
  55. package/dist/lib/tools/codex-diff.d.ts.map +1 -1
  56. package/dist/lib/tools/codex-diff.js +68 -8
  57. package/dist/lib/tools/codex-diff.js.map +1 -1
  58. package/dist/lib/tools/codex-health.d.ts.map +1 -1
  59. package/dist/lib/tools/codex-health.js +6 -4
  60. package/dist/lib/tools/codex-health.js.map +1 -1
  61. package/dist/lib/tools/codex-label.d.ts.map +1 -1
  62. package/dist/lib/tools/codex-label.js +3 -2
  63. package/dist/lib/tools/codex-label.js.map +1 -1
  64. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  65. package/dist/lib/tools/codex-limits.js +7 -5
  66. package/dist/lib/tools/codex-limits.js.map +1 -1
  67. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  68. package/dist/lib/tools/codex-list.js +4 -3
  69. package/dist/lib/tools/codex-list.js.map +1 -1
  70. package/dist/lib/tools/codex-note.d.ts.map +1 -1
  71. package/dist/lib/tools/codex-note.js +3 -2
  72. package/dist/lib/tools/codex-note.js.map +1 -1
  73. package/dist/lib/tools/codex-refresh.d.ts.map +1 -1
  74. package/dist/lib/tools/codex-refresh.js +3 -2
  75. package/dist/lib/tools/codex-refresh.js.map +1 -1
  76. package/dist/lib/tools/codex-remove.d.ts.map +1 -1
  77. package/dist/lib/tools/codex-remove.js +9 -6
  78. package/dist/lib/tools/codex-remove.js.map +1 -1
  79. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  80. package/dist/lib/tools/codex-status.js +4 -3
  81. package/dist/lib/tools/codex-status.js.map +1 -1
  82. package/dist/lib/tools/codex-switch.d.ts.map +1 -1
  83. package/dist/lib/tools/codex-switch.js +4 -3
  84. package/dist/lib/tools/codex-switch.js.map +1 -1
  85. package/dist/lib/tools/codex-tag.d.ts.map +1 -1
  86. package/dist/lib/tools/codex-tag.js +3 -2
  87. package/dist/lib/tools/codex-tag.js.map +1 -1
  88. package/dist/lib/tools/index.d.ts +4 -1
  89. package/dist/lib/tools/index.d.ts.map +1 -1
  90. package/dist/lib/tools/index.js.map +1 -1
  91. package/dist/lib/ui/auth-menu.d.ts +4 -1
  92. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  93. package/dist/lib/ui/auth-menu.js +10 -7
  94. package/dist/lib/ui/auth-menu.js.map +1 -1
  95. package/package.json +150 -150
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAKH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AAkM/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MA03G/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAKH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AAsM/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAojH/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -32,13 +32,15 @@ import { queuedRefresh } from "./lib/refresh-queue.js";
32
32
  import { openBrowserUrl } from "./lib/auth/browser.js";
33
33
  import { startLocalOAuthServer } from "./lib/auth/server.js";
34
34
  import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
35
- import { getCodexMode, getRequestTransformMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRetryProfile, getRetryBudgetOverrides, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getAutoUpdate, getToastDurationMs, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, loadPluginConfig, } from "./lib/config.js";
35
+ import { getCodexMode, getRequestTransformMode, getFastSession, getFastSessionStrategy, getFastSessionMaxInputItems, getRetryProfile, getRetryBudgetOverrides, getRateLimitToastDebounceMs, getRetryAllAccountsMaxRetries, getRetryAllAccountsMaxWaitMs, getRetryAllAccountsRateLimited, getFallbackToGpt52OnUnsupportedGpt53, getUnsupportedCodexPolicy, getUnsupportedCodexFallbackChain, getTokenRefreshSkewMs, getSessionRecovery, getAutoResume, getAutoUpdate, getToastDurationMs, getPerProjectAccounts, getEmptyResponseMaxRetries, getEmptyResponseRetryDelayMs, getPidOffsetEnabled, getFetchTimeoutMs, getStreamStallTimeoutMs, getCodexTuiV2, getCodexTuiColorProfile, getCodexTuiGlyphMode, getBeginnerSafeMode, getCodexTuiMaskEmail, loadPluginConfig, } from "./lib/config.js";
36
36
  import { AUTH_LABELS, CODEX_BASE_URL, DUMMY_API_KEY, LOG_STAGES, PLUGIN_NAME, PROVIDER_ID, ACCOUNT_LIMITS, } from "./lib/constants.js";
37
37
  import { initLogger, logRequest, logDebug, logInfo, logWarn, logError, setCorrelationId, clearCorrelationId, } from "./lib/logger.js";
38
38
  import { checkAndNotify } from "./lib/auto-update-checker.js";
39
39
  import { handleContextOverflow } from "./lib/context-overflow.js";
40
40
  import { AccountManager, extractAccountEmail, extractAccountId, formatAccountLabel, formatWaitTime, sanitizeEmail, shouldUpdateAccountIdFromToken, resolveRequestAccountId, parseRateLimitReason, lookupCodexCliTokensByEmail, } from "./lib/accounts.js";
41
- import { getStoragePath, loadAccounts, saveAccounts, clearAccounts, setStoragePath, loadFlaggedAccounts, saveFlaggedAccounts, withFlaggedAccountStorageTransaction, clearFlaggedAccounts, getWorkspaceIdentityKey, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
41
+ import { resolveDisplayEmail } from "./lib/account-display.js";
42
+ import { CodexAuthError } from "./lib/errors.js";
43
+ import { getStoragePath, loadAccounts, saveAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, loadFlaggedAccounts, saveFlaggedAccounts, withFlaggedAccountStorageTransaction, clearFlaggedAccounts, getWorkspaceIdentityKey, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
42
44
  import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, isDeactivatedWorkspaceError, getUnsupportedCodexModelInfo, resolveUnsupportedCodexFallbackModel, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
43
45
  import { createDeactivatedWorkspaceError, DEACTIVATED_WORKSPACE_ERROR_CODE, isDeactivatedWorkspaceErrorMessage, } from "./lib/error-sentinels.js";
44
46
  import { applyFastSessionDefaults, upsertBackendModelIdentityMessage, } from "./lib/request/request-transformer.js";
@@ -399,15 +401,22 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
399
401
  if (accountsToHydrate.length === 0)
400
402
  return storage;
401
403
  let changed = false;
404
+ // Record hydrated field updates keyed by the account's ORIGINAL
405
+ // refresh token (captured before the network call) so we can
406
+ // re-apply them onto a freshly-loaded snapshot inside a storage
407
+ // transaction — avoiding a lost-update race with concurrent saves.
408
+ const hydrationUpdates = new Map();
402
409
  // process in chunks of 3 to avoid auth0 rate limits (429) on startup
403
410
  const chunkSize = 3;
404
411
  for (let i = 0; i < accountsToHydrate.length; i += chunkSize) {
405
412
  const chunk = accountsToHydrate.slice(i, i + chunkSize);
406
413
  await Promise.all(chunk.map(async (account) => {
414
+ const originalRefreshToken = account.refreshToken;
407
415
  try {
408
416
  const refreshed = await queuedRefresh(account.refreshToken);
409
417
  if (refreshed.type !== "success")
410
418
  return;
419
+ const update = hydrationUpdates.get(originalRefreshToken) ?? {};
411
420
  const id = extractAccountId(refreshed.access);
412
421
  const email = sanitizeEmail(extractAccountEmail(refreshed.access, refreshed.idToken));
413
422
  if (id &&
@@ -415,28 +424,38 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
415
424
  shouldUpdateAccountIdFromToken(account.accountIdSource, account.accountId)) {
416
425
  account.accountId = id;
417
426
  account.accountIdSource = "token";
427
+ update.accountId = id;
428
+ update.accountIdSource = "token";
418
429
  changed = true;
419
430
  }
420
431
  if (email && email !== account.email) {
421
432
  account.email = email;
433
+ update.email = email;
422
434
  changed = true;
423
435
  }
424
436
  if (refreshed.access && refreshed.access !== account.accessToken) {
425
437
  account.accessToken = refreshed.access;
438
+ update.accessToken = refreshed.access;
426
439
  changed = true;
427
440
  }
428
441
  if (typeof refreshed.expires === "number" && refreshed.expires !== account.expiresAt) {
429
442
  account.expiresAt = refreshed.expires;
443
+ update.expiresAt = refreshed.expires;
430
444
  changed = true;
431
445
  }
432
446
  if (refreshed.scope && refreshed.scope !== account.oauthScope) {
433
447
  account.oauthScope = refreshed.scope;
448
+ update.oauthScope = refreshed.scope;
434
449
  changed = true;
435
450
  }
436
451
  if (refreshed.refresh && refreshed.refresh !== account.refreshToken) {
437
452
  account.refreshToken = refreshed.refresh;
453
+ update.refreshToken = refreshed.refresh;
438
454
  changed = true;
439
455
  }
456
+ if (Object.keys(update).length > 0) {
457
+ hydrationUpdates.set(originalRefreshToken, update);
458
+ }
440
459
  }
441
460
  catch {
442
461
  logWarn(`[${PLUGIN_NAME}] Failed to hydrate email for account`);
@@ -444,8 +463,37 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
444
463
  }));
445
464
  }
446
465
  if (changed) {
466
+ // Persist under the storage lock against a fresh snapshot so a
467
+ // concurrent save during the (potentially multi-second) hydration
468
+ // network loop is not clobbered. Match accounts by their original
469
+ // refresh token.
470
+ await withAccountStorageTransaction(async (current, persist) => {
471
+ if (!current)
472
+ return;
473
+ for (const acc of current.accounts) {
474
+ const update = hydrationUpdates.get(acc.refreshToken);
475
+ if (!update)
476
+ continue;
477
+ if (update.accountId !== undefined)
478
+ acc.accountId = update.accountId;
479
+ if (update.accountIdSource !== undefined)
480
+ acc.accountIdSource = update.accountIdSource;
481
+ if (update.email !== undefined)
482
+ acc.email = update.email;
483
+ if (update.accessToken !== undefined)
484
+ acc.accessToken = update.accessToken;
485
+ if (update.expiresAt !== undefined)
486
+ acc.expiresAt = update.expiresAt;
487
+ if (update.oauthScope !== undefined)
488
+ acc.oauthScope = update.oauthScope;
489
+ // Apply the rotated refresh token LAST so the map key
490
+ // (original token) still matches above.
491
+ if (update.refreshToken !== undefined)
492
+ acc.refreshToken = update.refreshToken;
493
+ }
494
+ await persist(current);
495
+ });
447
496
  storage.accounts = accountsCopy;
448
- await saveAccounts(storage);
449
497
  }
450
498
  return storage;
451
499
  };
@@ -487,6 +535,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
487
535
  const resolveUiRuntime = () => {
488
536
  return applyUiRuntimeFromConfig(loadPluginConfig());
489
537
  };
538
+ const resolveMaskEmail = () => {
539
+ return getCodexTuiMaskEmail(loadPluginConfig());
540
+ };
490
541
  const getStatusMarker = (ui, status) => {
491
542
  if (!ui.v2Enabled) {
492
543
  if (status === "ok")
@@ -509,8 +560,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
509
560
  return normalized;
510
561
  return `${normalized.slice(0, 8)}...${normalized.slice(-6)}`;
511
562
  };
512
- const formatCommandAccountLabel = (account, index) => {
513
- const email = account?.email?.trim();
563
+ const formatCommandAccountLabel = (account, index, options = {}) => {
564
+ const email = resolveDisplayEmail(account?.email, options.maskEmail ?? false);
514
565
  const workspace = account?.accountLabel?.trim();
515
566
  const accountId = formatAccountIdForDisplay(account?.accountId);
516
567
  const tags = Array.isArray(account?.accountTags)
@@ -555,8 +606,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
555
606
  return null;
556
607
  try {
557
608
  const { select } = await import("./lib/ui/select.js");
609
+ const maskEmail = resolveMaskEmail();
558
610
  const selected = await select(storage.accounts.map((account, index) => ({
559
- label: formatCommandAccountLabel(account, index),
611
+ label: formatCommandAccountLabel(account, index, { maskEmail }),
560
612
  value: index,
561
613
  })), {
562
614
  message: title,
@@ -819,8 +871,23 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
819
871
  }
820
872
  };
821
873
  const invalidateAccountManagerCache = () => {
874
+ // Dispose the outgoing manager so we don't leak its shutdown handler
875
+ // into the global cleanup queue or leave a pending debounce timer
876
+ // pointing at a stale instance. Flush first (best-effort, in the
877
+ // background) so any queued debounced save is not silently dropped.
878
+ const previous = cachedAccountManager;
822
879
  cachedAccountManager = null;
823
880
  accountManagerPromise = null;
881
+ if (previous) {
882
+ void previous
883
+ .flushPendingSave()
884
+ .catch((error) => {
885
+ logWarn(`Failed to flush pending save while invalidating account manager: ${error instanceof Error ? error.message : String(error)}`);
886
+ })
887
+ .finally(() => {
888
+ previous.disposeShutdownHandler();
889
+ });
890
+ }
824
891
  };
825
892
  const persistAuthenticatedSelections = async (results, replaceAll) => {
826
893
  try {
@@ -919,6 +986,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
919
986
  resolveUiRuntime,
920
987
  getStatusMarker,
921
988
  formatCommandAccountLabel,
989
+ resolveMaskEmail,
922
990
  normalizeAccountTags,
923
991
  supportsInteractiveMenus,
924
992
  promptAccountIndexSelection,
@@ -1027,6 +1095,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1027
1095
  const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig);
1028
1096
  const beginnerSafeMode = getBeginnerSafeMode(pluginConfig);
1029
1097
  beginnerSafeModeEnabled = beginnerSafeMode;
1098
+ const maskEmailEnabled = getCodexTuiMaskEmail(pluginConfig);
1030
1099
  const retryProfile = beginnerSafeMode
1031
1100
  ? "conservative"
1032
1101
  : getRetryProfile(pluginConfig);
@@ -1345,8 +1414,24 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1345
1414
  runtimeMetrics.accountRotations++;
1346
1415
  runtimeMetrics.lastError = err?.message ?? String(err);
1347
1416
  runtimeMetrics.lastErrorCategory = "auth-refresh";
1417
+ // Transient refresh failures (network blip / upstream 5xx) must NOT
1418
+ // count toward permanent account removal — the credentials are still
1419
+ // valid and a flaky network or outage would otherwise silently delete
1420
+ // them. Cool the account down briefly and rotate instead.
1421
+ const isTransientRefreshFailure = err instanceof CodexAuthError && err.retryable === true;
1422
+ if (isTransientRefreshFailure) {
1423
+ const cooledCount = accountManager.markAccountsWithRefreshTokenCoolingDown(account.refreshToken, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
1424
+ if (cooledCount <= 0) {
1425
+ accountManager.markAccountCoolingDown(account, ACCOUNT_LIMITS.AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
1426
+ }
1427
+ accountManager.saveToDiskDebounced();
1428
+ logWarn(`[${PLUGIN_NAME}] Transient auth refresh failure for account ${account.index + 1} (${err.refreshFailureReason ?? "unknown"}${err.statusCode ? ` ${err.statusCode}` : ""}); cooling down without counting toward removal.`);
1429
+ continue;
1430
+ }
1348
1431
  const failures = await accountManager.incrementAuthFailures(account);
1349
- const accountLabel = formatAccountLabel(account, account.index);
1432
+ const accountLabel = formatAccountLabel(account, account.index, {
1433
+ maskEmail: maskEmailEnabled,
1434
+ });
1350
1435
  if (failures >= ACCOUNT_LIMITS.MAX_AUTH_FAILURES_BEFORE_REMOVAL) {
1351
1436
  const removedCount = accountManager.removeAccountsWithSameRefreshToken(account);
1352
1437
  if (removedCount <= 0) {
@@ -1392,7 +1477,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1392
1477
  accountManager.saveToDiskDebounced();
1393
1478
  if (accountCount > 1 &&
1394
1479
  accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
1395
- const accountLabel = formatAccountLabel(account, account.index);
1480
+ const accountLabel = formatAccountLabel(account, account.index, {
1481
+ maskEmail: maskEmailEnabled,
1482
+ });
1396
1483
  await showToast(`Using ${accountLabel} (${account.index + 1}/${accountCount})`, "info");
1397
1484
  accountManager.markToastShown(account.index);
1398
1485
  }
@@ -1410,7 +1497,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1410
1497
  `Local token bucket depleted for account ${account.index + 1} (${modelFamily}${model ? `:${model}` : ""})`;
1411
1498
  runtimeMetrics.lastErrorCategory = "rate-limit-local";
1412
1499
  logWarn(`Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`);
1413
- break;
1500
+ // Skip THIS account and rotate to the next one. `account.index` is
1501
+ // already in `attempted` (added above), so the traversal loop guard
1502
+ // prevents reselecting it and terminates once all are exhausted.
1503
+ // Using `break` here would abandon every other healthy account.
1504
+ continue;
1414
1505
  }
1415
1506
  // RC-8: per-(account, family) circuit-breaker key. The breaker gates
1416
1507
  // upstream calls so that repeated failures short-circuit to the
@@ -1525,7 +1616,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1525
1616
  });
1526
1617
  const workspaceDeactivated = isDeactivatedWorkspaceError(errorBody, response.status);
1527
1618
  if (workspaceDeactivated) {
1528
- const accountLabel = formatAccountLabel(account, account.index);
1619
+ const accountLabel = formatAccountLabel(account, account.index, {
1620
+ maskEmail: maskEmailEnabled,
1621
+ });
1529
1622
  accountManager.refundToken(account, modelFamily, model);
1530
1623
  accountManager.recordFailure(account, modelFamily, model);
1531
1624
  account.lastSwitchReason = "rotation";
@@ -1552,12 +1645,13 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1552
1645
  catch (flagError) {
1553
1646
  logWarn(`Failed to persist deactivated workspace flag for ${accountLabel}: ${flagError instanceof Error ? flagError.message : String(flagError)}`);
1554
1647
  }
1555
- // Keep deactivated workspace cleanup aligned with the existing
1556
- // refresh-token removal path. saveToDiskDebounced reuses the
1557
- // storage temp-file + EPERM/EBUSY retry path covered in
1558
- // test/storage.test.ts, and surfaced persistence failures stay
1559
- // sanitized by the redaction checks in test/login-runner.test.ts.
1560
- const removedCount = accountManager.removeAccountsWithSameRefreshToken(account);
1648
+ // Remove ONLY the deactivated workspace, scoped by workspace
1649
+ // identity (org/account id). A single multi-org OAuth login
1650
+ // produces sibling accounts that share one refresh token but are
1651
+ // independently valid; removing all refresh-token siblings here
1652
+ // would silently drop still-valid workspaces from rotation. The
1653
+ // refresh token itself is still good, so siblings must survive.
1654
+ const removedCount = accountManager.removeAccountsByWorkspaceIdentity(account);
1561
1655
  if (removedCount > 0) {
1562
1656
  accountManager.saveToDiskDebounced();
1563
1657
  restartAccountTraversalAfterWorkspaceDeactivation = true;
@@ -1774,9 +1868,32 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1774
1868
  }
1775
1869
  resetRateLimitBackoff(account.index, quotaKey);
1776
1870
  runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs;
1777
- const successResponse = await handleSuccessResponse(response, isStreaming, {
1778
- streamStallTimeoutMs,
1779
- });
1871
+ let successResponse;
1872
+ try {
1873
+ successResponse = await handleSuccessResponse(response, isStreaming, {
1874
+ streamStallTimeoutMs,
1875
+ });
1876
+ }
1877
+ catch (streamError) {
1878
+ // A stream stall or SSE-parse failure happened AFTER a token was
1879
+ // consumed (line ~token-consume above). Without this catch the
1880
+ // exception escaped both loops, leaking the consumed token and
1881
+ // skipping account rotation. Refund, mark the breaker/account
1882
+ // failed, and rotate to the next account.
1883
+ accountManager.refundToken(account, modelFamily, model);
1884
+ accountManager.recordFailure(account, modelFamily, model);
1885
+ circuitBreaker.recordFailure();
1886
+ account.lastSwitchReason = "rotation";
1887
+ runtimeMetrics.failedRequests++;
1888
+ runtimeMetrics.accountRotations++;
1889
+ runtimeMetrics.lastError =
1890
+ streamError instanceof Error ? streamError.message : String(streamError);
1891
+ runtimeMetrics.lastErrorCategory = "stream";
1892
+ logWarn(`Stream/response handling failed for account ${account.index + 1}: ${runtimeMetrics.lastError}. Rotating.`);
1893
+ // Account for the server-class retry budget, then rotate.
1894
+ consumeRetryBudget("server", "Stream/response handling failure");
1895
+ break;
1896
+ }
1780
1897
  if (!successResponse.ok) {
1781
1898
  runtimeMetrics.failedRequests++;
1782
1899
  runtimeMetrics.lastError = `HTTP ${successResponse.status}`;
@@ -1795,10 +1912,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1795
1912
  runtimeMetrics.emptyResponseRetries++;
1796
1913
  logWarn(`Empty response received (attempt ${emptyResponseRetries}/${emptyResponseMaxRetries}). Retrying...`);
1797
1914
  await showToast(`Empty response. Retrying (${emptyResponseRetries}/${emptyResponseMaxRetries})...`, "warning", { duration: toastDurationMs });
1798
- accountManager.refundToken(account, modelFamily, model);
1799
- accountManager.recordFailure(account, modelFamily, model);
1800
1915
  await sleep(addJitter(emptyResponseRetryDelayMs, 0.2));
1801
- break;
1916
+ // Re-issue against the SAME account by re-entering the inner
1917
+ // request loop. Using `break` here exited to account rotation,
1918
+ // which is a no-op for single-account pools and surfaced a
1919
+ // misleading "all accounts failed" 503 instead of retrying.
1920
+ continue;
1802
1921
  }
1803
1922
  logWarn(`Empty response after ${emptyResponseMaxRetries} retries. Returning as-is.`);
1804
1923
  }
@@ -2165,12 +2284,15 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2165
2284
  let ok = 0;
2166
2285
  let disabled = 0;
2167
2286
  let errors = 0;
2287
+ const maskEmailEnabled = getCodexTuiMaskEmail(loadPluginConfig());
2168
2288
  console.log(`\nChecking ${deepProbe ? "full account health" : "quotas"} for all accounts...\n`);
2169
2289
  for (let i = 0; i < total; i += 1) {
2170
2290
  const account = workingStorage.accounts[i];
2171
2291
  if (!account)
2172
2292
  continue;
2173
- const label = account.email ?? account.accountLabel ?? `Account ${i + 1}`;
2293
+ const label = account.accountLabel?.trim() ||
2294
+ resolveDisplayEmail(account.email, maskEmailEnabled) ||
2295
+ `Account ${i + 1}`;
2174
2296
  if (account.enabled === false) {
2175
2297
  disabled += 1;
2176
2298
  console.log(`[${i + 1}/${total}] ${label}: DISABLED`);
@@ -2343,7 +2465,45 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2343
2465
  storageChanged = true;
2344
2466
  }
2345
2467
  if (storageChanged) {
2346
- await saveAccounts(workingStorage);
2468
+ // Persist under the storage lock against a fresh snapshot so
2469
+ // concurrent saves during the (long) health-check network loop
2470
+ // are not clobbered. Re-apply this run's per-account quota/state
2471
+ // updates by workspace identity and re-apply removals.
2472
+ const workingByIdentity = new Map(workingStorage.accounts.map((account) => [
2473
+ getWorkspaceIdentityKey(account),
2474
+ account,
2475
+ ]));
2476
+ await withAccountStorageTransaction(async (current, persist) => {
2477
+ if (!current) {
2478
+ // No on-disk state to merge into; fall back to the working copy.
2479
+ await persist(workingStorage);
2480
+ return;
2481
+ }
2482
+ const merged = [];
2483
+ for (const acc of current.accounts) {
2484
+ const identity = getWorkspaceIdentityKey(acc);
2485
+ if (removeFromActive.has(identity))
2486
+ continue;
2487
+ const updated = workingByIdentity.get(identity);
2488
+ if (updated) {
2489
+ // Carry forward ONLY the token/identity fields this health
2490
+ // check refreshes. Labels, tags, notes, and rate-limit /
2491
+ // cooldown state stay as the fresh snapshot has them so a
2492
+ // concurrent edit during the network loop is not reverted.
2493
+ acc.accountId = updated.accountId;
2494
+ acc.accountIdSource = updated.accountIdSource;
2495
+ acc.refreshToken = updated.refreshToken;
2496
+ acc.accessToken = updated.accessToken;
2497
+ acc.expiresAt = updated.expiresAt;
2498
+ acc.oauthScope = updated.oauthScope;
2499
+ acc.email = updated.email;
2500
+ }
2501
+ merged.push(acc);
2502
+ }
2503
+ current.accounts = merged;
2504
+ clampActiveIndices(current);
2505
+ await persist(current);
2506
+ });
2347
2507
  invalidateAccountManagerCache();
2348
2508
  }
2349
2509
  if (flaggedChanged) {
@@ -2372,13 +2532,16 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2372
2532
  return;
2373
2533
  }
2374
2534
  console.log("\nVerifying flagged accounts...\n");
2535
+ const maskEmailEnabled = getCodexTuiMaskEmail(loadPluginConfig());
2375
2536
  const remaining = [];
2376
2537
  const restored = [];
2377
2538
  for (let i = 0; i < flaggedStorage.accounts.length; i += 1) {
2378
2539
  const flagged = flaggedStorage.accounts[i];
2379
2540
  if (!flagged)
2380
2541
  continue;
2381
- const label = flagged.email ?? flagged.accountLabel ?? `Flagged ${i + 1}`;
2542
+ const label = flagged.accountLabel?.trim() ||
2543
+ resolveDisplayEmail(flagged.email, maskEmailEnabled) ||
2544
+ `Flagged ${i + 1}`;
2382
2545
  if (flagged.flaggedReason === "workspace-deactivated") {
2383
2546
  console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (workspace deactivated)`);
2384
2547
  remaining.push(flagged);
@@ -2494,8 +2657,10 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2494
2657
  enabled: account.enabled !== false,
2495
2658
  };
2496
2659
  });
2660
+ const maskEmailEnabled = getCodexTuiMaskEmail(loadPluginConfig());
2497
2661
  const menuResult = await promptLoginMode(existingAccounts, {
2498
2662
  flaggedCount: flaggedStorage.accounts.length,
2663
+ maskEmail: maskEmailEnabled,
2499
2664
  });
2500
2665
  if (menuResult.mode === "cancel") {
2501
2666
  return {
@@ -2531,7 +2696,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2531
2696
  accounts: flaggedStorage.accounts.filter((flagged) => !matchesWorkspaceIdentity(flagged, getWorkspaceIdentityKey(target))),
2532
2697
  });
2533
2698
  invalidateAccountManagerCache();
2534
- console.log(`\nDeleted ${target.email ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`);
2699
+ console.log(`\nDeleted ${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`);
2535
2700
  }
2536
2701
  continue;
2537
2702
  }
@@ -2541,7 +2706,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2541
2706
  target.enabled = target.enabled === false ? true : false;
2542
2707
  await saveAccounts(workingStorage);
2543
2708
  invalidateAccountManagerCache();
2544
- console.log(`\n${target.email ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`);
2709
+ console.log(`\n${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`);
2545
2710
  }
2546
2711
  continue;
2547
2712
  }