fix: a token-gated or LAN-bound dashboard works end to end (WSL mirrored)
- /ws-pty carries the dashboard token like /ws (the terminal never attached with DASHBOARD_TOKEN set), and so does everything that cannot send the header: images, downloads, exports, drops (withToken/tokenHeader). A refused upgrade answers 401/403 and is logged and counted by reason. - The browser asks for a missing or wrong token (401 EUNAUTHORIZED) and takes ?token= from the link agenthub start prints, dropping it from the URL. - The CLI sends the token (DASHBOARD_TOKEN, else the one the running server publishes 0600 in <data dir>/runtime/dashboard-token); MCP and statusline read the same file. - DASHBOARD_HOST takes a list, and 127.0.0.1 is always listened on too, so a LAN-only bind keeps hooks and the CLI working. - agenthub start --host/--allowed-hosts/--token are saved in <data dir>/network.json for every later start; a wide bind gets a token; every reachable URL is printed. - tmux < 3.7 is an amber, once-per-version notice that says only the pane scrollbar is missing, with a ~/.local build instead of sudo make install. - INSTALL: PATH belongs in ~/.profile for sudo -iu/ssh; WSL mirrored notes.
This commit is contained in:
+33
-2
@@ -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
@@ -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",
|
||||
"",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,18 +8,26 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Check, Info, X } from "lucide-react";
|
||||
import { AlertCircle, AlertTriangle, Check, Info, X } from "lucide-react";
|
||||
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
||||
|
||||
const TONE: Record<ToastItem["kind"], string> = {
|
||||
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
||||
info: "border-border bg-surface-2 text-fg-primary",
|
||||
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
||||
warning: "border-status-warning/50 bg-status-warning/10 text-fg-primary",
|
||||
};
|
||||
|
||||
function Item({ item }: { item: ToastItem }) {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
|
||||
const Icon =
|
||||
item.kind === "error"
|
||||
? AlertCircle
|
||||
: item.kind === "warning"
|
||||
? AlertTriangle
|
||||
: item.kind === "info"
|
||||
? Info
|
||||
: Check;
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
@@ -27,6 +27,9 @@ const LANES = [
|
||||
];
|
||||
vi.mock("../../lib/api", () => ({
|
||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||
TOKEN_REQUIRED_EVENT: "agenthub:token-required",
|
||||
dashboardToken: () => localStorage.getItem("dashboard_token"),
|
||||
saveDashboardToken: (t: string) => localStorage.setItem("dashboard_token", t),
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
@@ -117,12 +120,42 @@ describe("AuthGate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the app when an older server or a dashboard token answers instead", async () => {
|
||||
for (const err of [
|
||||
{ status: 404 },
|
||||
{ status: 200, code: "EBADRESPONSE" },
|
||||
{ status: 401, code: "EUNAUTHORIZED" },
|
||||
]) {
|
||||
it("asks for the dashboard token when DASHBOARD_TOKEN refuses this browser", async () => {
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), { status: 401, code: "EUNAUTHORIZED" }));
|
||||
const reload = vi.fn();
|
||||
const loc = window.location;
|
||||
// @ts-expect-error test override
|
||||
delete window.location;
|
||||
// @ts-expect-error test override
|
||||
window.location = { ...loc, reload };
|
||||
try {
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
const input = await screen.findByLabelText("Token");
|
||||
expect(screen.queryByText("app")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/token was not accepted/)).not.toBeInTheDocument();
|
||||
fireEvent.change(input, { target: { value: " s3cret " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(localStorage.getItem("dashboard_token")).toBe("s3cret");
|
||||
expect(reload).toHaveBeenCalled();
|
||||
} finally {
|
||||
// @ts-expect-error test override
|
||||
window.location = loc;
|
||||
}
|
||||
});
|
||||
|
||||
it("says a saved token was refused, and asks on any request's 401 too", async () => {
|
||||
localStorage.setItem("dashboard_token", "old");
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("agenthub:token-required"));
|
||||
});
|
||||
expect(await screen.findByText(/token was not accepted/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the app when an older server answers instead", async () => {
|
||||
for (const err of [{ status: 404 }, { status: 200, code: "EBADRESPONSE" }]) {
|
||||
me.mockReset();
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
||||
const { unmount } = render(<AuthGate>app</AuthGate>);
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, withToken } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
/** The icon of a transcript row's sender. A JSONL `type:"user"` line is not
|
||||
@@ -1656,7 +1656,7 @@ function DropFile({ path }: { path: string }) {
|
||||
function DropImage({ name }: { name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gone, setGone] = useState(false);
|
||||
const src = `/api/run/drop/${encodeURIComponent(name)}`;
|
||||
const src = withToken(`/api/run/drop/${encodeURIComponent(name)}`);
|
||||
// Drops are pruned after a day: the name is all that is left to show.
|
||||
if (gone)
|
||||
return (
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
Share2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { api, gitTarget, withToken } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { dragSkill } from "../../lib/promptAttach";
|
||||
@@ -116,7 +116,7 @@ function useContent(path: string, scope: PreviewScope): Content {
|
||||
};
|
||||
const drop = DROP_RE.exec(path);
|
||||
if (drop) {
|
||||
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
|
||||
const src = withToken(`/api/run/drop/${encodeURIComponent(drop[1]!)}`);
|
||||
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
|
||||
else if (DROP_TEXT_RE.test(path))
|
||||
show(
|
||||
|
||||
@@ -72,7 +72,7 @@ import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, withToken } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
|
||||
import {
|
||||
@@ -717,7 +717,11 @@ export const TerminalView = memo(function TerminalView({
|
||||
// own size, so a pane on a background tab is never resized by it.
|
||||
const size =
|
||||
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
// The dashboard token, when one is set, rides the query like `/ws`'s:
|
||||
// the server refuses the upgrade without it.
|
||||
const sock = new WebSocket(
|
||||
withToken(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`)
|
||||
);
|
||||
ws = sock;
|
||||
wsRef.current = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
|
||||
@@ -43,7 +43,10 @@ const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
|
||||
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
|
||||
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
vi.mock("../../../lib/api", async () => ({
|
||||
// The real one: it reads the token from localStorage, as the page does.
|
||||
withToken: (await vi.importActual<typeof import("../../../lib/api")>("../../../lib/api"))
|
||||
.withToken,
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
@@ -236,6 +239,31 @@ describe("TerminalView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the dashboard token after its size, as /ws does (DASHBOARD_TOKEN)", () => {
|
||||
// The server refuses a /ws-pty upgrade without it: the terminal sat at
|
||||
// "reconnecting" forever while the status bar, on /ws, said Live.
|
||||
localStorage.setItem("dashboard_token", "t ok");
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28&token=t%20ok"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the token as the only query when it has no size yet", () => {
|
||||
localStorage.setItem("dashboard_token", "tok");
|
||||
termState.cols = 0;
|
||||
termState.rows = 0;
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?token=tok"
|
||||
);
|
||||
});
|
||||
|
||||
it("a terminal on a hidden tab sends no size until it is shown", () => {
|
||||
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
|
||||
// that size resized the real tmux pane under every other client.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ import {
|
||||
ChevronRight,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { api, type GitSource } from "../lib/api";
|
||||
import { api, withToken, type GitSource } from "../lib/api";
|
||||
import { useInnerReposMode } from "../lib/innerReposPref";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import type {
|
||||
@@ -661,16 +661,29 @@ export function Workspace() {
|
||||
const { local } = usePermissions();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// The server's tmux is older than this platform needs (psmux < 3.4.0 on
|
||||
// Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is.
|
||||
// Sticky (an error toast) so the command can be copied. An older server or
|
||||
// no answer says nothing.
|
||||
// The server's tmux is older than this platform needs: say how to upgrade
|
||||
// it. An old psmux (Windows) stalls terminals — an error, every visit. An old
|
||||
// tmux only lacks the pane scrollbar — an amber warning, shown once per
|
||||
// version in this browser (closing it is for good). Both sticky, so the
|
||||
// command can be copied. An older server or no answer says nothing.
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
(async () => {
|
||||
try {
|
||||
const { upgrade } = await api.run.tmuxAvailable();
|
||||
if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
|
||||
const { upgrade, upgradeSeverity } = await api.run.tmuxAvailable();
|
||||
if (!live || !upgrade) return;
|
||||
if (upgradeSeverity === "warning") {
|
||||
const SEEN = "agenthub.tmuxUpgrade.seen";
|
||||
try {
|
||||
if (localStorage.getItem(SEEN) === upgrade) return;
|
||||
localStorage.setItem(SEEN, upgrade);
|
||||
} catch {
|
||||
/* storage blocked: show it */
|
||||
}
|
||||
toast.warning(tLanes("tmuxUpgradeOptional", { advice: upgrade }));
|
||||
return;
|
||||
}
|
||||
toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
|
||||
} catch {
|
||||
/* nothing to say */
|
||||
}
|
||||
@@ -1477,7 +1490,9 @@ export function Workspace() {
|
||||
<div data-testid="proof-gallery" className="mt-2">
|
||||
{proofFeature.ticket_report && (
|
||||
<a
|
||||
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||||
href={withToken(
|
||||
`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-fg-muted"
|
||||
|
||||
+3
-1
@@ -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
@@ -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
@@ -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
@@ -38,9 +38,12 @@ build, so the registration survives plugin updates.
|
||||
| 3 | `~/.claude/.agenthub.json` — the live-dashboard discovery file the CLI uses (`server/lib/dashboard-url.js`) |
|
||||
| 4 | `http://127.0.0.1:4820` |
|
||||
|
||||
When the dashboard sets `DASHBOARD_TOKEN`, give the MCP process the same
|
||||
variable (`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`); it is
|
||||
sent as `Authorization: Bearer`.
|
||||
When the dashboard sets `DASHBOARD_TOKEN`, the MCP server sends it as
|
||||
`Authorization: Bearer`: its own `DASHBOARD_TOKEN` if set, else the token the
|
||||
running dashboard publishes for local tools in
|
||||
`<data dir>/runtime/dashboard-token` (0600, readable by the same account only).
|
||||
A dashboard of another account needs the variable
|
||||
(`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`).
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -5,8 +5,8 @@
|
||||
* `tools/list`, `tools/call`). Every tool is one GET against the running
|
||||
* dashboard's HTTP API, so the numbers are the dashboard's own; nothing here
|
||||
* opens the database or writes anything. The base URL is `AGENTHUB_URL`, else
|
||||
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN`, when
|
||||
* set, is sent as a bearer token. Run it as `agenthub mcp` or
|
||||
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN` —
|
||||
* else the token the running dashboard published — is sent as a bearer token. Run it as `agenthub mcp` or
|
||||
* `node mcp/server.js`; see docs/MCP.md.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -161,9 +161,11 @@ const TOOLS = {
|
||||
/** GET a dashboard path; resolves to an MCP tool result, never rejects. */
|
||||
async function callApi(pathname) {
|
||||
const url = `${baseUrl()}${pathname}`;
|
||||
const headers = process.env.DASHBOARD_TOKEN
|
||||
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
|
||||
: {};
|
||||
// DASHBOARD_TOKEN, else the token the running server published.
|
||||
const token = require(
|
||||
path.join(__dirname, "..", "server", "lib", "dashboard-url.js")
|
||||
).dashboardToken();
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||
if (process.env.AGENTHUB_PANE_TOKEN)
|
||||
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file dashboard-token-publish.test.js
|
||||
* @description A token-gated dashboard publishes its token (0600) for local
|
||||
* tools, and `dashboard-url.dashboardToken()` — what the `agenthub` CLI, the
|
||||
* MCP server and the statusline send — takes DASHBOARD_TOKEN first, else that
|
||||
* file, else nothing. Without it `agenthub remote login/status` answered 401
|
||||
* from any shell that had not exported the token.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, before, after, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "dash-token-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
const security = require("../lib/security");
|
||||
const { dashboardToken, tokenHeaders } = require("../lib/dashboard-url");
|
||||
|
||||
describe("the published dashboard token", () => {
|
||||
let saved;
|
||||
before(() => {
|
||||
saved = process.env.DASHBOARD_TOKEN;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.DASHBOARD_TOKEN;
|
||||
else process.env.DASHBOARD_TOKEN = saved;
|
||||
});
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
it("is written 0600 when the server has one, and read back without the env", () => {
|
||||
process.env.DASHBOARD_TOKEN = "server-tok";
|
||||
security.publishDashboardToken();
|
||||
const file = security.tokenFilePath();
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
delete process.env.DASHBOARD_TOKEN; // a shell that never exported it
|
||||
assert.equal(dashboardToken(), "server-tok");
|
||||
assert.deepEqual(tokenHeaders(), { "x-dashboard-token": "server-tok" });
|
||||
});
|
||||
|
||||
it("prefers the caller's own DASHBOARD_TOKEN", () => {
|
||||
process.env.DASHBOARD_TOKEN = "mine";
|
||||
assert.equal(dashboardToken(), "mine");
|
||||
});
|
||||
|
||||
it("is removed when the server runs without a token", () => {
|
||||
delete process.env.DASHBOARD_TOKEN;
|
||||
security.publishDashboardToken();
|
||||
assert.equal(fs.existsSync(security.tokenFilePath()), false);
|
||||
assert.equal(dashboardToken(), null);
|
||||
assert.deepEqual(tokenHeaders(), {});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user