oc-codex-multi-auth 6.13.0 → 6.14.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 (46) hide show
  1. package/README.md +3 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +305 -47
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/accounts/state.d.ts.map +1 -1
  6. package/dist/lib/accounts/state.js +2 -2
  7. package/dist/lib/accounts/state.js.map +1 -1
  8. package/dist/lib/codex-usage.d.ts.map +1 -1
  9. package/dist/lib/codex-usage.js +14 -79
  10. package/dist/lib/codex-usage.js.map +1 -1
  11. package/dist/lib/errors.d.ts +7 -0
  12. package/dist/lib/errors.d.ts.map +1 -1
  13. package/dist/lib/errors.js +14 -0
  14. package/dist/lib/errors.js.map +1 -1
  15. package/dist/lib/proactive-refresh.d.ts.map +1 -1
  16. package/dist/lib/proactive-refresh.js +14 -3
  17. package/dist/lib/proactive-refresh.js.map +1 -1
  18. package/dist/lib/request/fetch-helpers.d.ts +2 -1
  19. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  20. package/dist/lib/request/fetch-helpers.js +17 -4
  21. package/dist/lib/request/fetch-helpers.js.map +1 -1
  22. package/dist/lib/storage/coordinated-refresh.d.ts +15 -0
  23. package/dist/lib/storage/coordinated-refresh.d.ts.map +1 -0
  24. package/dist/lib/storage/coordinated-refresh.js +246 -0
  25. package/dist/lib/storage/coordinated-refresh.js.map +1 -0
  26. package/dist/lib/storage/flagged.d.ts.map +1 -1
  27. package/dist/lib/storage/flagged.js +13 -3
  28. package/dist/lib/storage/flagged.js.map +1 -1
  29. package/dist/lib/storage/load-save.d.ts.map +1 -1
  30. package/dist/lib/storage/load-save.js +6 -3
  31. package/dist/lib/storage/load-save.js.map +1 -1
  32. package/dist/lib/storage/transaction-lock.d.ts +21 -0
  33. package/dist/lib/storage/transaction-lock.d.ts.map +1 -0
  34. package/dist/lib/storage/transaction-lock.js +194 -0
  35. package/dist/lib/storage/transaction-lock.js.map +1 -0
  36. package/dist/lib/tools/codex-note.d.ts.map +1 -1
  37. package/dist/lib/tools/codex-note.js +19 -9
  38. package/dist/lib/tools/codex-note.js.map +1 -1
  39. package/dist/lib/tools/codex-tag.d.ts.map +1 -1
  40. package/dist/lib/tools/codex-tag.js +23 -12
  41. package/dist/lib/tools/codex-tag.js.map +1 -1
  42. package/dist/lib/tools/refresh-account.d.ts +5 -15
  43. package/dist/lib/tools/refresh-account.d.ts.map +1 -1
  44. package/dist/lib/tools/refresh-account.js +17 -110
  45. package/dist/lib/tools/refresh-account.js.map +1 -1
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -257,6 +257,7 @@ Most of these also run as a **direct CLI** with no agent/model involvement (no t
257
257
  - `reasoning.encrypted_content` is preserved for multi-turn continuity
258
258
  - GPT-5.6 tiers use the responses-lite request shape and default client identity `opencode`; other models default to `codex_cli_rs`
259
259
  - account rotation is health-aware (`rotationStrategy` default `hybrid`) and avoids repeatedly selecting cooling accounts
260
+ - same-host OpenCode processes sharing an account file serialize refresh-token exchange and commit so one current single-use token is exchanged once
260
261
  - 5xx bursts, network failures, and quota responses penalize account health
261
262
  - token refresh is queued to avoid refresh races
262
263
  - unsupported-model handling is strict by default, with opt-in fallback controls
@@ -356,6 +357,8 @@ Selected runtime/environment overrides:
356
357
 
357
358
  | Variable | Effect |
358
359
  | --- | --- |
360
+ | `OPENAI_BASE_URL=https://gateway.example/v1` | OpenAI-compatible OAuth inference gateway; requires `CODEX_AUTH_ALLOW_OPENAI_BASE_URL=1` |
361
+ | `CODEX_AUTH_ALLOW_OPENAI_BASE_URL=1` | Explicitly allow the trusted gateway to receive the ChatGPT OAuth access token; remote gateways require HTTPS, while HTTP is accepted only on literal loopback IPs |
359
362
  | `CODEX_AUTH_REQUEST_TRANSFORM_MODE=legacy` | Re-enable legacy Codex request rewriting |
360
363
  | `CODEX_MODE=0/1` | Disable/enable bridge prompt behavior |
361
364
  | `CODEX_TUI_V2=0/1` | Disable/enable codex-style tool output |
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AAyN/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAypH/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;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AA8S/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAg2H/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -29,7 +29,7 @@ import { join } from "node:path";
29
29
  import { createAuthorizationFlow, exchangeAuthorizationCode, parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js";
30
30
  import { buildDeviceCodeInstructions, completeDeviceCodeSession, createDeviceCodeSession, } from "./lib/auth/device-code.js";
31
31
  import { applyAccountSelectionFallbacks, persistResolvedAccountSelection, persistAccountPool, resolveAndPersistAccountSelection, resolveAccountSelection, } from "./lib/auth/login-runner.js";
32
- import { queuedRefresh } from "./lib/refresh-queue.js";
32
+ import { coordinateFlaggedPersistedRefresh, coordinatePersistedRefresh, } from "./lib/storage/coordinated-refresh.js";
33
33
  import { openBrowserUrl } from "./lib/auth/browser.js";
34
34
  import { startLocalOAuthServer } from "./lib/auth/server.js";
35
35
  import { promptAddAnotherAccount, promptLoginMode } from "./lib/cli.js";
@@ -42,7 +42,7 @@ import { AccountManager, extractAccountEmail, extractAccountId, formatAccountLab
42
42
  import { matchesModelPoolAccountKey } from "./lib/accounts/pool-identity.js";
43
43
  import { resolveDisplayEmail } from "./lib/account-display.js";
44
44
  import { CodexAuthError } from "./lib/errors.js";
45
- import { getStoragePath, loadAccounts, saveAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, loadFlaggedAccounts, saveFlaggedAccounts, withFlaggedAccountStorageTransaction, clearFlaggedAccounts, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
45
+ import { getStoragePath, loadAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, loadFlaggedAccounts, withFlaggedAccountStorageTransaction, clearFlaggedAccounts, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
46
46
  import { getWorkspaceIdentityKey } from "./lib/storage/identity.js";
47
47
  import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, isDeactivatedWorkspaceError, isInvalidatedAuthTokenError, createAbortError, getUnsupportedCodexModelInfo, resolveUnsupportedCodexFallbackModel, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
48
48
  import { shapeBodyForModel } from "./lib/request/helpers/responses-lite.js";
@@ -64,6 +64,83 @@ import { createToolRegistry, } from "./lib/tools/index.js";
64
64
  import { createUsageAccountFingerprint, fetchCodexUsage, formatUsageLimitSummary, formatUsageLimitTitle, hasUsageWindow, parseCodexUsagePayload, } from "./lib/codex-usage.js";
65
65
  import { getQuotaExhaustedResetAtMs } from "./lib/quota-windows.js";
66
66
  import { clearTuiQuotaSnapshot, parseTuiQuotaSnapshotFromHeaders, writeTuiQuotaSnapshot, } from "./lib/tui-quota-cache.js";
67
+ /**
68
+ * IPv6 loopback literal. WHATWG URL parsing compresses every equivalent
69
+ * spelling (`0:0:0:0:0:0:0:1` -> `::1`), so only the canonical form is listed.
70
+ */
71
+ const LOOPBACK_GATEWAY_HOSTS = new Set(["::1"]);
72
+ const IPV4_LITERAL_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
73
+ /**
74
+ * IPv4-mapped IPv6, which WHATWG serializes in hex rather than dotted form:
75
+ * `::ffff:127.0.0.1` -> `::ffff:7f00:1`, `::ffff:127.1.1.1` -> `::ffff:7f01:101`.
76
+ * The high byte of the first group is the first IPv4 octet.
77
+ */
78
+ const IPV4_MAPPED_PATTERN = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/;
79
+ /**
80
+ * Cleartext HTTP is only safe for a peer that cannot leave the host, so the check
81
+ * is deliberately restricted to *literal* loopback addresses. Hostnames such as
82
+ * `localhost` are rejected even though they usually resolve to 127.0.0.1: a host
83
+ * file or resolver can point them at a remote peer, which would leak the ChatGPT
84
+ * OAuth access token over the wire.
85
+ *
86
+ * The whole 127.0.0.0/8 range is accepted, not just 127.0.0.1 — running several
87
+ * local services on 127.0.0.2, 127.0.1.1, and friends is a common pattern, and
88
+ * every address in the block is equally unroutable.
89
+ */
90
+ function isLoopbackGatewayHost(hostname) {
91
+ const ipv4 = IPV4_LITERAL_PATTERN.exec(hostname);
92
+ if (ipv4) {
93
+ const octets = ipv4.slice(1).map((part) => Number(part));
94
+ if (octets.some((octet) => !Number.isInteger(octet) || octet > 255))
95
+ return false;
96
+ return octets[0] === 127;
97
+ }
98
+ const mapped = IPV4_MAPPED_PATTERN.exec(hostname);
99
+ if (mapped?.[1]) {
100
+ // `::ffff:7f00:2` is 127.0.0.2 — just as unroutable as the dotted
101
+ // spelling, so rejecting it would be an inconsistency, not a safeguard.
102
+ return Number.parseInt(mapped[1], 16) >>> 8 === 0x7f;
103
+ }
104
+ return LOOPBACK_GATEWAY_HOSTS.has(hostname);
105
+ }
106
+ function invalidBaseURL(reason) {
107
+ return new Error(`[oc-codex-multi-auth] OPENAI_BASE_URL is invalid: ${reason}. Fix the value, or unset ` +
108
+ "CODEX_AUTH_ALLOW_OPENAI_BASE_URL to fall back to the default ChatGPT Codex endpoint.");
109
+ }
110
+ function resolveOpenAIBaseURL() {
111
+ if (process.env.CODEX_AUTH_ALLOW_OPENAI_BASE_URL !== "1")
112
+ return undefined;
113
+ const raw = process.env.OPENAI_BASE_URL?.trim();
114
+ if (!raw)
115
+ return undefined;
116
+ let parsed;
117
+ try {
118
+ parsed = new URL(raw);
119
+ }
120
+ catch {
121
+ // `new URL()` throws a bare `TypeError: Invalid URL` that names neither the
122
+ // plugin nor the variable, which is useless when it surfaces out of the
123
+ // auth loader. A scheme-less value such as `gateway.example/v1` is the
124
+ // common way to hit this. The value itself is NOT echoed: it can carry a
125
+ // token in a query string, and this message reaches a toast.
126
+ throw invalidBaseURL("the value is not a valid absolute URL (an explicit https:// or http:// scheme is required)");
127
+ }
128
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
129
+ throw invalidBaseURL("it must use http:// or https://");
130
+ }
131
+ if (parsed.username || parsed.password) {
132
+ throw invalidBaseURL("it must not embed credentials");
133
+ }
134
+ if (parsed.search || parsed.hash || raw.includes("?") || raw.includes("#")) {
135
+ throw invalidBaseURL("it must not include a query string or fragment");
136
+ }
137
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
138
+ if (parsed.protocol === "http:" && !isLoopbackGatewayHost(hostname)) {
139
+ throw invalidBaseURL(`https:// is required for non-loopback host "${hostname}" so the ChatGPT OAuth ` +
140
+ "access token is never sent in cleartext");
141
+ }
142
+ return parsed.toString().replace(/\/+$/, "");
143
+ }
67
144
  /**
68
145
  * OpenAI Codex OAuth authentication plugin for opencode
69
146
  *
@@ -82,6 +159,8 @@ import { clearTuiQuotaSnapshot, parseTuiQuotaSnapshotFromHeaders, writeTuiQuotaS
82
159
  */
83
160
  export const OpenAIOAuthPlugin = async ({ client }) => {
84
161
  initLogger(client);
162
+ let customBaseURLWarningShown = false;
163
+ let customBaseURLErrorShown = false;
85
164
  let cachedAccountManager = null;
86
165
  let accountManagerPromise = null;
87
166
  let loaderMutex = null;
@@ -440,16 +519,15 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
440
519
  process.env.OPENCODE_SKIP_EMAIL_HYDRATE === "1";
441
520
  if (skipHydrate)
442
521
  return storage;
443
- const accountsCopy = storage.accounts.map((account) => account ? { ...account } : account);
522
+ let accountsCopy = storage.accounts.map((account) => account ? { ...account } : account);
444
523
  const accountsToHydrate = accountsCopy.filter((account) => account && !account.email);
445
524
  if (accountsToHydrate.length === 0)
446
525
  return storage;
447
526
  let changed = false;
448
- // Record hydrated field updates keyed by the account's ORIGINAL
449
- // refresh token (captured before the network call) so we can
450
- // re-apply them onto a freshly-loaded snapshot inside a storage
451
- // transaction — avoiding a lost-update race with concurrent saves.
452
527
  const hydrationUpdates = new Map();
528
+ // Keyed by stable workspace identity rather than by refresh token, for
529
+ // accounts whose token the coordinator rotated on disk mid-loop.
530
+ const hydrationUpdatesByIdentity = new Map();
453
531
  // process in chunks of 3 to avoid auth0 rate limits (429) on startup
454
532
  const chunkSize = 3;
455
533
  for (let i = 0; i < accountsToHydrate.length; i += chunkSize) {
@@ -457,7 +535,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
457
535
  await Promise.all(chunk.map(async (account) => {
458
536
  const originalRefreshToken = account.refreshToken;
459
537
  try {
460
- const refreshed = await queuedRefresh(account.refreshToken);
538
+ const refreshed = await coordinatePersistedRefresh(account);
461
539
  if (refreshed.type !== "success")
462
540
  return;
463
541
  const update = hydrationUpdates.get(originalRefreshToken) ?? {};
@@ -497,8 +575,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
497
575
  update.refreshToken = refreshed.refresh;
498
576
  changed = true;
499
577
  }
578
+ const rotatedAt = refreshed.rotatedAt ?? account.tokenRotatedAt;
579
+ if (typeof rotatedAt === "number") {
580
+ account.tokenRotatedAt = rotatedAt;
581
+ update.tokenRotatedAt = rotatedAt;
582
+ }
500
583
  if (Object.keys(update).length > 0) {
501
584
  hydrationUpdates.set(originalRefreshToken, update);
585
+ // The coordinator has already committed the rotated token to
586
+ // disk, so matching this update by refresh token alone would
587
+ // miss this account and — worse — hit any SIBLING whose token
588
+ // was rotated in place to the same value. Siblings can belong
589
+ // to distinct orgs, so that would hand one workspace another
590
+ // workspace's access token. Register the update under this
591
+ // account's stable workspace identity instead; that key is
592
+ // built from org/account/member ids and does not rotate.
593
+ const identityKey = getWorkspaceIdentityKey(account);
594
+ if (!identityKey.startsWith("refreshToken:")) {
595
+ hydrationUpdatesByIdentity.set(identityKey, update);
596
+ }
502
597
  }
503
598
  }
504
599
  catch {
@@ -515,7 +610,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
515
610
  if (!current)
516
611
  return;
517
612
  for (const acc of current.accounts) {
518
- const update = hydrationUpdates.get(acc.refreshToken);
613
+ // Identity first: it survives a rotation the coordinator already
614
+ // committed. The refresh-token key is the fallback for records
615
+ // that carry no org/account/member id at all.
616
+ const identityKey = getWorkspaceIdentityKey(acc);
617
+ const update = (identityKey.startsWith("refreshToken:")
618
+ ? undefined
619
+ : hydrationUpdatesByIdentity.get(identityKey)) ??
620
+ hydrationUpdates.get(acc.refreshToken);
519
621
  if (!update)
520
622
  continue;
521
623
  if (update.accountId !== undefined)
@@ -524,18 +626,32 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
524
626
  acc.accountIdSource = update.accountIdSource;
525
627
  if (update.email !== undefined)
526
628
  acc.email = update.email;
527
- if (update.accessToken !== undefined)
528
- acc.accessToken = update.accessToken;
529
- if (update.expiresAt !== undefined)
530
- acc.expiresAt = update.expiresAt;
531
629
  if (update.oauthScope !== undefined)
532
630
  acc.oauthScope = update.oauthScope;
533
- // Apply the rotated refresh token LAST so the map key
534
- // (original token) still matches above.
535
- if (update.refreshToken !== undefined)
536
- acc.refreshToken = update.refreshToken;
631
+ // The credential triple is single-use. This transaction runs after
632
+ // the whole (multi-second) hydration loop, so a concurrent process
633
+ // may have rotated the account in the meantime; writing our older
634
+ // tuple back would restore a consumed token and cost a re-login.
635
+ const updateRotatedAt = update.tokenRotatedAt ?? 0;
636
+ const diskRotatedAt = acc.tokenRotatedAt ?? 0;
637
+ if (updateRotatedAt >= diskRotatedAt) {
638
+ if (update.accessToken !== undefined)
639
+ acc.accessToken = update.accessToken;
640
+ if (update.expiresAt !== undefined)
641
+ acc.expiresAt = update.expiresAt;
642
+ // Apply the rotated refresh token LAST so the map key
643
+ // (original token) still matches above.
644
+ if (update.refreshToken !== undefined)
645
+ acc.refreshToken = update.refreshToken;
646
+ if (update.tokenRotatedAt !== undefined)
647
+ acc.tokenRotatedAt = update.tokenRotatedAt;
648
+ }
537
649
  }
538
650
  await persist(current);
651
+ // Return what actually landed on disk, not the in-memory copy: a
652
+ // credential we declined to write above must not resurface through
653
+ // the returned storage either.
654
+ accountsCopy = current.accounts.map((acc) => ({ ...acc }));
539
655
  });
540
656
  storage.accounts = accountsCopy;
541
657
  }
@@ -1001,16 +1117,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1001
1117
  }
1002
1118
  const now = Date.now();
1003
1119
  const account = storage.accounts[index];
1004
- if (account) {
1005
- account.lastUsed = now;
1006
- account.lastSwitchReason = "rotation";
1007
- }
1008
- storage.activeIndex = index;
1009
- storage.activeIndexByFamily = storage.activeIndexByFamily ?? {};
1010
- for (const family of MODEL_FAMILIES) {
1011
- storage.activeIndexByFamily[family] = index;
1012
- }
1013
- await saveAccounts(storage);
1120
+ if (!account)
1121
+ return;
1122
+ const identityKey = getWorkspaceIdentityKey(account);
1123
+ await withAccountStorageTransaction(async (current, persist) => {
1124
+ if (!current)
1125
+ return;
1126
+ const currentIndex = current.accounts.findIndex((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
1127
+ const currentAccount = current.accounts[currentIndex];
1128
+ if (!currentAccount || currentIndex < 0)
1129
+ return;
1130
+ currentAccount.lastUsed = now;
1131
+ currentAccount.lastSwitchReason = "rotation";
1132
+ current.activeIndex = currentIndex;
1133
+ current.activeIndexByFamily = current.activeIndexByFamily ?? {};
1134
+ for (const family of MODEL_FAMILIES) {
1135
+ current.activeIndexByFamily[family] = currentIndex;
1136
+ }
1137
+ await persist(current);
1138
+ });
1014
1139
  await clearPromptQuotaCache();
1015
1140
  // Reload manager from disk so we don't overwrite newer rotated
1016
1141
  // refresh tokens with stale in-memory state.
@@ -1102,6 +1227,28 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1102
1227
  async loader(getAuth, provider) {
1103
1228
  const auth = await getAuth();
1104
1229
  const pluginConfig = loadPluginConfig();
1230
+ let openAIBaseURL;
1231
+ try {
1232
+ openAIBaseURL = resolveOpenAIBaseURL();
1233
+ }
1234
+ catch (err) {
1235
+ // Fail closed rather than silently falling back to the default
1236
+ // endpoint: the operator asked for a gateway, so quietly bypassing
1237
+ // it would send ChatGPT traffic somewhere they did not choose.
1238
+ // Surface the reason the same way storage failures are surfaced.
1239
+ const message = err instanceof Error ? err.message : String(err);
1240
+ if (!customBaseURLErrorShown) {
1241
+ customBaseURLErrorShown = true;
1242
+ await showToast(message, "error");
1243
+ }
1244
+ throw err;
1245
+ }
1246
+ if (openAIBaseURL && !customBaseURLWarningShown) {
1247
+ customBaseURLWarningShown = true;
1248
+ logWarn("Routing ChatGPT OAuth inference through OPENAI_BASE_URL", {
1249
+ origin: new URL(openAIBaseURL).origin,
1250
+ });
1251
+ }
1105
1252
  applyUiRuntimeFromConfig(pluginConfig);
1106
1253
  const perProjectAccounts = getPerProjectAccounts(pluginConfig);
1107
1254
  setStoragePath(perProjectAccounts ? process.cwd() : null);
@@ -1255,7 +1402,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1255
1402
  // Return SDK configuration
1256
1403
  return {
1257
1404
  apiKey: DUMMY_API_KEY,
1258
- baseURL: CODEX_BASE_URL,
1405
+ baseURL: openAIBaseURL ?? CODEX_BASE_URL,
1259
1406
  /**
1260
1407
  * Custom fetch implementation for Codex API
1261
1408
  *
@@ -1278,7 +1425,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1278
1425
  }
1279
1426
  // Step 1: Extract and rewrite URL for Codex backend
1280
1427
  const originalUrl = extractRequestUrl(input);
1281
- const url = rewriteUrlForCodex(originalUrl);
1428
+ const url = openAIBaseURL ? originalUrl : rewriteUrlForCodex(originalUrl);
1282
1429
  // Step 3: Transform request body with model-specific Codex instructions
1283
1430
  // Instructions are fetched per model family (codex-max, codex, gpt-5.4, etc.)
1284
1431
  // Capture original stream value before transformation
@@ -1493,7 +1640,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1493
1640
  let accountAuth = accountManager.toAuthDetails(account);
1494
1641
  try {
1495
1642
  if (shouldRefreshToken(accountAuth, tokenRefreshSkewMs)) {
1496
- accountAuth = (await refreshAndUpdateToken(accountAuth, client));
1643
+ accountAuth = (await refreshAndUpdateToken(accountAuth, client, {
1644
+ organizationId: account.organizationId,
1645
+ accountId: account.accountId,
1646
+ accountUserId: account.accountUserId,
1647
+ }));
1497
1648
  accountManager.updateFromAuth(account, accountAuth);
1498
1649
  accountManager.clearAuthFailures(account);
1499
1650
  accountManager.saveToDiskDebounced();
@@ -1676,6 +1827,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1676
1827
  ...requestInit,
1677
1828
  headers,
1678
1829
  signal: fetchController.signal,
1830
+ ...(openAIBaseURL ? { redirect: "manual" } : {}),
1679
1831
  });
1680
1832
  }
1681
1833
  catch (networkError) {
@@ -1721,6 +1873,42 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1721
1873
  }
1722
1874
  }
1723
1875
  const fetchLatencyMs = Math.round(performance.now() - fetchStart);
1876
+ // `redirect: "manual"` hands back the raw 3xx instead of following it,
1877
+ // so it has to be rejected explicitly. Left alone it would reach
1878
+ // ensureContentType/convertSseToJson as an empty-bodied "success" and
1879
+ // be reported as a content-type or empty-response error that says
1880
+ // nothing about the redirect. Only the Location *origin* is logged;
1881
+ // the full target can carry credentials in its query string.
1882
+ if (openAIBaseURL && response.status >= 300 && response.status < 400) {
1883
+ let redirectTarget = "an unparseable location";
1884
+ const location = response.headers.get("location");
1885
+ if (location) {
1886
+ try {
1887
+ redirectTarget = new URL(location, url).origin;
1888
+ }
1889
+ catch {
1890
+ // keep the placeholder
1891
+ }
1892
+ }
1893
+ else {
1894
+ redirectTarget = "no location header";
1895
+ }
1896
+ logWarn(`OPENAI_BASE_URL gateway attempted a redirect (${response.status}) to ${redirectTarget}`, { origin: new URL(openAIBaseURL).origin });
1897
+ accountManager.refundToken(account, modelFamily, model);
1898
+ return new Response(JSON.stringify({
1899
+ error: {
1900
+ message: `The OPENAI_BASE_URL gateway responded with a ${response.status} redirect to ` +
1901
+ `${redirectTarget}. Redirects are not followed, because the ChatGPT OAuth ` +
1902
+ "access token must never be replayed to an endpoint that was not explicitly " +
1903
+ "configured. Point OPENAI_BASE_URL at the final endpoint instead.",
1904
+ },
1905
+ }), {
1906
+ status: 502,
1907
+ headers: {
1908
+ "content-type": "application/json; charset=utf-8",
1909
+ },
1910
+ });
1911
+ }
1724
1912
  logRequest(LOG_STAGES.RESPONSE, {
1725
1913
  status: response.status,
1726
1914
  ok: response.ok,
@@ -2401,7 +2589,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2401
2589
  }
2402
2590
  }
2403
2591
  if (!accessToken) {
2404
- const refreshResult = await queuedRefresh(account.refreshToken);
2592
+ const refreshResult = await coordinatePersistedRefresh(account);
2405
2593
  if (refreshResult.type !== "success") {
2406
2594
  errors += 1;
2407
2595
  const message = refreshResult.message ?? refreshResult.reason ?? "refresh failed";
@@ -2425,6 +2613,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2425
2613
  account.refreshToken = refreshResult.refresh;
2426
2614
  storageChanged = true;
2427
2615
  }
2616
+ // Carry the coordinator's rotation stamp onto the working
2617
+ // copy so the merge below can tell this snapshot's token
2618
+ // apart from one a concurrent process rotated later.
2619
+ if (typeof refreshResult.rotatedAt === "number" &&
2620
+ refreshResult.rotatedAt !== account.tokenRotatedAt) {
2621
+ account.tokenRotatedAt = refreshResult.rotatedAt;
2622
+ storageChanged = true;
2623
+ }
2428
2624
  if (refreshResult.access && refreshResult.access !== account.accessToken) {
2429
2625
  account.accessToken = refreshResult.access;
2430
2626
  storageChanged = true;
@@ -2551,11 +2747,24 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2551
2747
  // concurrent edit during the network loop is not reverted.
2552
2748
  acc.accountId = updated.accountId;
2553
2749
  acc.accountIdSource = updated.accountIdSource;
2554
- acc.refreshToken = updated.refreshToken;
2555
- acc.accessToken = updated.accessToken;
2556
- acc.expiresAt = updated.expiresAt;
2557
2750
  acc.oauthScope = updated.oauthScope;
2558
2751
  acc.email = updated.email;
2752
+ // The credential triple is single-use and must only move
2753
+ // backwards-in-time never. `workingStorage` was snapshotted
2754
+ // before the (multi-second) network loop, so its token can
2755
+ // already be consumed: a sibling's refresh rotates every
2756
+ // record sharing that token *on disk*, and a concurrent
2757
+ // process can rotate it too. Writing the stale token back
2758
+ // would resurrect a dead credential and cost the user a
2759
+ // re-login with `refresh_token_reused`.
2760
+ const workingRotatedAt = updated.tokenRotatedAt ?? 0;
2761
+ const diskRotatedAt = acc.tokenRotatedAt ?? 0;
2762
+ if (workingRotatedAt >= diskRotatedAt) {
2763
+ acc.refreshToken = updated.refreshToken;
2764
+ acc.accessToken = updated.accessToken;
2765
+ acc.expiresAt = updated.expiresAt;
2766
+ acc.tokenRotatedAt = updated.tokenRotatedAt;
2767
+ }
2559
2768
  }
2560
2769
  merged.push(acc);
2561
2770
  }
@@ -2594,6 +2803,15 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2594
2803
  const maskEmailEnabled = getCodexTuiMaskEmail(loadPluginConfig());
2595
2804
  const remaining = [];
2596
2805
  const restored = [];
2806
+ const processedIdentityKeys = new Set(flaggedStorage.accounts.map((account) => getWorkspaceIdentityKey(account)));
2807
+ const processedRefreshTokens = new Set(flaggedStorage.accounts.map((account) => account.refreshToken));
2808
+ // Only a record we positively restored may be deleted below. A record
2809
+ // can *look* processed without having been restored — its token can be
2810
+ // rotated in place by a sibling's refresh, so neither replacement
2811
+ // lookup finds it — and dropping that record would erase the account
2812
+ // from flagged-accounts.json with no trace anywhere.
2813
+ const restoredIdentityKeys = new Set();
2814
+ const restoredRefreshTokens = new Set();
2597
2815
  for (let i = 0; i < flaggedStorage.accounts.length; i += 1) {
2598
2816
  const flagged = flaggedStorage.accounts[i];
2599
2817
  if (!flagged)
@@ -2629,10 +2847,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2629
2847
  accountLabel: flagged.accountLabel,
2630
2848
  });
2631
2849
  restored.push(...resolved.variantsForPersistence);
2850
+ restoredIdentityKeys.add(getWorkspaceIdentityKey(flagged));
2851
+ restoredRefreshTokens.add(flagged.refreshToken);
2632
2852
  console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED (Codex CLI cache)`);
2633
2853
  continue;
2634
2854
  }
2635
- const refreshResult = await queuedRefresh(flagged.refreshToken);
2855
+ const refreshResult = await coordinateFlaggedPersistedRefresh(flagged);
2636
2856
  if (refreshResult.type !== "success") {
2637
2857
  console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: STILL FLAGGED (${refreshResult.message ?? refreshResult.reason ?? "refresh failed"})`);
2638
2858
  remaining.push(flagged);
@@ -2645,6 +2865,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2645
2865
  accountLabel: flagged.accountLabel,
2646
2866
  });
2647
2867
  restored.push(...resolved.variantsForPersistence);
2868
+ processedRefreshTokens.add(refreshResult.refresh);
2869
+ restoredIdentityKeys.add(getWorkspaceIdentityKey(flagged));
2870
+ restoredRefreshTokens.add(flagged.refreshToken);
2648
2871
  console.log(`[${i + 1}/${flaggedStorage.accounts.length}] ${label}: RESTORED`);
2649
2872
  }
2650
2873
  catch (error) {
@@ -2660,9 +2883,28 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2660
2883
  await persistAccountPool(restored, false);
2661
2884
  invalidateAccountManagerCache();
2662
2885
  }
2663
- await saveFlaggedAccounts({
2664
- version: 1,
2665
- accounts: remaining,
2886
+ const remainingByIdentity = new Map(remaining.map((account) => [getWorkspaceIdentityKey(account), account]));
2887
+ const remainingByRefreshToken = new Map(remaining.map((account) => [account.refreshToken, account]));
2888
+ await withFlaggedAccountStorageTransaction(async (current, persist) => {
2889
+ current.accounts = current.accounts.flatMap((account) => {
2890
+ const identityKey = getWorkspaceIdentityKey(account);
2891
+ const processed = processedIdentityKeys.has(identityKey) ||
2892
+ processedRefreshTokens.has(account.refreshToken);
2893
+ if (!processed)
2894
+ return [account];
2895
+ const replacement = remainingByIdentity.get(identityKey) ??
2896
+ remainingByRefreshToken.get(account.refreshToken);
2897
+ if (replacement) {
2898
+ return [{ ...account, lastError: replacement.lastError }];
2899
+ }
2900
+ // Drop only what was positively restored. Anything else that
2901
+ // merely looks processed is kept: a stale flagged entry is
2902
+ // recoverable, a silently deleted account is not.
2903
+ const wasRestored = restoredIdentityKeys.has(identityKey) ||
2904
+ restoredRefreshTokens.has(account.refreshToken);
2905
+ return wasRestored ? [] : [account];
2906
+ });
2907
+ await persist(current);
2666
2908
  });
2667
2909
  console.log("");
2668
2910
  console.log(`Results: ${restored.length} restored, ${remaining.length} still flagged`);
@@ -2747,12 +2989,20 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2747
2989
  if (typeof menuResult.deleteAccountIndex === "number") {
2748
2990
  const target = workingStorage.accounts[menuResult.deleteAccountIndex];
2749
2991
  if (target) {
2750
- workingStorage.accounts.splice(menuResult.deleteAccountIndex, 1);
2751
- clampActiveIndices(workingStorage);
2752
- await saveAccounts(workingStorage);
2753
- await saveFlaggedAccounts({
2754
- version: 1,
2755
- accounts: flaggedStorage.accounts.filter((flagged) => !matchesWorkspaceIdentity(flagged, getWorkspaceIdentityKey(target))),
2992
+ const identityKey = getWorkspaceIdentityKey(target);
2993
+ await withAccountStorageTransaction(async (current, persist) => {
2994
+ if (!current)
2995
+ return;
2996
+ const currentIndex = current.accounts.findIndex((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
2997
+ if (currentIndex < 0)
2998
+ return;
2999
+ current.accounts.splice(currentIndex, 1);
3000
+ clampActiveIndices(current);
3001
+ await persist(current);
3002
+ });
3003
+ await withFlaggedAccountStorageTransaction(async (current, persist) => {
3004
+ current.accounts = current.accounts.filter((flagged) => !matchesWorkspaceIdentity(flagged, identityKey));
3005
+ await persist(current);
2756
3006
  });
2757
3007
  invalidateAccountManagerCache();
2758
3008
  console.log(`\nDeleted ${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.deleteAccountIndex + 1}`}.\n`);
@@ -2762,10 +3012,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2762
3012
  if (typeof menuResult.toggleAccountIndex === "number") {
2763
3013
  const target = workingStorage.accounts[menuResult.toggleAccountIndex];
2764
3014
  if (target) {
2765
- target.enabled = target.enabled === false ? true : false;
2766
- await saveAccounts(workingStorage);
3015
+ const identityKey = getWorkspaceIdentityKey(target);
3016
+ let enabled = target.enabled !== false;
3017
+ await withAccountStorageTransaction(async (current, persist) => {
3018
+ const currentTarget = current?.accounts.find((candidate) => getWorkspaceIdentityKey(candidate) === identityKey);
3019
+ if (!current || !currentTarget)
3020
+ return;
3021
+ currentTarget.enabled = currentTarget.enabled === false;
3022
+ enabled = currentTarget.enabled;
3023
+ await persist(current);
3024
+ });
2767
3025
  invalidateAccountManagerCache();
2768
- console.log(`\n${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${target.enabled === false ? "disabled" : "enabled"}.\n`);
3026
+ console.log(`\n${resolveDisplayEmail(target.email, maskEmailEnabled) ?? `Account ${menuResult.toggleAccountIndex + 1}`} ${enabled ? "enabled" : "disabled"}.\n`);
2769
3027
  }
2770
3028
  continue;
2771
3029
  }