codex-multi-auth 2.22.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 (43) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +4 -2
  3. package/config/schema/config.schema.json +8 -0
  4. package/dist/lib/api-route-store.d.ts +1 -0
  5. package/dist/lib/api-route-store.js +2 -1
  6. package/dist/lib/auth/account-access.js +2 -1
  7. package/dist/lib/auth/auth.js +3 -2
  8. package/dist/lib/auth/device-auth.js +2 -1
  9. package/dist/lib/auth/http.d.ts +1 -0
  10. package/dist/lib/auth/http.js +1 -0
  11. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  12. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  13. package/dist/lib/codex-manager/commands/locale.js +25 -0
  14. package/dist/lib/codex-manager/commands/status.d.ts +4 -0
  15. package/dist/lib/codex-manager/commands/status.js +476 -5
  16. package/dist/lib/codex-manager/help.js +3 -1
  17. package/dist/lib/codex-manager.js +98 -29
  18. package/dist/lib/config.js +3 -0
  19. package/dist/lib/locale.d.ts +7 -0
  20. package/dist/lib/locale.js +35 -0
  21. package/dist/lib/quota-probe.js +2 -1
  22. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  23. package/dist/lib/request/proxy-fetch.js +37 -0
  24. package/dist/lib/request/url-rewriting.js +17 -0
  25. package/dist/lib/runtime/account-model-catalog.d.ts +2 -0
  26. package/dist/lib/runtime/account-model-catalog.js +7 -2
  27. package/dist/lib/runtime/app-bind.d.ts +4 -1
  28. package/dist/lib/runtime/app-bind.js +63 -3
  29. package/dist/lib/runtime/responses-websocket.d.ts +30 -0
  30. package/dist/lib/runtime/responses-websocket.js +243 -29
  31. package/dist/lib/runtime/rotation-account-selection.d.ts +5 -4
  32. package/dist/lib/runtime/rotation-server-types.d.ts +4 -1
  33. package/dist/lib/runtime-rotation-proxy.js +30 -9
  34. package/dist/lib/schemas.d.ts +5 -0
  35. package/dist/lib/schemas.js +1 -0
  36. package/dist/lib/web-dashboard-html.d.ts +1 -0
  37. package/dist/lib/web-dashboard-html.js +66 -0
  38. package/dist/lib/web-dashboard.d.ts +36 -0
  39. package/dist/lib/web-dashboard.js +322 -0
  40. package/package.json +3 -1
  41. package/scripts/codex-app-router.js +45 -1
  42. package/scripts/codex-routing.js +1 -0
  43. package/scripts/codex.js +150 -13
