codex-multi-auth 2.21.0 → 2.23.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 (70) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +8 -5
  3. package/config/schema/config.schema.json +8 -0
  4. package/dist/lib/accounts.d.ts +5 -0
  5. package/dist/lib/accounts.js +33 -4
  6. package/dist/lib/api-route-store.d.ts +7 -0
  7. package/dist/lib/api-route-store.js +100 -31
  8. package/dist/lib/auth/account-access.js +2 -1
  9. package/dist/lib/auth/auth.js +3 -2
  10. package/dist/lib/auth/device-auth.js +2 -1
  11. package/dist/lib/auth/http.d.ts +1 -0
  12. package/dist/lib/auth/http.js +1 -0
  13. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  14. package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
  15. package/dist/lib/codex-manager/api-login-menu.js +4 -6
  16. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  17. package/dist/lib/codex-manager/commands/locale.js +25 -0
  18. package/dist/lib/codex-manager/commands/status.d.ts +5 -1
  19. package/dist/lib/codex-manager/commands/status.js +501 -13
  20. package/dist/lib/codex-manager/help.js +3 -1
  21. package/dist/lib/codex-manager.js +98 -23
  22. package/dist/lib/config.js +3 -0
  23. package/dist/lib/constants.d.ts +1 -0
  24. package/dist/lib/constants.js +1 -0
  25. package/dist/lib/locale.d.ts +7 -0
  26. package/dist/lib/locale.js +35 -0
  27. package/dist/lib/model-route-policy.d.ts +1 -0
  28. package/dist/lib/model-route-policy.js +2 -1
  29. package/dist/lib/quota-probe.js +2 -1
  30. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  31. package/dist/lib/request/proxy-fetch.js +37 -0
  32. package/dist/lib/request/url-rewriting.js +17 -0
  33. package/dist/lib/runtime/account-model-catalog.d.ts +3 -0
  34. package/dist/lib/runtime/account-model-catalog.js +7 -2
  35. package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
  36. package/dist/lib/runtime/api-model-capabilities.js +155 -24
  37. package/dist/lib/runtime/app-bind.d.ts +7 -0
  38. package/dist/lib/runtime/app-bind.js +92 -1
  39. package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
  40. package/dist/lib/runtime/automatic-api-checks.js +188 -0
  41. package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
  42. package/dist/lib/runtime/model-discovery-status.js +64 -49
  43. package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
  44. package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
  45. package/dist/lib/runtime/responses-websocket.d.ts +48 -5
  46. package/dist/lib/runtime/responses-websocket.js +275 -39
  47. package/dist/lib/runtime/rotation-account-selection.d.ts +25 -5
  48. package/dist/lib/runtime/rotation-account-selection.js +94 -4
  49. package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
  50. package/dist/lib/runtime/rotation-proxy-state.js +376 -4
  51. package/dist/lib/runtime/rotation-server-types.d.ts +6 -0
  52. package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
  53. package/dist/lib/runtime/rotation-token-refresh.js +218 -27
  54. package/dist/lib/runtime-rotation-proxy.js +601 -105
  55. package/dist/lib/schemas.d.ts +5 -0
  56. package/dist/lib/schemas.js +1 -0
  57. package/dist/lib/storage/credential-sidecars.js +34 -2
  58. package/dist/lib/storage/file-lock.d.ts +2 -0
  59. package/dist/lib/storage/file-lock.js +44 -30
  60. package/dist/lib/usage/ledger.js +1 -0
  61. package/dist/lib/usage/redaction.js +1 -0
  62. package/dist/lib/usage/types.d.ts +1 -1
  63. package/dist/lib/web-dashboard-html.d.ts +1 -0
  64. package/dist/lib/web-dashboard-html.js +66 -0
  65. package/dist/lib/web-dashboard.d.ts +36 -0
  66. package/dist/lib/web-dashboard.js +322 -0
  67. package/package.json +3 -1
  68. package/scripts/codex-app-router.js +86 -14
  69. package/scripts/codex-routing.js +1 -0
  70. package/scripts/codex.js +150 -13
