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

- /ws-pty carries the dashboard token like /ws (the terminal never attached
  with DASHBOARD_TOKEN set), and so does everything that cannot send the
  header: images, downloads, exports, drops (withToken/tokenHeader). A
  refused upgrade answers 401/403 and is logged and counted by reason.
- The browser asks for a missing or wrong token (401 EUNAUTHORIZED) and takes
  ?token= from the link agenthub start prints, dropping it from the URL.
- The CLI sends the token (DASHBOARD_TOKEN, else the one the running server
  publishes 0600 in <data dir>/runtime/dashboard-token); MCP and statusline
  read the same file.
- DASHBOARD_HOST takes a list, and 127.0.0.1 is always listened on too, so a
  LAN-only bind keeps hooks and the CLI working.
- agenthub start --host/--allowed-hosts/--token are saved in
  <data dir>/network.json for every later start; a wide bind gets a token;
  every reachable URL is printed.
- tmux < 3.7 is an amber, once-per-version notice that says only the pane
  scrollbar is missing, with a ~/.local build instead of sudo make install.
- INSTALL: PATH belongs in ~/.profile for sudo -iu/ssh; WSL mirrored notes.
This commit is contained in:
2026-11-06 14:59:48 +07:00
parent 1e3b34ec03
commit 4a665da79e
39 changed files with 1108 additions and 105 deletions
+84 -1
View File
@@ -11,6 +11,9 @@
* `usePermissions()` says what that user's role may do, so pages hide the rest.
* Once an account is known (local mode is one), its own UI preferences are
* loaded into this browser (`lib/userPrefs.ts`).
* With DASHBOARD_TOKEN set, a 401 `EUNAUTHORIZED` (from `/auth/me` or any
* request, `TOKEN_REQUIRED_EVENT`) shows the token prompt instead: the token is
* kept in this browser and the page reloads with it.
* The login page follows the OS's light/dark (`AuthCard`). An account whose
* password an admin gave (`mustChangePassword`) sees only
* `FirstPasswordPage` until it picks its own.
@@ -30,7 +33,15 @@ import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { AuthCard } from "./AuthCard";
import { FirstPasswordPage } from "./FirstPasswordPage";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import {
api,
AUTH_REQUIRED_EVENT,
dashboardToken,
saveDashboardToken,
TOKEN_REQUIRED_EVENT,
type AuthState,
type AuthUser,
} from "../lib/api";
import { hydrate } from "../lib/userPrefs";
type AuthValue = {
@@ -95,6 +106,8 @@ export function AuthGate({ children }: { children: ReactNode }) {
// null = still asking the server. A browser that last saw local mode starts
// there and is corrected by the answer (or by a 401) if that changed.
const [state, setState] = useState<AuthState | null>(cachedState);
// DASHBOARD_TOKEN is set and this browser's token is missing or wrong.
const [needToken, setNeedToken] = useState(false);
useEffect(() => {
let timer: number | undefined;
@@ -113,6 +126,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
})
.catch((err: { status?: number; code?: string }) => {
if (!alive) return;
if (err?.status === 401 && err?.code === "EUNAUTHORIZED") {
setNeedToken(true);
return;
}
// An answer that is not this build's: an older server still running
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
@@ -134,10 +151,13 @@ export function AuthGate({ children }: { children: ReactNode }) {
// Any request answering 401 EAUTH means server mode with no valid login.
const onRequired = () => setState({ mode: "server", user: null });
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
const onToken = () => setNeedToken(true);
window.addEventListener(TOKEN_REQUIRED_EVENT, onToken);
return () => {
alive = false;
window.clearTimeout(timer);
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
window.removeEventListener(TOKEN_REQUIRED_EVENT, onToken);
};
}, []);
@@ -162,6 +182,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
[]
);
if (needToken) return <TokenPage />;
if (!state) return null;
if (state.mode === "server" && !state.user) {
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
@@ -266,3 +287,65 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
</AuthCard>
);
}
/**
* DASHBOARD_TOKEN's own prompt: the token is kept in this browser
* (`localStorage.dashboard_token`) and the page reloads, so every request,
* WebSocket and image starts again with it. A token already saved here that
* the server refused is shown as wrong.
*/
function TokenPage() {
const { t } = useTranslation();
const [token, setToken] = useState("");
const [show, setShow] = useState(false);
const hadOne = Boolean(dashboardToken());
const submit = (e: FormEvent) => {
e.preventDefault();
const value = token.trim();
if (!value) return;
saveDashboardToken(value);
window.location.reload();
};
return (
<AuthCard title={t("auth.tokenTitle")} subtitle={t("auth.tokenSubtitle")} onSubmit={submit}>
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
<label htmlFor="dashboard-token">{t("auth.tokenLabel")}</label>
<div className="relative">
<input
id="dashboard-token"
className="input h-11 w-full pl-4 pr-11 font-mono"
type={show ? "text" : "password"}
autoComplete="off"
autoFocus
required
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<button
type="button"
onClick={() => setShow((v) => !v)}
aria-label={show ? t("auth.hidePassword") : t("auth.showPassword")}
aria-pressed={show}
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{hadOne && (
<p role="alert" className="text-sm text-status-danger">
{t("auth.tokenWrong")}
</p>
)}
<button
type="submit"
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0"
>
{t("auth.tokenSubmit")}
</button>
<p className="text-center text-sm text-fg-secondary">{t("auth.tokenHint")}</p>
</AuthCard>
);
}
+10 -2
View File
@@ -8,18 +8,26 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, Check, Info, X } from "lucide-react";
import { AlertCircle, AlertTriangle, Check, Info, X } from "lucide-react";
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
const TONE: Record<ToastItem["kind"], string> = {
success: "border-status-success/40 bg-status-success/10 text-status-success",
info: "border-border bg-surface-2 text-fg-primary",
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
warning: "border-status-warning/50 bg-status-warning/10 text-fg-primary",
};
function Item({ item }: { item: ToastItem }) {
const { t } = useTranslation("common");
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
const Icon =
item.kind === "error"
? AlertCircle
: item.kind === "warning"
? AlertTriangle
: item.kind === "info"
? Info
: Check;
return (
<div
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
@@ -13,7 +13,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../i18n";
@@ -27,6 +27,9 @@ const LANES = [
];
vi.mock("../../lib/api", () => ({
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
TOKEN_REQUIRED_EVENT: "agenthub:token-required",
dashboardToken: () => localStorage.getItem("dashboard_token"),
saveDashboardToken: (t: string) => localStorage.setItem("dashboard_token", t),
api: {
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
@@ -117,12 +120,42 @@ describe("AuthGate", () => {
}
});
it("renders the app when an older server or a dashboard token answers instead", async () => {
for (const err of [
{ status: 404 },
{ status: 200, code: "EBADRESPONSE" },
{ status: 401, code: "EUNAUTHORIZED" },
]) {
it("asks for the dashboard token when DASHBOARD_TOKEN refuses this browser", async () => {
me.mockRejectedValue(Object.assign(new Error("x"), { status: 401, code: "EUNAUTHORIZED" }));
const reload = vi.fn();
const loc = window.location;
// @ts-expect-error test override
delete window.location;
// @ts-expect-error test override
window.location = { ...loc, reload };
try {
render(<AuthGate>app</AuthGate>);
const input = await screen.findByLabelText("Token");
expect(screen.queryByText("app")).not.toBeInTheDocument();
expect(screen.queryByText(/token was not accepted/)).not.toBeInTheDocument();
fireEvent.change(input, { target: { value: " s3cret " } });
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
expect(localStorage.getItem("dashboard_token")).toBe("s3cret");
expect(reload).toHaveBeenCalled();
} finally {
// @ts-expect-error test override
window.location = loc;
}
});
it("says a saved token was refused, and asks on any request's 401 too", async () => {
localStorage.setItem("dashboard_token", "old");
me.mockResolvedValue({ mode: "local" });
render(<AuthGate>app</AuthGate>);
expect(await screen.findByText("app")).toBeInTheDocument();
act(() => {
window.dispatchEvent(new Event("agenthub:token-required"));
});
expect(await screen.findByText(/token was not accepted/)).toBeInTheDocument();
});
it("renders the app when an older server answers instead", async () => {
for (const err of [{ status: 404 }, { status: 200, code: "EBADRESPONSE" }]) {
me.mockReset();
me.mockRejectedValue(Object.assign(new Error("x"), err));
const { unmount } = render(<AuthGate>app</AuthGate>);
@@ -118,7 +118,7 @@ import {
} from "lucide-react";
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
import { api } from "../../lib/api";
import { api, withToken } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
/** The icon of a transcript row's sender. A JSONL `type:"user"` line is not
@@ -1656,7 +1656,7 @@ function DropFile({ path }: { path: string }) {
function DropImage({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
const src = `/api/run/drop/${encodeURIComponent(name)}`;
const src = withToken(`/api/run/drop/${encodeURIComponent(name)}`);
// Drops are pruned after a day: the name is all that is left to show.
if (gone)
return (
@@ -35,7 +35,7 @@ import {
Share2,
X,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, withToken } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
@@ -116,7 +116,7 @@ function useContent(path: string, scope: PreviewScope): Content {
};
const drop = DROP_RE.exec(path);
if (drop) {
const src = `/api/run/drop/${encodeURIComponent(drop[1]!)}`;
const src = withToken(`/api/run/drop/${encodeURIComponent(drop[1]!)}`);
if (IMAGE_RE.test(path)) setContent({ kind: "image", src });
else if (DROP_TEXT_RE.test(path))
show(
+6 -2
View File
@@ -72,7 +72,7 @@ import { ArrowDown, ArrowUp, X } from "lucide-react";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { api, withToken } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
import {
@@ -717,7 +717,11 @@ export const TerminalView = memo(function TerminalView({
// own size, so a pane on a background tab is never resized by it.
const size =
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
// The dashboard token, when one is set, rides the query like `/ws`'s:
// the server refuses the upgrade without it.
const sock = new WebSocket(
withToken(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`)
);
ws = sock;
wsRef.current = sock;
// Server sends PTY bytes as binary frames — default binaryType ("blob")
@@ -43,7 +43,10 @@ const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
vi.mock("../../../lib/api", () => ({
vi.mock("../../../lib/api", async () => ({
// The real one: it reads the token from localStorage, as the page does.
withToken: (await vi.importActual<typeof import("../../../lib/api")>("../../../lib/api"))
.withToken,
api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
@@ -236,6 +239,31 @@ describe("TerminalView", () => {
}
});
it("carries the dashboard token after its size, as /ws does (DASHBOARD_TOKEN)", () => {
// The server refuses a /ws-pty upgrade without it: the terminal sat at
// "reconnecting" forever while the status bar, on /ws, said Live.
localStorage.setItem("dashboard_token", "t ok");
const restore = withBox();
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28&token=t%20ok"
);
} finally {
restore();
}
});
it("carries the token as the only query when it has no size yet", () => {
localStorage.setItem("dashboard_token", "tok");
termState.cols = 0;
termState.rows = 0;
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?token=tok"
);
});
it("a terminal on a hidden tab sends no size until it is shown", () => {
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
// that size resized the real tmux pane under every other client.
+7 -1
View File
@@ -206,7 +206,13 @@
"confirmPassword": "Confirm new password",
"mismatch": "The two new passwords differ",
"mustChangeSubmit": "Save and continue",
"saving": "Saving…"
"saving": "Saving…",
"tokenTitle": "Dashboard token",
"tokenSubtitle": "This dashboard requires its access token (DASHBOARD_TOKEN). It is saved in this browser only.",
"tokenWrong": "That token was not accepted — check it and try again.",
"tokenLabel": "Token",
"tokenHint": "On the dashboard's machine, `agenthub start` prints a link that carries it.",
"tokenSubmit": "Continue"
},
"insights": {
"help": {
+1
View File
@@ -735,6 +735,7 @@
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
"tmuxUpgrade": "Upgrade tmux: {{advice}}",
"tmuxUpgradeOptional": "tmux is older than recommended — terminals work as they are; this message shows once. {{advice}}",
"changes.rescanRepos": "Scan for repositories again",
"tree.newGroup": "New group",
"tree.groupDefaultName": "New group",
+7 -1
View File
@@ -206,7 +206,13 @@
"confirmPassword": "Nhập lại mật khẩu mới",
"mismatch": "Hai mật khẩu mới không khớp",
"mustChangeSubmit": "Lưu và tiếp tục",
"saving": "Đang lưu…"
"saving": "Đang lưu…",
"tokenTitle": "Token của dashboard",
"tokenSubtitle": "Dashboard này yêu cầu token truy cập (DASHBOARD_TOKEN). Token chỉ được lưu trong trình duyệt này.",
"tokenWrong": "Token không đúng — kiểm tra lại rồi thử lần nữa.",
"tokenLabel": "Token",
"tokenHint": "Trên máy chạy dashboard, lệnh `agenthub start` in sẵn một đường link có kèm token.",
"tokenSubmit": "Tiếp tục"
},
"insights": {
"help": {
+1
View File
@@ -735,6 +735,7 @@
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}",
"tmuxUpgradeOptional": "tmux đã cũ — terminal vẫn chạy bình thường; thông báo này chỉ hiện một lần. {{advice}}",
"changes.rescanRepos": "Quét lại các repository",
"tree.newGroup": "Nhóm mới",
"tree.groupDefaultName": "Nhóm mới",
@@ -0,0 +1,51 @@
/**
* @file apiDashboardToken.test.ts
* @description DASHBOARD_TOKEN in the browser: `withToken` adds `?token=` (or
* `&token=`) to what cannot send the header — WebSockets, images, downloads —
* and leaves the URL alone with no token; `adoptTokenFromUrl` keeps the token
* of the link `agenthub start` prints and takes it out of the address bar;
* a 401 EUNAUTHORIZED from any request fires TOKEN_REQUIRED_EVENT.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { adoptTokenFromUrl, api, TOKEN_REQUIRED_EVENT, withToken } from "../api";
beforeEach(() => localStorage.clear());
afterEach(() => vi.unstubAllGlobals());
describe("the dashboard token in the browser", () => {
it("rides the query only when there is one", () => {
expect(withToken("/api/x")).toBe("/api/x");
localStorage.setItem("dashboard_token", "a b");
expect(withToken("/api/x")).toBe("/api/x?token=a%20b");
expect(withToken("/api/x?path=1")).toBe("/api/x?path=1&token=a%20b");
expect(api.artifacts.rawUrl(3, "f.png")).toBe("/api/artifacts/3/raw?path=f.png&token=a%20b");
});
it("is taken from the page's ?token= and dropped from the address bar", () => {
window.history.replaceState(null, "", "/workspace?lane=2&token=t0k#x");
adoptTokenFromUrl();
expect(localStorage.getItem("dashboard_token")).toBe("t0k");
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
"/workspace?lane=2#x"
);
});
it("is asked for when a request answers 401 EUNAUTHORIZED", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: { code: "EUNAUTHORIZED", message: "no" } }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
)
);
const seen = vi.fn();
window.addEventListener(TOKEN_REQUIRED_EVENT, seen);
await expect(api.auth.me()).rejects.toMatchObject({ status: 401 });
window.removeEventListener(TOKEN_REQUIRED_EVENT, seen);
expect(seen).toHaveBeenCalledTimes(1);
});
});
+86 -24
View File
@@ -505,6 +505,24 @@ export function dashboardToken(): string | null {
}
}
/**
* `url` with the dashboard token (if one is configured) in its query string —
* for what cannot send the `x-dashboard-token` header: an `<img>`, an
* `<a download>`, an iframe, a WebSocket, a bare `fetch`. Unchanged when no
* token is set (the loopback default).
*/
export function withToken(url: string): string {
const token = dashboardToken();
if (!token) return url;
return `${url}${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
}
/** The token header for a bare `fetch` that bypasses {@link request}; empty with no token. */
export function tokenHeader(): Record<string, string> {
const token = dashboardToken();
return token ? { "x-dashboard-token": token } : {};
}
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
function ccQuery(scope?: CcScope, cwd?: string): string {
const qs = new URLSearchParams();
@@ -546,6 +564,36 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
*/
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** Fired on a 401 EUNAUTHORIZED: DASHBOARD_TOKEN is set and this browser's is missing or wrong. */
export const TOKEN_REQUIRED_EVENT = "agenthub:token-required";
/** Keep `token` as this browser's dashboard token (an empty one forgets it). */
export function saveDashboardToken(token: string): void {
try {
if (token) localStorage.setItem("dashboard_token", token);
else localStorage.removeItem("dashboard_token");
} catch {
/* storage blocked: the prompt will ask again */
}
}
/**
* A `?token=` on the page's own URL (the link `agenthub start` prints): keep
* it in this browser and take it out of the address bar and history, so it is
* not left in a screenshot or a bookmark. Call once, before the first request.
*/
export function adoptTokenFromUrl(): void {
try {
const url = new URL(window.location.href);
const token = url.searchParams.get("token");
if (!token) return;
saveDashboardToken(token);
url.searchParams.delete("token");
window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
} catch {
/* no URL API / history: nothing to adopt */
}
}
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
@@ -1066,6 +1114,10 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
if (res.status === 401 && body?.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
// DASHBOARD_TOKEN: this browser has none or a wrong one — AuthGate asks.
if (res.status === 401 && body?.error?.code === "EUNAUTHORIZED") {
window.dispatchEvent(new Event(TOKEN_REQUIRED_EVENT));
}
// Server mode: a role refusal reads the same in every toast, translated.
const message =
body?.error?.code === "EADMIN"
@@ -1212,6 +1264,9 @@ const sendRaw = <T>(
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
if (xhr.status === 401 && body.error?.code === "EUNAUTHORIZED") {
window.dispatchEvent(new Event(TOKEN_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
@@ -1546,7 +1601,7 @@ export const api = {
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
avatarUrl: (avatarAt?: string | null) =>
avatarAt ? `${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}` : null,
avatarAt ? withToken(`${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}`) : null,
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
setAvatar: (file: Blob) =>
request<MyAccount>("/me/avatar", {
@@ -1579,7 +1634,7 @@ export const api = {
users: {
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
avatarAt ? withToken(`${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}`) : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () =>
request<{ users: ServerUserUsage[]; quotaDefault?: UserQuota | null }>(
@@ -1941,7 +1996,7 @@ export const api = {
/** GET /api/sessions/:id/image — an image the session's Read tool read,
* wherever it lies (local mode only), for an `<img>`. */
readImageUrl: (id: string, path: string) =>
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
withToken(`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`),
/** GET /api/sessions/:id/file — a text file the session itself read or
* wrote, wherever it lies (an output outside the workspace). */
readFile: (id: string, path: string) =>
@@ -2451,7 +2506,7 @@ export const api = {
*
* @returns The absolute-on-origin URL (`/api/settings/export`) to link to.
*/
exportData: () => `${BASE}/settings/export`,
exportData: () => withToken(`${BASE}/settings/export`),
/**
* POST /api/settings/import (multipart) - restore a bundle previously
* produced by {@link exportData}. Idempotent and non-destructive: sessions
@@ -2460,7 +2515,7 @@ export const api = {
*
* Like {@link api.import.upload}, this bypasses {@link request} to send a
* `multipart/form-data` body (field name "file") and let the browser set the
* boundary. No auth token is attached (local/zero-config flow).
* boundary. The dashboard token, when one is set, rides the header.
*
* @param file The `.json` export file the user selected.
* @returns {@link ImportBackupResult} — per-table restore counts.
@@ -2469,7 +2524,11 @@ export const api = {
importData: async (file: File): Promise<ImportBackupResult> => {
const form = new FormData();
form.append("file", file, file.name);
const res = await fetch(`${BASE}/settings/import`, { method: "POST", body: form });
const res = await fetch(`${BASE}/settings/import`, {
method: "POST",
body: form,
headers: tokenHeader(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `HTTP ${res.status}`);
@@ -2778,7 +2837,11 @@ export const api = {
// Append each file under the repeated "files" field, keeping its filename.
for (const f of files) form.append("files", f, f.name);
// Do NOT set Content-Type manually: the browser adds the multipart boundary.
const res = await fetch(`${BASE}/import/upload`, { method: "POST", body: form });
const res = await fetch(`${BASE}/import/upload`, {
method: "POST",
body: form,
headers: tokenHeader(),
});
if (!res.ok) {
// Same error-shaping contract as request(), duplicated because this call
// intentionally does not route through the JSON wrapper.
@@ -3367,8 +3430,8 @@ export const api = {
/** One file's download URL, on a workspace or a home. */
downloadUrl: (t: FsPlace, path: string) =>
isHomePlace(t)
? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`
: `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`,
? withToken(`${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`)
: withToken(`${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`),
list: (t: FsPlace, path = "") =>
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
mkdir: (t: FsPlace, path: string) =>
@@ -3430,7 +3493,7 @@ export const api = {
zipUrl: (t: FsPlace, paths: string[]) => {
const q = new URLSearchParams();
for (const p of paths) q.append("p", p);
return `${BASE}${fsUrl(t, "/zip", q)}`;
return withToken(`${BASE}${fsUrl(t, "/zip", q)}`);
},
/**
* Stream `file` to `path` (sub-folders made as needed), reporting upload
@@ -3769,14 +3832,14 @@ export const api = {
if (path) qs.set("path", path);
if (download) qs.set("download", "1");
const s = qs.toString();
return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`;
return withToken(`${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`);
},
},
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
pets: {
list: () => request<PetList>("/pets"),
/** The pet's spritesheet (a Codex pet) or image, served raster-only. */
spriteUrl: (id: string) => `${BASE}/pets/${encodeURIComponent(id)}/sprite`,
spriteUrl: (id: string) => withToken(`${BASE}/pets/${encodeURIComponent(id)}/sprite`),
/** POST /api/pets/import — a `.codex-pet.zip` (pet.json + spritesheet.webp). */
importBundle: (file: File) =>
request<{ pet: PetView }>("/pets/import", {
@@ -3859,11 +3922,7 @@ export const api = {
job: () => request<AiToolJob | null>("/ai-tools/job"),
/** GET /api/ai-tools/versions - npm version badges, cached a day server-side. */
/** The server-cached icon; an <img> cannot send the bearer header, so the token rides the query. */
iconUrl: (id: string) => {
const token = dashboardToken();
const q = token ? `?token=${encodeURIComponent(token)}` : "";
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
},
iconUrl: (id: string) => withToken(`${BASE}/ai-tools/${encodeURIComponent(id)}/icon`),
},
// ──────────────────────────────── AI Proxy API ──────────────────────────────
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
@@ -3976,9 +4035,12 @@ export const api = {
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
* version, and this platform's upgrade advice when it is too old. */
tmuxAvailable: () =>
request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
"/run/tmux"
),
request<{
available: boolean;
version?: string | null;
upgrade?: string | null;
upgradeSeverity?: "warning" | "error" | null;
}>("/run/tmux"),
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
@@ -4213,7 +4275,7 @@ export const api = {
qs.delete("limit");
qs.delete("offset");
qs.set("format", format);
return `${BASE}/query?${qs.toString()}`;
return withToken(`${BASE}/query?${qs.toString()}`);
},
},
@@ -4235,7 +4297,7 @@ export const api = {
runs: (id: number) => request<{ runs: ReportRun[] }>(`/reports/${id}/runs`),
/** A run's file, as a download link. */
fileUrl: (id: number, runId: number, format: string) =>
`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`,
withToken(`${BASE}/reports/${id}/runs/${runId}/${encodeURIComponent(format)}`),
},
// ───────────────────────────────── Webhooks API ─────────────────────────────
@@ -4797,11 +4859,11 @@ export const api = {
request<LaneFileContent>(gitUrl(id, "/file", new URLSearchParams({ path, rev }))),
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
withToken(`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`),
/** GET /api/lanes/:id/git/download — one working file as an attachment
* (any type; a plain, non-git folder too). For an `<a download>`. */
gitDownloadUrl: (id: GitTarget, path: string) =>
`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`,
withToken(`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`),
/**
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
* ("" = the workspace root). Only creates: a taken name answers 409
+2 -1
View File
@@ -9,6 +9,7 @@
*/
import type { NotifSound } from "./notifPrefs";
import { tokenHeader } from "./api";
const audio = new Map<NotifSound, HTMLAudioElement>();
@@ -63,7 +64,7 @@ export async function notify(title: string, body: string, url?: string): Promise
try {
const res = await fetch("/api/push/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...tokenHeader() },
body: JSON.stringify({ title, body, url }),
});
const result = res.ok ? await res.json().catch(() => null) : null;
+7 -2
View File
@@ -17,7 +17,7 @@
*/
import type { DragEvent } from "react";
import { api } from "./api";
import { api, withToken } from "./api";
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
export interface Attachment {
@@ -331,7 +331,12 @@ function readRecent(): Recent[] {
}
export function recentUploads(): Attachment[] {
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
return readRecent().map(({ path, name, url }) => ({
path,
name,
// Stored without the token; added on the way out.
...(url ? { url: withToken(url) } : {}),
}));
}
/** Remember uploads just saved, newest first; an image keeps a thumbnail
+6 -3
View File
@@ -23,6 +23,9 @@
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { tokenHeader } from "./api";
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
@@ -138,7 +141,7 @@ export async function subscribeToPush(): Promise<void> {
if (existing) return; // already subscribed -> idempotent no-op
// Fetch the server's VAPID public key and decode it for `applicationServerKey`.
const res = await fetch("/api/push/vapid-public-key");
const res = await fetch("/api/push/vapid-public-key", { headers: tokenHeader() });
const { publicKey } = await res.json();
const subscription = await registration.pushManager.subscribe({
@@ -149,7 +152,7 @@ export async function subscribeToPush(): Promise<void> {
// Register the endpoint + keys with the backend so it can push to this browser.
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...tokenHeader() },
body: JSON.stringify(subscription.toJSON()), // { endpoint, keys: { p256dh, auth } }
});
}
@@ -179,7 +182,7 @@ export async function unsubscribeFromPush(): Promise<void> {
// Tell the backend to forget this endpoint so it stops attempting deliveries.
await fetch("/api/push/subscribe", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...tokenHeader() },
body: JSON.stringify({ endpoint }),
});
}
+7 -3
View File
@@ -2,12 +2,13 @@
* @file toast.ts
* @description App-wide toast queue: `toast.success(msg)` / `toast.error(msg)`
* from anywhere, rendered once by `components/Toaster.tsx` inside live regions.
* A success fades after a few seconds; an error stays until dismissed, because
* A success fades after a few seconds; an error — and an amber `warning`, for
* advice that is worth reading but is no failure — stays until dismissed, because
* a message that vanishes before it is read is no message at all (WCAG 2.2.1).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export type ToastKind = "success" | "error" | "info";
export type ToastKind = "success" | "error" | "info" | "warning";
export interface ToastItem {
id: number;
@@ -43,7 +44,8 @@ function push(kind: ToastKind, message: string): number {
}
items = next;
emit();
if (kind !== "error") window.setTimeout(() => dismissToast(id), SUCCESS_MS);
// An error or a warning stays until closed, so its advice can be copied.
if (kind !== "error" && kind !== "warning") window.setTimeout(() => dismissToast(id), SUCCESS_MS);
return id;
}
@@ -59,6 +61,8 @@ export function subscribeToasts(fn: (items: ToastItem[]) => void): () => void {
export const toast = {
success: (message: string) => push("success", message),
info: (message: string) => push("info", message),
/** Amber and sticky: worth reading, not a failure. */
warning: (message: string) => push("warning", message),
error: (message: unknown) =>
push("error", message instanceof Error ? message.message : String(message)),
};
+4
View File
@@ -73,6 +73,7 @@ import "@fontsource/maple-mono/700.css";
import App from "./App";
import { AuthGate } from "./components/AuthGate";
import { i18nReady } from "./i18n";
import { adoptTokenFromUrl } from "./lib/api";
import "./index.css";
// Deep link into the Workspace: start its (lazy) chunk now, in parallel with the
@@ -103,6 +104,9 @@ if ("serviceWorker" in navigator) {
.catch(() => {});
}
// `agenthub start` prints a link carrying `?token=`: keep it, drop it from the URL.
adoptTokenFromUrl();
const root = document.getElementById("root");
if (!root) throw new Error("Root element not found");
+23 -8
View File
@@ -71,7 +71,7 @@ import {
ChevronRight,
GitBranch,
} from "lucide-react";
import { api, type GitSource } from "../lib/api";
import { api, withToken, type GitSource } from "../lib/api";
import { useInnerReposMode } from "../lib/innerReposPref";
import { usePermissions } from "../components/AuthGate";
import type {
@@ -661,16 +661,29 @@ export function Workspace() {
const { local } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams();
// The server's tmux is older than this platform needs (psmux < 3.4.0 on
// Windows, tmux < 3.7 elsewhere): say how to upgrade it, until it is.
// Sticky (an error toast) so the command can be copied. An older server or
// no answer says nothing.
// The server's tmux is older than this platform needs: say how to upgrade
// it. An old psmux (Windows) stalls terminals — an error, every visit. An old
// tmux only lacks the pane scrollbar — an amber warning, shown once per
// version in this browser (closing it is for good). Both sticky, so the
// command can be copied. An older server or no answer says nothing.
useEffect(() => {
let live = true;
(async () => {
try {
const { upgrade } = await api.run.tmuxAvailable();
if (live && upgrade) toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
const { upgrade, upgradeSeverity } = await api.run.tmuxAvailable();
if (!live || !upgrade) return;
if (upgradeSeverity === "warning") {
const SEEN = "agenthub.tmuxUpgrade.seen";
try {
if (localStorage.getItem(SEEN) === upgrade) return;
localStorage.setItem(SEEN, upgrade);
} catch {
/* storage blocked: show it */
}
toast.warning(tLanes("tmuxUpgradeOptional", { advice: upgrade }));
return;
}
toast.error(tLanes("tmuxUpgrade", { advice: upgrade }));
} catch {
/* nothing to say */
}
@@ -1477,7 +1490,9 @@ export function Workspace() {
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
href={withToken(
`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`
)}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"