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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +4 -2
- package/config/schema/config.schema.json +8 -0
- package/dist/lib/api-route-store.d.ts +1 -0
- package/dist/lib/api-route-store.js +2 -1
- 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/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 +4 -0
- package/dist/lib/codex-manager/commands/status.js +476 -5
- package/dist/lib/codex-manager/help.js +3 -1
- package/dist/lib/codex-manager.js +98 -29
- package/dist/lib/config.js +3 -0
- package/dist/lib/locale.d.ts +7 -0
- package/dist/lib/locale.js +35 -0
- 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 +2 -0
- package/dist/lib/runtime/account-model-catalog.js +7 -2
- package/dist/lib/runtime/app-bind.d.ts +4 -1
- package/dist/lib/runtime/app-bind.js +63 -3
- package/dist/lib/runtime/responses-websocket.d.ts +30 -0
- package/dist/lib/runtime/responses-websocket.js +243 -29
- package/dist/lib/runtime/rotation-account-selection.d.ts +5 -4
- package/dist/lib/runtime/rotation-server-types.d.ts +4 -1
- package/dist/lib/runtime-rotation-proxy.js +30 -9
- package/dist/lib/schemas.d.ts +5 -0
- package/dist/lib/schemas.js +1 -0
- 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 +45 -1
- package/scripts/codex-routing.js +1 -0
- 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({"&":"&","<":"<",">":">",'"':""","'":"'"})[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.
|
|
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
|
-
|
|
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
|
|