@@ -0,0 +1,322 @@
1
+ import { createServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import { renderDashboardHtml } from "./web-dashboard-html.js";
4
+ import { localized, resolveOutputLocale } from "./locale.js";
5
+ function isLoopbackHost(host) {
6
+ const normalized = host.trim().toLowerCase();
7
+ return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1" || normalized === "[::1]";
8
+ }
9
+ function parsePort(value) {
10
+ const port = value === undefined ? 4317 : Number(value);
11
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
12
+ throw new Error("Web port must be an integer between 0 and 65535.");
13
+ }
14
+ return port;
15
+ }
16
+ function unauthorized(response) {
17
+ response.writeHead(401, {
18
+ "content-type": "application/json; charset=utf-8",
19
+ "cache-control": "no-store",
20
+ "www-authenticate": "Bearer",
21
+ });
22
+ response.end(JSON.stringify({ error: "Unauthorized" }));
23
+ }
24
+ function authorized(request, token, url) {
25
+ if (!token)
26
+ return true;
27
+ const header = request.headers.authorization;
28
+ const bearer = typeof header === "string" && header.startsWith("Bearer ")
29
+ ? header.slice("Bearer ".length).trim()
30
+ : "";
31
+ return bearer === token || url.searchParams.get("token") === token;
32
+ }
33
+ /**
34
+ * Reject requests addressed to a foreign Host. A loopback listener without a
35
+ * token is otherwise reachable via DNS rebinding: a hostile page resolving its
36
+ * own domain to 127.0.0.1 would read the pool status as same-origin. Token-gated
37
+ * binds skip the check — the bearer token is already the guard there.
38
+ */
39
+ function requestHostAllowed(request, host, token) {
40
+ if (token)
41
+ return true;
42
+ const bound = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
43
+ if (!isLoopbackHost(bound))
44
+ return true;
45
+ const hostHeader = request.headers.host;
46
+ if (typeof hostHeader !== "string" || !hostHeader.trim())
47
+ return true;
48
+ let hostname = hostHeader.trim();
49
+ const bracketed = hostname.match(/^\[(.*)\](?::\d+)?$/);
50
+ hostname = (bracketed ? bracketed[1] ?? "" : hostname.replace(/:\d+$/, "")).toLowerCase();
51
+ return isLoopbackHost(hostname);
52
+ }
53
+ function sendJson(response, value, status = 200) {
54
+ response.writeHead(status, {
55
+ "content-type": "application/json; charset=utf-8",
56
+ "cache-control": "no-store",
57
+ "x-content-type-options": "nosniff",
58
+ });
59
+ response.end(JSON.stringify(value));
60
+ }
61
+ function openBrowserDefault(url) {
62
+ const command = process.platform === "darwin"
63
+ ? { file: "open", args: [url] }
64
+ : process.platform === "win32"
65
+ ? { file: "cmd", args: ["/c", "start", "", url] }
66
+ : { file: "xdg-open", args: [url] };
67
+ try {
68
+ const child = spawn(command.file, command.args, {
69
+ detached: true,
70
+ stdio: "ignore",
71
+ });
72
+ // A missing opener (e.g. no xdg-open on a headless box) fails
73
+ // asynchronously: without a listener the process-level default would
74
+ // tear down the already-running dashboard.
75
+ child.on("error", () => undefined);
76
+ child.unref();
77
+ }
78
+ catch {
79
+ // The server remains usable when no graphical opener is installed.
80
+ }
81
+ }
82
+ function parseArgs(args) {
83
+ let host = "127.0.0.1";
84
+ let port = 4317;
85
+ let token = (process.env.CODEX_MULTI_AUTH_WEB_TOKEN ?? "").trim() || null;
86
+ let refreshMs = 5000;
87
+ let noOpen = false;
88
+ for (let i = 0; i < args.length; i += 1) {
89
+ const arg = args[i] ?? "";
90
+ const next = () => {
91
+ const value = args[i + 1];
92
+ i += 1;
93
+ return value;
94
+ };
95
+ if (arg === "--host")
96
+ host = (next() ?? "").trim();
97
+ else if (arg.startsWith("--host="))
98
+ host = arg.slice("--host=".length).trim();
99
+ else if (arg === "--port")
100
+ port = parsePort(next());
101
+ else if (arg.startsWith("--port="))
102
+ port = parsePort(arg.slice("--port=".length));
103
+ else if (arg === "--token")
104
+ token = (next() ?? "").trim() || null;
105
+ else if (arg.startsWith("--token="))
106
+ token = arg.slice("--token=".length).trim() || null;
107
+ else if (arg === "--interval")
108
+ refreshMs = Math.max(1000, Number(next() ?? 5000));
109
+ else if (arg.startsWith("--interval="))
110
+ refreshMs = Math.max(1000, Number(arg.slice("--interval=".length)));
111
+ else if (arg === "--no-open")
112
+ noOpen = true;
113
+ else if (arg === "--help" || arg === "-h") {
114
+ throw new Error("Usage: codex-multi-auth web [--host 127.0.0.1] [--port 4317] [--token <token>] [--interval <ms>] [--no-open]");
115
+ }
116
+ else {
117
+ throw new Error("Unknown web option: " + arg);
118
+ }
119
+ }
120
+ if (!host)
121
+ throw new Error("Web host cannot be empty.");
122
+ if (!Number.isFinite(refreshMs) || refreshMs < 1000)
123
+ throw new Error("Web refresh interval must be at least 1000 ms.");
124
+ if (!isLoopbackHost(host) && !token) {
125
+ throw new Error("Binding the web dashboard beyond loopback requires --token or CODEX_MULTI_AUTH_WEB_TOKEN.");
126
+ }
127
+ return { host, port, token, refreshMs: Math.round(refreshMs), noOpen };
128
+ }
129
+ export async function startWebServer(options) {
130
+ const host = options.host?.trim() || "127.0.0.1";
131
+ const port = options.port ?? 4317;
132
+ const refreshMs = Math.max(1000, options.refreshMs ?? 5000);
133
+ const token = options.token?.trim() || null;
134
+ if (!isLoopbackHost(host) && !token) {
135
+ throw new Error("Binding the web dashboard beyond loopback requires a token.");
136
+ }
137
+ let latest = null;
138
+ let refreshPromise = null;
139
+ const clients = new Set();
140
+ const refresh = async () => {
141
+ if (!refreshPromise) {
142
+ refreshPromise = options.getStatus().finally(() => {
143
+ refreshPromise = null;
144
+ });
145
+ }
146
+ latest = await refreshPromise;
147
+ return latest;
148
+ };
149
+ const broadcast = (snapshot) => {
150
+ const message = "data: " + JSON.stringify(snapshot) + "\n\n";
151
+ for (const client of clients) {
152
+ try {
153
+ if (client.write(message))
154
+ continue;
155
+ // A client that stopped reading would otherwise accumulate one full
156
+ // snapshot per refresh forever. end() would stall behind its own
157
+ // unsent queue, so hard-close the connection instead.
158
+ clients.delete(client);
159
+ client.destroy();
160
+ client.socket?.destroy();
161
+ options.onSseClientDrop?.(client);
162
+ }
163
+ catch {
164
+ clients.delete(client);
165
+ }
166
+ }
167
+ };
168
+ // The request handler intentionally awaits the status read before answering;
169
+ // this keeps one response atomic while the server continues accepting SSE
170
+ // connections. Node's callback type is void-returning, so mark the promise
171
+ // boundary explicitly for the linter.
172
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
173
+ const server = createServer(async (request, response) => {
174
+ try {
175
+ if (!requestHostAllowed(request, host, token)) {
176
+ sendJson(response, { error: "Host header not allowed" }, 403);
177
+ return;
178
+ }
179
+ let requestUrl;
180
+ try {
181
+ requestUrl = new URL(request.url ?? "/", "http://" + (request.headers.host ?? host));
182
+ }
183
+ catch {
184
+ sendJson(response, { error: "Malformed request" }, 400);
185
+ return;
186
+ }
187
+ if (!authorized(request, token, requestUrl)) {
188
+ unauthorized(response);
189
+ return;
190
+ }
191
+ if (requestUrl.pathname === "/" || requestUrl.pathname === "/index.html") {
192
+ response.writeHead(200, {
193
+ "content-type": "text/html; charset=utf-8",
194
+ "cache-control": "no-store",
195
+ "x-content-type-options": "nosniff",
196
+ "content-security-policy": "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'",
197
+ });
198
+ response.end(renderDashboardHtml(token, options.locale));
199
+ return;
200
+ }
201
+ if (requestUrl.pathname === "/api/health") {
202
+ sendJson(response, { ok: true, service: "codex-multi-auth-web" });
203
+ return;
204
+ }
205
+ if (requestUrl.pathname === "/api/status") {
206
+ try {
207
+ sendJson(response, await refresh());
208
+ }
209
+ catch (error) {
210
+ sendJson(response, { error: error instanceof Error ? error.message : String(error) }, 503);
211
+ }
212
+ return;
213
+ }
214
+ if (requestUrl.pathname === "/events") {
215
+ // Cleanup must register before the awaited refresh: a disconnect
216
+ // during that read would otherwise leave the response registered
217
+ // until server shutdown.
218
+ response.on("close", () => clients.delete(response));
219
+ response.writeHead(200, {
220
+ "content-type": "text/event-stream; charset=utf-8",
221
+ "cache-control": "no-cache, no-store",
222
+ connection: "keep-alive",
223
+ "x-accel-buffering": "no",
224
+ });
225
+ response.write("retry: " + refreshMs + "\n\n");
226
+ clients.add(response);
227
+ try {
228
+ response.write("data: " + JSON.stringify(await refresh()) + "\n\n");
229
+ }
230
+ catch {
231
+ response.end();
232
+ }
233
+ return;
234
+ }
235
+ sendJson(response, { error: "Not found" }, 404);
236
+ }
237
+ catch {
238
+ try {
239
+ sendJson(response, { error: "Internal error" }, 500);
240
+ }
241
+ catch {
242
+ response.destroy();
243
+ }
244
+ }
245
+ });
246
+ await new Promise((resolve, reject) => {
247
+ const onError = (error) => { server.off("listening", onListening); reject(error); };
248
+ const onListening = () => { server.off("error", onError); resolve(); };
249
+ server.once("error", onError);
250
+ server.once("listening", onListening);
251
+ server.listen(port, host);
252
+ });
253
+ const address = server.address();
254
+ const actualPort = typeof address === "object" && address ? address.port : port;
255
+ const hostForUrl = host.includes(":") && !host.startsWith("[") ? "[" + host + "]" : host;
256
+ const baseUrl = "http://" + hostForUrl + ":" + actualPort + "/";
257
+ const url = token ? baseUrl + "?token=" + encodeURIComponent(token) : baseUrl;
258
+ const interval = setInterval(() => { void refresh().then(broadcast).catch(() => undefined); }, refreshMs);
259
+ interval.unref();
260
+ return {
261
+ server,
262
+ url,
263
+ token,
264
+ close: async () => {
265
+ clearInterval(interval);
266
+ for (const client of clients)
267
+ client.end();
268
+ clients.clear();
269
+ await new Promise((resolve) => server.close(() => resolve()));
270
+ },
271
+ };
272
+ }
273
+ export async function runWebCommand(args, deps) {
274
+ if (args.includes("--help") || args.includes("-h")) {
275
+ (deps.logInfo ?? ((message) => process.stdout.write(message + "\n")))("Usage: codex-multi-auth web [--host 127.0.0.1] [--port 4317] [--token <token>] [--interval <ms>] [--no-open]");
276
+ return 0;
277
+ }
278
+ let parsed;
279
+ try {
280
+ parsed = parseArgs(args);
281
+ }
282
+ catch (error) {
283
+ (deps.logInfo ?? ((message) => process.stderr.write(message + "\n")))(error instanceof Error ? error.message : String(error));
284
+ return 1;
285
+ }
286
+ const logInfo = deps.logInfo ?? ((message) => process.stdout.write(message + "\n"));
287
+ const locale = deps.locale ?? resolveOutputLocale();
288
+ let handle;
289
+ try {
290
+ handle = await startWebServer({
291
+ ...parsed,
292
+ getStatus: deps.getStatus,
293
+ openBrowser: deps.openBrowser,
294
+ locale,
295
+ });
296
+ }
297
+ catch (error) {
298
+ (deps.logInfo ?? ((message) => process.stderr.write(message + "\n")))(error instanceof Error ? error.message : String(error));
299
+ return 1;
300
+ }
301
+ logInfo("Codex Multi-Auth web dashboard: " + handle.url);
302
+ logInfo(localized(locale, `Live refresh every ${parsed.refreshMs} ms; press Ctrl-C to stop.`, `实时刷新间隔:${parsed.refreshMs} ms;按 Ctrl-C 停止。`));
303
+ if (!parsed.noOpen)
304
+ (deps.openBrowser ?? openBrowserDefault)(handle.url);
305
+ return await new Promise((resolve) => {
306
+ let stopped = false;
307
+ const stop = () => {
308
+ if (stopped)
309
+ return;
310
+ stopped = true;
311
+ process.removeListener("SIGINT", stop);
312
+ process.removeListener("SIGTERM", stop);
313
+ void handle.close().finally(() => resolve(0));
314
+ };
315
+ process.once("SIGINT", stop);
316
+ process.once("SIGTERM", stop);
317
+ });
318
+ }
319
+ export function parseWebCommandArgs(args) {
320
+ const parsed = parseArgs(args);
321
+ return { ...parsed, openBrowser: !parsed.noOpen };
322
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.21.0",
3
+ "version": "2.23.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",
@@ -116,6 +116,7 @@
116
116
  },
