Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f975066c62 | |||
| c557c0ad2f | |||
| d086a7f198 | |||
| 33bb3eccb5 | |||
| 4a665da79e | |||
| 1e3b34ec03 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.8.78 (2026-11-06) — sharing: payer billing, one-session shares, pane presence + typing audit._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.8.80 (2026-11-06) — Shift+Enter new line in terminals; chat nested and continued lists._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
+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
|
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.
|
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 →
|
On a native Windows install, workspace terminals are unavailable, but Settings →
|
||||||
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
|
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
|
||||||
and runs a `.cmd` updater or npm through `cmd.exe`.
|
and runs a `.cmd` updater or npm through `cmd.exe`.
|
||||||
|
|
||||||
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
|
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
|
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
|
```bash
|
||||||
sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison
|
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
|
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
|
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.
|
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_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) |
|
| `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_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:
|
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();
|
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
|
/** Thrown when the dashboard server does not answer — the dispatcher decides
|
||||||
* whether the active command has an offline fallback or must abort. */
|
* whether the active command has an offline fallback or must abort. */
|
||||||
class ServerDownError extends Error {}
|
class ServerDownError extends Error {}
|
||||||
@@ -162,6 +167,8 @@ async function api(method, pathname, body, options = {}) {
|
|||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
...(body && !form ? { "Content-Type": "application/json" } : {}),
|
...(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.
|
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||||
...(process.env.AGENTHUB_PANE_TOKEN
|
...(process.env.AGENTHUB_PANE_TOKEN
|
||||||
? { "x-agenthub-pane-token": 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`);
|
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
|
* 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
|
* 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) {
|
async function cmdStart(flags) {
|
||||||
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
|
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
|
||||||
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
|
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
|
||||||
|
const changed = saveNetworkFlags(flags);
|
||||||
if (await serverIsUp()) {
|
if (await serverIsUp()) {
|
||||||
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// A plugin install starts the way a Claude Code session does: its bootstrap
|
// 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) {
|
while (Date.now() < deadline) {
|
||||||
if (await serverIsUp()) {
|
if (await serverIsUp()) {
|
||||||
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
|
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
|
||||||
|
printAccessUrls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
@@ -1572,6 +1646,7 @@ async function cmdStart(flags) {
|
|||||||
console.log(
|
console.log(
|
||||||
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
|
`${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"));
|
console.log(c.dim(" Stop it with: agenthub stop"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2928,7 +3003,11 @@ const COMMAND_GROUPS = [
|
|||||||
"Server",
|
"Server",
|
||||||
[
|
[
|
||||||
["status", "", "Up/down indicator for the dashboard 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",
|
"stop",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
* `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
|
* Once an account is known (local mode is one), its own UI preferences are
|
||||||
* loaded into this browser (`lib/userPrefs.ts`).
|
* 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
|
* The login page follows the OS's light/dark (`AuthCard`). An account whose
|
||||||
* password an admin gave (`mustChangePassword`) sees only
|
* password an admin gave (`mustChangePassword`) sees only
|
||||||
* `FirstPasswordPage` until it picks its own.
|
* `FirstPasswordPage` until it picks its own.
|
||||||
@@ -30,7 +33,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import { AuthCard } from "./AuthCard";
|
import { AuthCard } from "./AuthCard";
|
||||||
import { FirstPasswordPage } from "./FirstPasswordPage";
|
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";
|
import { hydrate } from "../lib/userPrefs";
|
||||||
|
|
||||||
type AuthValue = {
|
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
|
// 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.
|
// there and is corrected by the answer (or by a 401) if that changed.
|
||||||
const [state, setState] = useState<AuthState | null>(cachedState);
|
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(() => {
|
useEffect(() => {
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
@@ -113,6 +126,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
.catch((err: { status?: number; code?: string }) => {
|
.catch((err: { status?: number; code?: string }) => {
|
||||||
if (!alive) return;
|
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
|
// 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
|
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
||||||
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
// 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.
|
// Any request answering 401 EAUTH means server mode with no valid login.
|
||||||
const onRequired = () => setState({ mode: "server", user: null });
|
const onRequired = () => setState({ mode: "server", user: null });
|
||||||
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||||
|
const onToken = () => setNeedToken(true);
|
||||||
|
window.addEventListener(TOKEN_REQUIRED_EVENT, onToken);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
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) return null;
|
||||||
if (state.mode === "server" && !state.user) {
|
if (state.mode === "server" && !state.user) {
|
||||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||||
@@ -266,3 +287,65 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
|||||||
</AuthCard>
|
</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 { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
||||||
|
|
||||||
const TONE: Record<ToastItem["kind"], string> = {
|
const TONE: Record<ToastItem["kind"], string> = {
|
||||||
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
||||||
info: "border-border bg-surface-2 text-fg-primary",
|
info: "border-border bg-surface-2 text-fg-primary",
|
||||||
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
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 }) {
|
function Item({ item }: { item: ToastItem }) {
|
||||||
const { t } = useTranslation("common");
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
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 { 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 userEvent from "@testing-library/user-event";
|
||||||
import "../../i18n";
|
import "../../i18n";
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ const LANES = [
|
|||||||
];
|
];
|
||||||
vi.mock("../../lib/api", () => ({
|
vi.mock("../../lib/api", () => ({
|
||||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
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: {
|
api: {
|
||||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
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 () => {
|
it("asks for the dashboard token when DASHBOARD_TOKEN refuses this browser", async () => {
|
||||||
for (const err of [
|
me.mockRejectedValue(Object.assign(new Error("x"), { status: 401, code: "EUNAUTHORIZED" }));
|
||||||
{ status: 404 },
|
const reload = vi.fn();
|
||||||
{ status: 200, code: "EBADRESPONSE" },
|
const loc = window.location;
|
||||||
{ status: 401, code: "EUNAUTHORIZED" },
|
// @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.mockReset();
|
||||||
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
||||||
const { unmount } = render(<AuthGate>app</AuthGate>);
|
const { unmount } = render(<AuthGate>app</AuthGate>);
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
* @file MarkdownContent.tsx
|
* @file MarkdownContent.tsx
|
||||||
* @description Lightweight markdown renderer for conversation messages. Supports the
|
* @description Lightweight markdown renderer for conversation messages. Supports the
|
||||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
* fenced code blocks, ATX headings, ordered/unordered lists (nested by indent; a
|
||||||
|
* numbered list split by a blank line keeps counting from its own numbers),
|
||||||
|
* task lists, blockquotes,
|
||||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||||
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||||
@@ -68,7 +70,7 @@ import { onLinkClick } from "../../lib/openInPreview";
|
|||||||
type Block =
|
type Block =
|
||||||
| { kind: "code"; lang: string; code: string }
|
| { kind: "code"; lang: string; code: string }
|
||||||
| { kind: "heading"; level: number; text: string }
|
| { kind: "heading"; level: number; text: string }
|
||||||
| { kind: "list"; ordered: boolean; items: string[] }
|
| { kind: "list"; ordered: boolean; start: number; items: string[] }
|
||||||
| { kind: "quote"; text: string }
|
| { kind: "quote"; text: string }
|
||||||
| { kind: "hr" }
|
| { kind: "hr" }
|
||||||
| {
|
| {
|
||||||
@@ -196,27 +198,35 @@ function parseBlocks(src: string): Block[] {
|
|||||||
if (ulMatch || olMatch) {
|
if (ulMatch || olMatch) {
|
||||||
const ordered = !!olMatch;
|
const ordered = !!olMatch;
|
||||||
const itemRe = ordered ? OL_RE : UL_RE;
|
const itemRe = ordered ? OL_RE : UL_RE;
|
||||||
|
const base = (olMatch ?? ulMatch)![1]!.length;
|
||||||
|
// The number the list starts at: a list broken by a blank line or a
|
||||||
|
// stray block still counts on from where the text says ("2.", "3.").
|
||||||
|
const start = olMatch ? Number(olMatch[2]) || 1 : 1;
|
||||||
const items: string[] = [];
|
const items: string[] = [];
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const m = lines[i]!.match(itemRe);
|
const m = lines[i]!.match(itemRe);
|
||||||
if (m) {
|
if (m && m[1]!.length <= base) {
|
||||||
items.push(m[3]!);
|
items.push(m[3]!);
|
||||||
i++;
|
i++;
|
||||||
|
// The item's own lines: indented text, and a list nested under it
|
||||||
|
// (indented deeper than this list's marker) — kept with its
|
||||||
|
// relative indent so the item renders it as a sub-list.
|
||||||
while (
|
while (
|
||||||
i < lines.length &&
|
i < lines.length &&
|
||||||
lines[i]!.trim() !== "" &&
|
lines[i]!.trim() !== "" &&
|
||||||
!lines[i]!.match(UL_RE) &&
|
/^\s+\S/.test(lines[i]!) &&
|
||||||
!lines[i]!.match(OL_RE) &&
|
(lines[i]!.match(/^\s*/)![0].length > base ||
|
||||||
/^\s+\S/.test(lines[i]!)
|
(!lines[i]!.match(UL_RE) && !lines[i]!.match(OL_RE)))
|
||||||
) {
|
) {
|
||||||
items[items.length - 1] += "\n" + lines[i]!.trim();
|
const nested = UL_RE.test(lines[i]!) || OL_RE.test(lines[i]!);
|
||||||
|
items[items.length - 1] += "\n" + (nested ? lines[i]! : lines[i]!.trim());
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blocks.push({ kind: "list", ordered, items });
|
blocks.push({ kind: "list", ordered, start, items });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,8 +534,27 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render a single list item, handling [ ] / [x] task list prefixes. */
|
/** Render a single list item: its text, then any list nested under it. */
|
||||||
function renderListItem(item: string, key: string): React.ReactNode {
|
function renderListItem(item: string, key: string): React.ReactNode {
|
||||||
|
const lines = item.split("\n");
|
||||||
|
const sub = lines.findIndex((l, n) => n > 0 && (UL_RE.test(l) || OL_RE.test(l)));
|
||||||
|
if (sub > 0) {
|
||||||
|
const rest = lines.slice(sub);
|
||||||
|
const indent = Math.min(...rest.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderItemText(lines.slice(0, sub).join("\n"), key)}
|
||||||
|
<div className="mt-1">
|
||||||
|
<MarkdownContent text={rest.map((l) => l.slice(indent)).join("\n")} dense />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderItemText(item, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An item's own text, handling [ ] / [x] task list prefixes. */
|
||||||
|
function renderItemText(item: string, key: string): React.ReactNode {
|
||||||
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
|
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
|
||||||
if (taskMatch) {
|
if (taskMatch) {
|
||||||
const checked = taskMatch[1]!.toLowerCase() === "x";
|
const checked = taskMatch[1]!.toLowerCase() === "x";
|
||||||
@@ -594,6 +623,7 @@ export function MarkdownContent({ text, dense = false, prose = false }: Markdown
|
|||||||
return (
|
return (
|
||||||
<ol
|
<ol
|
||||||
key={idx}
|
key={idx}
|
||||||
|
start={b.start !== 1 ? b.start : undefined}
|
||||||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||||
>
|
>
|
||||||
{b.items.map((item, i) => (
|
{b.items.map((item, i) => (
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
|
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
|
||||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||||
import { api } from "../../lib/api";
|
import { api, withToken } from "../../lib/api";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
|
|
||||||
/** The icon of a transcript row's sender. A JSONL `type:"user"` line is not
|
/** 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 }) {
|
function DropImage({ name }: { name: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [gone, setGone] = 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.
|
// Drops are pruned after a day: the name is all that is left to show.
|
||||||
if (gone)
|
if (gone)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -89,6 +89,37 @@ describe("<MarkdownContent />", () => {
|
|||||||
expect(container.querySelectorAll("ol li")).toHaveLength(2);
|
expect(container.querySelectorAll("ol li")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps one numbered list when its items carry indented bullets", () => {
|
||||||
|
const text = [
|
||||||
|
"Có 4 điểm:",
|
||||||
|
"",
|
||||||
|
"1. **yt-dlp**",
|
||||||
|
" - Hướng dẫn",
|
||||||
|
" - YouTube",
|
||||||
|
"2. **Nút Setup**",
|
||||||
|
" - site-packages",
|
||||||
|
"3. **Log khởi động**",
|
||||||
|
" Có thể bỏ dòng này.",
|
||||||
|
"4. **Nên kiểm tra**",
|
||||||
|
].join("\n");
|
||||||
|
const { container } = render(<MarkdownContent text={text} />);
|
||||||
|
const top = container.querySelectorAll(":scope > div > ol");
|
||||||
|
expect(top).toHaveLength(1);
|
||||||
|
expect(top[0]!.querySelectorAll(":scope > li")).toHaveLength(4);
|
||||||
|
expect(top[0]!.querySelectorAll("ul li")).toHaveLength(3);
|
||||||
|
expect(screen.getByText(/Có thể bỏ dòng này/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues the numbering of a list split by a blank line or a block", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownContent text={"1. first\n\n```\ncode\n```\n\n2. second\n3. third"} />
|
||||||
|
);
|
||||||
|
const lists = container.querySelectorAll("ol");
|
||||||
|
expect(lists).toHaveLength(2);
|
||||||
|
expect(lists[0]!.getAttribute("start")).toBeNull();
|
||||||
|
expect(lists[1]!.getAttribute("start")).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders blockquotes", () => {
|
it("renders blockquotes", () => {
|
||||||
const { container } = render(<MarkdownContent text={"> a quote"} />);
|
const { container } = render(<MarkdownContent text={"> a quote"} />);
|
||||||
expect(container.querySelector("blockquote")).not.toBeNull();
|
expect(container.querySelector("blockquote")).not.toBeNull();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
Share2,
|
Share2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget, withToken } from "../../lib/api";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
import { dragSkill } from "../../lib/promptAttach";
|
import { dragSkill } from "../../lib/promptAttach";
|
||||||
@@ -116,7 +116,7 @@ function useContent(path: string, scope: PreviewScope): Content {
|
|||||||
};
|
};
|
||||||
const drop = DROP_RE.exec(path);
|
const drop = DROP_RE.exec(path);
|
||||||
if (drop) {
|
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 });
|
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
|
||||||
else if (DROP_TEXT_RE.test(path))
|
else if (DROP_TEXT_RE.test(path))
|
||||||
show(
|
show(
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ import { ArrowDown, ArrowUp, X } from "lucide-react";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTheme } from "../../hooks/useTheme";
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||||
import { api } from "../../lib/api";
|
import { api, withToken } from "../../lib/api";
|
||||||
import { writeClipboard } from "../../lib/clipboard";
|
import { writeClipboard } from "../../lib/clipboard";
|
||||||
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
|
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
|
||||||
import {
|
import {
|
||||||
@@ -634,6 +634,15 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
term.input(altArrow, true);
|
term.input(altArrow, true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Shift+Enter: xterm sends a plain CR, so claude/codex SUBMIT the
|
||||||
|
// prompt. Send Ctrl+J (LF) instead — both prompts take it as a new
|
||||||
|
// line (Option+Enter is the other way, awkward on a Mac), while a
|
||||||
|
// shell still runs the line, as Enter did.
|
||||||
|
if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
term.input("\n", true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
@@ -717,7 +726,11 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// own size, so a pane on a background tab is never resized by it.
|
// own size, so a pane on a background tab is never resized by it.
|
||||||
const size =
|
const size =
|
||||||
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
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;
|
ws = sock;
|
||||||
wsRef.current = sock;
|
wsRef.current = sock;
|
||||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
// 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 }[] };
|
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
|
||||||
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||||
const selectedFilesMock = 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: {
|
api: {
|
||||||
run: {
|
run: {
|
||||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
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", () => {
|
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
|
// 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.
|
// that size resized the real tmux pane under every other client.
|
||||||
@@ -606,6 +634,21 @@ describe("TerminalView", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends Shift+Enter as Ctrl+J, a new line in claude's prompt, not a submit", () => {
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
|
const ev = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, cancelable: true });
|
||||||
|
expect(handler(ev)).toBe(false);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\n", true);
|
||||||
|
inputMock.mockClear();
|
||||||
|
// Plain Enter and Alt+Enter stay xterm's own.
|
||||||
|
expect(handler(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(true);
|
||||||
|
expect(handler(new KeyboardEvent("keydown", { key: "Enter", altKey: true }))).toBe(true);
|
||||||
|
expect(inputMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||||
// next row's left margin, and tmux never repaints a row it believes
|
// next row's left margin, and tmux never repaints a row it believes
|
||||||
|
|||||||
@@ -206,7 +206,13 @@
|
|||||||
"confirmPassword": "Confirm new password",
|
"confirmPassword": "Confirm new password",
|
||||||
"mismatch": "The two new passwords differ",
|
"mismatch": "The two new passwords differ",
|
||||||
"mustChangeSubmit": "Save and continue",
|
"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": {
|
"insights": {
|
||||||
"help": {
|
"help": {
|
||||||
|
|||||||
@@ -735,6 +735,7 @@
|
|||||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
"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.",
|
"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}}",
|
"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",
|
"changes.rescanRepos": "Scan for repositories again",
|
||||||
"tree.newGroup": "New group",
|
"tree.newGroup": "New group",
|
||||||
"tree.groupDefaultName": "New group",
|
"tree.groupDefaultName": "New group",
|
||||||
|
|||||||
@@ -206,7 +206,13 @@
|
|||||||
"confirmPassword": "Nhập lại mật khẩu mới",
|
"confirmPassword": "Nhập lại mật khẩu mới",
|
||||||
"mismatch": "Hai mật khẩu mới không khớp",
|
"mismatch": "Hai mật khẩu mới không khớp",
|
||||||
"mustChangeSubmit": "Lưu và tiếp tục",
|
"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": {
|
"insights": {
|
||||||
"help": {
|
"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.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.",
|
"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}}",
|
"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",
|
"changes.rescanRepos": "Quét lại các repository",
|
||||||
"tree.newGroup": "Nhóm mới",
|
"tree.newGroup": "Nhóm mới",
|
||||||
"tree.groupDefaultName": "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. */
|
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
|
||||||
function ccQuery(scope?: CcScope, cwd?: string): string {
|
function ccQuery(scope?: CcScope, cwd?: string): string {
|
||||||
const qs = new URLSearchParams();
|
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). */
|
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
||||||
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
|
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`). */
|
/** One account in Settings → Users (`GET /api/users`). */
|
||||||
export type ServerUserUsage = AuthUser & {
|
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") {
|
if (res.status === 401 && body?.error?.code === "EAUTH") {
|
||||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
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.
|
// Server mode: a role refusal reads the same in every toast, translated.
|
||||||
const message =
|
const message =
|
||||||
body?.error?.code === "EADMIN"
|
body?.error?.code === "EADMIN"
|
||||||
@@ -1212,6 +1264,9 @@ const sendRaw = <T>(
|
|||||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||||
}
|
}
|
||||||
|
if (xhr.status === 401 && body.error?.code === "EUNAUTHORIZED") {
|
||||||
|
window.dispatchEvent(new Event(TOKEN_REQUIRED_EVENT));
|
||||||
|
}
|
||||||
reject(
|
reject(
|
||||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||||
code: body.error?.code,
|
code: body.error?.code,
|
||||||
@@ -1546,7 +1601,7 @@ export const api = {
|
|||||||
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
|
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
|
||||||
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
|
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
|
||||||
avatarUrl: (avatarAt?: string | null) =>
|
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. */
|
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
|
||||||
setAvatar: (file: Blob) =>
|
setAvatar: (file: Blob) =>
|
||||||
request<MyAccount>("/me/avatar", {
|
request<MyAccount>("/me/avatar", {
|
||||||
@@ -1579,7 +1634,7 @@ export const api = {
|
|||||||
users: {
|
users: {
|
||||||
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
||||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
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). */
|
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||||
list: () =>
|
list: () =>
|
||||||
request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>(
|
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,
|
/** GET /api/sessions/:id/image — an image the session's Read tool read,
|
||||||
* wherever it lies (local mode only), for an `<img>`. */
|
* wherever it lies (local mode only), for an `<img>`. */
|
||||||
readImageUrl: (id: string, path: string) =>
|
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
|
/** GET /api/sessions/:id/file — a text file the session itself read or
|
||||||
* wrote, wherever it lies (an output outside the workspace). */
|
* wrote, wherever it lies (an output outside the workspace). */
|
||||||
readFile: (id: string, path: string) =>
|
readFile: (id: string, path: string) =>
|
||||||
@@ -2451,7 +2506,7 @@ export const api = {
|
|||||||
*
|
*
|
||||||
* @returns The absolute-on-origin URL (`/api/settings/export`) to link to.
|
* @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
|
* POST /api/settings/import (multipart) - restore a bundle previously
|
||||||
* produced by {@link exportData}. Idempotent and non-destructive: sessions
|
* 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
|
* 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
|
* `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.
|
* @param file The `.json` export file the user selected.
|
||||||
* @returns {@link ImportBackupResult} — per-table restore counts.
|
* @returns {@link ImportBackupResult} — per-table restore counts.
|
||||||
@@ -2469,7 +2524,11 @@ export const api = {
|
|||||||
importData: async (file: File): Promise<ImportBackupResult> => {
|
importData: async (file: File): Promise<ImportBackupResult> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file, file.name);
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
throw new Error(body?.error?.message || `HTTP ${res.status}`);
|
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.
|
// Append each file under the repeated "files" field, keeping its filename.
|
||||||
for (const f of files) form.append("files", f, f.name);
|
for (const f of files) form.append("files", f, f.name);
|
||||||
// Do NOT set Content-Type manually: the browser adds the multipart boundary.
|
// 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) {
|
if (!res.ok) {
|
||||||
// Same error-shaping contract as request(), duplicated because this call
|
// Same error-shaping contract as request(), duplicated because this call
|
||||||
// intentionally does not route through the JSON wrapper.
|
// 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. */
|
/** One file's download URL, on a workspace or a home. */
|
||||||
downloadUrl: (t: FsPlace, path: string) =>
|
downloadUrl: (t: FsPlace, path: string) =>
|
||||||
isHomePlace(t)
|
isHomePlace(t)
|
||||||
? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`
|
? withToken(`${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`)
|
||||||
: `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`,
|
: withToken(`${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`),
|
||||||
list: (t: FsPlace, path = "") =>
|
list: (t: FsPlace, path = "") =>
|
||||||
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
|
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
|
||||||
mkdir: (t: FsPlace, path: string) =>
|
mkdir: (t: FsPlace, path: string) =>
|
||||||
@@ -3430,7 +3493,7 @@ export const api = {
|
|||||||
zipUrl: (t: FsPlace, paths: string[]) => {
|
zipUrl: (t: FsPlace, paths: string[]) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
for (const p of paths) q.append("p", p);
|
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
|
* 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 (path) qs.set("path", path);
|
||||||
if (download) qs.set("download", "1");
|
if (download) qs.set("download", "1");
|
||||||
const s = qs.toString();
|
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`). */
|
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
|
||||||
pets: {
|
pets: {
|
||||||
list: () => request<PetList>("/pets"),
|
list: () => request<PetList>("/pets"),
|
||||||
/** The pet's spritesheet (a Codex pet) or image, served raster-only. */
|
/** 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). */
|
/** POST /api/pets/import — a `.codex-pet.zip` (pet.json + spritesheet.webp). */
|
||||||
importBundle: (file: File) =>
|
importBundle: (file: File) =>
|
||||||
request<{ pet: PetView }>("/pets/import", {
|
request<{ pet: PetView }>("/pets/import", {
|
||||||
@@ -3859,11 +3922,7 @@ export const api = {
|
|||||||
job: () => request<AiToolJob | null>("/ai-tools/job"),
|
job: () => request<AiToolJob | null>("/ai-tools/job"),
|
||||||
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
|
/** 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. */
|
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
|
||||||
iconUrl: (id: string) => {
|
iconUrl: (id: string) => withToken(`${BASE}/ai-tools/${encodeURIComponent(id)}/icon`),
|
||||||
const token = dashboardToken();
|
|
||||||
const q = token ? `?token=${encodeURIComponent(token)}` : "";
|
|
||||||
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
// ──────────────────────────────── AI Proxy API ──────────────────────────────
|
// ──────────────────────────────── AI Proxy API ──────────────────────────────
|
||||||
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
/** 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
|
/** 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. */
|
* version, and this platform's upgrade advice when it is too old. */
|
||||||
tmuxAvailable: () =>
|
tmuxAvailable: () =>
|
||||||
request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
|
request<{
|
||||||
"/run/tmux"
|
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. */
|
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
|
||||||
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
||||||
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
|
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
|
||||||
@@ -4213,7 +4275,7 @@ export const api = {
|
|||||||
qs.delete("limit");
|
qs.delete("limit");
|
||||||
qs.delete("offset");
|
qs.delete("offset");
|
||||||
qs.set("format", format);
|
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`),
|
runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`),
|
||||||
/** A run's file, as a download link. */
|
/** A run's file, as a download link. */
|
||||||
fileUrl: (id: number, runId: number, format: string) =>
|
fileUrl: (id: number, runId: number, format: string) =>
|
||||||
`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`,
|
withToken(`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ───────────────────────────────── Webhooks API ─────────────────────────────
|
// ───────────────────────────────── Webhooks API ─────────────────────────────
|
||||||
@@ -4797,11 +4859,11 @@ export const api = {
|
|||||||
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
|
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
|
||||||
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
|
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
|
||||||
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
|
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
|
/** GET /api/lanes/:id/git/download — one working file as an attachment
|
||||||
* (any type; a plain, non-git folder too). For an `<a download>`. */
|
* (any type; a plain, non-git folder too). For an `<a download>`. */
|
||||||
gitDownloadUrl: (id: GitTarget, path: string) =>
|
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`
|
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
|
||||||
* ("" = the workspace root). Only creates: a taken name answers 409
|
* ("" = the workspace root). Only creates: a taken name answers 409
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { NotifSound } from "./notifPrefs";
|
import type { NotifSound } from "./notifPrefs";
|
||||||
|
import { tokenHeader } from "./api";
|
||||||
|
|
||||||
const audio = new Map<NotifSound, HTMLAudioElement>();
|
const audio = new Map<NotifSound, HTMLAudioElement>();
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ export async function notify(title: string, body: string, url?: string): Promise
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/push/send", {
|
const res = await fetch("/api/push/send", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...tokenHeader() },
|
||||||
body: JSON.stringify({ title, body, url }),
|
body: JSON.stringify({ title, body, url }),
|
||||||
});
|
});
|
||||||
const result = res.ok ? await res.json().catch(() => null) : null;
|
const result = res.ok ? await res.json().catch(() => null) : null;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DragEvent } from "react";
|
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. */
|
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
@@ -331,7 +331,12 @@ function readRecent(): Recent[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function recentUploads(): Attachment[] {
|
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
|
/** 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>
|
* @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)
|
* 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
|
if (existing) return; // already subscribed -> idempotent no-op
|
||||||
|
|
||||||
// Fetch the server's VAPID public key and decode it for `applicationServerKey`.
|
// 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 { publicKey } = await res.json();
|
||||||
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
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.
|
// Register the endpoint + keys with the backend so it can push to this browser.
|
||||||
await fetch("/api/push/subscribe", {
|
await fetch("/api/push/subscribe", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...tokenHeader() },
|
||||||
body: JSON.stringify(subscription.toJSON()), // { endpoint, keys: { p256dh, auth } }
|
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.
|
// Tell the backend to forget this endpoint so it stops attempting deliveries.
|
||||||
await fetch("/api/push/subscribe", {
|
await fetch("/api/push/subscribe", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...tokenHeader() },
|
||||||
body: JSON.stringify({ endpoint }),
|
body: JSON.stringify({ endpoint }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
* @file toast.ts
|
* @file toast.ts
|
||||||
* @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)`
|
* @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)`
|
||||||
* from anywhere, rendered once by `components/Toaster.tsx` inside live regions.
|
* 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).
|
* 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>
|
* @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 {
|
export interface ToastItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -43,7 +44,8 @@ function push(kind: ToastKind, message: string): number {
|
|||||||
}
|
}
|
||||||
items = next;
|
items = next;
|
||||||
emit();
|
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;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,8 @@ export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void {
|
|||||||
export const toast = {
|
export const toast = {
|
||||||
success: (message: string) => push("success", message),
|
success: (message: string) => push("success", message),
|
||||||
info: (message: string) => push("info", message),
|
info: (message: string) => push("info", message),
|
||||||
|
/** Amber and sticky: worth reading, not a failure. */
|
||||||
|
warning: (message: string) => push("warning", message),
|
||||||
error: (message: unknown) =>
|
error: (message: unknown) =>
|
||||||
push("error", message instanceof Error ? message.message : String(message)),
|
push("error", message instanceof Error ? message.message : String(message)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import "@fontsource/maple-mono/700.css";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { AuthGate } from "./components/AuthGate";
|
import { AuthGate } from "./components/AuthGate";
|
||||||
import { i18nReady } from "./i18n";
|
import { i18nReady } from "./i18n";
|
||||||
|
import { adoptTokenFromUrl } from "./lib/api";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
// Deep link into the Workspace: start its (lazy) chunk now, in parallel with the
|
// Deep link into the Workspace: start its (lazy) chunk now, in parallel with the
|
||||||
@@ -103,6 +104,9 @@ if ("serviceWorker" in navigator) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `agenthub start` prints a link carrying `?token=`: keep it, drop it from the URL.
|
||||||
|
adoptTokenFromUrl();
|
||||||
|
|
||||||
const root = document.getElementById("root");
|
const root = document.getElementById("root");
|
||||||
if (!root) throw new Error("Root element not found");
|
if (!root) throw new Error("Root element not found");
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type GitSource } from "../lib/api";
|
import { api, withToken, type GitSource } from "../lib/api";
|
||||||
import { useInnerReposMode } from "../lib/innerReposPref";
|
import { useInnerReposMode } from "../lib/innerReposPref";
|
||||||
import { usePermissions } from "../components/AuthGate";
|
import { usePermissions } from "../components/AuthGate";
|
||||||
import type {
|
import type {
|
||||||
@@ -661,16 +661,29 @@ export function Workspace() {
|
|||||||
const { local } = usePermissions();
|
const { local } = usePermissions();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
// The server's tmux is older than this platform needs (psmux < 3.4.0 on
|
// The server's tmux is older than this platform needs: say how to upgrade
|
||||||
// Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is.
|
// it. An old psmux (Windows) stalls terminals — an error, every visit. An old
|
||||||
// Sticky (an error toast) so the command can be copied. An older server or
|
// tmux only lacks the pane scrollbar — an amber warning, shown once per
|
||||||
// no answer says nothing.
|
// 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(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { upgrade } = await api.run.tmuxAvailable();
|
const { upgrade, upgradeSeverity } = await api.run.tmuxAvailable();
|
||||||
if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
|
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 {
|
} catch {
|
||||||
/* nothing to say */
|
/* nothing to say */
|
||||||
}
|
}
|
||||||
@@ -1477,7 +1490,9 @@ export function Workspace() {
|
|||||||
<div data-testid="proof-gallery" className="mt-2">
|
<div data-testid="proof-gallery" className="mt-2">
|
||||||
{proofFeature.ticket_report && (
|
{proofFeature.ticket_report && (
|
||||||
<a
|
<a
|
||||||
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
href={withToken(
|
||||||
|
`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`
|
||||||
|
)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-xs text-fg-muted"
|
className="text-xs text-fg-muted"
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.78 (rebuild client to match)
|
UI build v2.8.80 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-1
@@ -77,6 +77,8 @@ Authentication is **off by default** (the loopback bind is the trust boundary).
|
|||||||
- `x-dashboard-token: <token>` header
|
- `x-dashboard-token: <token>` header
|
||||||
- `?token=<token>` query parameter
|
- `?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`.
|
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`)
|
### Server mode login (`AGENTHUB_MODE=server`)
|
||||||
@@ -3387,7 +3389,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
|
|||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/run List all live runs (computed fresh from tmux state)
|
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/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
||||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
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
|
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 |
|
| Command | Description |
|
||||||
| ------- | ----------- |
|
| ------- | ----------- |
|
||||||
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
|
| `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 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 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 |
|
| `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
|
# 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
|
# transcripts, exports all data, and can spawn `claude`, so only widen the bind
|
||||||
# deliberately — and require a token when you do.
|
# 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_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
|
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`) |
|
| 3 | `~/.claude/.agenthub.json` — the live-dashboard discovery file the CLI uses (`server/lib/dashboard-url.js`) |
|
||||||
| 4 | `http://127.0.0.1:4820` |
|
| 4 | `http://127.0.0.1:4820` |
|
||||||
|
|
||||||
When the dashboard sets `DASHBOARD_TOKEN`, give the MCP process the same
|
When the dashboard sets `DASHBOARD_TOKEN`, the MCP server sends it as
|
||||||
variable (`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`); it is
|
`Authorization: Bearer`: its own `DASHBOARD_TOKEN` if set, else the token the
|
||||||
sent as `Authorization: Bearer`.
|
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
|
## Tools
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -579,7 +579,10 @@ a login is single use, so the server is the one that refreshes it, before it
|
|||||||
expires (`claude-pool.refreshLogins`, checked every 60 s; `DASHBOARD_POOL_REFRESH_MS=0` turns it off). It then writes the new pair into every
|
expires (`claude-pool.refreshLogins`, checked every 60 s; `DASHBOARD_POOL_REFRESH_MS=0` turns it off). It then writes the new pair into every
|
||||||
home it placed the login in, and Claude Code picks up tokens another process
|
home it placed the login in, and Claude Code picks up tokens another process
|
||||||
refreshed. A pair a member's Claude refreshed first is copied back only after
|
refreshed. A pair a member's Claude refreshed first is copied back only after
|
||||||
Anthropic confirms it is the same account. Removing the account deletes it from
|
Anthropic confirms it is the same account; while Anthropic does not answer, the
|
||||||
|
home is kept and checked again on the next beat. A home still holding one of the
|
||||||
|
account's earlier pairs (its Claude raced the server's refresh) stays the pool's
|
||||||
|
and gets the newest pair, so its member is not asked to `/login`. Removing the account deletes it from
|
||||||
the homes that still hold AgentHub's copy. Each
|
the homes that still hold AgentHub's copy. Each
|
||||||
change asks for the admin's own password again and is written to the audit log
|
change asks for the admin's own password again and is written to the audit log
|
||||||
by account name. The token only goes in; the page, like `agenthub accounts list`,
|
by account name. The token only goes in; the page, like `agenthub accounts list`,
|
||||||
|
|||||||
+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
|
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
|
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
|
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
|
## Install / uninstall
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -5,8 +5,8 @@
|
|||||||
* `tools/list`, `tools/call`). Every tool is one GET against the running
|
* `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
|
* 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
|
* 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
|
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN` —
|
||||||
* set, is sent as a bearer token. Run it as `agenthub mcp` or
|
* 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.
|
* `node mcp/server.js`; see docs/MCP.md.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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. */
|
/** GET a dashboard path; resolves to an MCP tool result, never rejects. */
|
||||||
async function callApi(pathname) {
|
async function callApi(pathname) {
|
||||||
const url = `${baseUrl()}${pathname}`;
|
const url = `${baseUrl()}${pathname}`;
|
||||||
const headers = process.env.DASHBOARD_TOKEN
|
// DASHBOARD_TOKEN, else the token the running server published.
|
||||||
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
|
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.
|
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||||
if (process.env.AGENTHUB_PANE_TOKEN)
|
if (process.env.AGENTHUB_PANE_TOKEN)
|
||||||
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
|
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.78",
|
"version": "2.8.80",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.78",
|
"version": "2.8.80",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.78",
|
"version": "2.8.80",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -182,6 +182,31 @@ describe("a pool login", () => {
|
|||||||
assert.equal(diskCreds().refreshToken, other.refreshToken);
|
assert.equal(diskCreds().refreshToken, other.refreshToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a home whose Claude kept an old pair of the pool's, and rewrites it", async () => {
|
||||||
|
pool.addLoginAccount("team", { oauth: oauth("a", 10 * 60e3), account: ACCOUNT });
|
||||||
|
pool.forSpawn(acct).place();
|
||||||
|
await pool.refreshLogins(); // the server spends a → r1
|
||||||
|
// The member's Claude raced the refresh and wrote the spent pair back.
|
||||||
|
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("a", 10 * 60e3) }));
|
||||||
|
const spawn = pool.forSpawn(acct);
|
||||||
|
assert.equal(spawn?.name, "team", "an old pair of the pool's is still the pool's home");
|
||||||
|
await pool.refreshLogins();
|
||||||
|
assert.ok("mia" in pool.__read("team").placed, "never dropped");
|
||||||
|
assert.equal(diskCreds().refreshToken, oauth("r1").refreshToken, "the newest pair written");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not drop a home while Anthropic cannot say whose newer pair it holds", async () => {
|
||||||
|
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||||
|
pool.forSpawn(acct).place();
|
||||||
|
const theirs = oauth("cli", 9 * H);
|
||||||
|
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: theirs }));
|
||||||
|
await pool.refreshLogins(); // fetchOwner answers null: offline
|
||||||
|
assert.ok("mia" in pool.__read("team").placed, "kept for a later beat");
|
||||||
|
owners[theirs.accessToken] = { uuid: "uuid-team" };
|
||||||
|
await pool.refreshLogins();
|
||||||
|
assert.equal(pool.__read("team").oauth.refreshToken, theirs.refreshToken, "copied back");
|
||||||
|
});
|
||||||
|
|
||||||
it("is taken out of the homes it is still ours in when removed", () => {
|
it("is taken out of the homes it is still ours in when removed", () => {
|
||||||
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||||
pool.forSpawn(acct).place();
|
pool.forSpawn(acct).place();
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* @file dashboard-token-publish.test.js
|
||||||
|
* @description A token-gated dashboard publishes its token (0600) for local
|
||||||
|
* tools, and `dashboard-url.dashboardToken()` — what the `agenthub` CLI, the
|
||||||
|
* MCP server and the statusline send — takes DASHBOARD_TOKEN first, else that
|
||||||
|
* file, else nothing. Without it `agenthub remote login/status` answered 401
|
||||||
|
* from any shell that had not exported the token.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after, afterEach } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "dash-token-"));
|
||||||
|
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||||
|
const security = require("../lib/security");
|
||||||
|
const { dashboardToken, tokenHeaders } = require("../lib/dashboard-url");
|
||||||
|
|
||||||
|
describe("the published dashboard token", () => {
|
||||||
|
let saved;
|
||||||
|
before(() => {
|
||||||
|
saved = process.env.DASHBOARD_TOKEN;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
if (saved === undefined) delete process.env.DASHBOARD_TOKEN;
|
||||||
|
else process.env.DASHBOARD_TOKEN = saved;
|
||||||
|
});
|
||||||
|
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("is written 0600 when the server has one, and read back without the env", () => {
|
||||||
|
process.env.DASHBOARD_TOKEN = "server-tok";
|
||||||
|
security.publishDashboardToken();
|
||||||
|
const file = security.tokenFilePath();
|
||||||
|
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||||
|
delete process.env.DASHBOARD_TOKEN; // a shell that never exported it
|
||||||
|
assert.equal(dashboardToken(), "server-tok");
|
||||||
|
assert.deepEqual(tokenHeaders(), { "x-dashboard-token": "server-tok" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the caller's own DASHBOARD_TOKEN", () => {
|
||||||
|
process.env.DASHBOARD_TOKEN = "mine";
|
||||||
|
assert.equal(dashboardToken(), "mine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is removed when the server runs without a token", () => {
|
||||||
|
delete process.env.DASHBOARD_TOKEN;
|
||||||
|
security.publishDashboardToken();
|
||||||
|
assert.equal(fs.existsSync(security.tokenFilePath()), false);
|
||||||
|
assert.equal(dashboardToken(), null);
|
||||||
|
assert.deepEqual(tokenHeaders(), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", "darwin"), /brew upgrade tmux/);
|
||||||
assert.match(tmux.upgradeAdvice("3.4", "linux"), /github\.com\/tmux\/tmux\/releases/);
|
assert.match(tmux.upgradeAdvice("3.4", "linux"), /github\.com\/tmux\/tmux\/releases/);
|
||||||
assert.equal(tmux.upgradeAdvice("3.7c", "linux"), null);
|
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);
|
assert.equal(tmux.upgradeAdvice("next-3.8", "darwin"), null);
|
||||||
// Nothing to compare: no advice rather than a wrong one.
|
// Nothing to compare: no advice rather than a wrong one.
|
||||||
assert.equal(tmux.upgradeAdvice(null, "linux"), null);
|
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 { getDataDir } = require("./lib/claude-home");
|
||||||
const {
|
const {
|
||||||
resolveHost,
|
resolveHost,
|
||||||
isLoopbackHostname,
|
extraListenHosts,
|
||||||
|
isExposedBind,
|
||||||
|
accessUrls,
|
||||||
corsOptions,
|
corsOptions,
|
||||||
hostGuard,
|
hostGuard,
|
||||||
tokenGuard,
|
tokenGuard,
|
||||||
@@ -158,6 +160,8 @@ const APP_VERSION = (() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
function createApp() {
|
function createApp() {
|
||||||
|
// Saved network settings first: the CORS options below are read once here.
|
||||||
|
require("./lib/network-config").applyNetworkConfig();
|
||||||
const app = express();
|
const app = express();
|
||||||
// Express 5 parses a query string with node's `querystring` by default;
|
// Express 5 parses a query string with node's `querystring` by default;
|
||||||
// keep Express 4's `qs` parsing, which every route and guard (`namedIds`
|
// keep Express 4's `qs` parsing, which every route and guard (`namedIds`
|
||||||
@@ -293,6 +297,9 @@ function createApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startServer(app, port) {
|
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);
|
const server = http.createServer(app);
|
||||||
initWebSocket(server);
|
initWebSocket(server);
|
||||||
initPtyWebSocket(server);
|
initPtyWebSocket(server);
|
||||||
@@ -368,8 +375,12 @@ function startServer(app, port) {
|
|||||||
// Bind to loopback by default so the dashboard is not network-reachable out
|
// 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
|
// 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.
|
// DASHBOARD_HOST=0.0.0.0 — and are warned to set DASHBOARD_TOKEN when they do.
|
||||||
const host = resolveHost();
|
// DASHBOARD_HOST may list several addresses; the first is `server`'s own
|
||||||
const boundLoopback = isLoopbackHostname(host);
|
// 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
// A port another program holds fails here, not as an uncaught 'error'
|
// 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.once("error", reject);
|
||||||
server.listen(port, host, () => {
|
server.listen(port, host, () => {
|
||||||
server.off("error", reject);
|
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
|
// 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
|
// server even when it bound a non-default port (the desktop app falls
|
||||||
// back off 4820 when that port is already taken).
|
// back off 4820 when that port is already taken).
|
||||||
writeServerInfo(port);
|
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.
|
// The AI Proxy gateway URL a server-mode pane is given names this port.
|
||||||
require("./lib/proxy-gateway").setListenPort(server.address().port);
|
require("./lib/proxy-gateway").setListenPort(server.address().port);
|
||||||
// S3: what members' panes run — this install's scripts and every
|
// 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 mode = isProduction ? "production" : "development";
|
||||||
const shown = boundLoopback ? "localhost" : host;
|
const urls = accessUrls(boundPort);
|
||||||
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
|
console.log(`Agent Dashboard server running on ${urls.join(" ")} (${mode})`);
|
||||||
if (isServerMode()) {
|
if (isServerMode()) {
|
||||||
console.log("Server mode: every page and API call needs a login.");
|
console.log("Server mode: every page and API call needs a login.");
|
||||||
if (!hasAdmin()) {
|
if (!hasAdmin()) {
|
||||||
|
|||||||
@@ -46,12 +46,17 @@
|
|||||||
* "another process landed". A home whose Claude refreshed first is copied
|
* "another process landed". A home whose Claude refreshed first is copied
|
||||||
* back, but only after Anthropic confirms the token is the same account
|
* back, but only after Anthropic confirms the token is the same account
|
||||||
* (`fetchOwner`): a member could otherwise plant a login every other
|
* (`fetchOwner`): a member could otherwise plant a login every other
|
||||||
* member's panes would get.
|
* member's panes would get — and a home is never dropped while Anthropic
|
||||||
|
* does not answer. A home still holding one of the account's PAST pairs
|
||||||
|
* (hashes in `retired`: its Claude raced the server's refresh) stays the
|
||||||
|
* pool's and gets the newest pair, rather than being left on a dead
|
||||||
|
* login that asked its member to /login again.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
const { getClaudeHome } = require("./claude-home");
|
const { getClaudeHome } = require("./claude-home");
|
||||||
|
|
||||||
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||||
@@ -249,9 +254,36 @@ function placedHomes(a) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether `member`'s credentials are still the pair AgentHub last wrote there for `a`. */
|
/** How many of a login's past refresh tokens are remembered (as hashes). */
|
||||||
|
const RETIRED_KEEP = 20;
|
||||||
|
const rtHash = (rt) =>
|
||||||
|
crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(String(rt || ""))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 32);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `a` with `old`'s refresh token remembered as one of its own past pairs. A
|
||||||
|
* home still holding such a pair (its Claude raced the server's refresh, or a
|
||||||
|
* spawn wrote a pair the server spent a moment later) is still the pool's:
|
||||||
|
* nobody but this account ever held that token.
|
||||||
|
*/
|
||||||
|
function retire(a, old) {
|
||||||
|
if (!old?.refreshToken || old.refreshToken === a.oauth?.refreshToken) return a;
|
||||||
|
const h = rtHash(old.refreshToken);
|
||||||
|
return { ...a, retired: [h, ...(a.retired || []).filter((x) => x !== h)].slice(0, RETIRED_KEEP) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `member`'s credentials are the pool's `a`: the pair AgentHub last
|
||||||
|
* wrote there, or one of `a`'s own past pairs (stale — the next write fixes it).
|
||||||
|
*/
|
||||||
const ownsHome = (a, member, disk) =>
|
const ownsHome = (a, member, disk) =>
|
||||||
a.kind === "login" && Boolean(disk) && disk.refreshToken === a.placed?.[member];
|
a.kind === "login" &&
|
||||||
|
Boolean(disk?.refreshToken) &&
|
||||||
|
(disk.refreshToken === a.placed?.[member] ||
|
||||||
|
(a.retired || []).includes(rtHash(disk.refreshToken)));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write `a`'s login into a placement — a member's home as the member, the
|
* Write `a`'s login into a placement — a member's home as the member, the
|
||||||
@@ -297,10 +329,19 @@ async function refreshLogins() {
|
|||||||
isOauth(disk) &&
|
isOauth(disk) &&
|
||||||
Number(disk.expiresAt) > Number(a.oauth.expiresAt) &&
|
Number(disk.expiresAt) > Number(a.oauth.expiresAt) &&
|
||||||
Number(disk.expiresAt) <= Date.now() + 24 * 3600e3 + FUTURE_SLACK_MS;
|
Number(disk.expiresAt) <= Date.now() + 24 * 3600e3 + FUTURE_SLACK_MS;
|
||||||
const owner = newer ? await usage.fetchOwner(disk.accessToken) : null;
|
let owner = null;
|
||||||
|
if (newer) {
|
||||||
|
owner = await usage.fetchOwner(disk.accessToken);
|
||||||
|
// A spawn may have placed the login somewhere while we waited.
|
||||||
|
a = readAccount(a.name) || a;
|
||||||
|
// Anthropic did not answer (offline, a hiccup): decide on a later
|
||||||
|
// beat. Dropping the home now would strand it on a pair only it
|
||||||
|
// holds, and no spawn would ever write the pool's login there again.
|
||||||
|
if (!owner) continue;
|
||||||
|
}
|
||||||
const placed = { ...a.placed };
|
const placed = { ...a.placed };
|
||||||
if (owner && owner.uuid === a.account?.accountUuid) {
|
if (owner && owner.uuid === a.account?.accountUuid) {
|
||||||
a = { ...a, oauth: { ...a.oauth, ...disk } };
|
a = retire({ ...a, oauth: { ...a.oauth, ...disk } }, a.oauth);
|
||||||
placed[member] = disk.refreshToken;
|
placed[member] = disk.refreshToken;
|
||||||
} else {
|
} else {
|
||||||
delete placed[member]; // their own login now: never ours to write over
|
delete placed[member]; // their own login now: never ours to write over
|
||||||
@@ -310,11 +351,19 @@ async function refreshLogins() {
|
|||||||
}
|
}
|
||||||
// 2. The server refreshes ahead of every home.
|
// 2. The server refreshes ahead of every home.
|
||||||
if (!(Number(a.oauth.expiresAt) > Date.now() + REFRESH_AHEAD_MS)) {
|
if (!(Number(a.oauth.expiresAt) > Date.now() + REFRESH_AHEAD_MS)) {
|
||||||
|
const old = a.oauth;
|
||||||
|
let fresh = null;
|
||||||
|
let refreshError = null;
|
||||||
try {
|
try {
|
||||||
a = { ...a, oauth: await usage.exchangeRefresh(a.oauth), refreshError: null };
|
fresh = await usage.exchangeRefresh(old);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
a = { ...a, refreshError: e.code || "EUPSTREAM" };
|
refreshError = e.code || "EUPSTREAM";
|
||||||
}
|
}
|
||||||
|
// Re-read: a spawn's place() may have saved a new placement meanwhile.
|
||||||
|
a = readAccount(a.name) || a;
|
||||||
|
a = fresh
|
||||||
|
? retire({ ...a, oauth: fresh, refreshError: null }, old)
|
||||||
|
: { ...a, refreshError };
|
||||||
save(a);
|
save(a);
|
||||||
quotaCache.delete(a.name);
|
quotaCache.delete(a.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* statusline (`statusline/statusline.js`). An explicit CLAUDE_DASHBOARD_PORT /
|
* statusline (`statusline/statusline.js`). An explicit CLAUDE_DASHBOARD_PORT /
|
||||||
* DASHBOARD_PORT wins (the hook handler's contract), then the live-server
|
* DASHBOARD_PORT wins (the hook handler's contract), then the live-server
|
||||||
* discovery file `~/.claude/.agenthub.json` (`server-info.js`, PID-checked),
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,4 +28,23 @@ function dashboardUrl() {
|
|||||||
return "http://127.0.0.1:4820";
|
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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
const crypto = require("node:crypto");
|
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
|
// 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
|
// browser may resolve a 0.0.0.0 bind via localhost; an empty Host is treated as
|
||||||
// loopback (HTTP/1.0 / local tooling).
|
// loopback (HTTP/1.0 / local tooling).
|
||||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", ""]);
|
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() {
|
function resolveHost() {
|
||||||
const h = (process.env.DASHBOARD_HOST || "").trim();
|
return resolveHosts()[0];
|
||||||
return h || "127.0.0.1";
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
function isLoopbackHostname(name) {
|
||||||
@@ -101,6 +162,36 @@ function getDashboardToken() {
|
|||||||
return typeof t === "string" && t.length > 0 ? t : null;
|
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) {
|
function tokensMatch(provided, expected) {
|
||||||
if (typeof provided !== "string" || provided.length === 0) return false;
|
if (typeof provided !== "string" || provided.length === 0) return false;
|
||||||
const a = Buffer.from(provided);
|
const a = Buffer.from(provided);
|
||||||
@@ -162,6 +253,11 @@ function isWebSocketAuthorized(req) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
LOOPBACK_HOSTS,
|
LOOPBACK_HOSTS,
|
||||||
resolveHost,
|
resolveHost,
|
||||||
|
resolveHosts,
|
||||||
|
extraListenHosts,
|
||||||
|
isWildcardHost,
|
||||||
|
isExposedBind,
|
||||||
|
accessUrls,
|
||||||
isLoopbackHostname,
|
isLoopbackHostname,
|
||||||
allowedHostnames,
|
allowedHostnames,
|
||||||
hostnameOf,
|
hostnameOf,
|
||||||
@@ -169,6 +265,8 @@ module.exports = {
|
|||||||
hostGuard,
|
hostGuard,
|
||||||
corsOptions,
|
corsOptions,
|
||||||
getDashboardToken,
|
getDashboardToken,
|
||||||
|
tokenFilePath,
|
||||||
|
publishDashboardToken,
|
||||||
tokenGuard,
|
tokenGuard,
|
||||||
isWebSocketAuthorized,
|
isWebSocketAuthorized,
|
||||||
// exported for tests
|
// exported for tests
|
||||||
|
|||||||
+25
-4
@@ -1003,16 +1003,30 @@ const UPGRADES = {
|
|||||||
win32: {
|
win32: {
|
||||||
min: [3, 4, 0],
|
min: [3, 4, 0],
|
||||||
name: "psmux",
|
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",
|
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: {
|
linux: {
|
||||||
min: [3, 7, 0],
|
min: [3, 7, 0],
|
||||||
name: "tmux",
|
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:
|
how:
|
||||||
"distros ship older ones (Ubuntu 24.04 has 3.4), so build the release from " +
|
"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: " +
|
"https://github.com/tmux/tmux/releases into your own ~/.local, leaving the " +
|
||||||
"./configure && make && sudo make install)",
|
"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 have = [m[1], m[2], m[3] || 0].map(Number);
|
||||||
const i = have.findIndex((n, k) => n !== want.min[k]);
|
const i = have.findIndex((n, k) => n !== want.min[k]);
|
||||||
if (i === -1 || have[i] > want.min[i]) return null;
|
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
|
/** The RUNNING server's version, null when none runs. A server started before
|
||||||
@@ -1063,6 +1083,7 @@ module.exports = {
|
|||||||
clientVersion,
|
clientVersion,
|
||||||
serverVersion,
|
serverVersion,
|
||||||
upgradeAdvice,
|
upgradeAdvice,
|
||||||
|
upgradeSeverity,
|
||||||
serverPid,
|
serverPid,
|
||||||
memberAttach,
|
memberAttach,
|
||||||
memberServerInfo,
|
memberServerInfo,
|
||||||
|
|||||||
@@ -618,6 +618,9 @@ router.get("/tmux", (_req, res) => {
|
|||||||
available: version !== null,
|
available: version !== null,
|
||||||
version,
|
version,
|
||||||
upgrade: version ? tmux.upgradeAdvice(version) : null,
|
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",
|
"agent_updated",
|
||||||
"new_event",
|
"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.
|
// 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;
|
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
|
||||||
|
|
||||||
@@ -32,9 +74,13 @@ function initWebSocket(server) {
|
|||||||
noServer: true,
|
noServer: true,
|
||||||
maxPayload: 64 * 1024,
|
maxPayload: 64 * 1024,
|
||||||
verifyClient(info, done) {
|
verifyClient(info, done) {
|
||||||
if (!isHostAllowed(info.req.headers.host)) return done(false, 403, "host not allowed");
|
const refuse = (reason, code, msg) => {
|
||||||
if (!isWebSocketAuthorized(info.req)) return done(false, 401, "unauthorized");
|
noteRefusal(reason, "/ws");
|
||||||
if (!isWebSocketLoggedIn(info.req)) return done(false, 401, "log in first");
|
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);
|
return done(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -207,19 +253,13 @@ function initPtyWebSocket(server) {
|
|||||||
if (!match) return; // not ours — the `/ws` WebSocketServer (registered
|
if (!match) return; // not ours — the `/ws` WebSocketServer (registered
|
||||||
// by initWebSocket, also attached to this same http.Server) handles its
|
// by initWebSocket, also attached to this same http.Server) handles its
|
||||||
// own path independently and ignores upgrades it doesn't match too.
|
// own path independently and ignores upgrades it doesn't match too.
|
||||||
if (!isHostAllowed(req.headers.host)) {
|
if (!isHostAllowed(req.headers.host)) return refuseUpgrade(socket, "host", url.pathname);
|
||||||
socket.destroy();
|
if (!isWebSocketAuthorized(req)) return refuseUpgrade(socket, "token", url.pathname);
|
||||||
return;
|
if (!isWebSocketLoggedIn(req)) return refuseUpgrade(socket, "login", url.pathname);
|
||||||
}
|
|
||||||
if (!isWebSocketAuthorized(req) || !isWebSocketLoggedIn(req)) {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Server mode: a terminal is a write, so a developer or an analyst
|
// Server mode: a terminal is a write, so a developer or an analyst
|
||||||
// attaches only to a pane of their own workspace.
|
// attaches only to a pane of their own workspace.
|
||||||
if (isServerMode() && !canSeeRun({ user: userFromRequest(req), method: "POST" }, match[1])) {
|
if (isServerMode() && !canSeeRun({ user: userFromRequest(req), method: "POST" }, match[1])) {
|
||||||
socket.destroy();
|
return refuseUpgrade(socket, "scope", url.pathname);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Same shutdown race as `/ws` above — and far easier to hit here, since
|
// 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.
|
// TerminalView re-attaches every 1.5s while a run console is open.
|
||||||
@@ -410,4 +450,5 @@ module.exports = {
|
|||||||
onConnection,
|
onConnection,
|
||||||
closeWebSocket,
|
closeWebSocket,
|
||||||
sizeFromQuery,
|
sizeFromQuery,
|
||||||
|
getUpgradeRefusals,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,9 +107,11 @@ async function todayCost({ url, file = cachePath(), now = Date.now() } = {}) {
|
|||||||
}
|
}
|
||||||
let today = null;
|
let today = null;
|
||||||
try {
|
try {
|
||||||
const headers = process.env.DASHBOARD_TOKEN
|
// DASHBOARD_TOKEN, else the token the running server published.
|
||||||
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
|
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.
|
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||||
if (process.env.AGENTHUB_PANE_TOKEN)
|
if (process.env.AGENTHUB_PANE_TOKEN)
|
||||||
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
|
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
|
||||||
|
|||||||
Reference in New Issue
Block a user