From 4a665da79e148fccd5088366bbd269777ee07213 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 6 Nov 2026 14:59:48 +0700 Subject: [PATCH] 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 /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 /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. --- INSTALL.md | 35 ++++- bin/agenthub.js | 81 ++++++++++- client/src/components/AuthGate.tsx | 85 ++++++++++- client/src/components/Toaster.tsx | 12 +- .../components/__tests__/AuthGate.test.tsx | 47 ++++++- .../components/conversation/MessageList.tsx | 4 +- client/src/components/run/ChatFilePreview.tsx | 4 +- client/src/components/run/TerminalView.tsx | 8 +- .../run/__tests__/TerminalView.test.tsx | 30 +++- client/src/i18n/locales/en/common.json | 8 +- client/src/i18n/locales/en/lanes.json | 1 + client/src/i18n/locales/vi/common.json | 8 +- client/src/i18n/locales/vi/lanes.json | 1 + .../lib/__tests__/apiDashboardToken.test.ts | 51 +++++++ client/src/lib/api.ts | 110 +++++++++++---- client/src/lib/notify.ts | 3 +- client/src/lib/promptAttach.ts | 9 +- client/src/lib/push.ts | 9 +- client/src/lib/toast.ts | 10 +- client/src/main.tsx | 4 + client/src/pages/Workspace.tsx | 31 ++-- docs/API.md | 4 +- docs/CLI.md | 2 +- docs/DEPLOYMENT.md | 4 +- docs/MCP.md | 9 +- docs/STATUSLINE.md | 2 +- mcp/server.js | 12 +- .../__tests__/dashboard-token-publish.test.js | 54 +++++++ server/__tests__/listen-hosts.test.js | 133 ++++++++++++++++++ server/__tests__/tmux.test.js | 7 + server/__tests__/websocket-pty-token.test.js | 82 +++++++++++ server/index.js | 33 ++++- server/lib/dashboard-url.js | 27 +++- server/lib/network-config.js | 82 +++++++++++ server/lib/security.js | 104 +++++++++++++- server/lib/tmux.js | 29 +++- server/routes/run.js | 3 + server/websocket.js | 67 +++++++-- statusline/statusline.js | 8 +- 39 files changed, 1108 insertions(+), 105 deletions(-) create mode 100644 client/src/lib/__tests__/apiDashboardToken.test.ts create mode 100644 server/__tests__/dashboard-token-publish.test.js create mode 100644 server/__tests__/listen-hosts.test.js create mode 100644 server/__tests__/websocket-pty-token.test.js create mode 100644 server/lib/network-config.js diff --git a/INSTALL.md b/INSTALL.md index 23d69069..cbe4bfc3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -57,20 +57,48 @@ sudo pacman -S git tmux # Arch Windows has no native tmux. Run AgentHub under **WSL2** and install it inside the WSL distro — a tmux from Git Bash or MSYS is not what the server executes. + +**PATH for `agenthub`:** put `export PATH="$HOME/.local/bin:$PATH"` in +`~/.profile`, not only `~/.bashrc`. A login shell that is not interactive — +`sudo -iu agenthub remote login`, an `ssh host agenthub …`, a systemd +unit — reads `~/.profile`, and Ubuntu's `~/.bashrc` returns early when the shell +is not interactive, so a PATH set only there is never seen (`agenthub: command +not found`). Or call it by its full path, `~/.local/bin/agenthub`. + +**WSL2 with `networkingMode=mirrored`:** on some machines a Windows browser +cannot reach WSL's `localhost`, so the dashboard must listen on an address +Windows can reach. Save it once — every later start, the autostart unit and an +update keep it: + +```bash +agenthub start --host 0.0.0.0 # every interface (LAN and VPN too) +agenthub start --host 192.168.1.20 # or only this address (and 127.0.0.1) +``` + +A non-loopback bind always gets a token (generated when you give none; `--token +` sets your own, `--token off` removes it) and `agenthub start` prints the +links to open, each carrying `?token=` — the page keeps it and drops it from +the address bar. The server also listens on `127.0.0.1` whenever the bind does +not cover it, so hooks and the CLI keep working. `agenthub start --host local` +goes back to loopback only. On a native Windows install, workspace terminals are unavailable, but Settings → Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`) and runs a `.cmd` updater or npm through `cmd.exe`. A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the -latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release: +latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release +into your own `~/.local` — the system's tmux stays as it is: ```bash sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison curl -fsSL https://github.com/tmux/tmux/releases/download/3.7c/tmux-3.7c.tar.gz | tar xz -cd tmux-3.7c && ./configure && make && sudo make install # into /usr/local/bin +cd tmux-3.7c && ./configure --prefix="$HOME/.local" && make && make install # into ~/.local/bin +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile # ahead of /usr/bin ``` +(`sudo make install` instead puts it in `/usr/local/bin` for every account.) + A tmux server already running keeps its old version until every tmux session has closed (or the machine reboots) — only then do the scrollbars appear. @@ -444,6 +472,9 @@ npm run install-hooks | `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request | | `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) | | `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it | +| `DASHBOARD_HOST` | `127.0.0.1` | Address(es) to listen on, comma-separated (`192.168.1.20,100.64.0.7`); `127.0.0.1` is added whenever none covers it. Saved by `agenthub start --host` in `/network.json`; the variable wins over the file | +| `DASHBOARD_ALLOWED_HOSTS` | — | Extra `Host` names (comma-separated) for a LAN bind. Saved by `agenthub start --allowed-hosts` | +| `DASHBOARD_TOKEN` | — | Access token for `/api/*` and the WebSockets. Saved by `agenthub start --token`; the running server publishes it in `/runtime/dashboard-token` (0600) for the local CLI, MCP server and statusline | Example with a custom port: diff --git a/bin/agenthub.js b/bin/agenthub.js index 71f2cbbd..bfe8a775 100755 --- a/bin/agenthub.js +++ b/bin/agenthub.js @@ -147,6 +147,11 @@ function baseUrl() { return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl(); } +/** The dashboard-token header (`dashboard-url.js`), {} when none is set. */ +function dashboardTokenHeaders() { + return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).tokenHeaders(); +} + /** Thrown when the dashboard server does not answer — the dispatcher decides * whether the active command has an offline fallback or must abort. */ class ServerDownError extends Error {} @@ -162,6 +167,8 @@ async function api(method, pathname, body, options = {}) { method, headers: { ...(body && !form ? { "Content-Type": "application/json" } : {}), + // DASHBOARD_TOKEN, else the one the running server published. + ...dashboardTokenHeaders(), // Server mode: inside a dashboard pane, act as the workspace's owner. ...(process.env.AGENTHUB_PANE_TOKEN ? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN } @@ -1495,6 +1502,64 @@ async function cmdReinstallHooks() { console.log(`${c.green("✔")} Claude Code hooks reinstalled`); } +/** + * Save `start`'s network flags into `/network.json` (applied by every + * later server start): `--host A[,B]` (`local` = back to loopback only), + * `--allowed-hosts H,..` (`none` clears), `--token T|auto|off`. A wide bind + * with no token gets one made (`auto`), never left open silently. Returns + * whether anything changed. + */ +function saveNetworkFlags(flags) { + const net = require(path.join(REPO_ROOT, "server", "lib", "network-config.js")); + const patch = {}; + if (typeof flags.host === "string") { + patch.host = flags.host === "local" ? null : flags.host; + } else if (flags.host === true) { + console.error(c.red("✖ --host needs an address, e.g. --host 0.0.0.0 (or --host local)")); + process.exit(1); + } + if (typeof flags["allowed-hosts"] === "string") { + patch.allowedHosts = flags["allowed-hosts"] === "none" ? null : flags["allowed-hosts"]; + } + if (flags.token === "off") patch.token = null; + else if (flags.token === "auto" || flags.token === true) patch.token = net.newToken(); + else if (typeof flags.token === "string") patch.token = flags.token; + if (Object.keys(patch).length === 0) { + net.applyNetworkConfig(); + return false; + } + const saved = net.writeNetworkConfig(patch); + const wide = (saved.host || "") + .split(",") + .map((h) => h.trim()) + .some((h) => h && !["127.0.0.1", "localhost", "::1", "[::1]"].includes(h)); + if (wide && !saved.token && !process.env.DASHBOARD_TOKEN) { + // Reachable from the network with no token would be an open dashboard. + Object.assign(saved, net.writeNetworkConfig({ token: net.newToken() })); + console.log(c.yellow(" A token was generated: the dashboard is reachable from the network.")); + } + console.log(`${c.green("✔")} Network settings saved to ${c.dim(net.configPath())}`); + net.applyNetworkConfig(); + return true; +} + +/** + * Print every URL the dashboard can be opened at (all of this machine's + * addresses for a 0.0.0.0 bind), each carrying `?token=` when one is set — + * the page stores it and drops it from the address bar. + */ +function printAccessUrls() { + const sec = require(path.join(REPO_ROOT, "server", "lib", "security.js")); + const port = Number(new URL(baseUrl()).port) || 4820; + const token = sec.getDashboardToken(); + const urls = sec.accessUrls(port); + if (urls.length <= 1 && !token) return; + console.log(c.dim(" Open it at:")); + for (const u of urls) { + console.log(` ${c.bold(token ? `${u}/?token=${encodeURIComponent(token)}` : u)}`); + } +} + /** * Start the dashboard server in the background (production mode, serving the * built client) and wait until /api/health answers. No-ops with a pointer to @@ -1505,8 +1570,16 @@ async function cmdReinstallHooks() { async function cmdStart(flags) { // A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it. require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit(); + const changed = saveNetworkFlags(flags); if (await serverIsUp()) { console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`); + if (changed) { + console.log( + c.yellow(" The new network settings were saved; they apply at the next start: ") + + c.bold("agenthub stop && agenthub start") + ); + } + printAccessUrls(); return; } // A plugin install starts the way a Claude Code session does: its bootstrap @@ -1523,6 +1596,7 @@ async function cmdStart(flags) { while (Date.now() < deadline) { if (await serverIsUp()) { console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`); + printAccessUrls(); return; } await new Promise((r) => setTimeout(r, 500)); @@ -1572,6 +1646,7 @@ async function cmdStart(flags) { console.log( `${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}` ); + printAccessUrls(); console.log(c.dim(" Stop it with: agenthub stop")); return; } @@ -2928,7 +3003,11 @@ const COMMAND_GROUPS = [ "Server", [ ["status", "", "Up/down indicator for the dashboard server"], - ["start", "[--port N]", "Start the server in the background and wait for healthy"], + [ + "start", + "[--port N] [--host A[,B]|local] [--allowed-hosts H,..] [--token T|auto|off]", + "Start the server in the background and wait for healthy; --host/--allowed-hosts/--token are saved (data dir network.json) for every later start", + ], [ "stop", "", diff --git a/client/src/components/AuthGate.tsx b/client/src/components/AuthGate.tsx index f4583660..b731647d 100644 --- a/client/src/components/AuthGate.tsx +++ b/client/src/components/AuthGate.tsx @@ -11,6 +11,9 @@ * `usePermissions()` says what that user's role may do, so pages hide the rest. * Once an account is known (local mode is one), its own UI preferences are * loaded into this browser (`lib/userPrefs.ts`). + * With DASHBOARD_TOKEN set, a 401 `EUNAUTHORIZED` (from `/auth/me` or any + * request, `TOKEN_REQUIRED_EVENT`) shows the token prompt instead: the token is + * kept in this browser and the page reloads with it. * The login page follows the OS's light/dark (`AuthCard`). An account whose * password an admin gave (`mustChangePassword`) sees only * `FirstPasswordPage` until it picks its own. @@ -30,7 +33,15 @@ import { useTranslation } from "react-i18next"; import { Eye, EyeOff } from "lucide-react"; import { AuthCard } from "./AuthCard"; import { FirstPasswordPage } from "./FirstPasswordPage"; -import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api"; +import { + api, + AUTH_REQUIRED_EVENT, + dashboardToken, + saveDashboardToken, + TOKEN_REQUIRED_EVENT, + type AuthState, + type AuthUser, +} from "../lib/api"; import { hydrate } from "../lib/userPrefs"; type AuthValue = { @@ -95,6 +106,8 @@ export function AuthGate({ children }: { children: ReactNode }) { // null = still asking the server. A browser that last saw local mode starts // there and is corrected by the answer (or by a 401) if that changed. const [state, setState] = useState(cachedState); + // DASHBOARD_TOKEN is set and this browser's token is missing or wrong. + const [needToken, setNeedToken] = useState(false); useEffect(() => { let timer: number | undefined; @@ -113,6 +126,10 @@ export function AuthGate({ children }: { children: ReactNode }) { }) .catch((err: { status?: number; code?: string }) => { if (!alive) return; + if (err?.status === 401 && err?.code === "EUNAUTHORIZED") { + setNeedToken(true); + return; + } // An answer that is not this build's: an older server still running // under a freshly built UI (404, or its index.html → EBADRESPONSE), or // DASHBOARD_TOKEN's own 401 — neither is server mode, so render the @@ -134,10 +151,13 @@ export function AuthGate({ children }: { children: ReactNode }) { // Any request answering 401 EAUTH means server mode with no valid login. const onRequired = () => setState({ mode: "server", user: null }); window.addEventListener(AUTH_REQUIRED_EVENT, onRequired); + const onToken = () => setNeedToken(true); + window.addEventListener(TOKEN_REQUIRED_EVENT, onToken); return () => { alive = false; window.clearTimeout(timer); window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired); + window.removeEventListener(TOKEN_REQUIRED_EVENT, onToken); }; }, []); @@ -162,6 +182,7 @@ export function AuthGate({ children }: { children: ReactNode }) { [] ); + if (needToken) return ; if (!state) return null; if (state.mode === "server" && !state.user) { return setState({ mode: "server", user })} />; @@ -266,3 +287,65 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) { ); } + +/** + * DASHBOARD_TOKEN's own prompt: the token is kept in this browser + * (`localStorage.dashboard_token`) and the page reloads, so every request, + * WebSocket and image starts again with it. A token already saved here that + * the server refused is shown as wrong. + */ +function TokenPage() { + const { t } = useTranslation(); + const [token, setToken] = useState(""); + const [show, setShow] = useState(false); + const hadOne = Boolean(dashboardToken()); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const value = token.trim(); + if (!value) return; + saveDashboardToken(value); + window.location.reload(); + }; + + return ( + +
+ +
+ setToken(e.target.value)} + /> + +
+
+ {hadOne && ( +

+ {t("auth.tokenWrong")} +

+ )} + +