@@ -0,0 +1,66 @@
1
+ const DASHBOARD_HTML = `
2
+ <!doctype html>
3
+ <html lang="zh-CN">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <title>Codex Multi-Auth · 号池监控</title>
8
+ <style>
9
+ :root{color-scheme:dark;--bg:#09111f;--panel:#101c2f;--panel2:#14243b;--line:#223653;--text:#edf5ff;--muted:#8ea5c2;--green:#36d399;--amber:#f7c95c;--red:#fb7185;--blue:#65a8ff;--shadow:0 14px 40px rgba(0,0,0,.25)}
10
+ *{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#17345a 0,transparent 38%),var(--bg);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
11
+ .shell{max-width:1440px;margin:0 auto;padding:28px 26px 48px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:26px}.brand{display:flex;gap:14px;align-items:center}.mark{width:44px;height:44px;border-radius:13px;display:grid;place-items:center;background:linear-gradient(135deg,#4b8dff,#55d6ae);color:#07111f;font-weight:900;font-size:18px;box-shadow:0 8px 24px #3b82f655}.eyebrow{color:var(--blue);font-size:12px;letter-spacing:.12em;text-transform:uppercase}.title{font-size:28px;font-weight:800;letter-spacing:-.03em}.subtitle{color:var(--muted);margin-top:2px}.actions{display:flex;gap:10px;align-items:center}.button{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:9px;padding:9px 13px;cursor:pointer}.button:hover{border-color:var(--blue);background:var(--panel2)}.live{display:flex;gap:7px;align-items:center;color:var(--muted);font-size:12px}.dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 10px var(--green)}.dot.off{background:var(--red);box-shadow:0 0 10px var(--red)}
12
+ .summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:22px}.card,.panel{background:linear-gradient(145deg,#122139dd,#0e192add);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.card{padding:17px}.label{color:var(--muted);font-size:12px}.value{font-size:26px;font-weight:800;margin-top:7px}.value.green{color:var(--green)}.value.amber{color:var(--amber)}.value.red{color:var(--red)}.value.blue{color:var(--blue)}
13
+ .grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,.7fr);gap:18px;align-items:start}.panel{padding:18px}.panel-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.panel-title{font-size:16px;font-weight:750}.panel-meta{color:var(--muted);font-size:12px}.accounts{display:grid;gap:10px}.account{padding:15px;border:1px solid var(--line);border-radius:11px;background:#0e1a2caa}.account-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.account-name{font-weight:700;overflow-wrap:anywhere}.account-meta{color:var(--muted);font-size:12px;margin-top:3px}.badge{border-radius:99px;padding:3px 9px;font-size:11px;white-space:nowrap;border:1px solid #ffffff18}.badge.ready{color:var(--green);background:#36d39918}.badge.login{color:var(--red);background:#fb718518}.badge.disabled{color:var(--muted);background:#8ea5c218}.badge.current{color:var(--blue);background:#65a8ff18}.account-stats{display:grid;grid-template-columns:1fr 1fr 120px;gap:15px;margin-top:14px}.quota{min-width:0}.quota-row{display:flex;justify-content:space-between;gap:7px;color:var(--muted);font-size:11px;margin-top:8px}.bar{height:8px;border-radius:99px;background:#243752;overflow:hidden;margin-top:5px}.fill{height:100%;border-radius:99px;background:linear-gradient(90deg,var(--green),#7de2ab)}.fill.warn{background:linear-gradient(90deg,var(--amber),#ff9e5c)}.fill.danger{background:linear-gradient(90deg,var(--red),#ffadad)}.risk{text-align:right}.risk-num{font-size:23px;font-weight:800}.risk-low{color:var(--green)}.risk-medium{color:var(--amber)}.risk-high{color:var(--red)}.reason{color:var(--muted);font-size:12px;margin-top:8px;overflow-wrap:anywhere}.empty{padding:35px;text-align:center;color:var(--muted)}.subhead{margin:14px 0 4px;color:var(--muted);font-size:11px;letter-spacing:.1em;text-transform:uppercase}.side{display:grid;gap:18px}.kv{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #ffffff0d}.kv:last-child{border-bottom:0}.kv b{font-weight:650;text-align:right;overflow-wrap:anywhere}.notice{padding:12px;border-radius:10px;background:#f7c95c13;border:1px solid #f7c95c3d;color:#fbe6a2;font-size:12px}.error{padding:12px;border-radius:10px;background:#fb718513;border:1px solid #fb71853d;color:#ffc2cb;font-size:12px}.foot{color:var(--muted);font-size:12px;margin-top:20px;text-align:center}
14
+ @media(max-width:1050px){.summary{grid-template-columns:repeat(3,minmax(0,1fr))}.grid{grid-template-columns:1fr}.side{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:680px){.shell{padding:18px 13px 36px}.top{display:block}.actions{margin-top:15px;justify-content:space-between}.summary{grid-template-columns:repeat(2,minmax(0,1fr))}.account-stats{grid-template-columns:1fr 1fr}.risk{text-align:left}.side{grid-template-columns:1fr}.title{font-size:23px}}
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <main class="shell">
19
+ <header class="top"><div class="brand"><div class="mark">MA</div><div><div class="eyebrow">Codex Multi-Auth</div><div class="title" data-i18n="title">号池实时监控</div><div class="subtitle" data-i18n="subtitle">账号、配额与轮换状态</div></div></div><div class="actions"><div class="live"><span id="dot" class="dot"></span><span id="connection" data-i18n="connecting">连接中…</span></div><button id="lang" class="button">EN</button><button id="refresh" class="button" data-i18n="refresh">刷新</button></div></header>
20
+ <section id="summary" class="summary"></section>
21
+ <div id="error" class="error" hidden></div>
22
+ <section class="grid"><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="accounts">账号池</div><div id="updated" class="panel-meta">—</div></div><div id="accounts" class="accounts"></div></section><aside class="side"><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="routing">路由</div></div><div id="routing"></div></section><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="runtime">运行时</div></div><div id="runtime"></div></section></aside></section>
23
+ <div class="foot" data-i18n="foot">数据来自本机号池;页面默认仅监听回环地址,按 Ctrl-C 停止服务。</div>
24
+ </main>
25
+ <script>
26
+ (function(){
27
+ var token="__CMA_TOKEN__";
28
+ var lang="__CMA_LANG__";
29
+ var text={zh:{title:"号池实时监控",subtitle:"账号、配额与轮换状态",connecting:"连接中…",connected:"实时连接",offline:"连接中断",refresh:"刷新",accounts:"账号池",apiAccounts:"API/ZDR 账号",routing:"路由",runtime:"运行时",total:"总账号",ready:"可用",login:"需重新登录",disabled:"已禁用",enabled:"已启用",recommend:"推荐账号",active:"当前账号",requests:"请求",success:"成功",failed:"失败",last:"最近请求",quota:"配额",risk:"风险",unknown:"未知",reason:"原因",primary:"主窗口",secondary:"次窗口",priority:"优先级",models:"可见模型",foot:"数据来自本机号池;页面默认仅监听回环地址,按 Ctrl-C 停止服务。"},en:{title:"Pool live monitor",subtitle:"Accounts, quota and routing",connecting:"Connecting…",connected:"Live",offline:"Disconnected",refresh:"Refresh",accounts:"Account pool",apiAccounts:"API/ZDR accounts",routing:"Routing",runtime:"Runtime",total:"Accounts",ready:"Ready",login:"Re-login needed",disabled:"Disabled",enabled:"Enabled",recommend:"Recommended",active:"Current",requests:"Requests",success:"Success",failed:"Failed",last:"Last request",quota:"Quota",risk:"Risk",unknown:"Unknown",reason:"Reason",primary:"primary",secondary:"secondary",priority:"priority",models:"visible models",foot:"Data comes from the local pool; the server listens on loopback by default. Ctrl-C stops it."}};
30
+ function t(k){return text[lang][k]||k}
31
+ function label(v){var s=String(v==null?"":v);return lang==="zh"?s.replace(/^Account /,"账号 ").replace(/Token account/g,"令牌账号").replace(/, id:/g,",ID:").replace(/, /g,","):s}
32
+ function reasonText(v){var s=String(v==null?"—":v);if(lang!=="zh")return s;return s.replace("token-invalid — re-login needed","令牌无效——需要重新登录").replace("primary quota ","主窗口配额 ").replace(" secondary quota "," 次窗口配额 ").replace(" used","已使用").replace("account is disabled","账号已禁用").replace("quota cache exhausted","配额缓存已用尽")}
33
+ function esc(v){return String(v==null?"":v).replace(/[&<>"']/g,function(c){return({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]})}
34
+ function api(path){return path+(path.indexOf("?")>=0?"&":"?")+"token="+encodeURIComponent(token)}
35
+ function ago(ms){if(typeof ms!=="number"||!isFinite(ms))return "—";var s=Math.max(0,Math.round(ms/1000));if(s<60)return s+"s";if(s<3600)return Math.floor(s/60)+"m";if(s<86400)return Math.floor(s/3600)+"h";return Math.floor(s/86400)+"d"}
36
+ function statusClass(a){var m=(a.markers||[]).join(" ");if(a.enabled===false)return "disabled";if(/invalid|sign-in|re-login/i.test(m))return "login";return "ready"}
37
+ function winLabel(w,key){var m=w&&w.windowMinutes;if(typeof m==="number"&&isFinite(m)&&m>0){var zh=lang==="zh";if(m%1440===0)return (m/1440)+(zh?"天":"d");if(m%60===0)return (m/60)+(zh?"小时":"h");return m+(zh?"分钟":"m")}return t(key)}
38
+ function bar(window,key){if(!window||typeof window.usedPercent!=="number")return '<div class="label">'+t("unknown")+"</div>";var p=Math.max(0,Math.min(100,Math.round(window.usedPercent)));var cls=p>=90?" danger":p>=75?" warn":"";return '<div class="quota-row"><span>'+esc(winLabel(window,key))+" "+t("quota")+'</span><span>'+p+"% / "+(100-p)+"%</span></div><div class="bar"><div class="fill'+cls+'" style="width:'+p+'%"></div></div>'}
39
+ function draw(data){window.__data=data;var all=data.accounts||[];var ready=all.filter(function(a){return a.enabled!==false&&statusClass(a)==="ready"}).length;var login=all.filter(function(a){return statusClass(a)==="login"}).length;var disabled=all.filter(function(a){return a.enabled===false}).length;var rec=data.recommendedIndex==null?"—":"#"+(data.recommendedIndex+1);var rt=data.runtime||{};document.getElementById("summary").innerHTML='<div class="card"><div class="label">'+t("total")+'</div><div class="value blue">'+(data.totalAccountCount||all.length)+'</div></div><div class="card"><div class="label">'+t("ready")+'</div><div class="value green">'+ready+'</div></div><div class="card"><div class="label">'+t("login")+'</div><div class="value red">'+login+'</div></div><div class="card"><div class="label">'+t("disabled")+'</div><div class="value">'+disabled+'</div></div><div class="card"><div class="label">'+t("recommend")+'</div><div class="value amber">'+rec+'</div></div>';
40
+ document.getElementById("accounts").innerHTML=all.length?all.map(function(a){var sub=a.subscription||{};var q=a.quota||{};var risk=a.forecastRiskScore==null?"—":a.forecastRiskScore+"/100";var rl=a.forecastRiskLevel||"low";var badges=(a.markers||[]).map(function(m){return '<span class="badge '+(/current/.test(m)?"current":statusClass(a))+'">'+esc(label(m))+'</span>'}).join(" ");return '<article class="account"><div class="account-head"><div><div class="account-name">'+esc(label(a.label||("Account "+(a.index+1))))+'</div><div class="account-meta">'+esc(sub.planLabel||t("unknown"))+" · "+esc(label(sub.selectedWorkspace||""))+'</div></div><div>'+badges+' <span class="badge '+statusClass(a)+'">'+(statusClass(a)==="ready"?t("ready"):statusClass(a)==="login"?t("login"):t("disabled"))+'</span></div></div><div class="account-stats"><div class="quota">'+bar(q.primary,"primary")+bar(q.secondary,"secondary")+'</div><div><div class="label">'+t("last")+'</div><div class="value" style="font-size:18px">'+ago(a.lastInferenceRequestAt?Date.now()-a.lastInferenceRequestAt:null)+'</div><div class="reason">'+t("reason")+": "+esc(reasonText(a.reason||"—"))+'</div></div><div class="risk"><div class="label">'+t("risk")+'</div><div class="risk-num risk-'+esc(rl)+'">'+esc(risk)+"</div></div></div></article>"}).join(""):'<div class="empty">'+t("unknown")+"</div>";
41
+ var apis=(data.apiAccounts||[]).map(function(a){return '<article class="account"><div class="account-head"><div><div class="account-name">'+esc(label(a.label||"API"))+'</div><div class="account-meta">'+esc(a.kind||"api")+" · "+t("priority")+" "+esc(a.priority==null?"—":a.priority)+'</div></div><div><span class="badge '+(a.enabled===false?"disabled":"ready")+'">'+(a.enabled===false?t("disabled"):t("enabled"))+'</span></div></div><div class="account-meta">'+t("models")+": "+esc((a.visibleModels||[]).length||"—")+" · "+t("last")+": "+ago(a.lastInferenceRequestAt?Date.now()-a.lastInferenceRequestAt:null)+'</div></article>'}).join("");
42
+ if(apis)document.getElementById("accounts").innerHTML+='<div class="subhead">'+t("apiAccounts")+"</div>"+apis;
43
+ var recReason=data.recommendationReason||"—";document.getElementById("routing").innerHTML='<div class="kv"><span>'+t("active")+'</span><b>'+esc(data.activeIndex==null?"—":"#"+(data.activeIndex+1))+'</b></div><div class="kv"><span>'+t("recommend")+'</span><b>'+esc(rec)+"</b></div><div class="kv"><span>'+t("reason")+'</span><b>'+esc(recReason)+'</b></div>';
44
+ document.getElementById("runtime").innerHTML='<div class="kv"><span>'+t("requests")+'</span><b>'+esc(rt.responsesRequests==null?"—":rt.responsesRequests)+'</b></div><div class="kv"><span>'+t("success")+'</span><b>'+esc(rt.successfulRequests==null?"—":rt.successfulRequests)+'</b></div><div class="kv"><span>'+t("failed")+'</span><b>'+esc(rt.failedRequests==null?"—":rt.failedRequests)+'</b></div><div class="kv"><span>'+t("last")+'</span><b>'+esc(data.runtimeInUseIndex==null?"—":"#"+(data.runtimeInUseIndex+1))+'</b></div>';
45
+ document.getElementById("updated").textContent=new Date().toLocaleTimeString();document.getElementById("error").hidden=true;document.getElementById("dot").className="dot";document.getElementById("connection").textContent=t("connected")
46
+ }
47
+ function setLang(){document.documentElement.lang=lang==="zh"?"zh-CN":"en";document.querySelectorAll("[data-i18n]").forEach(function(e){e.textContent=t(e.getAttribute("data-i18n"))});document.getElementById("lang").textContent=lang==="zh"?"EN":"中文";if(window.__data)draw(window.__data)}
48
+ function failed(e){document.getElementById("error").hidden=false;document.getElementById("error").textContent=e&&e.message?e.message:"Request failed";document.getElementById("dot").className="dot off";document.getElementById("connection").textContent=t("offline")}
49
+ function load(){fetch(api("/api/status"),{cache:"no-store"}).then(function(r){if(!r.ok)throw Error("HTTP "+r.status);return r.json()}).then(draw).catch(failed)}
50
+ document.getElementById("refresh").onclick=load;document.getElementById("lang").onclick=function(){lang=lang==="zh"?"en":"zh";setLang()};setLang();load();try{var es=new EventSource(api("/events"));es.onmessage=function(e){try{draw(JSON.parse(e.data))}catch(_){}};es.onerror=function(){document.getElementById("dot").className="dot off";document.getElementById("connection").textContent=t("offline")}}catch(_){setInterval(load,5000)}
51
+ })();
52
+ </script>
53
+ </body>
54
+ </html>`;
55
+ export function renderDashboardHtml(token, locale = "zh") {
56
+ const safeToken = JSON.stringify(token ?? "")
57
+ .slice(1, -1)
58
+ .replace(/</g, "\\u003c")
59
+ .replace(/>/g, "\\u003e")
60
+ .replace(/&/g, "\\u0026")
61
+ .replace(/\u2028/g, "\\u2028")
62
+ .replace(/\u2029/g, "\\u2029");
63
+ return DASHBOARD_HTML
64
+ .replace("__CMA_TOKEN__", safeToken)
65
+ .replace("__CMA_LANG__", locale === "zh" ? "zh" : "en");
66
+ }
@@ -0,0 +1,36 @@
1
+ import { type Server, type ServerResponse } from "node:http";
2
+ import { type OutputLocale } from "./locale.js";
3
+ export interface WebStatusSnapshot {
4
+ [key: string]: unknown;
5
+ }
6
+ export interface WebServerOptions {
7
+ host?: string;
8
+ port?: number;
9
+ token?: string | null;
10
+ refreshMs?: number;
11
+ getStatus: () => Promise<WebStatusSnapshot>;
12
+ openBrowser?: (url: string) => void;
13
+ locale?: OutputLocale;
14
+ /** Called when an SSE client is dropped for falling behind on broadcasts. */
15
+ onSseClientDrop?: (client: ServerResponse) => void;
16
+ }
17
+ export interface WebServerHandle {
18
+ server: Server;
19
+ url: string;
20
+ token: string | null;
21
+ close: () => Promise<void>;
22
+ }
23
+ export declare function startWebServer(options: WebServerOptions): Promise<WebServerHandle>;
24
+ export declare function runWebCommand(args: string[], deps: {
25
+ getStatus: () => Promise<WebStatusSnapshot>;
26
+ logInfo?: (message: string) => void;
27
+ openBrowser?: (url: string) => void;
28
+ locale?: OutputLocale;
29
+ }): Promise<number>;
30
+ export declare function parseWebCommandArgs(args: string[]): {
31
+ host: string;
32
+ port: number;
33
+ token: string | null;
34
+ refreshMs: number;
35
+ openBrowser: boolean;
36
+ };
@@ -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.22.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",
@@ -143,6 +143,25 @@ function writeStatus(statusPath, payload) {
143
143
  function readDisconnect(value) {
144
144
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
145
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
+ }
146
165
  if (typeof at !== "number" || !Number.isFinite(at) || at < 0 || at > 8.64e15) return null;
147
166
  if (!["close", "transport_error", "invalid_event", "buffer_limit", "send_error"].includes(cause)) return null;
148
167
  if (!["awaiting_first_event", "streaming"].includes(stage)) return null;
@@ -157,9 +176,24 @@ function readDisconnect(value) {
157
176
  ...(routeKind !== undefined ? { routeKind } : {}),
158
177
  ...(connectionAgeMs !== undefined ? { connectionAgeMs } : {}),
159
178
  ...(eventCount !== undefined ? { eventCount } : {}),
179
+ ...transport,
160
180
  };
161
181
  }