117
117
  "bin": {
118
118
  "mcodex": "scripts/mcodex.js",
119
+ "codex-ma": "scripts/codex-multi-auth.js",
119
120
  "codex-multi-auth-codex": "scripts/codex.js",
120
121
  "codex-multi-auth-app-launcher": "scripts/codex-app-launcher.js",
121
122
  "codex-multi-auth": "scripts/codex-multi-auth.js"
@@ -169,6 +170,7 @@
169
170
  "fast-check": "^4.10.2",
170
171
  "husky": "^9.1.7",
171
172
  "lint-staged": "^17.6.0",
173
+ "playwright": "^1.64.1",
172
174
  "tsgo": "npm:typescript@^7.0.2",
173
175
  "typescript": "^6.0.3",
174
176
  "typescript-language-server": "^6.0.1",
@@ -139,6 +139,61 @@ 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
+ const transport = {};
147
+ for (const key of ["requestBytes", "inputItemCount"]) {
148
+ const field = value[key];
149
+ if (field !== undefined) {
150
+ if (typeof field !== "number" || !Number.isSafeInteger(field) || field < 0) return null;
151
+ transport[key] = field;
152
+ }
153
+ }
154
+ for (const key of ["isPrewarm", "hasPreviousResponse", "reusedConnection", "sendCompleted", "compressionNegotiated", "tcpEndReceived", "tcpCloseHadError"]) {
155
+ const field = value[key];
156
+ if (field !== undefined) {
157
+ if (typeof field !== "boolean") return null;
158
+ transport[key] = field;
159
+ }
160
+ }
161
+ if (value.transportErrorCode !== undefined) {
162
+ if (!["ECONNRESET", "EPIPE", "ETIMEDOUT", "OTHER"].includes(value.transportErrorCode)) return null;
163
+ transport.transportErrorCode = value.transportErrorCode;
164
+ }
165
+ if (typeof at !== "number" || !Number.isFinite(at) || at < 0 || at > 8.64e15) return null;
166
+ if (!["close", "transport_error", "invalid_event", "buffer_limit", "send_error"].includes(cause)) return null;
167
+ if (!["awaiting_first_event", "streaming"].includes(stage)) return null;
168
+ if (routeKind !== undefined && !["oauth", "api", "zdr"].includes(routeKind)) return null;
169
+ if (closeCode !== undefined && (!Number.isInteger(closeCode) || closeCode < 1000 || closeCode > 4999)) return null;
170
+ for (const count of [connectionAgeMs, eventCount]) {
171
+ if (count !== undefined && (!Number.isSafeInteger(count) || count < 0)) return null;
172
+ }
173
+ return {
174
+ at, cause, stage,
175
+ ...(closeCode !== undefined ? { closeCode } : {}),
176
+ ...(routeKind !== undefined ? { routeKind } : {}),
177
+ ...(connectionAgeMs !== undefined ? { connectionAgeMs } : {}),
178
+ ...(eventCount !== undefined ? { eventCount } : {}),
179
+ ...transport,
180
+ };
181
+ }
182
+
183
+ function readWebsocketCounter(value, maximum = Number.MAX_SAFE_INTEGER) {
184
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum
185
+ ? value : null;
186
+ }
187
+
188
+ function readUpgradeRejection(value) {
189
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
190
+ const { at, reason, status, activeConnections } = value;
191
+ if (readWebsocketCounter(at, 8.64e15) === null || readWebsocketCounter(activeConnections, 64) === null) return null;
192
+ if (!((reason === "origin" && status === 403) ||
193
+ ((reason === "capacity" || reason === "repeated_disconnect") && status === 426))) return null;
194
+ return { at, reason, status, activeConnections };
195
+ }
196
+
142
197
  function createStatusPayload({
143
198
  state,
144
199
  proxyServer,
@@ -148,9 +203,15 @@ function createStatusPayload({
148
203
  statusPath,
149
204
  routerScriptPath,
150
205
  identityToken,
206
+ previousDisconnect = null,
207
+ previousUpgradeRejection = null,
151
208
  }) {
152
209
  const proxyStatus =
153
210
  typeof proxyServer?.getStatus === "function" ? proxyServer.getStatus() : {};
211
+ const currentDisconnect = readDisconnect(proxyStatus.lastWebsocketDisconnect);
212
+ // A current-process event is newer history even after wall-clock rollback.
213
+ const lastDisconnect = currentDisconnect ?? previousDisconnect;
214
+ const lastUpgradeRejection = readUpgradeRejection(proxyStatus.lastWebsocketUpgradeRejection) ?? previousUpgradeRejection;
154
215
  const lastAccountIndex = proxyStatus.lastAccountIndex ?? null;
155
216
  const lastAccountLabel =
156
217
  typeof proxyStatus.lastAccountLabel === "string" &&
@@ -175,10 +236,15 @@ function createStatusPayload({
175
236
  null,
176
237
  updatedAt: Date.now(),
177
238
  baseUrl: proxyServer?.baseUrl ?? stateRecord?.baseUrl ?? null,
239
+ schedulingStrategy: proxyStatus.schedulingStrategy,
178
240
  totalRequests: proxyStatus.totalRequests ?? 0,
179
241
  upstreamRequests: proxyStatus.upstreamRequests ?? 0,
180
242
  websocketConnections: proxyStatus.websocketConnections ?? 0,
181
243
  websocketUpstreamRequests: proxyStatus.websocketUpstreamRequests ?? 0,
244
+ websocketActiveConnections: readWebsocketCounter(proxyStatus.websocketActiveConnections, 64) ?? 0,
245
+ websocketCapacityReclaims: readWebsocketCounter(proxyStatus.websocketCapacityReclaims) ?? 0,
246
+ lastWebsocketUpgradeRejection: lastUpgradeRejection,
247
+ lastWebsocketDisconnect: lastDisconnect,
182
248
  streamQuotaUpdates: proxyStatus.streamQuotaUpdates ?? 0,
183
249
  lastStreamQuotaUpdateAt: proxyStatus.lastStreamQuotaUpdateAt ?? null,
184
250
  retries: proxyStatus.retries ?? 0,
@@ -266,6 +332,9 @@ async function main() {
266
332
  const args = parseArgs(process.argv.slice(2));
267
333
  installLogBounds(args.maxLogBytes, args.logPath).unref?.();
268
334
  const stateRecord = readState(args.statePath);
335
+ const previousStatus = readState(args.statusPath || stateRecord?.statusPath || "");
336
+ let previousDisconnect = readDisconnect(previousStatus?.lastWebsocketDisconnect);
337
+ let previousUpgradeRejection = readUpgradeRejection(previousStatus?.lastWebsocketUpgradeRejection);
269
338
  if (args.statePath && stateRecord === null) {
270
339
  const error = new Error(
271
340
  "Codex app runtime router state is unreadable; refusing to bind an ephemeral port.",
@@ -273,6 +342,8 @@ async function main() {
273
342
  writeStatus(
274
343
  args.statusPath,
275
344
  createStatusPayload({
345
+ previousDisconnect,
346
+ previousUpgradeRejection,
276
347
  state: "error",
277
348
  proxyServer: null,
278
349
  error,
@@ -310,20 +381,21 @@ async function main() {
310
381
 
311
382
  let proxyServer = null;
312
383
  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
- );
384
+ const payload = createStatusPayload({
385
+ previousDisconnect,
386
+ previousUpgradeRejection,
387
+ state,
388
+ proxyServer,
389
+ error,
390
+ stateRecord,
391
+ startedAt: routerStartedAt,
392
+ statusPath: args.statusPath || stateRecord?.statusPath || "",
393
+ routerScriptPath: stateRecord?.routerScriptPath || process.argv[1],
394
+ identityToken: args.identityToken || stateRecord?.identityToken || "",
395
+ });
396
+ previousDisconnect = payload.lastWebsocketDisconnect;
397
+ previousUpgradeRejection = payload.lastWebsocketUpgradeRejection;
398
+ writeStatus(args.statusPath || stateRecord?.statusPath || "", payload);
327
399
  };
328
400
 
329
401
  try {
@@ -2,6 +2,7 @@ const AUTH_SUBCOMMANDS = new Set([
2
2
  "login",
3
3
  "list",
4
4
  "status",
5
+ "web",
5
6
  "limits",
6
7
  "switch",
7
8
  "unpin",