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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +8 -5
- package/config/schema/config.schema.json +8 -0
- package/dist/lib/accounts.d.ts +5 -0
- package/dist/lib/accounts.js +33 -4
- package/dist/lib/api-route-store.d.ts +7 -0
- package/dist/lib/api-route-store.js +100 -31
- package/dist/lib/auth/account-access.js +2 -1
- package/dist/lib/auth/auth.js +3 -2
- package/dist/lib/auth/device-auth.js +2 -1
- package/dist/lib/auth/http.d.ts +1 -0
- package/dist/lib/auth/http.js +1 -0
- package/dist/lib/codex-manager/account-manager-commands.js +1 -0
- package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
- package/dist/lib/codex-manager/api-login-menu.js +4 -6
- package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
- package/dist/lib/codex-manager/commands/locale.js +25 -0
- package/dist/lib/codex-manager/commands/status.d.ts +5 -1
- package/dist/lib/codex-manager/commands/status.js +501 -13
- package/dist/lib/codex-manager/help.js +3 -1
- package/dist/lib/codex-manager.js +98 -23
- package/dist/lib/config.js +3 -0
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +1 -0
- package/dist/lib/locale.d.ts +7 -0
- package/dist/lib/locale.js +35 -0
- package/dist/lib/model-route-policy.d.ts +1 -0
- package/dist/lib/model-route-policy.js +2 -1
- package/dist/lib/quota-probe.js +2 -1
- package/dist/lib/request/proxy-fetch.d.ts +5 -0
- package/dist/lib/request/proxy-fetch.js +37 -0
- package/dist/lib/request/url-rewriting.js +17 -0
- package/dist/lib/runtime/account-model-catalog.d.ts +3 -0
- package/dist/lib/runtime/account-model-catalog.js +7 -2
- package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
- package/dist/lib/runtime/api-model-capabilities.js +155 -24
- package/dist/lib/runtime/app-bind.d.ts +7 -0
- package/dist/lib/runtime/app-bind.js +92 -1
- package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
- package/dist/lib/runtime/automatic-api-checks.js +188 -0
- package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
- package/dist/lib/runtime/model-discovery-status.js +64 -49
- package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
- package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
- package/dist/lib/runtime/responses-websocket.d.ts +48 -5
- package/dist/lib/runtime/responses-websocket.js +275 -39
- package/dist/lib/runtime/rotation-account-selection.d.ts +25 -5
- package/dist/lib/runtime/rotation-account-selection.js +94 -4
- package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
- package/dist/lib/runtime/rotation-proxy-state.js +376 -4
- package/dist/lib/runtime/rotation-server-types.d.ts +6 -0
- package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
- package/dist/lib/runtime/rotation-token-refresh.js +218 -27
- package/dist/lib/runtime-rotation-proxy.js +601 -105
- package/dist/lib/schemas.d.ts +5 -0
- package/dist/lib/schemas.js +1 -0
- package/dist/lib/storage/credential-sidecars.js +34 -2
- package/dist/lib/storage/file-lock.d.ts +2 -0
- package/dist/lib/storage/file-lock.js +44 -30
- package/dist/lib/usage/ledger.js +1 -0
- package/dist/lib/usage/redaction.js +1 -0
- package/dist/lib/usage/types.d.ts +1 -1
- package/dist/lib/web-dashboard-html.d.ts +1 -0
- package/dist/lib/web-dashboard-html.js +66 -0
- package/dist/lib/web-dashboard.d.ts +36 -0
- package/dist/lib/web-dashboard.js +322 -0
- package/package.json +3 -1
- package/scripts/codex-app-router.js +86 -14
- package/scripts/codex-routing.js +1 -0
- 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.
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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 {
|