{t("auth.tokenHint")}

+
+ ); +} diff --git a/client/src/components/Toaster.tsx b/client/src/components/Toaster.tsx index 13d3bf29..ab022fd5 100644 --- a/client/src/components/Toaster.tsx +++ b/client/src/components/Toaster.tsx @@ -8,18 +8,26 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { AlertCircle, Check, Info, X } from "lucide-react"; +import { AlertCircle, AlertTriangle, Check, Info, X } from "lucide-react"; import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast"; const TONE: Record = { success: "border-status-success/40 bg-status-success/10 text-status-success", info: "border-border bg-surface-2 text-fg-primary", error: "border-status-danger/50 bg-status-danger/10 text-status-danger", + warning: "border-status-warning/50 bg-status-warning/10 text-fg-primary", }; function Item({ item }: { item: ToastItem }) { const { t } = useTranslation("common"); - const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check; + const Icon = + item.kind === "error" + ? AlertCircle + : item.kind === "warning" + ? AlertTriangle + : item.kind === "info" + ? Info + : Check; return (
({ AUTH_REQUIRED_EVENT: "agenthub:auth-required", + TOKEN_REQUIRED_EVENT: "agenthub:token-required", + dashboardToken: () => localStorage.getItem("dashboard_token"), + saveDashboardToken: (t: string) => localStorage.setItem("dashboard_token", t), api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() }, lanes: { list: () => Promise.resolve({ lanes: LANES }) }, @@ -117,12 +120,42 @@ describe("AuthGate", () => { } }); - it("renders the app when an older server or a dashboard token answers instead", async () => { - for (const err of [ - { status: 404 }, - { status: 200, code: "EBADRESPONSE" }, - { status: 401, code: "EUNAUTHORIZED" }, - ]) { + it("asks for the dashboard token when DASHBOARD_TOKEN refuses this browser", async () => { + me.mockRejectedValue(Object.assign(new Error("x"), { status: 401, code: "EUNAUTHORIZED" })); + const reload = vi.fn(); + const loc = window.location; + // @ts-expect-error test override + delete window.location; + // @ts-expect-error test override + window.location = { ...loc, reload }; + try { + render(app); + const input = await screen.findByLabelText("Token"); + expect(screen.queryByText("app")).not.toBeInTheDocument(); + expect(screen.queryByText(/token was not accepted/)).not.toBeInTheDocument(); + fireEvent.change(input, { target: { value: " s3cret " } }); + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + expect(localStorage.getItem("dashboard_token")).toBe("s3cret"); + expect(reload).toHaveBeenCalled(); + } finally { + // @ts-expect-error test override + window.location = loc; + } + }); + + it("says a saved token was refused, and asks on any request's 401 too", async () => { + localStorage.setItem("dashboard_token", "old"); + me.mockResolvedValue({ mode: "local" }); + render(app); + expect(await screen.findByText("app")).toBeInTheDocument(); + act(() => { + window.dispatchEvent(new Event("agenthub:token-required")); + }); + expect(await screen.findByText(/token was not accepted/)).toBeInTheDocument(); + }); + + it("renders the app when an older server answers instead", async () => { + for (const err of [{ status: 404 }, { status: 200, code: "EBADRESPONSE" }]) { me.mockReset(); me.mockRejectedValue(Object.assign(new Error("x"), err)); const { unmount } = render(app); diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx index 281a8234..5f074878 100644 --- a/client/src/components/conversation/MessageList.tsx +++ b/client/src/components/conversation/MessageList.tsx @@ -118,7 +118,7 @@ import { } from "lucide-react"; import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView"; import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types"; -import { api } from "../../lib/api"; +import { api, withToken } from "../../lib/api"; import { copyText } from "../../lib/clipboard"; /** The icon of a transcript row's sender. A JSONL `type:"user"` line is not @@ -1656,7 +1656,7 @@ function DropFile({ path }: { path: string }) { function DropImage({ name }: { name: string }) { const [open, setOpen] = useState(false); const [gone, setGone] = useState(false); - const src = `/api/run/drop/${encodeURIComponent(name)}`; + const src = withToken(`/api/run/drop/${encodeURIComponent(name)}`); // Drops are pruned after a day: the name is all that is left to show. if (gone) return ( diff --git a/client/src/components/run/ChatFilePreview.tsx b/client/src/components/run/ChatFilePreview.tsx index 571b569d..82cc0e3f 100644 --- a/client/src/components/run/ChatFilePreview.tsx +++ b/client/src/components/run/ChatFilePreview.tsx @@ -35,7 +35,7 @@ import { Share2, X, } from "lucide-react"; -import { api, gitTarget } from "../../lib/api"; +import { api, gitTarget, withToken } from "../../lib/api"; import { copyText } from "../../lib/clipboard"; import { useDialog } from "../../hooks/useDialog"; import { dragSkill } from "../../lib/promptAttach"; @@ -116,7 +116,7 @@ function useContent(path: string, scope: PreviewScope): Content { }; const drop = DROP_RE.exec(path); if (drop) { - const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`; + const src = withToken(`/api/run/drop/${encodeURIComponent(drop[1]!)}`); if (IMAGE_RE.test(path)) setContent({ kind: "image", src }); else if (DROP_TEXT_RE.test(path)) show( diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index f4950fa8..15634a94 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -72,7 +72,7 @@ import { ArrowDown, ArrowUp, X } from "lucide-react"; import "@xterm/xterm/css/xterm.css"; import { useTheme } from "../../hooks/useTheme"; import { terminalThemeFor } from "../../lib/terminalTheme"; -import { api } from "../../lib/api"; +import { api, withToken } from "../../lib/api"; import { writeClipboard } from "../../lib/clipboard"; import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste"; import { @@ -717,7 +717,11 @@ export const TerminalView = memo(function TerminalView({ // own size, so a pane on a background tab is never resized by it. const size = hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : ""; - const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`); + // The dashboard token, when one is set, rides the query like `/ws`'s: + // the server refuses the upgrade without it. + const sock = new WebSocket( + withToken(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`) + ); ws = sock; wsRef.current = sock; // Server sends PTY bytes as binary frames — default binaryType ("blob") diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index de9ad21c..0962ba47 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -43,7 +43,10 @@ const nativePasteMock = vi.fn(() => Promise.resolve({ native: true })); type Held = { files: { path: string; size: number | null; dir: boolean }[] }; const clipboardFilesMock = vi.fn((): Promise => Promise.resolve({ files: [] })); const selectedFilesMock = vi.fn((): Promise => Promise.resolve({ files: [] })); -vi.mock("../../../lib/api", () => ({ +vi.mock("../../../lib/api", async () => ({ + // The real one: it reads the token from localStorage, as the page does. + withToken: (await vi.importActual("../../../lib/api")) + .withToken, api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane), @@ -236,6 +239,31 @@ describe("TerminalView", () => { } }); + it("carries the dashboard token after its size, as /ws does (DASHBOARD_TOKEN)", () => { + // The server refuses a /ws-pty upgrade without it: the terminal sat at + // "reconnecting" forever while the status bar, on /ws, said Live. + localStorage.setItem("dashboard_token", "t ok"); + const restore = withBox(); + try { + render(); + expect(MockWebSocket.instances[0]!.url).toBe( + "ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28&token=t%20ok" + ); + } finally { + restore(); + } + }); + + it("carries the token as the only query when it has no size yet", () => { + localStorage.setItem("dashboard_token", "tok"); + termState.cols = 0; + termState.rows = 0; + render(); + expect(MockWebSocket.instances[0]!.url).toBe( + "ws://localhost:4820/ws-pty/agenthub-lane-1?token=tok" + ); + }); + it("a terminal on a hidden tab sends no size until it is shown", () => { // Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and // that size resized the real tmux pane under every other client. diff --git a/client/src/i18n/locales/en/common.json b/client/src/i18n/locales/en/common.json index 260eb4f6..5f18999d 100644 --- a/client/src/i18n/locales/en/common.json +++ b/client/src/i18n/locales/en/common.json @@ -206,7 +206,13 @@ "confirmPassword": "Confirm new password", "mismatch": "The two new passwords differ", "mustChangeSubmit": "Save and continue", - "saving": "Saving…" + "saving": "Saving…", + "tokenTitle": "Dashboard token", + "tokenSubtitle": "This dashboard requires its access token (DASHBOARD_TOKEN). It is saved in this browser only.", + "tokenWrong": "That token was not accepted — check it and try again.", + "tokenLabel": "Token", + "tokenHint": "On the dashboard's machine, `agenthub start` prints a link that carries it.", + "tokenSubmit": "Continue" }, "insights": { "help": { diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index d5140cdf..08d55e5e 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -735,6 +735,7 @@ "fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.", "fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.", "tmuxUpgrade": "Upgrade tmux: {{advice}}", + "tmuxUpgradeOptional": "tmux is older than recommended — terminals work as they are; this message shows once. {{advice}}", "changes.rescanRepos": "Scan for repositories again", "tree.newGroup": "New group", "tree.groupDefaultName": "New group", diff --git a/client/src/i18n/locales/vi/common.json b/client/src/i18n/locales/vi/common.json index 63b5c411..4a4c7500 100644 --- a/client/src/i18n/locales/vi/common.json +++ b/client/src/i18n/locales/vi/common.json @@ -206,7 +206,13 @@ "confirmPassword": "Nhập lại mật khẩu mới", "mismatch": "Hai mật khẩu mới không khớp", "mustChangeSubmit": "Lưu và tiếp tục", - "saving": "Đang lưu…" + "saving": "Đang lưu…", + "tokenTitle": "Token của dashboard", + "tokenSubtitle": "Dashboard này yêu cầu token truy cập (DASHBOARD_TOKEN). Token chỉ được lưu trong trình duyệt này.", + "tokenWrong": "Token không đúng — kiểm tra lại rồi thử lần nữa.", + "tokenLabel": "Token", + "tokenHint": "Trên máy chạy dashboard, lệnh `agenthub start` in sẵn một đường link có kèm token.", + "tokenSubmit": "Tiếp tục" }, "insights": { "help": { diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 9a55527c..a4603fca 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -735,6 +735,7 @@ "fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.", "fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.", "tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}", + "tmuxUpgradeOptional": "tmux đã cũ — terminal vẫn chạy bình thường; thông báo này chỉ hiện một lần. {{advice}}", "changes.rescanRepos": "Quét lại các repository", "tree.newGroup": "Nhóm mới", "tree.groupDefaultName": "Nhóm mới", diff --git a/client/src/lib/__tests__/apiDashboardToken.test.ts b/client/src/lib/__tests__/apiDashboardToken.test.ts new file mode 100644 index 00000000..b8e5082b --- /dev/null +++ b/client/src/lib/__tests__/apiDashboardToken.test.ts @@ -0,0 +1,51 @@ +/** + * @file apiDashboardToken.test.ts + * @description DASHBOARD_TOKEN in the browser: `withToken` adds `?token=` (or + * `&token=`) to what cannot send the header — WebSockets, images, downloads — + * and leaves the URL alone with no token; `adoptTokenFromUrl` keeps the token + * of the link `agenthub start` prints and takes it out of the address bar; + * a 401 EUNAUTHORIZED from any request fires TOKEN_REQUIRED_EVENT. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { adoptTokenFromUrl, api, TOKEN_REQUIRED_EVENT, withToken } from "../api"; + +beforeEach(() => localStorage.clear()); +afterEach(() => vi.unstubAllGlobals()); + +describe("the dashboard token in the browser", () => { + it("rides the query only when there is one", () => { + expect(withToken("/api/x")).toBe("/api/x"); + localStorage.setItem("dashboard_token", "a b"); + expect(withToken("/api/x")).toBe("/api/x?token=a%20b"); + expect(withToken("/api/x?path=1")).toBe("/api/x?path=1&token=a%20b"); + expect(api.artifacts.rawUrl(3, "f.png")).toBe("/api/artifacts/3/raw?path=f.png&token=a%20b"); + }); + + it("is taken from the page's ?token= and dropped from the address bar", () => { + window.history.replaceState(null, "", "/workspace?lane=2&token=t0k#x"); + adoptTokenFromUrl(); + expect(localStorage.getItem("dashboard_token")).toBe("t0k"); + expect(window.location.pathname + window.location.search + window.location.hash).toBe( + "/workspace?lane=2#x" + ); + }); + + it("is asked for when a request answers 401 EUNAUTHORIZED", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { code: "EUNAUTHORIZED", message: "no" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }) + ) + ); + const seen = vi.fn(); + window.addEventListener(TOKEN_REQUIRED_EVENT, seen); + await expect(api.auth.me()).rejects.toMatchObject({ status: 401 }); + window.removeEventListener(TOKEN_REQUIRED_EVENT, seen); + expect(seen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 3d8954fc..ebd8fa19 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -505,6 +505,24 @@ export function dashboardToken(): string | null { } } +/** + * `url` with the dashboard token (if one is configured) in its query string — + * for what cannot send the `x-dashboard-token` header: an ``, an + * ``, an iframe, a WebSocket, a bare `fetch`. Unchanged when no + * token is set (the loopback default). + */ +export function withToken(url: string): string { + const token = dashboardToken(); + if (!token) return url; + return `${url}${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`; +} + +/** The token header for a bare `fetch` that bypasses {@link request}; empty with no token. */ +export function tokenHeader(): Record { + const token = dashboardToken(); + return token ? { "x-dashboard-token": token } : {}; +} + /** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */ function ccQuery(scope?: CcScope, cwd?: string): string { const qs = new URLSearchParams(); @@ -546,6 +564,36 @@ function ccQuery(scope?: CcScope, cwd?: string): string { */ /** Fired on `window` when a request answers 401 `EAUTH` (server mode). */ export const AUTH_REQUIRED_EVENT = "agenthub:auth-required"; +/** Fired on a 401 EUNAUTHORIZED: DASHBOARD_TOKEN is set and this browser's is missing or wrong. */ +export const TOKEN_REQUIRED_EVENT = "agenthub:token-required"; + +/** Keep `token` as this browser's dashboard token (an empty one forgets it). */ +export function saveDashboardToken(token: string): void { + try { + if (token) localStorage.setItem("dashboard_token", token); + else localStorage.removeItem("dashboard_token"); + } catch { + /* storage blocked: the prompt will ask again */ + } +} + +/** + * A `?token=` on the page's own URL (the link `agenthub start` prints): keep + * it in this browser and take it out of the address bar and history, so it is + * not left in a screenshot or a bookmark. Call once, before the first request. + */ +export function adoptTokenFromUrl(): void { + try { + const url = new URL(window.location.href); + const token = url.searchParams.get("token"); + if (!token) return; + saveDashboardToken(token); + url.searchParams.delete("token"); + window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash); + } catch { + /* no URL API / history: nothing to adopt */ + } +} /** One account in Settings → Users (`GET /api/users`). */ export type ServerUserUsage = AuthUser & { @@ -1066,6 +1114,10 @@ async function send(path: string, options?: RequestInit): Promise { if (res.status === 401 && body?.error?.code === "EAUTH") { window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT)); } + // DASHBOARD_TOKEN: this browser has none or a wrong one — AuthGate asks. + if (res.status === 401 && body?.error?.code === "EUNAUTHORIZED") { + window.dispatchEvent(new Event(TOKEN_REQUIRED_EVENT)); + } // Server mode: a role refusal reads the same in every toast, translated. const message = body?.error?.code === "EADMIN" @@ -1212,6 +1264,9 @@ const sendRaw = ( if (xhr.status === 401 && body.error?.code === "EAUTH") { window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT)); } + if (xhr.status === 401 && body.error?.code === "EUNAUTHORIZED") { + window.dispatchEvent(new Event(TOKEN_REQUIRED_EVENT)); + } reject( Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), { code: body.error?.code, @@ -1546,7 +1601,7 @@ export const api = { signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }), /** URL of this account's avatar, versioned by `avatarAt` (null = none). */ avatarUrl: (avatarAt?: string | null) => - avatarAt ? `${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}` : null, + avatarAt ? withToken(`${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}`) : null, /** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */ setAvatar: (file: Blob) => request("/me/avatar", { @@ -1579,7 +1634,7 @@ export const api = { users: { /** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */ avatarUrl: (id: number, avatarAt?: string | null) => - avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null, + avatarAt ? withToken(`${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}`) : null, /** GET /api/users — every account with its recent usage (admin/analyst). */ list: () => request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>( @@ -1941,7 +1996,7 @@ export const api = { /** GET /api/sessions/:id/image — an image the session's Read tool read, * wherever it lies (local mode only), for an ``. */ readImageUrl: (id: string, path: string) => - `${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`, + withToken(`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`), /** GET /api/sessions/:id/file — a text file the session itself read or * wrote, wherever it lies (an output outside the workspace). */ readFile: (id: string, path: string) => @@ -2451,7 +2506,7 @@ export const api = { * * @returns The absolute-on-origin URL (`/api/settings/export`) to link to. */ - exportData: () => `${BASE}/settings/export`, + exportData: () => withToken(`${BASE}/settings/export`), /** * POST /api/settings/import (multipart) - restore a bundle previously * produced by {@link exportData}. Idempotent and non-destructive: sessions @@ -2460,7 +2515,7 @@ export const api = { * * Like {@link api.import.upload}, this bypasses {@link request} to send a * `multipart/form-data` body (field name "file") and let the browser set the - * boundary. No auth token is attached (local/zero-config flow). + * boundary. The dashboard token, when one is set, rides the header. * * @param file The `.json` export file the user selected. * @returns {@link ImportBackupResult} — per-table restore counts. @@ -2469,7 +2524,11 @@ export const api = { importData: async (file: File): Promise => { const form = new FormData(); form.append("file", file, file.name); - const res = await fetch(`${BASE}/settings/import`, { method: "POST", body: form }); + const res = await fetch(`${BASE}/settings/import`, { + method: "POST", + body: form, + headers: tokenHeader(), + }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body?.error?.message || `HTTP ${res.status}`); @@ -2778,7 +2837,11 @@ export const api = { // Append each file under the repeated "files" field, keeping its filename. for (const f of files) form.append("files", f, f.name); // Do NOT set Content-Type manually: the browser adds the multipart boundary. - const res = await fetch(`${BASE}/import/upload`, { method: "POST", body: form }); + const res = await fetch(`${BASE}/import/upload`, { + method: "POST", + body: form, + headers: tokenHeader(), + }); if (!res.ok) { // Same error-shaping contract as request(), duplicated because this call // intentionally does not route through the JSON wrapper. @@ -3367,8 +3430,8 @@ export const api = { /** One file's download URL, on a workspace or a home. */ downloadUrl: (t: FsPlace, path: string) => isHomePlace(t) - ? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}` - : `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`, + ? withToken(`${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`) + : withToken(`${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`), list: (t: FsPlace, path = "") => request(fsUrl(t, "/list", new URLSearchParams({ path }))), mkdir: (t: FsPlace, path: string) => @@ -3430,7 +3493,7 @@ export const api = { zipUrl: (t: FsPlace, paths: string[]) => { const q = new URLSearchParams(); for (const p of paths) q.append("p", p); - return `${BASE}${fsUrl(t, "/zip", q)}`; + return withToken(`${BASE}${fsUrl(t, "/zip", q)}`); }, /** * Stream `file` to `path` (sub-folders made as needed), reporting upload @@ -3769,14 +3832,14 @@ export const api = { if (path) qs.set("path", path); if (download) qs.set("download", "1"); const s = qs.toString(); - return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`; + return withToken(`${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`); }, }, /** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */ pets: { list: () => request("/pets"), /** The pet's spritesheet (a Codex pet) or image, served raster-only. */ - spriteUrl: (id: string) => `${BASE}/pets/${encodeURIComponent(id)}/sprite`, + spriteUrl: (id: string) => withToken(`${BASE}/pets/${encodeURIComponent(id)}/sprite`), /** POST /api/pets/import — a `.codex-pet.zip` (pet.json + spritesheet.webp). */ importBundle: (file: File) => request<{ pet: PetView }>("/pets/import", { @@ -3859,11 +3922,7 @@ export const api = { job: () => request("/ai-tools/job"), /** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */ /** The server-cached icon; an cannot send the bearer header, so the token rides the query. */ - iconUrl: (id: string) => { - const token = dashboardToken(); - const q = token ? `?token=${encodeURIComponent(token)}` : ""; - return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`; - }, + iconUrl: (id: string) => withToken(`${BASE}/ai-tools/${encodeURIComponent(id)}/icon`), }, // ──────────────────────────────── AI Proxy API ────────────────────────────── /** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a @@ -3976,9 +4035,12 @@ export const api = { /** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its * version, and this platform's upgrade advice when it is too old. */ tmuxAvailable: () => - request<{ available: boolean; version?: string | null; upgrade?: string | null }>( - "/run/tmux" - ), + request<{ + available: boolean; + version?: string | null; + upgrade?: string | null; + upgradeSeverity?: "warning" | "error" | null; + }>("/run/tmux"), /** GET /api/run/cwds - suggested working directories for the cwd picker. */ cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"), /** GET /api/run/files - path-completion suggestions under `cwd`; `dirs` @@ -4213,7 +4275,7 @@ export const api = { qs.delete("limit"); qs.delete("offset"); qs.set("format", format); - return `${BASE}/query?${qs.toString()}`; + return withToken(`${BASE}/query?${qs.toString()}`); }, }, @@ -4235,7 +4297,7 @@ export const api = { runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`), /** A run's file, as a download link. */ fileUrl: (id: number, runId: number, format: string) => - `${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`, + withToken(`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`), }, // ───────────────────────────────── Webhooks API ───────────────────────────── @@ -4797,11 +4859,11 @@ export const api = { request(gitUrl(id, "/file", new URLSearchParams({ path, rev }))), /** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an ``. */ gitRawUrl: (id: GitTarget, path: string, rev = "work") => - `${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`, + withToken(`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`), /** GET /api/lanes/:id/git/download — one working file as an attachment * (any type; a plain, non-git folder too). For an ``. */ gitDownloadUrl: (id: GitTarget, path: string) => - `${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`, + withToken(`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`), /** * POST /api/lanes/:id/git/upload — create `file` in the folder `dir` * ("" = the workspace root). Only creates: a taken name answers 409 diff --git a/client/src/lib/notify.ts b/client/src/lib/notify.ts index 7d9d8bde..bb62a1fb 100644 --- a/client/src/lib/notify.ts +++ b/client/src/lib/notify.ts @@ -9,6 +9,7 @@ */ import type { NotifSound } from "./notifPrefs"; +import { tokenHeader } from "./api"; const audio = new Map(); @@ -63,7 +64,7 @@ export async function notify(title: string, body: string, url?: string): Promise try { const res = await fetch("/api/push/send", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...tokenHeader() }, body: JSON.stringify({ title, body, url }), }); const result = res.ok ? await res.json().catch(() => null) : null; diff --git a/client/src/lib/promptAttach.ts b/client/src/lib/promptAttach.ts index 6ff6257d..90014fa5 100644 --- a/client/src/lib/promptAttach.ts +++ b/client/src/lib/promptAttach.ts @@ -17,7 +17,7 @@ */ import type { DragEvent } from "react"; -import { api } from "./api"; +import { api, withToken } from "./api"; /** One attachment, shaped like `PromptImage`: what is sent, and its label. */ export interface Attachment { @@ -331,7 +331,12 @@ function readRecent(): Recent[] { } export function recentUploads(): Attachment[] { - return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) })); + return readRecent().map(({ path, name, url }) => ({ + path, + name, + // Stored without the token; added on the way out. + ...(url ? { url: withToken(url) } : {}), + })); } /** Remember uploads just saved, newest first; an image keeps a thumbnail diff --git a/client/src/lib/push.ts b/client/src/lib/push.ts index 9311bd2d..a9cb836b 100644 --- a/client/src/lib/push.ts +++ b/client/src/lib/push.ts @@ -23,6 +23,9 @@ * * @author Nguyễn Ngọc Trí Vĩ */ + +import { tokenHeader } from "./api"; + /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= @@ -138,7 +141,7 @@ export async function subscribeToPush(): Promise { if (existing) return; // already subscribed -> idempotent no-op // Fetch the server's VAPID public key and decode it for `applicationServerKey`. - const res = await fetch("/api/push/vapid-public-key"); + const res = await fetch("/api/push/vapid-public-key", { headers: tokenHeader() }); const { publicKey } = await res.json(); const subscription = await registration.pushManager.subscribe({ @@ -149,7 +152,7 @@ export async function subscribeToPush(): Promise { // Register the endpoint + keys with the backend so it can push to this browser. await fetch("/api/push/subscribe", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...tokenHeader() }, body: JSON.stringify(subscription.toJSON()), // { endpoint, keys: { p256dh, auth } } }); } @@ -179,7 +182,7 @@ export async function unsubscribeFromPush(): Promise { // Tell the backend to forget this endpoint so it stops attempting deliveries. await fetch("/api/push/subscribe", { method: "DELETE", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...tokenHeader() }, body: JSON.stringify({ endpoint }), }); } diff --git a/client/src/lib/toast.ts b/client/src/lib/toast.ts index 781baf4d..1a659825 100644 --- a/client/src/lib/toast.ts +++ b/client/src/lib/toast.ts @@ -2,12 +2,13 @@ * @file toast.ts * @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)` * from anywhere, rendered once by `components/Toaster.tsx` inside live regions. - * A success fades after a few seconds; an error stays until dismissed, because + * A success fades after a few seconds; an error — and an amber `warning`, for + * advice that is worth reading but is no failure — stays until dismissed, because * a message that vanishes before it is read is no message at all (WCAG 2.2.1). * @author Nguyễn Ngọc Trí Vĩ */ -export type ToastKind = "success" | "error" | "info"; +export type ToastKind = "success" | "error" | "info" | "warning"; export interface ToastItem { id: number; @@ -43,7 +44,8 @@ function push(kind: ToastKind, message: string): number { } items = next; emit(); - if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS); + // An error or a warning stays until closed, so its advice can be copied. + if (kind !== "error" && kind !== "warning") window.setTimeout(() => dismissToast(id), SUCCESS_MS); return id; } @@ -59,6 +61,8 @@ export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void { export const toast = { success: (message: string) => push("success", message), info: (message: string) => push("info", message), + /** Amber and sticky: worth reading, not a failure. */ + warning: (message: string) => push("warning", message), error: (message: unknown) => push("error", message instanceof Error ? message.message : String(message)), }; diff --git a/client/src/main.tsx b/client/src/main.tsx index 8c73e1b6..4f66c7fc 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -73,6 +73,7 @@ import "@fontsource/maple-mono/700.css"; import App from "./App"; import { AuthGate } from "./components/AuthGate"; import { i18nReady } from "./i18n"; +import { adoptTokenFromUrl } from "./lib/api"; import "./index.css"; // Deep link into the Workspace: start its (lazy) chunk now, in parallel with the @@ -103,6 +104,9 @@ if ("serviceWorker" in navigator) { .catch(() => {}); } +// `agenthub start` prints a link carrying `?token=`: keep it, drop it from the URL. +adoptTokenFromUrl(); + const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); diff --git a/client/src/pages/Workspace.tsx b/client/src/pages/Workspace.tsx index 267e715c..62e05240 100644 --- a/client/src/pages/Workspace.tsx +++ b/client/src/pages/Workspace.tsx @@ -71,7 +71,7 @@ import { ChevronRight, GitBranch, } from "lucide-react"; -import { api, type GitSource } from "../lib/api"; +import { api, withToken, type GitSource } from "../lib/api"; import { useInnerReposMode } from "../lib/innerReposPref"; import { usePermissions } from "../components/AuthGate"; import type { @@ -661,16 +661,29 @@ export function Workspace() { const { local } = usePermissions(); const [searchParams, setSearchParams] = useSearchParams(); - // The server's tmux is older than this platform needs (psmux < 3.4.0 on - // Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is. - // Sticky (an error toast) so the command can be copied. An older server or - // no answer says nothing. + // The server's tmux is older than this platform needs: say how to upgrade + // it. An old psmux (Windows) stalls terminals — an error, every visit. An old + // tmux only lacks the pane scrollbar — an amber warning, shown once per + // version in this browser (closing it is for good). Both sticky, so the + // command can be copied. An older server or no answer says nothing. useEffect(() => { let live = true; (async () => { try { - const { upgrade } = await api.run.tmuxAvailable(); - if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade })); + const { upgrade, upgradeSeverity } = await api.run.tmuxAvailable(); + if (!live || !upgrade) return; + if (upgradeSeverity === "warning") { + const SEEN = "agenthub.tmuxUpgrade.seen"; + try { + if (localStorage.getItem(SEEN) === upgrade) return; + localStorage.setItem(SEEN, upgrade); + } catch { + /* storage blocked: show it */ + } + toast.warning(tLanes("tmuxUpgradeOptional", { advice: upgrade })); + return; + } + toast.error(tLanes("tmuxUpgrade", { advice: upgrade })); } catch { /* nothing to say */ } @@ -1477,7 +1490,9 @@ export function Workspace() {
{proofFeature.ticket_report && ( ` header - `?token=` query parameter +Local tools find it without being told: the server writes the active token to `/runtime/dashboard-token` (0600) at start and removes the file when no token is set, and the `agenthub` CLI, the MCP server and the statusline send `DASHBOARD_TOKEN` from their own environment, else that file. The browser keeps it in `localStorage` (`dashboard_token`) and adds it as `?token=` to what cannot send a header — the `/ws` and `/ws-pty/*` WebSockets, images, downloads. A refused WebSocket upgrade answers `401`/`403` and the server logs why (`[WS] refused …`, at most one line per reason a minute). + These paths stay exempt even when a token is configured: `/api/health`, `/api/openapi.json`, `/api/docs`, and `/api/hooks` (local Claude Code hook ingestion). Requests that fail the check get `401` with error code `EUNAUTHORIZED`. ### Server mode login (`AGENTHUB_MODE=server`) @@ -3387,7 +3389,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back ```http GET /api/run List all live runs (computed fresh from tmux state) -GET /api/run/tmux { available: bool, version, upgrade } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; the Workspace shows it as a sticky toast +GET /api/run/tmux { available: bool, version, upgrade, upgradeSeverity } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; `upgradeSeverity` is `error` for an old psmux (terminals stall — a red toast on every visit) and `warning` for an old tmux (only the pane scrollbar is missing — an amber toast, once per version in a browser) GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary GET /api/run/cwds Suggested cwds (dashboard, home, recent) GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs diff --git a/docs/CLI.md b/docs/CLI.md index f0654f77..c0c6640c 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -99,7 +99,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it | Command | Description | | ------- | ----------- | | `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down | -| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` | +| `agenthub start [--port N] [--host A[,B]\|local] [--allowed-hosts H,..\|none] [--token T\|auto\|off]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. `--host`, `--allowed-hosts` and `--token` are saved in `/network.json` (0600) and applied by every later start (a set environment variable wins); a non-loopback `--host` without a token gets one generated. The links to open — every address of a `0.0.0.0` bind, each with `?token=` — are printed. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` | | `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag | | `agenthub uninstall [--yes]` | **Uninstall AgentHub** (`scripts/uninstall.js`; Settings → System has the same button). Without `--yes` it only lists what is there and exits 1. With it, in order: closes every workspace terminal, removes the systemd/launchd unit, stops every dashboard server, the status-area icon and workspace stacks, takes AgentHub's own entries out of `~/.claude/settings.json` (hooks, statusline; backup `settings.json.agenthub-uninstall.bak`), Codex `hooks.json` and Antigravity `hooks.json`, deletes the AI Proxy `$CODEX_HOME/.config.toml` files, runs `claude plugin uninstall` for `agenthub@*` and every plugin of the `agenthub` marketplace and removes that marketplace (never a shared one), then deletes `~/.local/bin/agenthub`, `~/.config/agenthub/`, `~/.claude/.agenthub.json`, `~/.claude/agenthub-{proxies,routers,git-credentials,accounts,profiles}`, `agenthub-lanes/.state` and `.locks`, the plugin's cached copies and the data directory (database included). **Kept:** the AI tools' own conversations (`~/.claude/projects`, `~/.codex/sessions`, …), workspace worktrees and branches, anything in your repositories, config backups. A data directory that is the home, `CLAUDE_HOME`, a checkout or not recognisably AgentHub's is refused. A failing step is logged and the rest still run; log: `$TMPDIR/agenthub-uninstall-.log`. Needs no running server. Run inside a dashboard terminal, it closes that terminal last. | | `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running | diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 231faeb3..783b27c0 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -245,7 +245,9 @@ NODE_ENV=production # Environment mode # The server binds 127.0.0.1 by default and is NOT network-reachable. It reads # transcripts, exports all data, and can spawn `claude`, so only widen the bind # deliberately — and require a token when you do. -DASHBOARD_HOST=127.0.0.1 # set 0.0.0.0 ONLY if you must expose it +DASHBOARD_HOST=127.0.0.1 # set 0.0.0.0 ONLY if you must expose it; a list + # (192.168.1.20,100.64.0.7) binds each, plus 127.0.0.1 + # (`agenthub start --host` saves it in /network.json) DASHBOARD_TOKEN= # required on /api/* + WS when set; use with a non-loopback host DASHBOARD_ALLOWED_HOSTS= # extra Host names (comma-sep) for a LAN bind diff --git a/docs/MCP.md b/docs/MCP.md index 76cb1d02..5962a532 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -38,9 +38,12 @@ build, so the registration survives plugin updates. | 3 | `~/.claude/.agenthub.json` — the live-dashboard discovery file the CLI uses (`server/lib/dashboard-url.js`) | | 4 | `http://127.0.0.1:4820` | -When the dashboard sets `DASHBOARD_TOKEN`, give the MCP process the same -variable (`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`); it is -sent as `Authorization: Bearer`. +When the dashboard sets `DASHBOARD_TOKEN`, the MCP server sends it as +`Authorization: Bearer`: its own `DASHBOARD_TOKEN` if set, else the token the +running dashboard publishes for local tools in +`/runtime/dashboard-token` (0600, readable by the same account only). +A dashboard of another account needs the variable +(`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`). ## Tools diff --git a/docs/STATUSLINE.md b/docs/STATUSLINE.md index 340dcd7f..67a259a5 100644 --- a/docs/STATUSLINE.md +++ b/docs/STATUSLINE.md @@ -21,7 +21,7 @@ stopped dashboard costs one timeout per 30 s, not one per prompt. When the dashboard is down the part is simply left out. The script never throws, stops itself after 2.5 s and always exits 0. The dashboard is found like the CLI finds it (`AGENTHUB_URL`, then `CLAUDE_DASHBOARD_PORT`/`DASHBOARD_PORT`, then -`~/.claude/.agenthub.json`); `DASHBOARD_TOKEN` is sent when set. +`~/.claude/.agenthub.json`); `DASHBOARD_TOKEN` (else the token the running dashboard publishes in `/runtime/dashboard-token`) is sent when there is one. ## Install / uninstall diff --git a/mcp/server.js b/mcp/server.js index c71943b5..3328b32f 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -5,8 +5,8 @@ * `tools/list`, `tools/call`). Every tool is one GET against the running * dashboard's HTTP API, so the numbers are the dashboard's own; nothing here * opens the database or writes anything. The base URL is `AGENTHUB_URL`, else - * the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN`, when - * set, is sent as a bearer token. Run it as `agenthub mcp` or + * the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN` — + * else the token the running dashboard published — is sent as a bearer token. Run it as `agenthub mcp` or * `node mcp/server.js`; see docs/MCP.md. * @author Nguyễn Ngọc Trí Vĩ */ @@ -161,9 +161,11 @@ const TOOLS = { /** GET a dashboard path; resolves to an MCP tool result, never rejects. */ async function callApi(pathname) { const url = `${baseUrl()}${pathname}`; - const headers = process.env.DASHBOARD_TOKEN - ? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` } - : {}; + // DASHBOARD_TOKEN, else the token the running server published. + const token = require( + path.join(__dirname, "..", "server", "lib", "dashboard-url.js") + ).dashboardToken(); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; // Server mode: inside a dashboard pane, act as the workspace's owner. if (process.env.AGENTHUB_PANE_TOKEN) headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN; diff --git a/server/__tests__/dashboard-token-publish.test.js b/server/__tests__/dashboard-token-publish.test.js new file mode 100644 index 00000000..5c8483bb --- /dev/null +++ b/server/__tests__/dashboard-token-publish.test.js @@ -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ĩ + */ +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(), {}); + }); +}); diff --git a/server/__tests__/listen-hosts.test.js b/server/__tests__/listen-hosts.test.js new file mode 100644 index 00000000..f6691581 --- /dev/null +++ b/server/__tests__/listen-hosts.test.js @@ -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ĩ + */ +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]; + } + } + }); +}); diff --git a/server/__tests__/tmux.test.js b/server/__tests__/tmux.test.js index 4e9352d7..fa59f6bf 100644 --- a/server/__tests__/tmux.test.js +++ b/server/__tests__/tmux.test.js @@ -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); diff --git a/server/__tests__/websocket-pty-token.test.js b/server/__tests__/websocket-pty-token.test.js new file mode 100644 index 00000000..1295479a --- /dev/null +++ b/server/__tests__/websocket-pty-token.test.js @@ -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ĩ + */ +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/); + }); +}); diff --git a/server/index.js b/server/index.js index ff93b585..5b7484cd 100644 --- a/server/index.js +++ b/server/index.js @@ -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()) { diff --git a/server/lib/dashboard-url.js b/server/lib/dashboard-url.js index 413bdffe..efe31444 100644 --- a/server/lib/dashboard-url.js +++ b/server/lib/dashboard-url.js @@ -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 (`/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ĩ */ @@ -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 }; diff --git a/server/lib/network-config.js b/server/lib/network-config.js new file mode 100644 index 00000000..505cd88a --- /dev/null +++ b/server/lib/network-config.js @@ -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 `/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ĩ + */ +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, +}; diff --git a/server/lib/security.js b/server/lib/security.js index 82f3a4a6..e9ed208d 100644 --- a/server/lib/security.js +++ b/server/lib/security.js @@ -18,16 +18,77 @@ * @author Nguyễn Ngọc Trí Vĩ */ 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 diff --git a/server/lib/tmux.js b/server/lib/tmux.js index 6cfed6f8..66cf54f3 100644 --- a/server/lib/tmux.js +++ b/server/lib/tmux.js @@ -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, diff --git a/server/routes/run.js b/server/routes/run.js index fccea6e0..65290d13 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -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, }); }); diff --git a/server/websocket.js b/server/websocket.js index f315e389..e93f73e0 100644 --- a/server/websocket.js +++ b/server/websocket.js @@ -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, }; diff --git a/statusline/statusline.js b/statusline/statusline.js index b1151864..8401c04e 100644 --- a/statusline/statusline.js +++ b/statusline/statusline.js @@ -107,9 +107,11 @@ async function todayCost({ url, file = cachePath(), now = Date.now() } = {}) { } let today = null; try { - const headers = process.env.DASHBOARD_TOKEN - ? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` } - : {}; + // DASHBOARD_TOKEN, else the token the running server published. + const token = require( + path.join(__dirname, "..", "server", "lib", "dashboard-url.js") + ).dashboardToken(); + const headers = token ? { Authorization: `Bearer ${token}` } : {}; // Server mode: inside a dashboard pane, act as the workspace's owner. if (process.env.AGENTHUB_PANE_TOKEN) headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;