Compare commits

..

6 Commits

Author SHA1 Message Date
nntrivi2001 f975066c62 chore: v2.8.80 — Shift+Enter new line, chat lists 2026-11-06 15:22:24 +07:00
nntrivi2001 c557c0ad2f fix(chat): nested lists, and numbered lists split by a blank line keep counting
MarkdownContent nests list items by indent and starts an ordered list at the
number its text gives, so a list broken by a blank line or a stray block no
longer restarts at 1.
2026-11-06 15:06:09 +07:00
nntrivi2001 d086a7f198 feat(terminal): Shift+Enter adds a new line in claude/codex prompts
xterm sends a plain CR for Shift+Enter, so the prompt was submitted. Send
Ctrl+J (LF) instead: both prompts take it as a new line, a shell still runs
the line.
2026-11-06 15:06:09 +07:00
nntrivi2001 33bb3eccb5 chore: v2.8.79 — token-gated and LAN-bound dashboards; pool login recovery 2026-11-06 15:01:53 +07:00
nntrivi2001 4a665da79e 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.
2026-11-06 14:59:48 +07:00
nntrivi2001 1e3b34ec03 fix(server-mode): a pool login never strands a member on a dead pair
A home whose Claude raced the server's refresh kept a spent pair the pool
no longer recognised; the next beat dropped the home, and pick() then
treated it as the member's own login forever — the member was asked to
/login while every other home worked. The pool now remembers hashes of
its past refresh tokens (a home holding one stays the pool's and gets the
newest pair), never drops a home while fetchOwner cannot answer, and
re-reads the record after each await so a spawn's placement is not lost.
2026-11-06 14:44:14 +07:00
48 changed files with 1292 additions and 127 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.78 (2026-11-06) — sharing: payer billing, one-session shares, pane presence + typing audit._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.80 (2026-11-06) — Shift+Enter new line in terminals; chat nested and continued lists._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+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>);
@@ -2,7 +2,9 @@
* @file MarkdownContent.tsx
* @description Lightweight markdown renderer for conversation messages. Supports the
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* fenced code blocks, ATX headings, ordered/unordered lists (nested by indent; a
* numbered list split by a blank line keeps counting from its own numbers),
* task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
@@ -68,7 +70,7 @@ import { onLinkClick } from "../../lib/openInPreview";
type Block =
| { kind: "code"; lang: string; code: string }
| { kind: "heading"; level: number; text: string }
| { kind: "list"; ordered: boolean; items: string[] }
| { kind: "list"; ordered: boolean; start: number; items: string[] }
| { kind: "quote"; text: string }
| { kind: "hr" }
| {
@@ -196,27 +198,35 @@ function parseBlocks(src: string): Block[] {
if (ulMatch || olMatch) {
const ordered = !!olMatch;
const itemRe = ordered ? OL_RE : UL_RE;
const base = (olMatch ?? ulMatch)![1]!.length;
// The number the list starts at: a list broken by a blank line or a
// stray block still counts on from where the text says ("2.", "3.").
const start = olMatch ? Number(olMatch[2]) || 1 : 1;
const items: string[] = [];
while (i < lines.length) {
const m = lines[i]!.match(itemRe);
if (m) {
if (m && m[1]!.length <= base) {
items.push(m[3]!);
i++;
// The item's own lines: indented text, and a list nested under it
// (indented deeper than this list's marker) — kept with its
// relative indent so the item renders it as a sub-list.
while (
i < lines.length &&
lines[i]!.trim() !== "" &&
!lines[i]!.match(UL_RE) &&
!lines[i]!.match(OL_RE) &&
/^\s+\S/.test(lines[i]!)
/^\s+\S/.test(lines[i]!) &&
(lines[i]!.match(/^\s*/)![0].length > base ||
(!lines[i]!.match(UL_RE) && !lines[i]!.match(OL_RE)))
) {
items[items.length - 1] += "\n" + lines[i]!.trim();
const nested = UL_RE.test(lines[i]!) || OL_RE.test(lines[i]!);
items[items.length - 1] += "\n" + (nested ? lines[i]! : lines[i]!.trim());
i++;
}
} else {
break;
}
}
blocks.push({ kind: "list", ordered, items });
blocks.push({ kind: "list", ordered, start, items });
continue;
}
@@ -524,8 +534,27 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
return out;
}
/** Render a single list item, handling [ ] / [x] task list prefixes. */
/** Render a single list item: its text, then any list nested under it. */
function renderListItem(item: string, key: string): React.ReactNode {
const lines = item.split("\n");
const sub = lines.findIndex((l, n) => n > 0 && (UL_RE.test(l) || OL_RE.test(l)));
if (sub > 0) {
const rest = lines.slice(sub);
const indent = Math.min(...rest.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
return (
<>
{renderItemText(lines.slice(0, sub).join("\n"), key)}
<div className="mt-1">
<MarkdownContent text={rest.map((l) => l.slice(indent)).join("\n")} dense />
</div>
</>
);
}
return renderItemText(item, key);
}
/** An item's own text, handling [ ] / [x] task list prefixes. */
function renderItemText(item: string, key: string): React.ReactNode {
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
if (taskMatch) {
const checked = taskMatch[1]!.toLowerCase() === "x";
@@ -594,6 +623,7 @@ export function MarkdownContent({ text, dense = false, prose = false }: Markdown
return (
<ol
key={idx}
start={b.start !== 1 ? b.start : undefined}
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
>
{b.items.map((item, i) => (
@@ -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 (
@@ -89,6 +89,37 @@ describe("<MarkdownContent />", () => {
expect(container.querySelectorAll("ol li")).toHaveLength(2);
});
it("keeps one numbered list when its items carry indented bullets", () => {
const text = [
"Có 4 điểm:",
"",
"1. **yt-dlp**",
" - Hướng dẫn",
" - YouTube",
"2. **Nút Setup**",
" - site-packages",
"3. **Log khởi động**",
" Có thể bỏ dòng này.",
"4. **Nên kiểm tra**",
].join("\n");
const { container } = render(<MarkdownContent text={text} />);
const top = container.querySelectorAll(":scope > div > ol");
expect(top).toHaveLength(1);
expect(top[0]!.querySelectorAll(":scope > li")).toHaveLength(4);
expect(top[0]!.querySelectorAll("ul li")).toHaveLength(3);
expect(screen.getByText(/Có thể bỏ dòng này/)).toBeInTheDocument();
});
it("continues the numbering of a list split by a blank line or a block", () => {
const { container } = render(
<MarkdownContent text={"1. first\n\n```\ncode\n```\n\n2. second\n3. third"} />
);
const lists = container.querySelectorAll("ol");
expect(lists).toHaveLength(2);
expect(lists[0]!.getAttribute("start")).toBeNull();
expect(lists[1]!.getAttribute("start")).toBe("2");
});
it("renders blockquotes", () => {
const { container } = render(<MarkdownContent text={"> a quote"} />);
expect(container.querySelector("blockquote")).not.toBeNull();
@@ -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(
+15 -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 {
@@ -634,6 +634,15 @@ export const TerminalView = memo(function TerminalView({
term.input(altArrow, true);
return false;
}
// Shift+Enter: xterm sends a plain CR, so claude/codex SUBMIT the
// prompt. Send Ctrl+J (LF) instead — both prompts take it as a new
// line (Option+Enter is the other way, awkward on a Mac), while a
// shell still runs the line, as Enter did.
if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
ev.preventDefault();
term.input("\n", true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -717,7 +726,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.
@@ -606,6 +634,21 @@ describe("TerminalView", () => {
).toBe(true);
});
it("sends Shift+Enter as Ctrl+J, a new line in claude's prompt, not a submit", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith("\n", true);
inputMock.mockClear();
// Plain Enter and Alt+Enter stay xterm's own.
expect(handler(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(true);
expect(handler(new KeyboardEvent("keydown", { key: "Enter", altKey: true }))).toBe(true);
expect(inputMock).not.toHaveBeenCalled();
});
describe("size reporting (tmux must never be wider than the terminal)", () => {
// A tmux pane wider than xterm wraps each over-long row's tail onto the
// next row's left margin, and tmux never repaints a row it believes
+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"
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.78 (rebuild client to match)
UI build v2.8.80 (rebuild client to match)
</span>
</p>
</div>
+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
+4 -1
View File
@@ -579,7 +579,10 @@ a login is single use, so the server is the one that refreshes it, before it
expires (`claude-pool.refreshLogins`, checked every 60 s; `DASHBOARD_POOL_REFRESH_MS=0` turns it off). It then writes the new pair into every
home it placed the login in, and Claude Code picks up tokens another process
refreshed. A pair a member's Claude refreshed first is copied back only after
Anthropic confirms it is the same account. Removing the account deletes it from
Anthropic confirms it is the same account; while Anthropic does not answer, the
home is kept and checked again on the next beat. A home still holding one of the
account's earlier pairs (its Claude raced the server's refresh) stays the pool's
and gets the newest pair, so its member is not asked to `/login`. Removing the account deletes it from
the homes that still hold AgentHub's copy. Each
change asks for the admin's own password again and is written to the audit log
by account name. The token only goes in; the page, like `agenthub accounts list`,
+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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.78",
"version": "2.8.80",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.78",
"version": "2.8.80",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.78",
"version": "2.8.80",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -182,6 +182,31 @@ describe("a pool login", () => {
assert.equal(diskCreds().refreshToken, other.refreshToken);
});
it("keeps a home whose Claude kept an old pair of the pool's, and rewrites it", async () => {
pool.addLoginAccount("team", { oauth: oauth("a", 10 * 60e3), account: ACCOUNT });
pool.forSpawn(acct).place();
await pool.refreshLogins(); // the server spends a → r1
// The member's Claude raced the refresh and wrote the spent pair back.
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("a", 10 * 60e3) }));
const spawn = pool.forSpawn(acct);
assert.equal(spawn?.name, "team", "an old pair of the pool's is still the pool's home");
await pool.refreshLogins();
assert.ok("mia" in pool.__read("team").placed, "never dropped");
assert.equal(diskCreds().refreshToken, oauth("r1").refreshToken, "the newest pair written");
});
it("does not drop a home while Anthropic cannot say whose newer pair it holds", async () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
pool.forSpawn(acct).place();
const theirs = oauth("cli", 9 * H);
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: theirs }));
await pool.refreshLogins(); // fetchOwner answers null: offline
assert.ok("mia" in pool.__read("team").placed, "kept for a later beat");
owners[theirs.accessToken] = { uuid: "uuid-team" };
await pool.refreshLogins();
assert.equal(pool.__read("team").oauth.refreshToken, theirs.refreshToken, "copied back");
});
it("is taken out of the homes it is still ours in when removed", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
pool.forSpawn(acct).place();
@@ -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()) {
+56 -7
View File
@@ -46,12 +46,17 @@
* "another process landed". A home whose Claude refreshed first is copied
* back, but only after Anthropic confirms the token is the same account
* (`fetchOwner`): a member could otherwise plant a login every other
* member's panes would get.
* member's panes would get — and a home is never dropped while Anthropic
* does not answer. A home still holding one of the account's PAST pairs
* (hashes in `retired`: its Claude raced the server's refresh) stays the
* pool's and gets the newest pair, rather than being left on a dead
* login that asked its member to /login again.
* @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");
const { getClaudeHome } = require("./claude-home");
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
@@ -249,9 +254,36 @@ function placedHomes(a) {
return out;
}
/** Whether `member`'s credentials are still the pair AgentHub last wrote there for `a`. */
/** How many of a login's past refresh tokens are remembered (as hashes). */
const RETIRED_KEEP = 20;
const rtHash = (rt) =>
crypto
.createHash("sha256")
.update(String(rt || ""))
.digest("hex")
.slice(0, 32);
/**
* `a` with `old`'s refresh token remembered as one of its own past pairs. A
* home still holding such a pair (its Claude raced the server's refresh, or a
* spawn wrote a pair the server spent a moment later) is still the pool's:
* nobody but this account ever held that token.
*/
function retire(a, old) {
if (!old?.refreshToken || old.refreshToken === a.oauth?.refreshToken) return a;
const h = rtHash(old.refreshToken);
return { ...a, retired: [h, ...(a.retired || []).filter((x) => x !== h)].slice(0, RETIRED_KEEP) };
}
/**
* Whether `member`'s credentials are the pool's `a`: the pair AgentHub last
* wrote there, or one of `a`'s own past pairs (stale — the next write fixes it).
*/
const ownsHome = (a, member, disk) =>
a.kind === "login" && Boolean(disk) && disk.refreshToken === a.placed?.[member];
a.kind === "login" &&
Boolean(disk?.refreshToken) &&
(disk.refreshToken === a.placed?.[member] ||
(a.retired || []).includes(rtHash(disk.refreshToken)));
/**
* Write `a`'s login into a placement — a member's home as the member, the
@@ -297,10 +329,19 @@ async function refreshLogins() {
isOauth(disk) &&
Number(disk.expiresAt) > Number(a.oauth.expiresAt) &&
Number(disk.expiresAt) <= Date.now() + 24 * 3600e3 + FUTURE_SLACK_MS;
const owner = newer ? await usage.fetchOwner(disk.accessToken) : null;
let owner = null;
if (newer) {
owner = await usage.fetchOwner(disk.accessToken);
// A spawn may have placed the login somewhere while we waited.
a = readAccount(a.name) || a;
// Anthropic did not answer (offline, a hiccup): decide on a later
// beat. Dropping the home now would strand it on a pair only it
// holds, and no spawn would ever write the pool's login there again.
if (!owner) continue;
}
const placed = { ...a.placed };
if (owner && owner.uuid === a.account?.accountUuid) {
a = { ...a, oauth: { ...a.oauth, ...disk } };
a = retire({ ...a, oauth: { ...a.oauth, ...disk } }, a.oauth);
placed[member] = disk.refreshToken;
} else {
delete placed[member]; // their own login now: never ours to write over
@@ -310,11 +351,19 @@ async function refreshLogins() {
}
// 2. The server refreshes ahead of every home.
if (!(Number(a.oauth.expiresAt) > Date.now() + REFRESH_AHEAD_MS)) {
const old = a.oauth;
let fresh = null;
let refreshError = null;
try {
a = { ...a, oauth: await usage.exchangeRefresh(a.oauth), refreshError: null };
fresh = await usage.exchangeRefresh(old);
} catch (e) {
a = { ...a, refreshError: e.code || "EUPSTREAM" };
refreshError = e.code || "EUPSTREAM";
}
// Re-read: a spawn's place() may have saved a new placement meanwhile.
a = readAccount(a.name) || a;
a = fresh
? retire({ ...a, oauth: fresh, refreshError: null }, old)
: { ...a, refreshError };
save(a);
quotaCache.delete(a.name);
}
+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;