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

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