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
+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()) {