162
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
+
163
197
  function createStatusPayload({
164
198
  state,
165
199
  proxyServer,
@@ -170,12 +204,14 @@ function createStatusPayload({
170
204
  routerScriptPath,
171
205
  identityToken,
172
206
  previousDisconnect = null,
207
+ previousUpgradeRejection = null,
173
208
  }) {
174
209
  const proxyStatus =
175
210
  typeof proxyServer?.getStatus === "function" ? proxyServer.getStatus() : {};
176
211
  const currentDisconnect = readDisconnect(proxyStatus.lastWebsocketDisconnect);
177
212
  // A current-process event is newer history even after wall-clock rollback.
178
213
  const lastDisconnect = currentDisconnect ?? previousDisconnect;
214
+ const lastUpgradeRejection = readUpgradeRejection(proxyStatus.lastWebsocketUpgradeRejection) ?? previousUpgradeRejection;
179
215
  const lastAccountIndex = proxyStatus.lastAccountIndex ?? null;
180
216
  const lastAccountLabel =
181
217
  typeof proxyStatus.lastAccountLabel === "string" &&
@@ -205,6 +241,9 @@ function createStatusPayload({
205
241
  upstreamRequests: proxyStatus.upstreamRequests ?? 0,
206
242
  websocketConnections: proxyStatus.websocketConnections ?? 0,
207
243
  websocketUpstreamRequests: proxyStatus.websocketUpstreamRequests ?? 0,
244
+ websocketActiveConnections: readWebsocketCounter(proxyStatus.websocketActiveConnections, 64) ?? 0,
245
+ websocketCapacityReclaims: readWebsocketCounter(proxyStatus.websocketCapacityReclaims) ?? 0,
246
+ lastWebsocketUpgradeRejection: lastUpgradeRejection,
208
247
  lastWebsocketDisconnect: lastDisconnect,
209
248
  streamQuotaUpdates: proxyStatus.streamQuotaUpdates ?? 0,
210
249
  lastStreamQuotaUpdateAt: proxyStatus.lastStreamQuotaUpdateAt ?? null,
@@ -293,7 +332,9 @@ async function main() {
293
332
  const args = parseArgs(process.argv.slice(2));
294
333
  installLogBounds(args.maxLogBytes, args.logPath).unref?.();
295
334
  const stateRecord = readState(args.statePath);
296
- let previousDisconnect = readDisconnect(readState(args.statusPath || stateRecord?.statusPath || "")?.lastWebsocketDisconnect);
335
+ const previousStatus = readState(args.statusPath || stateRecord?.statusPath || "");
336
+ let previousDisconnect = readDisconnect(previousStatus?.lastWebsocketDisconnect);
337
+ let previousUpgradeRejection = readUpgradeRejection(previousStatus?.lastWebsocketUpgradeRejection);
297
338
  if (args.statePath && stateRecord === null) {
298
339
  const error = new Error(
299
340
  "Codex app runtime router state is unreadable; refusing to bind an ephemeral port.",
@@ -302,6 +343,7 @@ async function main() {
302
343
  args.statusPath,
303
344
  createStatusPayload({
304
345
  previousDisconnect,
346
+ previousUpgradeRejection,
305
347
  state: "error",
306
348
  proxyServer: null,
307
349
  error,
@@ -341,6 +383,7 @@ async function main() {
341
383
  const writeCurrentStatus = (state, error) => {
342
384
  const payload = createStatusPayload({
343
385
  previousDisconnect,
386
+ previousUpgradeRejection,
344
387
  state,
345
388
  proxyServer,
346
389
  error,
@@ -351,6 +394,7 @@ async function main() {
351
394
  identityToken: args.identityToken || stateRecord?.identityToken || "",
352
395
  });
353
396
  previousDisconnect = payload.lastWebsocketDisconnect;
397
+ previousUpgradeRejection = payload.lastWebsocketUpgradeRejection;
354
398
  writeStatus(args.statusPath || stateRecord?.statusPath || "", payload);
355
399
  };
356
400
 
@@ -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",