fix: a token-gated or LAN-bound dashboard works end to end (WSL mirrored)

- /ws-pty carries the dashboard token like /ws (the terminal never attached
  with DASHBOARD_TOKEN set), and so does everything that cannot send the
  header: images, downloads, exports, drops (withToken/tokenHeader). A
  refused upgrade answers 401/403 and is logged and counted by reason.
- The browser asks for a missing or wrong token (401 EUNAUTHORIZED) and takes
  ?token= from the link agenthub start prints, dropping it from the URL.
- The CLI sends the token (DASHBOARD_TOKEN, else the one the running server
  publishes 0600 in <data dir>/runtime/dashboard-token); MCP and statusline
  read the same file.
- DASHBOARD_HOST takes a list, and 127.0.0.1 is always listened on too, so a
  LAN-only bind keeps hooks and the CLI working.
- agenthub start --host/--allowed-hosts/--token are saved in
  <data dir>/network.json for every later start; a wide bind gets a token;
  every reachable URL is printed.
- tmux < 3.7 is an amber, once-per-version notice that says only the pane
  scrollbar is missing, with a ~/.local build instead of sudo make install.
- INSTALL: PATH belongs in ~/.profile for sudo -iu/ssh; WSL mirrored notes.
This commit is contained in:
2026-11-06 14:59:48 +07:00
parent 1e3b34ec03
commit 4a665da79e
39 changed files with 1108 additions and 105 deletions
@@ -0,0 +1,54 @@
/**
* @file dashboard-token-publish.test.js
* @description A token-gated dashboard publishes its token (0600) for local
* tools, and `dashboard-url.dashboardToken()` — what the `agenthub` CLI, the
* MCP server and the statusline send — takes DASHBOARD_TOKEN first, else that
* file, else nothing. Without it `agenthub remote login/status` answered 401
* from any shell that had not exported the token.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "dash-token-"));
process.env.DASHBOARD_DATA_DIR = TMP;
const security = require("../lib/security");
const { dashboardToken, tokenHeaders } = require("../lib/dashboard-url");
describe("the published dashboard token", () => {
let saved;
before(() => {
saved = process.env.DASHBOARD_TOKEN;
});
afterEach(() => {
if (saved === undefined) delete process.env.DASHBOARD_TOKEN;
else process.env.DASHBOARD_TOKEN = saved;
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
it("is written 0600 when the server has one, and read back without the env", () => {
process.env.DASHBOARD_TOKEN = "server-tok";
security.publishDashboardToken();
const file = security.tokenFilePath();
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
delete process.env.DASHBOARD_TOKEN; // a shell that never exported it
assert.equal(dashboardToken(), "server-tok");
assert.deepEqual(tokenHeaders(), { "x-dashboard-token": "server-tok" });
});
it("prefers the caller's own DASHBOARD_TOKEN", () => {
process.env.DASHBOARD_TOKEN = "mine";
assert.equal(dashboardToken(), "mine");
});
it("is removed when the server runs without a token", () => {
delete process.env.DASHBOARD_TOKEN;
security.publishDashboardToken();
assert.equal(fs.existsSync(security.tokenFilePath()), false);
assert.equal(dashboardToken(), null);
assert.deepEqual(tokenHeaders(), {});
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* @file listen-hosts.test.js
* @description DASHBOARD_HOST may name several addresses, and a dashboard
* bound only to a non-loopback address still answers on 127.0.0.1 — the hook
* handler, the CLI, the MCP server and the statusline all call there (WSL in
* mirrored networking binds a LAN address because Windows cannot reach the
* VM's loopback, and every hook was lost). 127.0.0.2 stands in for the LAN
* address: Linux routes all of 127/8 to lo, yet it is not "this machine's
* name" to the guards.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "listen-hosts-"));
process.env.DASHBOARD_DATA_DIR = TMP;
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.DASHBOARD_POOL_REFRESH_MS = "0";
const security = require("../lib/security");
function withHost(value, fn) {
const saved = process.env.DASHBOARD_HOST;
if (value === undefined) delete process.env.DASHBOARD_HOST;
else process.env.DASHBOARD_HOST = value;
try {
return fn();
} finally {
if (saved === undefined) delete process.env.DASHBOARD_HOST;
else process.env.DASHBOARD_HOST = saved;
}
}
describe("which addresses the dashboard listens on", () => {
it("is loopback alone by default, with nothing extra", () =>
withHost(undefined, () => {
assert.deepEqual(security.resolveHosts(), ["127.0.0.1"]);
assert.deepEqual(security.extraListenHosts(), []);
assert.equal(security.isExposedBind(), false);
}));
it("adds 127.0.0.1 beside a LAN-only bind, and keeps every listed address", () =>
withHost("192.168.1.20, 100.64.0.7", () => {
assert.equal(security.resolveHost(), "192.168.1.20");
assert.deepEqual(security.extraListenHosts(), ["100.64.0.7", "127.0.0.1"]);
assert.equal(security.isExposedBind(), true);
}));
it("adds nothing for a wildcard bind, which answers on loopback already — but calls it exposed", () =>
withHost("0.0.0.0", () => {
assert.deepEqual(security.extraListenHosts(), []);
assert.equal(security.isExposedBind(), true);
assert.ok(security.accessUrls(4821).includes("http://localhost:4821"));
}));
it("lists each bound address as a URL to open", () =>
withHost("192.168.1.20", () => {
assert.deepEqual(security.accessUrls(4821), [
"http://localhost:4821",
"http://192.168.1.20:4821",
]);
}));
});
describe("a dashboard bound to another address", () => {
let server;
after(async () => {
delete process.env.DASHBOARD_HOST;
if (server) await new Promise((r) => server.close(r));
fs.rmSync(TMP, { recursive: true, force: true });
});
it("still answers on 127.0.0.1, WebSocket included", async () => {
process.env.DASHBOARD_HOST = "127.0.0.2";
const { createApp, startServer } = require("../index");
server = await startServer(createApp(), 0);
const { address, port } = server.address();
assert.equal(address, "127.0.0.2");
await new Promise((r) => setTimeout(r, 50)); // the extra listener binds async
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
assert.equal(res.status, 200);
// The upgrade travels the forwarded connection to the same WS server.
const reply = await new Promise((resolve) => {
const sock = require("node:net").connect(port, "127.0.0.1", () =>
sock.write(
`GET /ws HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nUpgrade: websocket\r\n` +
`Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n` +
`Sec-WebSocket-Version: 13\r\n\r\n`
)
);
sock.once("data", (d) => {
resolve(d.toString());
sock.destroy();
});
sock.on("error", (e) => resolve(String(e)));
});
assert.match(reply, /^HTTP\/1\.1 101/);
});
});
describe("saved network settings (agenthub start --host/--token)", () => {
const net = require("../lib/network-config");
const keys = ["DASHBOARD_HOST", "DASHBOARD_ALLOWED_HOSTS", "DASHBOARD_TOKEN"];
it("are written 0600 and applied only where the environment says nothing", () => {
const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
try {
net.writeNetworkConfig({ host: "0.0.0.0", allowedHosts: "box.lan", token: "t1" });
assert.equal(fs.statSync(net.configPath()).mode & 0o777, 0o600);
const env = { DASHBOARD_TOKEN: "from-env" };
net.applyNetworkConfig(env);
assert.deepEqual(env, {
DASHBOARD_TOKEN: "from-env",
DASHBOARD_HOST: "0.0.0.0",
DASHBOARD_ALLOWED_HOSTS: "box.lan",
});
// null removes a key: `--host local`, `--token off`.
net.writeNetworkConfig({ host: null, token: null });
assert.deepEqual(net.readNetworkConfig(), { allowedHosts: "box.lan" });
} finally {
fs.rmSync(net.configPath(), { force: true });
for (const k of keys) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
}
});
});
+7
View File
@@ -379,6 +379,13 @@ describe("tmux wrapper", () => {
assert.match(tmux.upgradeAdvice("3.4", "darwin"), /brew upgrade tmux/);
assert.match(tmux.upgradeAdvice("3.4", "linux"), /github\.com\/tmux\/tmux\/releases/);
assert.equal(tmux.upgradeAdvice("3.7c", "linux"), null);
// An old tmux only lacks the pane scrollbar: a warning, and it says so.
assert.match(tmux.upgradeAdvice("3.4", "linux"), /only the pane scrollbar is missing/);
assert.match(tmux.upgradeAdvice("3.4", "linux"), /--prefix=\$HOME\/\.local/);
assert.doesNotMatch(tmux.upgradeAdvice("3.4", "linux"), /sudo make install/);
assert.equal(tmux.upgradeSeverity("3.4", "linux"), "warning");
assert.equal(tmux.upgradeSeverity("3.3.2", "win32"), "error");
assert.equal(tmux.upgradeSeverity("3.7", "linux"), null);
assert.equal(tmux.upgradeAdvice("next-3.8", "darwin"), null);
// Nothing to compare: no advice rather than a wrong one.
assert.equal(tmux.upgradeAdvice(null, "linux"), null);
@@ -0,0 +1,82 @@
/**
* @file websocket-pty-token.test.js
* @description With DASHBOARD_TOKEN set, a `/ws-pty/:runId` upgrade is let
* through only with the token (`?token=`, appended after the size query the
* terminal sends), and a refused one gets a real HTTP status and is counted
* by reason — it used to be a silent `socket.destroy()`, so a terminal stuck
* "reconnecting" behind a token gave no clue why. Real http.Server, raw
* upgrade requests; the run itself does not exist, so a let-through upgrade
* ends in the attach failing, never in a 401.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const net = require("node:net");
process.env.DASHBOARD_TOKEN = "s3cret-token";
const ws = require("../websocket");
function rawUpgrade(port, path) {
return new Promise((resolve) => {
const socket = net.connect(port, "127.0.0.1", () => {
socket.write(
`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n` +
`Upgrade: websocket\r\nConnection: Upgrade\r\n` +
`Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n`
);
});
let body = "";
socket.on("data", (d) => {
body += d.toString("utf8");
// A 101 means the upgrade was accepted: that is all this test needs.
if (body.startsWith("HTTP/1.1 101")) socket.destroy();
});
socket.on("close", () => resolve(body));
socket.on("error", () => resolve(body));
});
}
describe("/ws-pty behind DASHBOARD_TOKEN", () => {
let server;
let port;
before(async () => {
server = http.createServer((req, res) => res.end("ok"));
ws.initWebSocket(server);
ws.initPtyWebSocket(server);
await new Promise((r) => server.listen(0, "127.0.0.1", r));
port = server.address().port;
});
after(async () => {
ws.closeWebSocket();
await new Promise((r) => server.close(r));
delete process.env.DASHBOARD_TOKEN;
});
it("refuses an attach without the token with a 401, and counts why", async () => {
const before = ws.getUpgradeRefusals().token;
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?cols=120&rows=30");
assert.match(reply, /^HTTP\/1\.1 401/);
assert.equal(ws.getUpgradeRefusals().token, before + 1);
});
it("refuses a wrong token", async () => {
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?cols=120&rows=30&token=nope");
assert.match(reply, /^HTTP\/1\.1 401/);
});
it("lets the attach through with the token after the size query", async () => {
const before = ws.getUpgradeRefusals().token;
const reply = await rawUpgrade(
port,
"/ws-pty/agenthub-lane-1?cols=120&rows=30&token=s3cret-token"
);
assert.match(reply, /^HTTP\/1\.1 101/);
assert.equal(ws.getUpgradeRefusals().token, before);
});
it("lets a sizeless attach through with ?token=", async () => {
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?token=s3cret-token");
assert.match(reply, /^HTTP\/1\.1 101/);
});
});
+28 -5
View File
@@ -99,7 +99,9 @@ const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./li
const { getDataDir } = require("./lib/claude-home");
const {
resolveHost,
isLoopbackHostname,
extraListenHosts,
isExposedBind,
accessUrls,
corsOptions,
hostGuard,
tokenGuard,
@@ -158,6 +160,8 @@ const APP_VERSION = (() => {
})();
function createApp() {
// Saved network settings first: the CORS options below are read once here.
require("./lib/network-config").applyNetworkConfig();
const app = express();
// Express 5 parses a query string with node's `querystring` by default;
// keep Express 4's `qs` parsing, which every route and guard (`namedIds`
@@ -293,6 +297,9 @@ function createApp() {
}
function startServer(app, port) {
// `agenthub start --host/--allowed-hosts/--token`, saved for every start;
// a variable set in the environment wins.
require("./lib/network-config").applyNetworkConfig();
const server = http.createServer(app);
initWebSocket(server);
initPtyWebSocket(server);
@@ -368,8 +375,12 @@ function startServer(app, port) {
// Bind to loopback by default so the dashboard is not network-reachable out
// of the box (GHSA-gr74-4xfh-6jw9). Operators opt into a wider bind with
// DASHBOARD_HOST=0.0.0.0 — and are warned to set DASHBOARD_TOKEN when they do.
const host = resolveHost();
const boundLoopback = isLoopbackHostname(host);
// DASHBOARD_HOST may list several addresses; the first is `server`'s own
// bind, every other one (127.0.0.1 included whenever no address covers it,
// so hooks and the CLI keep working) a listener handing its connections to
// `server` — the same app, WebSockets and peer checks, the real socket kept.
const host = resolveHost().replace(/^\[(.*)\]$/, "$1");
const boundLoopback = !isExposedBind();
return new Promise((resolve, reject) => {
// A port another program holds fails here, not as an uncaught 'error'
@@ -377,10 +388,22 @@ function startServer(app, port) {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
const boundPort = server.address().port;
const extras = extraListenHosts().map((h) => {
const l = require("node:net").createServer((sock) => server.emit("connection", sock));
l.on("error", (e) =>
console.warn(`⚠️ Could not also listen on ${h}:${boundPort} — ${e.code || e.message}`)
);
l.listen(boundPort, h);
return l;
});
if (extras.length) server.on("close", () => extras.forEach((l) => l.close()));
// Publish the live port so the Claude Code hook handler can find this
// server even when it bound a non-default port (the desktop app falls
// back off 4820 when that port is already taken).
writeServerInfo(port);
// The token (if any) for a local CLI/MCP/statusline that never exported it.
require("./lib/security").publishDashboardToken();
// The AI Proxy gateway URL a server-mode pane is given names this port.
require("./lib/proxy-gateway").setListenPort(server.address().port);
// S3: what members' panes run — this install's scripts and every
@@ -413,8 +436,8 @@ function startServer(app, port) {
);
}
const mode = isProduction ? "production" : "development";
const shown = boundLoopback ? "localhost" : host;
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
const urls = accessUrls(boundPort);
console.log(`Agent Dashboard server running on ${urls.join(" ")} (${mode})`);
if (isServerMode()) {
console.log("Server mode: every page and API call needs a login.");
if (!hasAdmin()) {
+25 -2
View File
@@ -4,7 +4,11 @@
* statusline (`statusline/statusline.js`). An explicit CLAUDE_DASHBOARD_PORT /
* DASHBOARD_PORT wins (the hook handler's contract), then the live-server
* discovery file `~/.claude/.agenthub.json` (`server-info.js`, PID-checked),
* then the default port. Never throws.
* then the default port. `dashboardToken()` is the token such a process sends
* when the dashboard requires one: `DASHBOARD_TOKEN`, else the one the running
* server published (`<data dir>/runtime/dashboard-token`, 0600 — see
* `security.publishDashboardToken`), so a shell that never exported it still
* reaches a token-gated dashboard. Never throws.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,4 +28,23 @@ function dashboardUrl() {
return "http://127.0.0.1:4820";
}
module.exports = { dashboardUrl };
/** The dashboard token to send, or null when none is configured or readable. */
function dashboardToken() {
const env = process.env.DASHBOARD_TOKEN;
if (typeof env === "string" && env) return env;
try {
const { tokenFilePath } = require(path.join(__dirname, "security.js"));
const t = require("node:fs").readFileSync(tokenFilePath(), "utf8").trim();
return t || null;
} catch {
return null;
}
}
/** The headers carrying `dashboardToken()` (both spellings the server takes); {} with none. */
function tokenHeaders() {
const t = dashboardToken();
return t ? { "x-dashboard-token": t } : {};
}
module.exports = { dashboardUrl, dashboardToken, tokenHeaders };
+82
View File
@@ -0,0 +1,82 @@
/**
* @file network-config.js
* @description The dashboard's saved network settings — bind address(es),
* allowed Host names and the dashboard token — in `<data dir>/network.json`
* (0600). `agenthub start --host/--allowed-hosts/--token` writes it; every
* server start (a restart after an update, the autostart unit, the plugin
* bootstrap) applies it with `applyNetworkConfig()`, so a wider bind no longer
* falls back to loopback silently the next time something else starts the
* server. An environment variable that is set always wins over the file.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");
/** File key → the environment variable it stands in for. */
const KEYS = {
host: "DASHBOARD_HOST",
allowedHosts: "DASHBOARD_ALLOWED_HOSTS",
token: "DASHBOARD_TOKEN",
};
function configPath() {
const { getDataDir } = require("./claude-home");
return path.join(getDataDir(), "network.json");
}
/** The saved settings, `{}` when there are none (or the file is unreadable). */
function readNetworkConfig() {
try {
const c = JSON.parse(fs.readFileSync(configPath(), "utf8"));
if (!c || typeof c !== "object" || Array.isArray(c)) return {};
const out = {};
for (const k of Object.keys(KEYS)) {
if (typeof c[k] === "string" && c[k].trim()) out[k] = c[k].trim();
}
return out;
} catch {
return {};
}
}
/**
* Merge `patch` into the saved settings: a string sets a key, `null` removes
* it. Written 0600 through a temp file. Returns the new settings.
*/
function writeNetworkConfig(patch) {
const next = { ...readNetworkConfig() };
for (const [k, v] of Object.entries(patch)) {
if (!(k in KEYS)) continue;
if (v === null || v === "") delete next[k];
else next[k] = String(v).trim();
}
const file = configPath();
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, file);
return next;
}
/** Put every saved setting whose environment variable is unset into `env`. */
function applyNetworkConfig(env = process.env) {
const saved = readNetworkConfig();
for (const [k, name] of Object.entries(KEYS)) {
if (saved[k] && !(typeof env[name] === "string" && env[name].trim())) env[name] = saved[k];
}
return saved;
}
/** A fresh random token for `--token auto`. */
const newToken = () => crypto.randomBytes(24).toString("base64url");
module.exports = {
configPath,
readNetworkConfig,
writeNetworkConfig,
applyNetworkConfig,
newToken,
KEYS,
};
+101 -3
View File
@@ -18,16 +18,77 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
// Hostnames that count as "this machine". "0.0.0.0" is included because a
// browser may resolve a 0.0.0.0 bind via localhost; an empty Host is treated as
// loopback (HTTP/1.0 / local tooling).
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", ""]);
/** The interface to bind. Loopback unless the operator opts into a wider bind. */
/**
* Every address to bind, in order: `DASHBOARD_HOST` may list several,
* comma-separated (`192.168.1.20,100.64.0.7`). Loopback unless the operator
* opts into a wider bind.
*/
function resolveHosts() {
const list = (process.env.DASHBOARD_HOST || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return list.length ? [...new Set(list)] : ["127.0.0.1"];
}
/** The first (main) address to bind. */
function resolveHost() {
const h = (process.env.DASHBOARD_HOST || "").trim();
return h || "127.0.0.1";
return resolveHosts()[0];
}
/** An all-interfaces bind (it answers on loopback too). */
const isWildcardHost = (h) => ["0.0.0.0", "::", "[::]"].includes(String(h || "").trim());
/**
* The addresses to listen on besides the main one: the rest of the list, plus
* 127.0.0.1 when no address in it already answers there. The hook handler,
* the CLI, the MCP server and the statusline all call 127.0.0.1 — a dashboard
* bound only to a LAN address (WSL in mirrored networking, where Windows
* cannot reach the VM's loopback) would otherwise lose every hook.
*/
function extraListenHosts() {
const hosts = resolveHosts();
const extra = hosts.slice(1).map((h) => h.replace(/^\[(.*)\]$/, "$1"));
const coversLoopback = hosts.some(
(h) => isWildcardHost(h) || ["127.0.0.1", "localhost"].includes(h.toLowerCase())
);
if (!coversLoopback) extra.push("127.0.0.1");
return [...new Set(extra)];
}
/**
* The URLs a browser can open this dashboard at, for the start-up line and the
* CLI: `localhost` first, then each bound address — every non-internal IPv4
* address of this machine for a wildcard bind.
*/
function accessUrls(port) {
const os = require("node:os");
const urls = new Set([`http://localhost:${port}`]);
for (const h of resolveHosts()) {
if (isWildcardHost(h)) {
for (const addrs of Object.values(os.networkInterfaces())) {
for (const a of addrs || []) {
if (a.family === "IPv4" && !a.internal) urls.add(`http://${a.address}:${port}`);
}
}
} else if (!isLoopbackHostname(h)) {
urls.add(`http://${h.includes(":") && !h.startsWith("[") ? `[${h}]` : h}:${port}`);
}
}
return [...urls];
}
/** Whether any bind is reachable from beyond this machine. */
function isExposedBind() {
return resolveHosts().some((h) => isWildcardHost(h) || !isLoopbackHostname(h));
}
function isLoopbackHostname(name) {
@@ -101,6 +162,36 @@ function getDashboardToken() {
return typeof t === "string" && t.length > 0 ? t : null;
}
/** Where the running server publishes its token for the CLI, MCP server and statusline. */
function tokenFilePath() {
const { getDataDir } = require("./claude-home");
return path.join(getDataDir(), "runtime", "dashboard-token");
}
/**
* Publish the active token (0600, this account only) so a local CLI/MCP/
* statusline started from a shell that never exported DASHBOARD_TOKEN can
* still call a token-gated dashboard; remove a stale one when no token is
* set. Best effort: never throws.
*/
function publishDashboardToken() {
const file = tokenFilePath();
const token = getDashboardToken();
try {
if (!token) {
fs.rmSync(file, { force: true });
return;
}
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, token, { mode: 0o600 });
fs.chmodSync(tmp, 0o600);
fs.renameSync(tmp, file);
} catch (e) {
console.warn(`[security] could not publish the dashboard token: ${e.message}`);
}
}
function tokensMatch(provided, expected) {
if (typeof provided !== "string" || provided.length === 0) return false;
const a = Buffer.from(provided);
@@ -162,6 +253,11 @@ function isWebSocketAuthorized(req) {
module.exports = {
LOOPBACK_HOSTS,
resolveHost,
resolveHosts,
extraListenHosts,
isWildcardHost,
isExposedBind,
accessUrls,
isLoopbackHostname,
allowedHostnames,
hostnameOf,
@@ -169,6 +265,8 @@ module.exports = {
hostGuard,
corsOptions,
getDashboardToken,
tokenFilePath,
publishDashboardToken,
tokenGuard,
isWebSocketAuthorized,
// exported for tests
+25 -4
View File
@@ -1003,16 +1003,30 @@ const UPGRADES = {
win32: {
min: [3, 4, 0],
name: "psmux",
// An older psmux stalls a dashboard terminal: a real fault.
severity: "error",
impact: "dashboard terminals can stall until a page refresh",
how: "close every AgentHub terminal pane, then run: winget upgrade marlocarlo.psmux",
},
darwin: { min: [3, 7, 0], name: "tmux", how: "brew upgrade tmux" },
darwin: {
min: [3, 7, 0],
name: "tmux",
severity: "warning",
impact: "only the pane scrollbar is missing; terminals work",
how: "brew upgrade tmux",
},
linux: {
min: [3, 7, 0],
name: "tmux",
// Only the pane scrollbar is missing: never a reason a terminal fails.
severity: "warning",
impact: "only the pane scrollbar is missing; terminals work",
how:
"distros ship older ones (Ubuntu 24.04 has 3.4), so build the release from " +
"https://github.com/tmux/tmux/releases (needs libevent-dev libncurses-dev bison: " +
"./configure && make && sudo make install)",
"https://github.com/tmux/tmux/releases into your own ~/.local, leaving the " +
"system's tmux alone (needs libevent-dev libncurses-dev bison: " +
"./configure --prefix=$HOME/.local && make && make install, with ~/.local/bin " +
"first in PATH; then restart AgentHub once every terminal pane is closed)",
},
};
@@ -1026,7 +1040,13 @@ function upgradeAdvice(version = clientVersion(), platform = process.platform) {
const have = [m[1], m[2], m[3] || 0].map(Number);
const i = have.findIndex((n, k) => n !== want.min[k]);
if (i === -1 || have[i] > want.min[i]) return null;
return `${want.name} ${version} is older than ${want.min.join(".")} — ${want.how}`;
return `${want.name} ${version} is older than ${want.min.join(".")} (${want.impact}) — ${want.how}`;
}
/** How much an old version matters here: `error` (psmux: terminals stall) or
* `warning` (tmux: no pane scrollbar); null when there is nothing to say. */
function upgradeSeverity(version = clientVersion(), platform = process.platform) {
return upgradeAdvice(version, platform) ? UPGRADES[platform].severity : null;
}
/** The RUNNING server's version, null when none runs. A server started before
@@ -1063,6 +1083,7 @@ module.exports = {
clientVersion,
serverVersion,
upgradeAdvice,
upgradeSeverity,
serverPid,
memberAttach,
memberServerInfo,
+3
View File
@@ -618,6 +618,9 @@ router.get("/tmux", (_req, res) => {
available: version !== null,
version,
upgrade: version ? tmux.upgradeAdvice(version) : null,
// `warning` (tmux < 3.7: no pane scrollbar) or `error` (an old psmux
// stalls terminals) — the Workspace shows each at its own weight.
upgradeSeverity: version ? tmux.upgradeSeverity(version) : null,
});
});
+54 -13
View File
@@ -17,6 +17,48 @@ const MINE_TYPES = new Set([
"agent_updated",
"new_event",
]);
/**
* Upgrades refused, by reason (`host`, `token`, `login`, `scope`), since start
* — and a refusal's one warning line per reason a minute. A browser only sees
* a socket that closed and retries; this is where the server says why.
*/
const refusals = { host: 0, token: 0, login: 0, scope: 0 };
const lastRefusalLog = new Map();
const REFUSAL_LOG_MS = 60_000;
const REFUSAL_STATUS = {
host: [403, "Forbidden"],
token: [401, "Unauthorized"],
login: [401, "Unauthorized"],
scope: [403, "Forbidden"],
};
const REFUSAL_WHY = {
host: "Host header not allowed (DASHBOARD_ALLOWED_HOSTS)",
token: "missing or invalid dashboard token (DASHBOARD_TOKEN)",
login: "not logged in",
scope: "not this user's pane",
};
function noteRefusal(reason, pathname) {
refusals[reason] = (refusals[reason] || 0) + 1;
const now = Date.now();
if (now - (lastRefusalLog.get(reason) || 0) >= REFUSAL_LOG_MS) {
lastRefusalLog.set(reason, now);
console.warn(`[WS] refused ${pathname}: ${REFUSAL_WHY[reason]} (${refusals[reason]} so far)`);
}
}
function refuseUpgrade(socket, reason, pathname) {
noteRefusal(reason, pathname);
const [code, text] = REFUSAL_STATUS[reason] || [400, "Bad Request"];
// Like ws's own abortHandshake: answer, then drop once it is written.
try {
socket.once("finish", () => socket.destroy());
socket.end(`HTTP/1.1 ${code} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
} catch {
socket.destroy();
}
}
/** A copy of the refusal counters (for the tests and diagnostics). */
const getUpgradeRefusals = () => ({ ...refusals });
// How often an open terminal's login is re-checked in server mode.
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
@@ -32,9 +74,13 @@ function initWebSocket(server) {
noServer: true,
maxPayload: 64 * 1024,
verifyClient(info, done) {
if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed");
if (!isWebSocketAuthorized(info.req)) return done(false, 401, "unauthorized");
if (!isWebSocketLoggedIn(info.req)) return done(false, 401, "log in first");
const refuse = (reason, code, msg) => {
noteRefusal(reason, "/ws");
return done(false, code, msg);
};
if (!isHostAllowed(info.req.headers.host)) return refuse("host", 403, "host not allowed");
if (!isWebSocketAuthorized(info.req)) return refuse("token", 401, "unauthorized");
if (!isWebSocketLoggedIn(info.req)) return refuse("login", 401, "log in first");
return done(true);
},
});
@@ -207,19 +253,13 @@ function initPtyWebSocket(server) {
if (!match) return; // not ours — the `/ws` WebSocketServer (registered
// by initWebSocket, also attached to this same http.Server) handles its
// own path independently and ignores upgrades it doesn't match too.
if (!isHostAllowed(req.headers.host)) {
socket.destroy();
return;
}
if (!isWebSocketAuthorized(req) || !isWebSocketLoggedIn(req)) {
socket.destroy();
return;
}
if (!isHostAllowed(req.headers.host)) return refuseUpgrade(socket, "host", url.pathname);
if (!isWebSocketAuthorized(req)) return refuseUpgrade(socket, "token", url.pathname);
if (!isWebSocketLoggedIn(req)) return refuseUpgrade(socket, "login", url.pathname);
// Server mode: a terminal is a write, so a developer or an analyst
// attaches only to a pane of their own workspace.
if (isServerMode() && !canSeeRun({ user: userFromRequest(req), method: "POST" }, match[1])) {
socket.destroy();
return;
return refuseUpgrade(socket, "scope", url.pathname);
}
// Same shutdown race as `/ws` above — and far easier to hit here, since
// TerminalView re-attaches every 1.5s while a run console is open.
@@ -410,4 +450,5 @@ module.exports = {
onConnection,
closeWebSocket,
sizeFromQuery,
getUpgradeRefusals,
};