codex-multi-auth 2.12.0 → 2.13.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 (35) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +7 -1
  3. package/dist/lib/accounts.d.ts +75 -0
  4. package/dist/lib/accounts.d.ts.map +1 -1
  5. package/dist/lib/accounts.js +213 -42
  6. package/dist/lib/codex-manager/account-manager-commands.d.ts.map +1 -1
  7. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  8. package/dist/lib/codex-manager/commands/limits.d.ts +21 -0
  9. package/dist/lib/codex-manager/commands/limits.d.ts.map +1 -0
  10. package/dist/lib/codex-manager/commands/limits.js +158 -0
  11. package/dist/lib/codex-manager/help.d.ts.map +1 -1
  12. package/dist/lib/codex-manager/help.js +1 -0
  13. package/dist/lib/codex-manager/persist-selected-account.d.ts.map +1 -1
  14. package/dist/lib/codex-manager/persist-selected-account.js +14 -0
  15. package/dist/lib/codex-manager.d.ts.map +1 -1
  16. package/dist/lib/codex-manager.js +12 -0
  17. package/dist/lib/forecast.d.ts.map +1 -1
  18. package/dist/lib/forecast.js +1 -14
  19. package/dist/lib/preemptive-quota-scheduler.d.ts +12 -0
  20. package/dist/lib/preemptive-quota-scheduler.d.ts.map +1 -1
  21. package/dist/lib/preemptive-quota-scheduler.js +21 -0
  22. package/dist/lib/redaction.d.ts +17 -0
  23. package/dist/lib/redaction.d.ts.map +1 -0
  24. package/dist/lib/redaction.js +28 -0
  25. package/dist/lib/runtime/rotation-storage-meta.d.ts.map +1 -1
  26. package/dist/lib/runtime/rotation-storage-meta.js +4 -1
  27. package/dist/lib/runtime-rotation-proxy.d.ts +13 -0
  28. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  29. package/dist/lib/runtime-rotation-proxy.js +47 -7
  30. package/dist/lib/storage.d.ts +20 -2
  31. package/dist/lib/storage.d.ts.map +1 -1
  32. package/dist/lib/storage.js +69 -25
  33. package/package.json +1 -1
  34. package/scripts/codex-routing.js +1 -0
  35. package/scripts/codex.js +157 -2
