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,4 +1,5 @@
1
1
  import { promises as fs } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { join } from "node:path";
3
4
  import { getApiRoutesPath } from "../api-route-store.js";
4
5
  import { withRetry } from "../fs-retry.js";
@@ -13,18 +14,49 @@ const retry = { maxAttempts: 6, backoffMs: 25 };
13
14
  */
14
15
  export async function clearCredentialSidecars() {
15
16
  const routes = getApiRoutesPath();
16
- // Same locks as saveApiRoutes, so a concurrent menu save cannot resurrect keys.
17
- await withStorageLock(() => withFileTransactionLock(routes, () => withRetry(() => fs.rm(routes, { force: true }), retry)));
18
17
  const dir = getCodexMultiAuthDir();
18
+ // Same locks as saveApiRoutes, so a concurrent menu save cannot resurrect keys.
19
+ await withStorageLock(() => withFileTransactionLock(routes, async () => {
20
+ await withRetry(() => fs.rm(routes, { force: true }), retry);
21
+ const leases = join(dir, "api-probe-locks");
22
+ try {
23
+ const stat = await fs.lstat(leases);
24
+ if (!stat.isDirectory() || stat.isSymbolicLink())
25
+ throw Error("Invalid API probe lease directory; manual repair required");
26
+ // Detach while publication is excluded; never recursively delete a
27
+ // canonical directory that a replacement owner can populate.
28
+ await withRetry(() => fs.rename(leases, join(dir, `api-probe-locks.wiped-${randomUUID()}`)), retry);
29
+ }
30
+ catch (error) {
31
+ if (error.code !== "ENOENT")
32
+ throw error;
33
+ }
34
+ }));
19
35
  // Each writer read-modify-writes under its file lock; deleting under the same
20
36
  // lock stops an in-flight refresh or redemption renaming its old state (and a
21
37
  // last-resort policy) back after the reset.
22
38
  for (const path of [
23
39
  join(dir, "reset-credits.json"),
24
40
  join(dir, "api-capability-probes.json"),
41
+ join(dir, "automatic-api-checks.json"),
25
42
  ]) {
26
43
  await withFileTransactionLock(path, () => withRetry(() => fs.rm(path, { force: true }), retry));
27
44
  }
45
+ // Also finish deletion after an interrupted earlier wipe. These unique paths
46
+ // can contain old holders/candidates, but never a new canonical lease.
47
+ let leftovers;
48
+ try {
49
+ leftovers = await fs.readdir(dir);
50
+ }
51
+ catch (error) {
52
+ if (error.code !== "ENOENT")
53
+ throw error;
54
+ leftovers = [];
55
+ }
56
+ for (const entry of leftovers) {
57
+ if (/^api-probe-locks\.wiped-[a-f0-9-]{36}$/.test(entry))
58
+ await withRetry(() => fs.rm(join(dir, entry), { recursive: true, force: true }), retry);
59
+ }
28
60
  // Per-account timestamps only; their writers take no lock and hold no secrets.
29
61
  const activity = join(dir, "inference-activity");
30
62
  await withRetry(() => fs.rm(activity, { recursive: true, force: true }), retry);
@@ -5,4 +5,6 @@
5
5
  */
6
6
  export declare function withFileTransactionLock<T>(path: string, action: () => Promise<T>, options?: {
7
7
  waitMs?: number;
8
+ signal?: AbortSignal;
9
+ guardAcquisition?: <R>(action: () => Promise<R>) => Promise<R>;
8
10
  }): Promise<T>;
@@ -145,21 +145,32 @@ async function recoverDeadCandidates(lock) {
145
145
  * A reused PID fails closed (bounded contention) rather than risking two writers.
146
146
  */
147
147
  export async function withFileTransactionLock(path, action, options = {}) {
148
- const absolute = resolve(path);
149
- await fs.mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
150
- // Canonicalize the existing parent so /tmp and its symlink aliases share a lock.
151
- const key = join(await fs.realpath(dirname(absolute)), absolute.slice(dirname(absolute).length + 1));
152
- if (leases.getStore()?.get(key)?.active)
153
- return action();
154
- const lock = `${key}.write-lock`;
155
- await recoverDeadCandidates(lock);
156
- const candidate = await fs.mkdtemp(`${lock}.candidate-`);
148
+ options.signal?.throwIfAborted();
149
+ const guard = options.guardAcquisition ?? (async (run) => run());
150
+ let key = "", lock = "", candidate;
157
151
  const owner = `${host}.${process.pid}.${randomUUID()}`;
158
- let published = false;
152
+ let published = false, reentrant = false;
159
153
  try {
160
- await fs.chmod(candidate, 0o700);
161
- await fs.writeFile(join(candidate, owner), "", { flag: "wx", mode: 0o600 });
162
- await withRetry(async () => {
154
+ await guard(async () => {
155
+ options.signal?.throwIfAborted();
156
+ const absolute = resolve(path);
157
+ await fs.mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
158
+ key = join(await fs.realpath(dirname(absolute)), absolute.slice(dirname(absolute).length + 1));
159
+ options.signal?.throwIfAborted();
160
+ if (leases.getStore()?.get(key)?.active) {
161
+ reentrant = true;
162
+ return;
163
+ }
164
+ lock = `${key}.write-lock`;
165
+ await recoverDeadCandidates(lock);
166
+ candidate = await fs.mkdtemp(`${lock}.candidate-`);
167
+ await fs.chmod(candidate, 0o700);
168
+ await fs.writeFile(join(candidate, owner), "", { flag: "wx", mode: 0o600 });
169
+ });
170
+ if (reentrant)
171
+ return action();
172
+ await withRetry(() => guard(async () => {
173
+ options.signal?.throwIfAborted();
163
174
  try {
164
175
  await fs.rename(candidate, lock);
165
176
  published = true;
@@ -179,12 +190,13 @@ export async function withFileTransactionLock(path, action, options = {}) {
179
190
  await recoverDeadOwner(lock);
180
191
  throw Object.assign(Error("Storage is busy with another live writer; retry shortly."), { code: "ELOCKED" });
181
192
  }
182
- }, {
193
+ }), {
183
194
  maxAttempts: lockMaxAttemptsForWaitMs(options.waitMs ?? 10000),
184
195
  backoffMs: lockRetryBaseDelayMs,
185
196
  jitterMs: LOCK_RETRY_JITTER_MS,
186
197
  retryableCodes: ["ELOCKED", "EBUSY", "EPERM", "EACCES", "EAGAIN"],
187
198
  });
199
+ options.signal?.throwIfAborted();
188
200
  const lease = { active: true };
189
201
  const context = new Map(leases.getStore());
190
202
  context.set(key, lease);
@@ -197,22 +209,24 @@ export async function withFileTransactionLock(path, action, options = {}) {
197
209
  }
198
210
  finally {
199
211
  const directory = published ? lock : candidate;
200
- try {
201
- await withRetry(async () => {
202
- try {
203
- await fs.unlink(join(directory, owner));
204
- }
205
- catch (error) {
206
- if (code(error) !== "ENOENT")
207
- throw error;
208
- }
209
- await removeEmpty(directory);
210
- }, { maxAttempts: 6, backoffMs: 25 });
211
- }
212
- catch (error) {
213
- abandonedOwners.add(owner);
214
- retryAbandonedRelease(directory, owner);
215
- logWarn("Storage lock cleanup deferred", { code: code(error) ?? "unknown" });
212
+ if (directory) {
213
+ try {
214
+ await withRetry(async () => {
215
+ try {
216
+ await fs.unlink(join(directory, owner));
217
+ }
218
+ catch (error) {
219
+ if (code(error) !== "ENOENT")
220
+ throw error;
221
+ }
222
+ await removeEmpty(directory);
223
+ }, { maxAttempts: 6, backoffMs: 25 });
224
+ }
225
+ catch (error) {
226
+ abandonedOwners.add(owner);
227
+ retryAbandonedRelease(directory, owner);
228
+ logWarn("Storage lock cleanup deferred", { code: code(error) ?? "unknown" });
229
+ }
216
230
  }
217
231
  }
218
232
  }
@@ -24,6 +24,7 @@ const VALID_OPERATIONS = new Set([
24
24
  "images",
25
25
  "models",
26
26
  "thread-goal",
27
+ "transcribe",
27
28
  "auth-refresh",
28
29
  "diagnostic",
29
30
  "unknown",
@@ -13,6 +13,7 @@ const VALID_OPERATIONS = new Set([
13
13
  "images",
14
14
  "models",
15
15
  "thread-goal",
16
+ "transcribe",
16
17
  "auth-refresh",
17
18
  "diagnostic",
18
19
  "unknown",
@@ -1,5 +1,5 @@
1
1
  export type UsageLedgerSource = "runtime-proxy" | "plugin-host" | "local-bridge" | "cli" | "unknown";
2
- export type UsageLedgerOperation = "responses" | "images" | "models" | "thread-goal" | "auth-refresh" | "diagnostic" | "unknown";
2
+ export type UsageLedgerOperation = "responses" | "images" | "models" | "thread-goal" | "transcribe" | "auth-refresh" | "diagnostic" | "unknown";
3
3
  export type UsageLedgerOutcome = "success" | "failure" | "blocked" | "cancelled";
4
4
  /**
5
5
  * The service tier a response was actually billed at.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.20.0",
3
+ "version": "2.22.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",
@@ -139,6 +139,27 @@ function writeStatus(statusPath, payload) {
139
139
  }
140
140
  }
141
141
 
142
+ // Status history is untrusted local JSON; retain only safe operational fields.
143
+ function readDisconnect(value) {
144
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
145
+ const { at, cause, stage, closeCode, routeKind, connectionAgeMs, eventCount } = value;
146
+ if (typeof at !== "number" || !Number.isFinite(at) || at < 0 || at > 8.64e15) return null;
147
+ if (!["close", "transport_error", "invalid_event", "buffer_limit", "send_error"].includes(cause)) return null;
148
+ if (!["awaiting_first_event", "streaming"].includes(stage)) return null;
149
+ if (routeKind !== undefined && !["oauth", "api", "zdr"].includes(routeKind)) return null;
150
+ if (closeCode !== undefined && (!Number.isInteger(closeCode) || closeCode < 1000 || closeCode > 4999)) return null;
151
+ for (const count of [connectionAgeMs, eventCount]) {
152
+ if (count !== undefined && (!Number.isSafeInteger(count) || count < 0)) return null;
153
+ }
154
+ return {
155
+ at, cause, stage,
156
+ ...(closeCode !== undefined ? { closeCode } : {}),
157
+ ...(routeKind !== undefined ? { routeKind } : {}),
158
+ ...(connectionAgeMs !== undefined ? { connectionAgeMs } : {}),
159
+ ...(eventCount !== undefined ? { eventCount } : {}),
160
+ };
161
+ }
162
+
142
163
  function createStatusPayload({
143
164
  state,
144
165
  proxyServer,
@@ -148,9 +169,13 @@ function createStatusPayload({
148
169
  statusPath,
149
170
  routerScriptPath,
150
171
  identityToken,
172
+ previousDisconnect = null,
151
173
  }) {
152
174
  const proxyStatus =
153
175
  typeof proxyServer?.getStatus === "function" ? proxyServer.getStatus() : {};
176
+ const currentDisconnect = readDisconnect(proxyStatus.lastWebsocketDisconnect);
177
+ // A current-process event is newer history even after wall-clock rollback.
178
+ const lastDisconnect = currentDisconnect ?? previousDisconnect;
154
179
  const lastAccountIndex = proxyStatus.lastAccountIndex ?? null;
155
180
  const lastAccountLabel =
156
181
  typeof proxyStatus.lastAccountLabel === "string" &&
@@ -175,10 +200,12 @@ function createStatusPayload({
175
200
  null,
176
201
  updatedAt: Date.now(),
177
202
  baseUrl: proxyServer?.baseUrl ?? stateRecord?.baseUrl ?? null,
203
+ schedulingStrategy: proxyStatus.schedulingStrategy,
178
204
  totalRequests: proxyStatus.totalRequests ?? 0,
179
205
  upstreamRequests: proxyStatus.upstreamRequests ?? 0,
180
206
  websocketConnections: proxyStatus.websocketConnections ?? 0,
181
207
  websocketUpstreamRequests: proxyStatus.websocketUpstreamRequests ?? 0,
208
+ lastWebsocketDisconnect: lastDisconnect,
182
209
  streamQuotaUpdates: proxyStatus.streamQuotaUpdates ?? 0,
183
210
  lastStreamQuotaUpdateAt: proxyStatus.lastStreamQuotaUpdateAt ?? null,
184
211
  retries: proxyStatus.retries ?? 0,
@@ -266,6 +293,7 @@ async function main() {
266
293
  const args = parseArgs(process.argv.slice(2));
267
294
  installLogBounds(args.maxLogBytes, args.logPath).unref?.();
268
295
  const stateRecord = readState(args.statePath);
296
+ let previousDisconnect = readDisconnect(readState(args.statusPath || stateRecord?.statusPath || "")?.lastWebsocketDisconnect);
269
297
  if (args.statePath && stateRecord === null) {
270
298
  const error = new Error(
271
299
  "Codex app runtime router state is unreadable; refusing to bind an ephemeral port.",
@@ -273,6 +301,7 @@ async function main() {
273
301
  writeStatus(
274
302
  args.statusPath,
275
303
  createStatusPayload({
304
+ previousDisconnect,
276
305
  state: "error",
277
306
  proxyServer: null,
278
307
  error,
@@ -310,20 +339,19 @@ async function main() {
310
339
 
311
340
  let proxyServer = null;
312
341
  const writeCurrentStatus = (state, error) => {
313
- writeStatus(
314
- args.statusPath || stateRecord?.statusPath || "",
315
- createStatusPayload({
316
- state,
317
- proxyServer,
318
- error,
319
- stateRecord,
320
- startedAt: routerStartedAt,
321
- statusPath: args.statusPath || stateRecord?.statusPath || "",
322
- routerScriptPath: stateRecord?.routerScriptPath || process.argv[1],
323
- identityToken:
324
- args.identityToken || stateRecord?.identityToken || "",
325
- }),
326
- );
342
+ const payload = createStatusPayload({
343
+ previousDisconnect,
344
+ state,
345
+ proxyServer,
346
+ error,
347
+ stateRecord,
348
+ startedAt: routerStartedAt,
349
+ statusPath: args.statusPath || stateRecord?.statusPath || "",
350
+ routerScriptPath: stateRecord?.routerScriptPath || process.argv[1],
351
+ identityToken: args.identityToken || stateRecord?.identityToken || "",
352
+ });
353
+ previousDisconnect = payload.lastWebsocketDisconnect;
354
+ writeStatus(args.statusPath || stateRecord?.statusPath || "", payload);
327
355
  };
328
356
 
329
357
  try {