@@ -898,7 +898,8 @@ export function normalizeAccountStorage(data) {
898
898
  return undefined;
899
899
  if (typeof raw !== "number" ||
900
900
  !Number.isFinite(raw) ||
901
- !Number.isInteger(raw) ||
901
+ // See readAffinityGenerationFromDisk: an unsafe integer wedges the bump.
902
+ !Number.isSafeInteger(raw) ||
902
903
  raw < 0) {
903
904
  log.warn("Dropping invalid affinityGeneration from storage", {
904
905
  value: raw,
@@ -941,7 +942,11 @@ export function readAffinityGenerationFromDisk(path) {
941
942
  const generation = parsed.affinityGeneration;
942
943
  if (typeof generation === "number" &&
943
944
  Number.isFinite(generation) &&
944
- Number.isInteger(generation) &&
945
+ // Safe, not merely integral: past 2^53 `Math.max(gen, disk) + 1 === gen`,
946
+ // so `bumpStorageAffinityGeneration` silently stops advancing and no
947
+ // running proxy ever observes another `switch`. Rejecting the value
948
+ // lets the next bump write 1 and repair the file.
949
+ Number.isSafeInteger(generation) &&
945
950
  generation >= 0) {
946
951
  return generation;
947
952
  }
@@ -978,7 +983,12 @@ export function bumpStorageAffinityGeneration(storage, storagePath) {
978
983
  // No resolvable storage path (never configured). The in-memory counter
979
984
  // is still bumped below; only the lost-update guard is unavailable.
980
985
  }
981
- const inMemoryGeneration = storage.affinityGeneration ?? 0;
986
+ // Guard the in-memory side too, not just the disk read. Past 2^53
987
+ // `Math.max(gen, disk) + 1 === gen`, so an unsafe value here would write the
988
+ // same generation forever and no running proxy would observe another
989
+ // `switch`. Treating it as 0 makes the next write repair the counter.
990
+ const rawInMemory = storage.affinityGeneration ?? 0;
991
+ const inMemoryGeneration = Number.isSafeInteger(rawInMemory) && rawInMemory >= 0 ? rawInMemory : 0;
982
992
  const next = Math.max(inMemoryGeneration, diskGeneration) + 1;
983
993
  storage.affinityGeneration = next;
984
994
  return next;
@@ -990,34 +1000,68 @@ export function bumpStorageAffinityGeneration(storage, storagePath) {
990
1000
  * persisting routine state (rate-limit hits, cooldowns, etc.) so a CLI
991
1001
  * `switch`/`unpin` that landed between proxy startup and the save is not
992
1002
  * clobbered. Returns `pinnedAccountIndex: undefined` and
993
- * `affinityGeneration: 0` on any failure. See issue #474.
1003
+ * `affinityGeneration: 0` on any failure by default. See issue #474.
1004
+ *
1005
+ * `strict` callers additionally need to know when the metadata could not be
1006
+ * OBSERVED AT ALL, because persisting a stale in-memory pin over a newer disk
1007
+ * pin is exactly the #474 regression. Strict mode therefore throws for the two
1008
+ * cases where a newer selection may be hiding behind the failure:
1009
+ * - the file exists but cannot be read (EBUSY/EPERM/EACCES: on Windows an AV
1010
+ * scanner or indexer can hold accounts.json open), and
1011
+ * - the bytes are not valid JSON (a torn read of a concurrent atomic write).
1012
+ *
1013
+ * A MISSING file and a successfully parsed file with INVALID field values are
1014
+ * deliberately not failures, in either mode. Neither can be concealing a newer
1015
+ * pin (there is no file, or we can see the whole file and the field is
1016
+ * unusable), and both are self-repaired by the very write that follows. Making
1017
+ * them throw would wedge persistence permanently, since recreating or repairing
1018
+ * storage goes through the same save path, and it would take every rate-limit
1019
+ * window, cooldown and rotated refresh token in memory down with it.
994
1020
  */
995
- export function readPinAndGenFromDisk(path) {
1021
+ export function readPinAndGenFromDisk(path, options) {
1022
+ const unselected = {
1023
+ pinnedAccountIndex: undefined,
1024
+ affinityGeneration: 0,
1025
+ };
996
1026
  if (!existsSync(path)) {
997
- return { pinnedAccountIndex: undefined, affinityGeneration: 0 };
1027
+ return { ...unselected };
998
1028
  }
1029
+ let parsed;
999
1030
  try {
1000
- const bytes = readFileSync(path);
1001
- const parsed = JSON.parse(bytes.toString("utf8"));
1002
- const rawPin = parsed.pinnedAccountIndex;
1003
- const pinnedAccountIndex = typeof rawPin === "number" &&
1004
- Number.isFinite(rawPin) &&
1005
- Number.isInteger(rawPin) &&
1006
- rawPin >= 0
1007
- ? rawPin
1008
- : undefined;
1009
- const rawGen = parsed.affinityGeneration;
1010
- const affinityGeneration = typeof rawGen === "number" &&
1011
- Number.isFinite(rawGen) &&
1012
- Number.isInteger(rawGen) &&
1013
- rawGen >= 0
1014
- ? rawGen
1015
- : 0;
1016
- return { pinnedAccountIndex, affinityGeneration };
1031
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1017
1032
  }
1018
- catch {
1019
- return { pinnedAccountIndex: undefined, affinityGeneration: 0 };
1033
+ catch (error) {
1034
+ // The file was deleted or renamed between existsSync and the read; same
1035
+ // reasoning as the missing-file branch above.
1036
+ if (error.code === "ENOENT") {
1037
+ return { ...unselected };
1038
+ }
1039
+ if (options?.strict) {
1040
+ throw new Error("Unable to read account selection metadata", {
1041
+ cause: error,
1042
+ });
1043
+ }
1044
+ return { ...unselected };
1045
+ }
1046
+ if (!isRecord(parsed)) {
1047
+ return { ...unselected };
1020
1048
  }
1049
+ const rawPin = parsed.pinnedAccountIndex;
1050
+ const pinnedAccountIndex = typeof rawPin === "number" &&
1051
+ Number.isFinite(rawPin) &&
1052
+ Number.isInteger(rawPin) &&
1053
+ rawPin >= 0
1054
+ ? rawPin
1055
+ : undefined;
1056
+ const rawGen = parsed.affinityGeneration;
1057
+ const affinityGeneration = typeof rawGen === "number" &&
1058
+ Number.isFinite(rawGen) &&
1059
+ // See readAffinityGenerationFromDisk: an unsafe integer wedges the bump.
1060
+ Number.isSafeInteger(rawGen) &&
1061
+ rawGen >= 0
1062
+ ? rawGen
1063
+ : 0;
1064
+ return { pinnedAccountIndex, affinityGeneration };
1021
1065
  }
1022
1066
  /**
1023
1067
  * Loads OAuth accounts from disk storage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Codex CLI multi-account OAuth manager with account switching, health checks, runtime rotation, diagnostics, and recovery tools for @openai/codex",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -2,6 +2,7 @@ const AUTH_SUBCOMMANDS = new Set([
2
2
  "login",
3
3
  "list",
4
4
  "status",
5
+ "limits",
5
6
  "switch",
6
7
  "unpin",
7
8
  "workspace",
package/scripts/codex.js CHANGED
@@ -78,6 +78,8 @@ const APP_RUNTIME_HELPER_USE_CANONICAL_HOME_ENV =
78
78
  "CODEX_MULTI_AUTH_APP_ROTATION_USE_CANONICAL_HOME";
79
79
  const APP_RUNTIME_HELPER_INSTALL_APP_SERVER_SHIM_ENV =
80
80
  "CODEX_MULTI_AUTH_APP_ROTATION_INSTALL_APP_SERVER_SHIM";
81
+ const RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV =
82
+ "CODEX_MULTI_AUTH_RUNTIME_PROXY_UPSTREAM_BASE_URL";
81
83
  const APP_SERVER_CONFIG_ARGS_ENV =
82
84
  "CODEX_MULTI_AUTH_APP_SERVER_CONFIG_ARGS_JSON";
83
85
  const APP_RUNTIME_HELPER_STATUS_FILE =
@@ -168,6 +170,95 @@ let warnedShadowHomeSqliteLinkFailure = false;
168
170
  const warnedShadowHomeLinkOnlyDirectoryFailures = new Set();
169
171
  const warnedShadowHomeSqliteSidecarPlaceholderFailures = new Set();
170
172
 
173
+ /**
174
+ * True only for a NUMERIC loopback literal, as `new URL().hostname` reports it.
175
+ *
176
+ * A name is deliberately not enough. `localhost` is resolved by the OS at
177
+ * connect time, so an /etc/hosts or Windows hosts entry can point it at a
178
+ * routable address, and the upstream request carries the managed OAuth bearer
179
+ * token and the request body. WHATWG URL always reports an IPv6 host in its
180
+ * bracketed form, so `::1` is only ever seen here as `[::1]`.
181
+ */
182
+ function isLoopbackUrlHostname(hostname) {
183
+ if (hostname === "[::1]") {
184
+ return true;
185
+ }
186
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
187
+ if (!ipv4) {
188
+ return false;
189
+ }
190
+ const octets = ipv4.slice(1).map((part) => Number(part));
191
+ if (octets.some((octet) => !Number.isInteger(octet) || octet > 255)) {
192
+ return false;
193
+ }
194
+ // The whole 127.0.0.0/8 block is loopback, not just 127.0.0.1.
195
+ return octets[0] === 127;
196
+ }
197
+
198
+ /**
199
+ * Whether the raw URL text carries an explicit `:port`.
200
+ *
201
+ * `new URL()` erases a port that matches the scheme default, so
202
+ * `http://127.0.0.1:80/x` reports `parsed.port === ""` and a `!parsed.port`
203
+ * check would reject a perfectly valid, explicitly-ported loopback URL.
204
+ */
205
+ function hasExplicitUrlPort(raw) {
206
+ const withoutScheme = raw.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
207
+ const authority = withoutScheme.split(/[/?#]/, 1)[0] ?? "";
208
+ // Strip userinfo first, or a password containing ':' reads as a port.
209
+ const hostAndPort = authority.slice(authority.lastIndexOf("@") + 1);
210
+ if (hostAndPort.startsWith("[")) {
211
+ const close = hostAndPort.indexOf("]");
212
+ return close !== -1 && /^:\d+$/.test(hostAndPort.slice(close + 1));
213
+ }
214
+ const colon = hostAndPort.indexOf(":");
215
+ return colon !== -1 && /^:\d+$/.test(hostAndPort.slice(colon));
216
+ }
217
+
218
+ function resolveRuntimeRotationProxyUpstreamBaseUrl(env = process.env) {
219
+ const raw = (env[RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV] ?? "").trim();
220
+ if (!raw) {
221
+ return undefined;
222
+ }
223
+
224
+ let parsed;
225
+ try {
226
+ parsed = new URL(raw);
227
+ } catch {
228
+ throw new Error(
229
+ `${RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV} must be an absolute loopback HTTP URL.`,
230
+ );
231
+ }
232
+
233
+ if (
234
+ parsed.protocol !== "http:" ||
235
+ !isLoopbackUrlHostname(parsed.hostname.toLowerCase()) ||
236
+ !(parsed.port || hasExplicitUrlPort(raw)) ||
237
+ parsed.username ||
238
+ parsed.password ||
239
+ parsed.search ||
240
+ parsed.hash
241
+ ) {
242
+ throw new Error(
243
+ `${RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV} must use HTTP with a numeric loopback host (127.0.0.0/8 or [::1]; a name such as "localhost" is not accepted because it can resolve off-host), an explicit port, and no credentials, query, or fragment.`,
244
+ );
245
+ }
246
+
247
+ return parsed.toString().replace(/\/$/, "");
248
+ }
249
+
250
+ /**
251
+ * @param clientApiKey Shared secret the wrapper and proxy authenticate with.
252
+ * @param upstreamBaseUrl Already-resolved upstream, or undefined for the
253
+ * default backend. Callers pass the value they resolved rather than the env, so
254
+ * one launch never parses (and never re-reports) the same variable twice.
255
+ */
256
+ function createRuntimeRotationProxyOptions(clientApiKey, upstreamBaseUrl) {
257
+ return upstreamBaseUrl
258
+ ? { clientApiKey, upstreamBaseUrl }
259
+ : { clientApiKey };
260
+ }
261
+
171
262
  async function loadRuntimeConstants() {
172
263
  const fallback = {
173
264
  RUNTIME_ROTATION_PROXY_PROVIDER_ID: `${APP_SERVER_ACCOUNT_DISPLAY_NAME}-runtime-proxy`,
@@ -4711,7 +4802,12 @@ async function runRuntimeRotationAppHelper(identityToken = "") {
4711
4802
  throw new Error("runtime rotation config helpers are unavailable");
4712
4803
  }
4713
4804
  const clientApiKey = createRuntimeRotationProxyClientApiKey();
4714
- proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey });
4805
+ proxyServer = await proxyModule.startRuntimeRotationProxy(
4806
+ createRuntimeRotationProxyOptions(
4807
+ clientApiKey,
4808
+ resolveRuntimeRotationProxyUpstreamBaseUrl(),
4809
+ ),
4810
+ );
4715
4811
  const useCanonicalHome =
4716
4812
  (process.env[APP_RUNTIME_HELPER_USE_CANONICAL_HOME_ENV] ?? "").trim() ===
4717
4813
  "1";
@@ -5112,13 +5208,52 @@ async function createRuntimeRotationProxyContextIfEnabled(
5112
5208
  baseContext,
5113
5209
  rawArgs,
5114
5210
  ) {
5211
+ // Resolve the configured upstream BEFORE the enabled gate. Every path that
5212
+ // leaves this function with `baseContext` forwards Responses traffic, and its
5213
+ // managed OAuth bearer token, straight to the real backend. An explicit
5214
+ // upstream is a routing requirement rather than a rotation preference, so
5215
+ // `CODEX_MULTI_AUTH_RUNTIME_ROTATION_PROXY=0`, `CODEX_MULTI_AUTH_BYPASS=1`, a
5216
+ // missing `dist/lib/config.js` or a disabled setting must all fail closed
5217
+ // here instead of silently reaching chatgpt.com.
5218
+ let configuredUpstreamBaseUrl;
5219
+ try {
5220
+ configuredUpstreamBaseUrl =
5221
+ resolveRuntimeRotationProxyUpstreamBaseUrl(baseContext.env);
5222
+ } catch (error) {
5223
+ baseContext.cleanup?.();
5224
+ return {
5225
+ startupError: `codex-multi-auth runtime rotation upstream is invalid: ${
5226
+ error instanceof Error ? error.message : String(error)
5227
+ }`,
5228
+ };
5229
+ }
5230
+ // The one exception is a subcommand that never reaches the backend at all
5231
+ // (`--version`, `login`, ...): there is no traffic to misroute, so it passes
5232
+ // through with the upstream simply unused.
5233
+ const requireConfiguredUpstream =
5234
+ configuredUpstreamBaseUrl !== undefined &&
5235
+ shouldUseRuntimeRoutingForForwardedArgs(rawArgs);
5236
+
5115
5237
  const enabled = await isRuntimeRotationProxyEnabled(rawArgs, baseContext.env);
5116
5238
  if (!enabled) {
5239
+ if (requireConfiguredUpstream) {
5240
+ baseContext.cleanup?.();
5241
+ return {
5242
+ startupError: `codex-multi-auth runtime rotation is disabled, so ${RUNTIME_ROTATION_PROXY_UPSTREAM_BASE_URL_ENV} cannot be honored and requests would go to the direct backend instead. Enable runtime rotation or unset that variable.`,
5243
+ };
5244
+ }
5117
5245
  return baseContext;
5118
5246
  }
5119
5247
 
5120
5248
  const configTomlModule = await loadRuntimeConfigTomlModule();
5121
5249
  if (!configTomlModule) {
5250
+ if (requireConfiguredUpstream) {
5251
+ baseContext.cleanup?.();
5252
+ return {
5253
+ startupError:
5254
+ "codex-multi-auth runtime rotation config helpers are unavailable; configured upstream routing cannot continue.",
5255
+ };
5256
+ }
5122
5257
  console.error(
5123
5258
  "codex-multi-auth runtime rotation config helpers are unavailable; continuing without runtime rotation.",
5124
5259
  );
@@ -5188,6 +5323,13 @@ async function createRuntimeRotationProxyContextIfEnabled(
5188
5323
 
5189
5324
  const proxyModule = await loadRuntimeRotationProxyModule();
5190
5325
  if (!proxyModule) {
5326
+ if (requireConfiguredUpstream) {
5327
+ baseContext.cleanup?.();
5328
+ return {
5329
+ startupError:
5330
+ "codex-multi-auth runtime rotation proxy is unavailable; configured upstream routing cannot continue.",
5331
+ };
5332
+ }
5191
5333
  console.error(
5192
5334
  "codex-multi-auth runtime rotation proxy is unavailable; continuing without runtime rotation.",
5193
5335
  );
@@ -5198,7 +5340,12 @@ async function createRuntimeRotationProxyContextIfEnabled(
5198
5340
  let shadowContext;
5199
5341
  try {
5200
5342
  const clientApiKey = createRuntimeRotationProxyClientApiKey();
5201
- proxyServer = await proxyModule.startRuntimeRotationProxy({ clientApiKey });
5343
+ proxyServer = await proxyModule.startRuntimeRotationProxy(
5344
+ createRuntimeRotationProxyOptions(
5345
+ clientApiKey,
5346
+ configuredUpstreamBaseUrl,
5347
+ ),
5348
+ );
5202
5349
  shadowContext = createRuntimeRotationProxyCodexHome(
5203
5350
  baseContext.env,
5204
5351
  proxyServer.baseUrl,
@@ -5211,6 +5358,14 @@ async function createRuntimeRotationProxyContextIfEnabled(
5211
5358
  } catch {
5212
5359
  // Best-effort cleanup only.
5213
5360
  }
5361
+ if (requireConfiguredUpstream) {
5362
+ baseContext.cleanup?.();
5363
+ return {
5364
+ startupError: `codex-multi-auth runtime rotation proxy failed to start with configured upstream: ${
5365
+ error instanceof Error ? error.message : String(error)
5366
+ }`,
5367
+ };
5368
+ }
5214
5369
  console.error(
5215
5370
  `codex-multi-auth runtime rotation proxy failed to start; continuing without runtime rotation: ${error instanceof Error ? error.message : String(error)}`,
5216
5371
  );