Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f975066c62 | |||
| c557c0ad2f | |||
| d086a7f198 | |||
| 33bb3eccb5 | |||
| 4a665da79e | |||
| 1e3b34ec03 | |||
| decf176b4b | |||
| dea74b777e | |||
| 18e574a765 | |||
| 879a6bb02d | |||
| 1a365a45bd | |||
| 54001585e2 | |||
| 0bf552b793 | |||
| 87ba935c6e | |||
| 045e46e111 | |||
| e7573f13d9 | |||
| 9d28807f2c | |||
| e60ebe468b | |||
| 26977780cc | |||
| e06472ecd9 | |||
| ab27629139 | |||
| 3b1fdfac51 | |||
| 087c1ddb5d | |||
| 890cd08f57 | |||
| aabc95768c |
+33
-2
@@ -57,20 +57,48 @@ sudo pacman -S git tmux # Arch
|
||||
|
||||
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
|
||||
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
|
||||
|
||||
**PATH for `agenthub`:** put `export PATH="$HOME/.local/bin:$PATH"` in
|
||||
`~/.profile`, not only `~/.bashrc`. A login shell that is not interactive —
|
||||
`sudo -iu <user> agenthub remote login`, an `ssh host agenthub …`, a systemd
|
||||
unit — reads `~/.profile`, and Ubuntu's `~/.bashrc` returns early when the shell
|
||||
is not interactive, so a PATH set only there is never seen (`agenthub: command
|
||||
not found`). Or call it by its full path, `~/.local/bin/agenthub`.
|
||||
|
||||
**WSL2 with `networkingMode=mirrored`:** on some machines a Windows browser
|
||||
cannot reach WSL's `localhost`, so the dashboard must listen on an address
|
||||
Windows can reach. Save it once — every later start, the autostart unit and an
|
||||
update keep it:
|
||||
|
||||
```bash
|
||||
agenthub start --host 0.0.0.0 # every interface (LAN and VPN too)
|
||||
agenthub start --host 192.168.1.20 # or only this address (and 127.0.0.1)
|
||||
```
|
||||
|
||||
A non-loopback bind always gets a token (generated when you give none; `--token
|
||||
<t>` sets your own, `--token off` removes it) and `agenthub start` prints the
|
||||
links to open, each carrying `?token=` — the page keeps it and drops it from
|
||||
the address bar. The server also listens on `127.0.0.1` whenever the bind does
|
||||
not cover it, so hooks and the CLI keep working. `agenthub start --host local`
|
||||
goes back to loopback only.
|
||||
On a native Windows install, workspace terminals are unavailable, but Settings →
|
||||
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
|
||||
and runs a `.cmd` updater or npm through `cmd.exe`.
|
||||
|
||||
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
|
||||
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
|
||||
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release:
|
||||
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release
|
||||
into your own `~/.local` — the system's tmux stays as it is:
|
||||
|
||||
```bash
|
||||
sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison
|
||||
curl -fsSL https://github.com/tmux/tmux/releases/download/3.7c/tmux-3.7c.tar.gz | tar xz
|
||||
cd tmux-3.7c && ./configure && make && sudo make install # into /usr/local/bin
|
||||
cd tmux-3.7c && ./configure --prefix="$HOME/.local" && make && make install # into ~/.local/bin
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile # ahead of /usr/bin
|
||||
```
|
||||
|
||||
(`sudo make install` instead puts it in `/usr/local/bin` for every account.)
|
||||
|
||||
A tmux server already running keeps its old version until every tmux session
|
||||
has closed (or the machine reboots) — only then do the scrollbars appear.
|
||||
|
||||
@@ -444,6 +472,9 @@ npm run install-hooks
|
||||
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
|
||||
| `DASHBOARD_HOST` | `127.0.0.1` | Address(es) to listen on, comma-separated (`192.168.1.20,100.64.0.7`); `127.0.0.1` is added whenever none covers it. Saved by `agenthub start --host` in `<data dir>/network.json`; the variable wins over the file |
|
||||
| `DASHBOARD_ALLOWED_HOSTS` | — | Extra `Host` names (comma-separated) for a LAN bind. Saved by `agenthub start --allowed-hosts` |
|
||||
| `DASHBOARD_TOKEN` | — | Access token for `/api/*` and the WebSockets. Saved by `agenthub start --token`; the running server publishes it in `<data dir>/runtime/dashboard-token` (0600) for the local CLI, MCP server and statusline |
|
||||
|
||||
Example with a custom port:
|
||||
|
||||
|
||||
+80
-1
@@ -147,6 +147,11 @@ function baseUrl() {
|
||||
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).dashboardUrl();
|
||||
}
|
||||
|
||||
/** The dashboard-token header (`dashboard-url.js`), {} when none is set. */
|
||||
function dashboardTokenHeaders() {
|
||||
return require(path.join(REPO_ROOT, "server", "lib", "dashboard-url.js")).tokenHeaders();
|
||||
}
|
||||
|
||||
/** Thrown when the dashboard server does not answer — the dispatcher decides
|
||||
* whether the active command has an offline fallback or must abort. */
|
||||
class ServerDownError extends Error {}
|
||||
@@ -162,6 +167,8 @@ async function api(method, pathname, body, options = {}) {
|
||||
method,
|
||||
headers: {
|
||||
...(body && !form ? { "Content-Type": "application/json" } : {}),
|
||||
// DASHBOARD_TOKEN, else the one the running server published.
|
||||
...dashboardTokenHeaders(),
|
||||
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||
...(process.env.AGENTHUB_PANE_TOKEN
|
||||
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
|
||||
@@ -1495,6 +1502,64 @@ async function cmdReinstallHooks() {
|
||||
console.log(`${c.green("✔")} Claude Code hooks reinstalled`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save `start`'s network flags into `<data dir>/network.json` (applied by every
|
||||
* later server start): `--host A[,B]` (`local` = back to loopback only),
|
||||
* `--allowed-hosts H,..` (`none` clears), `--token T|auto|off`. A wide bind
|
||||
* with no token gets one made (`auto`), never left open silently. Returns
|
||||
* whether anything changed.
|
||||
*/
|
||||
function saveNetworkFlags(flags) {
|
||||
const net = require(path.join(REPO_ROOT, "server", "lib", "network-config.js"));
|
||||
const patch = {};
|
||||
if (typeof flags.host === "string") {
|
||||
patch.host = flags.host === "local" ? null : flags.host;
|
||||
} else if (flags.host === true) {
|
||||
console.error(c.red("✖ --host needs an address, e.g. --host 0.0.0.0 (or --host local)"));
|
||||
process.exit(1);
|
||||
}
|
||||
if (typeof flags["allowed-hosts"] === "string") {
|
||||
patch.allowedHosts = flags["allowed-hosts"] === "none" ? null : flags["allowed-hosts"];
|
||||
}
|
||||
if (flags.token === "off") patch.token = null;
|
||||
else if (flags.token === "auto" || flags.token === true) patch.token = net.newToken();
|
||||
else if (typeof flags.token === "string") patch.token = flags.token;
|
||||
if (Object.keys(patch).length === 0) {
|
||||
net.applyNetworkConfig();
|
||||
return false;
|
||||
}
|
||||
const saved = net.writeNetworkConfig(patch);
|
||||
const wide = (saved.host || "")
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.some((h) => h && !["127.0.0.1", "localhost", "::1", "[::1]"].includes(h));
|
||||
if (wide && !saved.token && !process.env.DASHBOARD_TOKEN) {
|
||||
// Reachable from the network with no token would be an open dashboard.
|
||||
Object.assign(saved, net.writeNetworkConfig({ token: net.newToken() }));
|
||||
console.log(c.yellow(" A token was generated: the dashboard is reachable from the network."));
|
||||
}
|
||||
console.log(`${c.green("✔")} Network settings saved to ${c.dim(net.configPath())}`);
|
||||
net.applyNetworkConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print every URL the dashboard can be opened at (all of this machine's
|
||||
* addresses for a 0.0.0.0 bind), each carrying `?token=` when one is set —
|
||||
* the page stores it and drops it from the address bar.
|
||||
*/
|
||||
function printAccessUrls() {
|
||||
const sec = require(path.join(REPO_ROOT, "server", "lib", "security.js"));
|
||||
const port = Number(new URL(baseUrl()).port) || 4820;
|
||||
const token = sec.getDashboardToken();
|
||||
const urls = sec.accessUrls(port);
|
||||
if (urls.length <= 1 && !token) return;
|
||||
console.log(c.dim(" Open it at:"));
|
||||
for (const u of urls) {
|
||||
console.log(` ${c.bold(token ? `${u}/?token=${encodeURIComponent(token)}` : u)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the dashboard server in the background (production mode, serving the
|
||||
* built client) and wait until /api/health answers. No-ops with a pointer to
|
||||
@@ -1505,8 +1570,16 @@ async function cmdReinstallHooks() {
|
||||
async function cmdStart(flags) {
|
||||
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
|
||||
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
|
||||
const changed = saveNetworkFlags(flags);
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
||||
if (changed) {
|
||||
console.log(
|
||||
c.yellow(" The new network settings were saved; they apply at the next start: ") +
|
||||
c.bold("agenthub stop && agenthub start")
|
||||
);
|
||||
}
|
||||
printAccessUrls();
|
||||
return;
|
||||
}
|
||||
// A plugin install starts the way a Claude Code session does: its bootstrap
|
||||
@@ -1523,6 +1596,7 @@ async function cmdStart(flags) {
|
||||
while (Date.now() < deadline) {
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
|
||||
printAccessUrls();
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
@@ -1572,6 +1646,7 @@ async function cmdStart(flags) {
|
||||
console.log(
|
||||
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
|
||||
);
|
||||
printAccessUrls();
|
||||
console.log(c.dim(" Stop it with: agenthub stop"));
|
||||
return;
|
||||
}
|
||||
@@ -2928,7 +3003,11 @@ const COMMAND_GROUPS = [
|
||||
"Server",
|
||||
[
|
||||
["status", "", "Up/down indicator for the dashboard server"],
|
||||
["start", "[--port N]", "Start the server in the background and wait for healthy"],
|
||||
[
|
||||
"start",
|
||||
"[--port N] [--host A[,B]|local] [--allowed-hosts H,..] [--token T|auto|off]",
|
||||
"Start the server in the background and wait for healthy; --host/--allowed-hosts/--token are saved (data dir network.json) for every later start",
|
||||
],
|
||||
[
|
||||
"stop",
|
||||
"",
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* With DASHBOARD_TOKEN set, a 401 `EUNAUTHORIZED` (from `/auth/me` or any
|
||||
* request, `TOKEN_REQUIRED_EVENT`) shows the token prompt instead: the token is
|
||||
* kept in this browser and the page reloads with it.
|
||||
* The login page follows the OS's light/dark (`AuthCard`). An account whose
|
||||
* password an admin gave (`mustChangePassword`) sees only
|
||||
* `FirstPasswordPage` until it picks its own.
|
||||
@@ -30,7 +33,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { AuthCard } from "./AuthCard";
|
||||
import { FirstPasswordPage } from "./FirstPasswordPage";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import {
|
||||
api,
|
||||
AUTH_REQUIRED_EVENT,
|
||||
dashboardToken,
|
||||
saveDashboardToken,
|
||||
TOKEN_REQUIRED_EVENT,
|
||||
type AuthState,
|
||||
type AuthUser,
|
||||
} from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = {
|
||||
@@ -72,6 +83,10 @@ export function usePermissions() {
|
||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||
// Owner-only actions on a workspace (rename, reset, remove, share): its
|
||||
// owner or an admin. A workspace shared with this user is not theirs.
|
||||
ownsLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || (!!user && lane?.owner_id === user.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,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;
|
||||
@@ -109,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
|
||||
@@ -130,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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -158,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 })} />;
|
||||
@@ -262,3 +287,65 @@ function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DASHBOARD_TOKEN's own prompt: the token is kept in this browser
|
||||
* (`localStorage.dashboard_token`) and the page reloads, so every request,
|
||||
* WebSocket and image starts again with it. A token already saved here that
|
||||
* the server refused is shown as wrong.
|
||||
*/
|
||||
function TokenPage() {
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState("");
|
||||
const [show, setShow] = useState(false);
|
||||
const hadOne = Boolean(dashboardToken());
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = token.trim();
|
||||
if (!value) return;
|
||||
saveDashboardToken(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthCard title={t("auth.tokenTitle")} subtitle={t("auth.tokenSubtitle")} onSubmit={submit}>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="dashboard-token">{t("auth.tokenLabel")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="dashboard-token"
|
||||
className="input h-11 w-full pl-4 pr-11 font-mono"
|
||||
type={show ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
required
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((v) => !v)}
|
||||
aria-label={show ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={show}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{hadOne && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{t("auth.tokenWrong")}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0"
|
||||
>
|
||||
{t("auth.tokenSubmit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.tokenHint")}</p>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,18 +8,26 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Check, Info, X } from "lucide-react";
|
||||
import { AlertCircle, AlertTriangle, Check, Info, X } from "lucide-react";
|
||||
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
||||
|
||||
const TONE: Record<ToastItem["kind"], string> = {
|
||||
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
||||
info: "border-border bg-surface-2 text-fg-primary",
|
||||
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
||||
warning: "border-status-warning/50 bg-status-warning/10 text-fg-primary",
|
||||
};
|
||||
|
||||
function Item({ item }: { item: ToastItem }) {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
|
||||
const Icon =
|
||||
item.kind === "error"
|
||||
? AlertCircle
|
||||
: item.kind === "warning"
|
||||
? AlertTriangle
|
||||
: item.kind === "info"
|
||||
? Info
|
||||
: Check;
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
|
||||
@@ -90,6 +90,9 @@ import { UserAvatar } from "./users/UserAvatar";
|
||||
const AddLaneModal = lazy(() =>
|
||||
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
|
||||
);
|
||||
const WorkspaceShareModal = lazy(() =>
|
||||
import("./lanes/WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
|
||||
);
|
||||
const DestructiveLaneModal = lazy(() =>
|
||||
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
|
||||
);
|
||||
@@ -116,7 +119,9 @@ export function WorkspaceTree() {
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const { state: auth } = useAuth();
|
||||
const { seesAll, canEditLane } = usePermissions();
|
||||
const { seesAll, canEditLane, ownsLane } = usePermissions();
|
||||
// Sharing a workspace with other users (server mode): the open dialog's lane.
|
||||
const [sharing, setSharing] = useState<Lane | null>(null);
|
||||
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
// Server mode staff see every user's workspaces, so the list says whose.
|
||||
const grouped = auth.mode === "server" && seesAll;
|
||||
@@ -375,6 +380,7 @@ export function WorkspaceTree() {
|
||||
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||
if (action === "rename") setRenamingId(lane.id);
|
||||
else if (action === "share") setSharing(lane);
|
||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||
};
|
||||
@@ -559,7 +565,8 @@ export function WorkspaceTree() {
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={nudge}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
// A workspace shared with this user keeps its owner's name.
|
||||
onRenameStart={() => ownsLane(lane) && setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
@@ -688,6 +695,7 @@ export function WorkspaceTree() {
|
||||
</ContextMenu>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
{sharing && <WorkspaceShareModal lane={sharing} onClose={() => setSharing(null)} />}
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
@@ -27,6 +27,9 @@ const LANES = [
|
||||
];
|
||||
vi.mock("../../lib/api", () => ({
|
||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||
TOKEN_REQUIRED_EVENT: "agenthub:token-required",
|
||||
dashboardToken: () => localStorage.getItem("dashboard_token"),
|
||||
saveDashboardToken: (t: string) => localStorage.setItem("dashboard_token", t),
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
@@ -117,12 +120,42 @@ describe("AuthGate", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders the app when an older server or a dashboard token answers instead", async () => {
|
||||
for (const err of [
|
||||
{ status: 404 },
|
||||
{ status: 200, code: "EBADRESPONSE" },
|
||||
{ status: 401, code: "EUNAUTHORIZED" },
|
||||
]) {
|
||||
it("asks for the dashboard token when DASHBOARD_TOKEN refuses this browser", async () => {
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), { status: 401, code: "EUNAUTHORIZED" }));
|
||||
const reload = vi.fn();
|
||||
const loc = window.location;
|
||||
// @ts-expect-error test override
|
||||
delete window.location;
|
||||
// @ts-expect-error test override
|
||||
window.location = { ...loc, reload };
|
||||
try {
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
const input = await screen.findByLabelText("Token");
|
||||
expect(screen.queryByText("app")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/token was not accepted/)).not.toBeInTheDocument();
|
||||
fireEvent.change(input, { target: { value: " s3cret " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(localStorage.getItem("dashboard_token")).toBe("s3cret");
|
||||
expect(reload).toHaveBeenCalled();
|
||||
} finally {
|
||||
// @ts-expect-error test override
|
||||
window.location = loc;
|
||||
}
|
||||
});
|
||||
|
||||
it("says a saved token was refused, and asks on any request's 401 too", async () => {
|
||||
localStorage.setItem("dashboard_token", "old");
|
||||
me.mockResolvedValue({ mode: "local" });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("agenthub:token-required"));
|
||||
});
|
||||
expect(await screen.findByText(/token was not accepted/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the app when an older server answers instead", async () => {
|
||||
for (const err of [{ status: 404 }, { status: 200, code: "EBADRESPONSE" }]) {
|
||||
me.mockReset();
|
||||
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
||||
const { unmount } = render(<AuthGate>app</AuthGate>);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file MarkdownContent.tsx
|
||||
* @description Lightweight markdown renderer for conversation messages. Supports the
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists (nested by indent; a
|
||||
* numbered list split by a blank line keeps counting from its own numbers),
|
||||
* task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
@@ -68,7 +70,7 @@ import { onLinkClick } from "../../lib/openInPreview";
|
||||
type Block =
|
||||
| { kind: "code"; lang: string; code: string }
|
||||
| { kind: "heading"; level: number; text: string }
|
||||
| { kind: "list"; ordered: boolean; items: string[] }
|
||||
| { kind: "list"; ordered: boolean; start: number; items: string[] }
|
||||
| { kind: "quote"; text: string }
|
||||
| { kind: "hr" }
|
||||
| {
|
||||
@@ -196,27 +198,35 @@ function parseBlocks(src: string): Block[] {
|
||||
if (ulMatch || olMatch) {
|
||||
const ordered = !!olMatch;
|
||||
const itemRe = ordered ? OL_RE : UL_RE;
|
||||
const base = (olMatch ?? ulMatch)![1]!.length;
|
||||
// The number the list starts at: a list broken by a blank line or a
|
||||
// stray block still counts on from where the text says ("2.", "3.").
|
||||
const start = olMatch ? Number(olMatch[2]) || 1 : 1;
|
||||
const items: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const m = lines[i]!.match(itemRe);
|
||||
if (m) {
|
||||
if (m && m[1]!.length <= base) {
|
||||
items.push(m[3]!);
|
||||
i++;
|
||||
// The item's own lines: indented text, and a list nested under it
|
||||
// (indented deeper than this list's marker) — kept with its
|
||||
// relative indent so the item renders it as a sub-list.
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i]!.trim() !== "" &&
|
||||
!lines[i]!.match(UL_RE) &&
|
||||
!lines[i]!.match(OL_RE) &&
|
||||
/^\s+\S/.test(lines[i]!)
|
||||
/^\s+\S/.test(lines[i]!) &&
|
||||
(lines[i]!.match(/^\s*/)![0].length > base ||
|
||||
(!lines[i]!.match(UL_RE) && !lines[i]!.match(OL_RE)))
|
||||
) {
|
||||
items[items.length - 1] += "\n" + lines[i]!.trim();
|
||||
const nested = UL_RE.test(lines[i]!) || OL_RE.test(lines[i]!);
|
||||
items[items.length - 1] += "\n" + (nested ? lines[i]! : lines[i]!.trim());
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
blocks.push({ kind: "list", ordered, items });
|
||||
blocks.push({ kind: "list", ordered, start, items });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -524,8 +534,27 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render a single list item, handling [ ] / [x] task list prefixes. */
|
||||
/** Render a single list item: its text, then any list nested under it. */
|
||||
function renderListItem(item: string, key: string): React.ReactNode {
|
||||
const lines = item.split("\n");
|
||||
const sub = lines.findIndex((l, n) => n > 0 && (UL_RE.test(l) || OL_RE.test(l)));
|
||||
if (sub > 0) {
|
||||
const rest = lines.slice(sub);
|
||||
const indent = Math.min(...rest.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
|
||||
return (
|
||||
<>
|
||||
{renderItemText(lines.slice(0, sub).join("\n"), key)}
|
||||
<div className="mt-1">
|
||||
<MarkdownContent text={rest.map((l) => l.slice(indent)).join("\n")} dense />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return renderItemText(item, key);
|
||||
}
|
||||
|
||||
/** An item's own text, handling [ ] / [x] task list prefixes. */
|
||||
function renderItemText(item: string, key: string): React.ReactNode {
|
||||
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
|
||||
if (taskMatch) {
|
||||
const checked = taskMatch[1]!.toLowerCase() === "x";
|
||||
@@ -594,6 +623,7 @@ export function MarkdownContent({ text, dense = false, prose = false }: Markdown
|
||||
return (
|
||||
<ol
|
||||
key={idx}
|
||||
start={b.start !== 1 ? b.start : undefined}
|
||||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { artifactKind, isArtifact, type ArtifactKind } from "../../lib/artifactView";
|
||||
import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, withToken } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
/** The icon of a transcript row's sender. A JSONL `type:"user"` line is not
|
||||
@@ -1656,7 +1656,7 @@ function DropFile({ path }: { path: string }) {
|
||||
function DropImage({ name }: { name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [gone, setGone] = useState(false);
|
||||
const src = `/api/run/drop/${encodeURIComponent(name)}`;
|
||||
const src = withToken(`/api/run/drop/${encodeURIComponent(name)}`);
|
||||
// Drops are pruned after a day: the name is all that is left to show.
|
||||
if (gone)
|
||||
return (
|
||||
|
||||
@@ -89,6 +89,37 @@ describe("<MarkdownContent />", () => {
|
||||
expect(container.querySelectorAll("ol li")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps one numbered list when its items carry indented bullets", () => {
|
||||
const text = [
|
||||
"Có 4 điểm:",
|
||||
"",
|
||||
"1. **yt-dlp**",
|
||||
" - Hướng dẫn",
|
||||
" - YouTube",
|
||||
"2. **Nút Setup**",
|
||||
" - site-packages",
|
||||
"3. **Log khởi động**",
|
||||
" Có thể bỏ dòng này.",
|
||||
"4. **Nên kiểm tra**",
|
||||
].join("\n");
|
||||
const { container } = render(<MarkdownContent text={text} />);
|
||||
const top = container.querySelectorAll(":scope > div > ol");
|
||||
expect(top).toHaveLength(1);
|
||||
expect(top[0]!.querySelectorAll(":scope > li")).toHaveLength(4);
|
||||
expect(top[0]!.querySelectorAll("ul li")).toHaveLength(3);
|
||||
expect(screen.getByText(/Có thể bỏ dòng này/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("continues the numbering of a list split by a blank line or a block", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent text={"1. first\n\n```\ncode\n```\n\n2. second\n3. third"} />
|
||||
);
|
||||
const lists = container.querySelectorAll("ol");
|
||||
expect(lists).toHaveLength(2);
|
||||
expect(lists[0]!.getAttribute("start")).toBeNull();
|
||||
expect(lists[1]!.getAttribute("start")).toBe("2");
|
||||
});
|
||||
|
||||
it("renders blockquotes", () => {
|
||||
const { container } = render(<MarkdownContent text={"> a quote"} />);
|
||||
expect(container.querySelector("blockquote")).not.toBeNull();
|
||||
|
||||
@@ -31,6 +31,7 @@ export function SessionMenu({
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onPin,
|
||||
onShare,
|
||||
}: MenuPoint & {
|
||||
laneId: number;
|
||||
session: Session | null;
|
||||
@@ -42,6 +43,8 @@ export function SessionMenu({
|
||||
onUnarchive?: () => void;
|
||||
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||
onPin?: () => void;
|
||||
/** Share this session on its own (server mode, the workspace's owner). */
|
||||
onShare?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -81,6 +84,11 @@ export function SessionMenu({
|
||||
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onShare && session && (
|
||||
<MenuItem testId="session-menu-share" onSelect={pick(onShare)}>
|
||||
{t("sessionMenu.share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* workspace home's right column (sticky: it follows the home's scroll) and,
|
||||
* `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||
@@ -157,7 +158,13 @@ function ContextBody({
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
||||
// On the home it follows the scroll (sticky in the home's scroller),
|
||||
// scrolling itself — without a bar — when taller than the window.
|
||||
className={
|
||||
compact
|
||||
? ""
|
||||
: "card no-scrollbar sticky top-2 max-h-[calc(100vh-7rem)] w-80 shrink-0 self-start overflow-y-auto p-0"
|
||||
}
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
|
||||
@@ -57,6 +57,8 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
import { WorkspaceShareModal } from "./WorkspaceShareModal";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { workspaceName } from "./WorkspaceRow";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { menuPoint, useContextMenu } from "../ContextMenu";
|
||||
@@ -118,6 +120,7 @@ function HomeBody({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const { ownsLane } = usePermissions();
|
||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||
const [tool, setTool] = useState(lane.tool);
|
||||
const entry = aiTools.byId(tool);
|
||||
@@ -195,6 +198,7 @@ function HomeBody({
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
|
||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||
@@ -270,10 +274,17 @@ function HomeBody({
|
||||
};
|
||||
|
||||
const text = promptWithImages(prompt, images, claude);
|
||||
// A workspace shared with this user: a session they start runs on their
|
||||
// own account and is billed to them (the server answers ENOCOLLABACCOUNT
|
||||
// when they have none).
|
||||
const collab = !ownsLane(lane);
|
||||
// Only a session of it is shared with them (phase 3): no new session here —
|
||||
// they open that session and send it messages (the server answers ESESSIONSHARE).
|
||||
const sessionOnly = lane.access === "session";
|
||||
const start = async () => {
|
||||
// Enter reaches here past the disabled button: the same guards again.
|
||||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||
if (startingRef.current || uploading || !text) return;
|
||||
if (sessionOnly || startingRef.current || uploading || !text) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
@@ -529,7 +540,7 @@ function HomeBody({
|
||||
type="button"
|
||||
data-testid="workspace-home-start"
|
||||
onClick={() => void start()}
|
||||
disabled={starting || uploading || !text}
|
||||
disabled={sessionOnly || starting || uploading || !text}
|
||||
aria-label={t("home.start")}
|
||||
title={t("home.start")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
@@ -539,6 +550,11 @@ function HomeBody({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{collab && (
|
||||
<p data-testid="workspace-home-collab" className="mt-2 text-xs text-fg-muted">
|
||||
{t(sessionOnly ? "shared.sessionOnly" : "shared.ownAccount")}
|
||||
</p>
|
||||
)}
|
||||
{/* The session's quieter choices, under the box like Claude Desktop's. */}
|
||||
<div
|
||||
data-testid="workspace-home-options"
|
||||
@@ -787,9 +803,16 @@ function HomeBody({
|
||||
x={menuAt.x}
|
||||
y={menuAt.y}
|
||||
onClose={() => setMenuAt(null)}
|
||||
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
|
||||
onAction={(a) =>
|
||||
a === "rename"
|
||||
? setTitle(lane.title || "")
|
||||
: a === "share"
|
||||
? setSharing(true)
|
||||
: onMenuAction(a)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{sharing && <WorkspaceShareModal lane={lane} onClose={() => setSharing(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||
* existing call: Rename (the row's inline field), New workspace here (the
|
||||
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* Add modal prefilled), Copy path, Share (server mode: `WorkspaceShareModal`),
|
||||
* and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* managed lane — plus Move to group (the sidebar's own groups,
|
||||
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
|
||||
* server's guards are the real ones; the menu only hides
|
||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||
* what the lane's kind or tool makes meaningless, and the owner-only items
|
||||
* (Rename, Share, Reset, Remove) on a workspace shared with this user. The shell (position,
|
||||
* Escape / outside-click close) is `ContextMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -17,8 +19,9 @@ import { Check } from "lucide-react";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "share" | "reset" | "remove";
|
||||
|
||||
export function WorkspaceRowMenu({
|
||||
lane,
|
||||
@@ -43,9 +46,11 @@ export function WorkspaceRowMenu({
|
||||
onGroup?: (target: string | null | "new") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const { local, ownsLane } = usePermissions();
|
||||
const owner = ownsLane(lane);
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
||||
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
|
||||
{owner && <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>}
|
||||
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
@@ -55,6 +60,11 @@ export function WorkspaceRowMenu({
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
{owner && !local && (
|
||||
<MenuItem testId="workspace-menu-share" onSelect={() => onAction("share")}>
|
||||
{t("menu.share")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onGroup && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
@@ -84,15 +94,19 @@ export function WorkspaceRowMenu({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
{t("menu.reset")}
|
||||
</MenuItem>
|
||||
{owner && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
{t("menu.reset")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||
{t("menu.remove")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||
{t("menu.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
|
||||
import { endRuns } from "../../lib/stopSession";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
@@ -46,6 +46,11 @@ import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||
import { useContextMenu } from "../ContextMenu";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
const WorkspaceShareModal = lazy(() =>
|
||||
import("./WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
|
||||
);
|
||||
|
||||
export const SESSION_LIMIT = 6;
|
||||
|
||||
@@ -256,6 +261,9 @@ export function WorkspaceSessionList({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctx = useContextMenu<RunHandle>();
|
||||
const sessionCtx = useContextMenu<Session>();
|
||||
// Share one session (server mode, the workspace's owner or an admin).
|
||||
const { local, ownsLane } = usePermissions();
|
||||
const [sharing, setSharing] = useState<Session | null>(null);
|
||||
// Rows shown; See all raises it to the whole list.
|
||||
const [limit, setLimit] = useState(SESSION_LIMIT);
|
||||
|
||||
@@ -637,8 +645,18 @@ export function WorkspaceSessionList({
|
||||
onPin={() =>
|
||||
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
||||
}
|
||||
onShare={!local && ownsLane(lane) ? () => setSharing(sessionCtx.menu!.item) : undefined}
|
||||
/>
|
||||
)}
|
||||
{sharing && (
|
||||
<Suspense fallback={null}>
|
||||
<WorkspaceShareModal
|
||||
lane={lane}
|
||||
session={{ id: sharing.id, name: sharing.name }}
|
||||
onClose={() => setSharing(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file WorkspaceShareModal — share a workspace, or one of its sessions
|
||||
* (`session`), with other users (server mode, docs/plans/workspace-sharing/
|
||||
* phases 1 and 3). Ticks people from `GET /api/artifacts/people` (names only)
|
||||
* and saves the whole list through `PUT /api/lanes/:id/shares` or
|
||||
* `PUT /api/lanes/:id/sessions/:sid/shares`. It says plainly what sharing means on this
|
||||
* server: the people it is shared with work as the owner's account and can
|
||||
* read everything in the owner's home. Only the owner or an admin opens it —
|
||||
* the server refuses anyone else (`ENOTOWNER`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
type Person = { id: number; username: string; name: string; disabled?: boolean };
|
||||
|
||||
export function WorkspaceShareModal({
|
||||
lane,
|
||||
session,
|
||||
onClose,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Share this one session instead of the whole workspace. */
|
||||
session?: { id: string; name?: string | null };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
const [people, setPeople] = useState<Person[] | null>(null);
|
||||
const [initial, setInitial] = useState<Set<number>>(new Set());
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const sid = session?.id;
|
||||
Promise.all([
|
||||
api.artifacts.people(),
|
||||
sid ? api.lanes.sessionShares(lane.id, sid) : api.lanes.shares(lane.id),
|
||||
])
|
||||
.then(([p, s]) => {
|
||||
if (!alive) return;
|
||||
// Never the owner: a workspace is not shared with them. Someone it is
|
||||
// already shared with but who was disabled since is not in `people`:
|
||||
// listed anyway, so they can be taken off.
|
||||
const known = new Set(p.people.map((x) => x.id));
|
||||
setPeople([
|
||||
...p.people.filter((x) => x.id !== lane.owner_id),
|
||||
...s.shares.filter((x) => !known.has(x.id)).map((x) => ({ ...x, disabled: true })),
|
||||
]);
|
||||
const ids = new Set(s.shares.map((x) => x.id));
|
||||
setInitial(ids);
|
||||
setPicked(new Set(ids));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!alive) return;
|
||||
toast.error(err);
|
||||
setPeople([]);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lane.id, lane.owner_id, session?.id]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (people ?? []).filter(
|
||||
(p) => !q || p.name.toLowerCase().includes(q) || p.username.toLowerCase().includes(q)
|
||||
);
|
||||
}, [people, query]);
|
||||
const added = [...picked].filter((id) => !initial.has(id));
|
||||
const changed = added.length > 0 || [...initial].some((id) => !picked.has(id));
|
||||
const name = session ? session.name || session.id : lane.title || lane.cwd;
|
||||
|
||||
const save = async () => {
|
||||
if (added.length > 0) {
|
||||
const names = (people ?? []).filter((p) => added.includes(p.id)).map((p) => p.name);
|
||||
const ok = await confirmDialog({
|
||||
title: t("share.confirmTitle", { name }),
|
||||
message: t("share.confirmBody", { people: names.join(", ") }),
|
||||
confirmLabel: t("share.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = session
|
||||
? await api.lanes.setSessionShares(lane.id, session.id, [...picked])
|
||||
: await api.lanes.setShares(lane.id, [...picked]);
|
||||
toast.success(
|
||||
r.shares.length ? t("share.saved", { count: r.shares.length }) : t("share.savedNone")
|
||||
);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (id: number, on: boolean) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="workspace-share-title"
|
||||
data-testid="workspace-share-modal"
|
||||
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="workspace-share-title" className="text-base font-semibold text-fg-primary">
|
||||
{t(session ? "share.sessionTitle" : "share.title")}
|
||||
</h2>
|
||||
<p className="mt-0.5 truncate text-xs text-fg-muted" title={lane.cwd}>
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("share.close")}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||
<p className="text-xs text-fg-secondary">
|
||||
{t(session ? "share.sessionWhat" : "share.what")}
|
||||
</p>
|
||||
<div
|
||||
data-testid="workspace-share-warning"
|
||||
className="flex gap-2 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("share.warning")}</span>
|
||||
</div>
|
||||
{people === null ? (
|
||||
<p className="text-xs text-fg-muted">{t("share.loading")}</p>
|
||||
) : people.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("share.nobody")}</p>
|
||||
) : (
|
||||
<>
|
||||
{people.length > 8 && (
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("share.search")}
|
||||
aria-label={t("share.search")}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
)}
|
||||
<ul className="space-y-1" data-testid="workspace-share-people">
|
||||
{shown.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Checkbox
|
||||
checked={picked.has(p.id)}
|
||||
onChange={(v) => toggle(p.id, v)}
|
||||
disabled={busy}
|
||||
label={
|
||||
<span>
|
||||
{p.name}
|
||||
{p.name !== p.username && (
|
||||
<span className="ml-1.5 text-fg-muted">@{p.username}</span>
|
||||
)}
|
||||
{p.disabled && (
|
||||
<span className="ml-1.5 text-fg-muted">{t("share.disabled")}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("share.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
data-testid="workspace-share-save"
|
||||
onClick={() => void save()}
|
||||
disabled={busy || !changed || people === null}
|
||||
>
|
||||
{busy ? t("share.saving") : t("share.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @file Tests for workspace sharing in the client: WorkspaceShareModal starts
|
||||
* on the current list, never offers the owner, warns what sharing means, asks
|
||||
* once before adding someone and saves the WHOLE list; WorkspaceRowMenu offers
|
||||
* Share (server mode) and the other owner-only items only to the workspace's
|
||||
* owner or an admin; given a session, it lists and saves THAT session's
|
||||
* shares instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act, fireEvent } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import i18n from "../../../i18n";
|
||||
import { settleConfirm } from "../../../lib/confirm";
|
||||
import { WorkspaceShareModal } from "../WorkspaceShareModal";
|
||||
import { WorkspaceRowMenu } from "../WorkspaceRowMenu";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: false, me: 1, admin: false }));
|
||||
vi.mock("../../AuthGate", () => ({
|
||||
usePermissions: () => ({
|
||||
local: perms.local,
|
||||
isAdmin: perms.admin,
|
||||
ownsLane: (l?: { owner_id?: number | null } | null) =>
|
||||
perms.local || perms.admin || l?.owner_id === perms.me,
|
||||
}),
|
||||
}));
|
||||
|
||||
const m = vi.hoisted(() => ({
|
||||
people: vi.fn(),
|
||||
shares: vi.fn(),
|
||||
setShares: vi.fn(),
|
||||
sessionShares: vi.fn(),
|
||||
setSessionShares: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
artifacts: { people: m.people },
|
||||
lanes: {
|
||||
shares: m.shares,
|
||||
setShares: m.setShares,
|
||||
sessionShares: m.sessionShares,
|
||||
setSessionShares: m.setSessionShares,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const lane = {
|
||||
id: 7,
|
||||
title: "Shop",
|
||||
cwd: "/home/alice/shop",
|
||||
owner_id: 1,
|
||||
kind: "adopted",
|
||||
} as Lane;
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
perms.local = false;
|
||||
perms.me = 1;
|
||||
perms.admin = false;
|
||||
m.people.mockResolvedValue({
|
||||
people: [
|
||||
{ id: 1, username: "alice", name: "Alice" },
|
||||
{ id: 2, username: "bob", name: "Bob" },
|
||||
{ id: 3, username: "carol", name: "Carol" },
|
||||
],
|
||||
});
|
||||
m.shares.mockResolvedValue({
|
||||
shares: [{ id: 2, username: "bob", name: "Bob", sharedAt: "2026-11-06T00:00:00Z" }],
|
||||
canManage: true,
|
||||
});
|
||||
m.setShares.mockImplementation(async (_id: number, ids: number[]) => ({
|
||||
shares: ids.map((id) => ({ id, username: `u${id}`, name: `U${id}`, sharedAt: "" })),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("WorkspaceShareModal", () => {
|
||||
it("starts on the current list, never offers the owner and says what sharing means", async () => {
|
||||
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||
await settle();
|
||||
const list = screen.getByTestId("workspace-share-people");
|
||||
expect(list).not.toHaveTextContent("Alice");
|
||||
expect(screen.getByRole("checkbox", { name: /Bob/ })).toHaveAttribute("aria-checked", "true");
|
||||
expect(screen.getByRole("checkbox", { name: /Carol/ })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false"
|
||||
);
|
||||
expect(screen.getByTestId("workspace-share-warning")).toHaveTextContent(/your home folder/i);
|
||||
// Nothing changed yet: nothing to save.
|
||||
expect(screen.getByTestId("workspace-share-save")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("asks once before adding someone, then saves the whole list", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<WorkspaceShareModal lane={lane} onClose={onClose} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
|
||||
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||
await settle();
|
||||
expect(m.setShares).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(m.setShares).toHaveBeenCalledWith(7, [2, 3]);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists a collaborator disabled since, so they can be taken off", async () => {
|
||||
m.shares.mockResolvedValue({
|
||||
shares: [
|
||||
{ id: 2, username: "bob", name: "Bob", sharedAt: "" },
|
||||
{ id: 9, username: "dan", name: "Dan", sharedAt: "", disabled: true },
|
||||
],
|
||||
canManage: true,
|
||||
});
|
||||
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||
await settle();
|
||||
const dan = screen.getByRole("checkbox", { name: /Dan/ });
|
||||
expect(dan).toHaveAttribute("aria-checked", "true");
|
||||
expect(dan).toHaveTextContent("(disabled)");
|
||||
fireEvent.click(dan);
|
||||
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||
await settle();
|
||||
expect(m.setShares).toHaveBeenCalledWith(7, [2]);
|
||||
});
|
||||
|
||||
it("takes someone off without asking", async () => {
|
||||
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
|
||||
await settle();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Bob/ }));
|
||||
fireEvent.click(screen.getByTestId("workspace-share-save"));
|
||||
await settle();
|
||||
expect(m.setShares).toHaveBeenCalledWith(7, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceRowMenu on a shared workspace", () => {
|
||||
const open = () =>
|
||||
render(<WorkspaceRowMenu lane={lane} x={0} y={0} onClose={() => {}} onAction={() => {}} />);
|
||||
|
||||
it("offers its owner Share, Rename and Delete", () => {
|
||||
open();
|
||||
expect(screen.getByTestId("workspace-menu-share")).toBeInTheDocument();
|
||||
expect(screen.getByText("Rename")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete workspace…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a user it is shared with none of the owner-only items", () => {
|
||||
perms.me = 2;
|
||||
open();
|
||||
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
|
||||
expect(screen.queryByText("Rename")).toBeNull();
|
||||
expect(screen.queryByText("Delete workspace…")).toBeNull();
|
||||
expect(screen.getByText("Copy path")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no Share in local mode", () => {
|
||||
perms.local = true;
|
||||
open();
|
||||
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
|
||||
expect(screen.getByText("Rename")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceShareModal for one session", () => {
|
||||
it("reads and saves that session's own list, never the workspace's", async () => {
|
||||
m.shares.mockClear();
|
||||
m.setShares.mockClear();
|
||||
m.sessionShares.mockResolvedValue({ shares: [], canManage: true });
|
||||
m.setSessionShares.mockResolvedValue({
|
||||
shares: [{ id: 3, username: "carol", name: "Carol", sharedAt: "" }],
|
||||
});
|
||||
render(
|
||||
<WorkspaceShareModal
|
||||
lane={lane}
|
||||
session={{ id: "s-1", name: "Fix cart" }}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
await settle();
|
||||
expect(m.sessionShares).toHaveBeenCalledWith(7, "s-1");
|
||||
expect(m.shares).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Share session")).toBeTruthy();
|
||||
expect(screen.getByText("Fix cart")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await settle();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(m.setSessionShares).toHaveBeenCalledWith(7, "s-1", [3]);
|
||||
expect(m.setShares).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -85,6 +85,8 @@ import {
|
||||
slotOfKey,
|
||||
} from "../../lib/paneLayout";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { usePanePresence } from "../../hooks/usePanePresence";
|
||||
|
||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||
|
||||
@@ -234,6 +236,19 @@ export function ConsoleTabs({
|
||||
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const tabCtx = useContextMenu<Tab>();
|
||||
// Server mode: who else has these panes open, and whose money a pane is.
|
||||
const presence = usePanePresence(lane.id);
|
||||
const { state: auth } = useAuth();
|
||||
const meId = auth.mode === "server" ? auth.user?.id : undefined;
|
||||
const othersIn = (tab: Tab) => {
|
||||
const seen = new Map<number, { id: number; name: string }>();
|
||||
for (const r of tab.runs) {
|
||||
for (const u of presence[r.id] ?? []) if (u.id !== meId) seen.set(u.id, u);
|
||||
}
|
||||
return [...seen.values()];
|
||||
};
|
||||
const billedTo = (tab: Tab) =>
|
||||
[...new Set(tab.runs.map((r) => r.startedBy?.name).filter(Boolean))] as string[];
|
||||
|
||||
// A group whose runs the list has not caught up with yet is not drawn.
|
||||
const tabs = useMemo<Tab[]>(
|
||||
@@ -532,6 +547,7 @@ export function ConsoleTabs({
|
||||
{tabs.map((tab, i) => {
|
||||
const active = !settings?.active && tab.index === groups.active;
|
||||
const single = tab.runs.length === 1 ? tab.focus : null;
|
||||
const others = othersIn(tab);
|
||||
const state = tabState(tab);
|
||||
if (single && renaming === single.id) {
|
||||
return (
|
||||
@@ -606,7 +622,7 @@ export function ConsoleTabs({
|
||||
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
|
||||
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
|
||||
onDoubleClick={() => single && startRename(single)}
|
||||
title={
|
||||
title={[
|
||||
single
|
||||
? [
|
||||
toolLabel(single),
|
||||
@@ -617,8 +633,14 @@ export function ConsoleTabs({
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
|
||||
}
|
||||
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n"),
|
||||
...billedTo(tab).map((name) => t("tabs.billedTo", { name })),
|
||||
others.length
|
||||
? t("tabs.here", { names: others.map((u) => u.name).join(", ") })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")}
|
||||
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
@@ -627,6 +649,23 @@ export function ConsoleTabs({
|
||||
))}
|
||||
</span>
|
||||
<span className="truncate">{tabLabel(tab)}</span>
|
||||
{others.length > 0 && (
|
||||
<span
|
||||
data-testid={`console-tab-people-${tab.key}`}
|
||||
className="flex shrink-0 -space-x-1"
|
||||
aria-label={t("tabs.here", { names: others.map((u) => u.name).join(", ") })}
|
||||
>
|
||||
{others.slice(0, 3).map(({ id, name }) => (
|
||||
<span
|
||||
key={id}
|
||||
aria-hidden
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full border border-surface-1 bg-accent-solid text-[9px] font-semibold uppercase text-white"
|
||||
>
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{state && <RunDot state={state} />}
|
||||
</button>
|
||||
{tab.runs.some(canSleep) && (
|
||||
|
||||
@@ -72,7 +72,7 @@ import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, withToken } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
|
||||
import {
|
||||
@@ -634,6 +634,15 @@ export const TerminalView = memo(function TerminalView({
|
||||
term.input(altArrow, true);
|
||||
return false;
|
||||
}
|
||||
// Shift+Enter: xterm sends a plain CR, so claude/codex SUBMIT the
|
||||
// prompt. Send Ctrl+J (LF) instead — both prompts take it as a new
|
||||
// line (Option+Enter is the other way, awkward on a Mac), while a
|
||||
// shell still runs the line, as Enter did.
|
||||
if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
term.input("\n", true);
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
@@ -717,7 +726,11 @@ export const TerminalView = memo(function TerminalView({
|
||||
// own size, so a pane on a background tab is never resized by it.
|
||||
const size =
|
||||
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
// The dashboard token, when one is set, rides the query like `/ws`'s:
|
||||
// the server refuses the upgrade without it.
|
||||
const sock = new WebSocket(
|
||||
withToken(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`)
|
||||
);
|
||||
ws = sock;
|
||||
wsRef.current = sock;
|
||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file ConsoleTabs.presence.test.tsx
|
||||
* @description Workspace sharing phase 4 on the tab bar: a tab shows who ELSE
|
||||
* has its panes open (never the viewer), and its tooltip says whose money a
|
||||
* pane a collaborator opened is.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../../i18n";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { pane, type PaneGroups } from "../../../lib/paneLayout";
|
||||
import { ConsoleTabs } from "../ConsoleTabs";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../AuthGate", () => ({
|
||||
useAuth: () => ({ state: { mode: "server", user: { id: 1, username: "alice" } } }),
|
||||
usePermissions: () => ({ local: false, isAdmin: false }),
|
||||
}));
|
||||
vi.mock("../../../hooks/usePanePresence", () => ({
|
||||
usePanePresence: () => ({
|
||||
"agenthub-lane-1": [
|
||||
{ id: 1, name: "Alice" },
|
||||
{ id: 2, name: "Bob" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
|
||||
const RUN: RunHandle = {
|
||||
id: "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
slot: 0,
|
||||
status: "running",
|
||||
cwd: "/w",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
startedBy: { id: 2, name: "Bob" },
|
||||
tool: "claude",
|
||||
};
|
||||
const GROUPS: PaneGroups = { groups: [{ root: pane(0), focus: 0 }], active: 0 };
|
||||
|
||||
describe("ConsoleTabs presence", () => {
|
||||
it("shows who else is in a pane, and whose money it is", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ConsoleTabs
|
||||
lane={LANE}
|
||||
tabs={[RUN]}
|
||||
groups={GROUPS}
|
||||
onGroupsChange={vi.fn()}
|
||||
onOpen={vi.fn()}
|
||||
onClosed={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const people = screen.getAllByTestId(/^console-tab-people-/)[0]!;
|
||||
expect(people.textContent).toBe("B");
|
||||
expect(people.getAttribute("aria-label")).toBe("Also here: Bob");
|
||||
const tab = screen.getAllByRole("tab")[0]!;
|
||||
expect(tab.getAttribute("title")).toContain("Billed to Bob");
|
||||
expect(tab.getAttribute("title")).not.toContain("Alice");
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,10 @@ const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
|
||||
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
|
||||
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
vi.mock("../../../lib/api", async () => ({
|
||||
// The real one: it reads the token from localStorage, as the page does.
|
||||
withToken: (await vi.importActual<typeof import("../../../lib/api")>("../../../lib/api"))
|
||||
.withToken,
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
@@ -236,6 +239,31 @@ describe("TerminalView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the dashboard token after its size, as /ws does (DASHBOARD_TOKEN)", () => {
|
||||
// The server refuses a /ws-pty upgrade without it: the terminal sat at
|
||||
// "reconnecting" forever while the status bar, on /ws, said Live.
|
||||
localStorage.setItem("dashboard_token", "t ok");
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28&token=t%20ok"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the token as the only query when it has no size yet", () => {
|
||||
localStorage.setItem("dashboard_token", "tok");
|
||||
termState.cols = 0;
|
||||
termState.rows = 0;
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?token=tok"
|
||||
);
|
||||
});
|
||||
|
||||
it("a terminal on a hidden tab sends no size until it is shown", () => {
|
||||
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
|
||||
// that size resized the real tmux pane under every other client.
|
||||
@@ -606,6 +634,21 @@ describe("TerminalView", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("sends Shift+Enter as Ctrl+J, a new line in claude's prompt, not a submit", () => {
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
const ev = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, cancelable: true });
|
||||
expect(handler(ev)).toBe(false);
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(inputMock).toHaveBeenCalledWith("\n", true);
|
||||
inputMock.mockClear();
|
||||
// Plain Enter and Alt+Enter stay xterm's own.
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(true);
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "Enter", altKey: true }))).toBe(true);
|
||||
expect(inputMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||
// next row's left margin, and tmux never repaints a row it believes
|
||||
|
||||
@@ -60,6 +60,10 @@ export const THEME_LABELS: Record<string, string> = {
|
||||
canvas: "Canvas",
|
||||
"orca-dark": "Orca Dark",
|
||||
"orca-light": "Orca Light",
|
||||
"sunset-cloud": "Sunset Cloud",
|
||||
terminal: "Terminal",
|
||||
"night-sea": "Night Sea",
|
||||
sakura: "Sakura",
|
||||
};
|
||||
|
||||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
|
||||
* mode, every user picks which account of the shared account pool their own
|
||||
* new sessions run on, per tool, among the accounts their pool group may use
|
||||
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session. Renders nothing in local mode
|
||||
* or when the pool holds no account the user may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsRow, SettingsSection } from "./SettingsRow";
|
||||
|
||||
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
||||
|
||||
export function PoolAccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { local } = usePermissions();
|
||||
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
|
||||
const [busy, setBusy] = useState<PoolTool | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => setTools(null));
|
||||
}, [local]);
|
||||
|
||||
if (local || !tools?.length) return null;
|
||||
|
||||
const choose = async (tool: PoolTool, account: string | null) => {
|
||||
setBusy(tool);
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(tool, account);
|
||||
setTools((list) =>
|
||||
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
|
||||
);
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="pool-account"
|
||||
title={t("poolAccount.title")}
|
||||
description={t("poolAccount.description")}
|
||||
>
|
||||
{tools.map((x) => (
|
||||
<SettingsRow
|
||||
key={x.tool}
|
||||
id={`pool-account-${x.tool}`}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
|
||||
{LABEL[x.tool]}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
x.pin ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: x.pin })}
|
||||
</span>
|
||||
) : (
|
||||
t("poolAccount.count", { count: x.accounts.length })
|
||||
)
|
||||
}
|
||||
disabled={!!x.pin}
|
||||
control={
|
||||
<select
|
||||
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
|
||||
value={x.pin ?? x.choice ?? ""}
|
||||
disabled={!!x.pin || busy === x.tool}
|
||||
onChange={(e) => void choose(x.tool, e.target.value || null)}
|
||||
className="select !py-1 !text-xs max-w-[16rem]"
|
||||
>
|
||||
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
|
||||
{x.accounts.map((a) => (
|
||||
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
|
||||
{[
|
||||
a.name,
|
||||
a.email && a.email !== a.name ? `(${a.email})` : null,
|
||||
a.expired
|
||||
? `— ${t("poolAccount.expired")}`
|
||||
: a.full
|
||||
? `— ${t("poolAccount.full")}`
|
||||
: a.quotaPercent != null
|
||||
? `— ${Math.round(a.quotaPercent)}%`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file PoolProfiles — the shared account pool's accounts as rows of Settings →
|
||||
* AI tools → Profiles (server mode). Each account this user's pool group may
|
||||
* use is one row beside the saved profiles, plus an "Automatic" row; "Switch
|
||||
* to" picks which account the user's OWN new sessions run on
|
||||
* (`PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* Automatic leaves it to the balancer; an admin's pin locks every row. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
|
||||
const btnCls =
|
||||
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
|
||||
|
||||
/** Lower-cased emails of a tool's pool accounts — a saved profile with one is the same account. */
|
||||
export function poolEmails(entry: MyPoolTool | null | undefined): Set<string> {
|
||||
return new Set(
|
||||
(entry?.accounts ?? []).map((a) => a.email?.toLowerCase()).filter((e): e is string => !!e)
|
||||
);
|
||||
}
|
||||
|
||||
export function PoolProfiles({
|
||||
entry,
|
||||
onChange,
|
||||
}: {
|
||||
entry: MyPoolTool;
|
||||
onChange: (next: MyPoolTool) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t: tc } = useTranslation("ccConfig");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const locked = !!entry.pin;
|
||||
/** The account the user's new sessions run on now; null = Automatic. */
|
||||
const current = entry.pin ?? entry.choice;
|
||||
|
||||
const choose = async (account: string | null) => {
|
||||
setBusy(account ?? "");
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(entry.tool, account);
|
||||
onChange({ ...entry, choice: r.choice });
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const inUse = (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||
{t("poolAccount.inUse")}
|
||||
</span>
|
||||
);
|
||||
const poolBadge = (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-border bg-surface-3 text-fg-muted">
|
||||
{t("poolAccount.badge")}
|
||||
</span>
|
||||
);
|
||||
const switchBtn = (account: string | null, disabled = false) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null || disabled}
|
||||
onClick={() => void choose(account)}
|
||||
className={btnCls}
|
||||
>
|
||||
{busy === (account ?? "") ? tc("profiles.switching") : tc("profiles.switchButton")}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid={`pool-profiles-${entry.tool}`}>
|
||||
{locked && (
|
||||
<p className="text-[11px] text-fg-muted inline-flex items-center gap-1.5">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: entry.pin })}
|
||||
</p>
|
||||
)}
|
||||
{!locked && (
|
||||
<div className={rowCls} data-testid={`pool-row-${entry.tool}-automatic`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-fg-primary">
|
||||
{t("poolAccount.automatic")}
|
||||
</span>
|
||||
{poolBadge}
|
||||
{current === null && inUse}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{t("poolAccount.automaticHint")}
|
||||
</span>
|
||||
{current !== null && switchBtn(null)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{entry.accounts.map((a) => {
|
||||
const status = a.expired
|
||||
? t("poolAccount.expired")
|
||||
: a.full
|
||||
? t("poolAccount.full")
|
||||
: a.quotaPercent != null
|
||||
? t("poolAccount.quota", { pct: Math.round(a.quotaPercent) })
|
||||
: null;
|
||||
return (
|
||||
<div key={a.name} className={rowCls} data-testid={`pool-row-${entry.tool}-${a.name}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-fg-primary">{a.name}</span>
|
||||
{poolBadge}
|
||||
{current === a.name && inUse}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{a.email && a.email !== a.name ? a.email : ""}
|
||||
</span>
|
||||
{!locked && current !== a.name && switchBtn(a.name, a.expired)}
|
||||
</div>
|
||||
{status && (
|
||||
<div
|
||||
className={`text-[10px] mt-0.5 ${
|
||||
a.expired || a.full ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,20 @@
|
||||
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
|
||||
* warning — this panel used to be a modal on the Claude Code page); Codex and
|
||||
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
|
||||
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
|
||||
* each Claude/Codex profile to the account pool in Administration → Users,
|
||||
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
|
||||
* pool's Add dialog on it), and shows how many accounts the pool holds.
|
||||
* (Antigravity: an OS-keyring login) says why. In server mode the shared
|
||||
* account pool's accounts this user may use (`GET /api/me/pool-accounts`) are
|
||||
* rows of the same list (PoolProfiles: "Switch to" picks the account the
|
||||
* user's own new sessions run on, plus an Automatic row) — there is no
|
||||
* separate Pool account section any more. A saved profile holding the same
|
||||
* account (email) as a pool row is folded into that row. Staff also see the
|
||||
* host's saved logins and how many accounts the whole pool holds; an admin can
|
||||
* move a profile into the pool in Administration → Users
|
||||
* (`?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). A
|
||||
* developer sees only the pool rows of the tools they may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
@@ -33,6 +39,7 @@ import {
|
||||
type CcProfile,
|
||||
type CcProfileUsage,
|
||||
type CcUsageWindow,
|
||||
type MyPoolTool,
|
||||
type ToolProfile,
|
||||
type ToolProfileIdentity,
|
||||
type ToolProfilesTool,
|
||||
@@ -53,11 +60,14 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import { SkeletonRows } from "../config/configPrimitives";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
import { PoolProfiles, poolEmails } from "./PoolProfiles";
|
||||
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in. */
|
||||
const TOOLS: { id: string; label: string; login: string }[] = [
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in.
|
||||
* `poolOnly`: no saved logins of its own — a tab only while the pool has one. */
|
||||
const TOOLS: { id: string; label: string; login: string; poolOnly?: boolean }[] = [
|
||||
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
||||
{ id: "codex", label: "Codex", login: "codex login" },
|
||||
{ id: "cursor", label: "Cursor", login: "", poolOnly: true },
|
||||
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
||||
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
||||
];
|
||||
@@ -72,15 +82,21 @@ export function ToolProfilesSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const { local, seesAll } = usePermissions();
|
||||
const [tab, setTab] = useState<string>("claude");
|
||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
/** Server mode: the pool accounts THIS user may use, per tool. */
|
||||
const [mine, setMine] = useState<MyPoolTool[] | null>(null);
|
||||
const mineOf = (tool: string) => mine?.find((x) => x.tool === tool) ?? null;
|
||||
const shown = TOOLS.filter((x) => {
|
||||
if (mineOf(x.id)) return true;
|
||||
// The host's own logins are a staff-only read (a developer would get 403s).
|
||||
if (!seesAll || x.poolOnly) return false;
|
||||
const tool = aiTools.byId(x.id);
|
||||
if (x.id === "claude") return tool?.enabled !== false;
|
||||
return !!tool && tool.installed && tool.enabled !== false;
|
||||
});
|
||||
const [tab, setTab] = useState<string>("claude");
|
||||
const current = shown.find((x) => x.id === tab) ?? shown[0];
|
||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
|
||||
const loadOthers = useCallback(
|
||||
() =>
|
||||
@@ -91,9 +107,9 @@ export function ToolProfilesSettings() {
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadOthers();
|
||||
}, [loadOthers]);
|
||||
// The pool is a server-mode, staff-only read.
|
||||
if (seesAll) void loadOthers();
|
||||
}, [loadOthers, seesAll]);
|
||||
// The whole pool is a server-mode, staff-only read.
|
||||
useEffect(() => {
|
||||
if (local || !seesAll) return;
|
||||
api.accountPool
|
||||
@@ -101,10 +117,23 @@ export function ToolProfilesSettings() {
|
||||
.then(setPool)
|
||||
.catch(() => setPool(null));
|
||||
}, [local, seesAll]);
|
||||
// The accounts this user may run on: server mode, every role.
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setMine(r.tools))
|
||||
.catch(() => setMine([]));
|
||||
}, [local]);
|
||||
|
||||
if (!current) return null;
|
||||
const poolCount = (tool: string) =>
|
||||
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
||||
const entry = mineOf(current.id);
|
||||
const setEntry = (next: MyPoolTool) =>
|
||||
setMine((list) => (list ?? []).map((x) => (x.tool === next.tool ? next : x)));
|
||||
const poolRows = entry ? <PoolProfiles entry={entry} onChange={setEntry} /> : null;
|
||||
const merged = poolEmails(entry);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -132,11 +161,18 @@ export function ToolProfilesSettings() {
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="mt-3 space-y-3">
|
||||
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
{seesAll && POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
<PoolLine count={poolCount(current.id)!} />
|
||||
)}
|
||||
{current.id === "claude" ? (
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} local={local} />
|
||||
{!seesAll || current.poolOnly ? (
|
||||
poolRows
|
||||
) : current.id === "claude" ? (
|
||||
<ClaudeProfiles
|
||||
pooled={!local && POOL_TOOLS.has("claude")}
|
||||
local={local}
|
||||
merged={merged}
|
||||
poolRows={poolRows}
|
||||
/>
|
||||
) : (
|
||||
<OtherProfiles
|
||||
key={current.id}
|
||||
@@ -146,6 +182,8 @@ export function ToolProfilesSettings() {
|
||||
state={others?.find((o) => o.tool === current.id) ?? null}
|
||||
loading={others === null}
|
||||
pooled={!local && POOL_TOOLS.has(current.id)}
|
||||
merged={merged}
|
||||
poolRows={poolRows}
|
||||
onChanged={loadOthers}
|
||||
/>
|
||||
)}
|
||||
@@ -295,6 +333,8 @@ function OtherProfiles({
|
||||
state,
|
||||
loading,
|
||||
pooled,
|
||||
merged,
|
||||
poolRows,
|
||||
onChanged,
|
||||
}: {
|
||||
tool: string;
|
||||
@@ -303,6 +343,10 @@ function OtherProfiles({
|
||||
state: ToolProfilesTool | null;
|
||||
loading: boolean;
|
||||
pooled: boolean;
|
||||
/** Emails of the pool rows: a saved login of one is shown as that row. */
|
||||
merged: Set<string>;
|
||||
/** The pool accounts this user may use, as rows of this list. */
|
||||
poolRows: ReactNode;
|
||||
onChanged: () => Promise<unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -320,8 +364,8 @@ function OtherProfiles({
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const items = state.items;
|
||||
const activeSaved = items.some((p) => p.active);
|
||||
const activeSaved = state.items.some((p) => p.active);
|
||||
const items = state.items.filter((p) => !p.email || !merged.has(p.email.toLowerCase()));
|
||||
const remove = async (p: ToolProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
@@ -376,7 +420,7 @@ function OtherProfiles({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
{items.length === 0 && !poolRows && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||
{tc("profiles.empty")}
|
||||
</div>
|
||||
@@ -445,6 +489,7 @@ function OtherProfiles({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{poolRows}
|
||||
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
||||
</div>
|
||||
);
|
||||
@@ -482,7 +527,19 @@ function ActiveBadge() {
|
||||
// save button only appears while the live account is unsaved, because one
|
||||
// account is one profile.
|
||||
|
||||
function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {
|
||||
function ClaudeProfiles({
|
||||
pooled,
|
||||
local,
|
||||
merged,
|
||||
poolRows,
|
||||
}: {
|
||||
pooled: boolean;
|
||||
local: boolean;
|
||||
/** Emails of the pool rows: a saved profile of one is shown as that row. */
|
||||
merged: Set<string>;
|
||||
/** The pool accounts this user may use, as rows of this list. */
|
||||
poolRows: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
@@ -538,6 +595,12 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
||||
)
|
||||
: null;
|
||||
const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active);
|
||||
/** Listed as its own row: not hidden by this browser, and not a pool account
|
||||
* (that one is the pool's row — one account, one row). */
|
||||
const listed = useCallback(
|
||||
(p: CcProfile) => (reveal || !isHidden(p)) && !(p.email && merged.has(p.email.toLowerCase())),
|
||||
[reveal, isHidden, merged]
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -586,11 +649,11 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
if (!reveal && isHidden(p)) continue;
|
||||
if (!listed(p)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage, isHidden, reveal]);
|
||||
}, [items, checkUsage, listed]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
@@ -691,75 +754,73 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
|
||||
{items !== null && items.filter(listed).length === 0 && !poolRows && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items
|
||||
?.filter((p) => reveal || !isHidden(p))
|
||||
.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
onClickCapture={(e) => toggleRow(e, p)}
|
||||
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{shownActive(p) && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!shownActive(p) && (
|
||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||
{busy === `switch:${p.name}`
|
||||
? t("profiles.switching")
|
||||
: t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
{items?.filter(listed).map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
onClickCapture={(e) => toggleRow(e, p)}
|
||||
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
{shownActive(p) && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!shownActive(p) && (
|
||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{poolRows}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||
|
||||
@@ -53,6 +53,10 @@ describe("AppearanceSettings", () => {
|
||||
"Apple Light",
|
||||
"Canvas",
|
||||
"Orca Light",
|
||||
"Sunset Cloud",
|
||||
"Terminal",
|
||||
"Night Sea",
|
||||
"Sakura",
|
||||
]) {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
|
||||
* hidden in local mode and with no usable account; one select per tool that
|
||||
* starts on the user's choice (or Automatic) and saves a pick through
|
||||
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
import { PoolAccountSettings } from "../PoolAccountSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
|
||||
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
|
||||
}));
|
||||
|
||||
const account = (name: string) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email: `${name}@x.io`,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
async function show() {
|
||||
render(<PoolAccountSettings />);
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockReset();
|
||||
m.choosePoolAccount.mockReset();
|
||||
});
|
||||
|
||||
describe("PoolAccountSettings", () => {
|
||||
it("renders nothing in local mode or with no account to choose", async () => {
|
||||
perms.local = true;
|
||||
await show();
|
||||
expect(m.poolAccounts).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts on the user's choice and saves a new pick", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.value).toBe("two");
|
||||
await userEvent.selectOptions(select, "one");
|
||||
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
await userEvent.selectOptions(select, "");
|
||||
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
});
|
||||
|
||||
it("is locked on an admin's pin", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(select.value).toBe("one");
|
||||
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,16 @@
|
||||
* failing account costs the others nothing), and its right-click delete asks
|
||||
* first and never offers to delete the only copy of the live account; Codex's
|
||||
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
|
||||
* switched; and in server mode a profile links into the account pool.
|
||||
* switched; and in server mode a profile links into the account pool, while
|
||||
* the pool accounts the user may use are rows of the same list — "Switch to"
|
||||
* picks one through `PUT /api/me/pool-accounts`, an Automatic row goes back to
|
||||
* the balancer, an admin's pin locks them, a saved profile of the same email
|
||||
* is folded into the pool row, and a developer sees only the pool rows.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { render, screen, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../../i18n";
|
||||
@@ -38,6 +42,7 @@ const m = vi.hoisted(() => {
|
||||
},
|
||||
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
||||
pool: { list: vi.fn() },
|
||||
me: { poolAccounts: vi.fn(), choosePoolAccount: vi.fn() },
|
||||
};
|
||||
});
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -45,6 +50,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
ccConfig: m.cc,
|
||||
toolProfiles: m.tp,
|
||||
accountPool: m.pool,
|
||||
me: m.me,
|
||||
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
||||
},
|
||||
}));
|
||||
@@ -70,6 +76,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
perms.local = true;
|
||||
perms.seesAll = true;
|
||||
m.cc.profiles.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
@@ -106,6 +113,7 @@ beforeEach(() => {
|
||||
});
|
||||
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
||||
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
||||
m.me.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — Claude", () => {
|
||||
@@ -218,3 +226,96 @@ describe("ToolProfilesSettings — account pool", () => {
|
||||
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
||||
});
|
||||
});
|
||||
|
||||
const poolAccount = (name: string, email: string | null = `${name}@x.io`) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — pool accounts as profiles", () => {
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.me.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "claude",
|
||||
pin: null,
|
||||
choice: "two",
|
||||
accounts: [poolAccount("one", "d@e.f"), poolAccount("two")],
|
||||
},
|
||||
],
|
||||
});
|
||||
m.me.choosePoolAccount.mockImplementation(async (tool: string, account: string | null) => ({
|
||||
tool,
|
||||
choice: account,
|
||||
switched: account ? "switched" : null,
|
||||
}));
|
||||
});
|
||||
|
||||
it("lists the pool accounts beside the saved profiles, one row per account", async () => {
|
||||
await show();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("Quota 20%");
|
||||
expect(screen.getByTestId("pool-row-claude-two")).toHaveTextContent("in use");
|
||||
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
|
||||
// "spare" is d@e.f — the pool's "one": shown once, as the pool row.
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("switches the user's own sessions to a pool account, and back to automatic", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
const one = screen.getByTestId("pool-row-claude-one");
|
||||
await user.click(within(one).getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.me.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
expect(m.cc.activateProfile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
|
||||
const auto = screen.getByTestId("pool-row-claude-automatic");
|
||||
await user.click(within(auto).getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.me.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
expect(screen.getByTestId("pool-row-claude-automatic")).toHaveTextContent("in use");
|
||||
});
|
||||
|
||||
it("locks every pool row on an admin's pin", async () => {
|
||||
m.me.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "claude",
|
||||
pin: "one",
|
||||
choice: null,
|
||||
accounts: [poolAccount("one"), poolAccount("two")],
|
||||
},
|
||||
],
|
||||
});
|
||||
await show();
|
||||
expect(screen.getByText(/pinned you to one/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pool-row-claude-automatic")).toBeNull();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
|
||||
expect(
|
||||
within(screen.getByTestId("pool-row-claude-two")).queryByRole("button", { name: "Switch to" })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a developer only the pool rows, never the host's logins", async () => {
|
||||
perms.seesAll = false;
|
||||
await show();
|
||||
expect(m.cc.profiles).not.toHaveBeenCalled();
|
||||
expect(m.tp.list).not.toHaveBeenCalled();
|
||||
expect(m.pool.list).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /codex/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("is not shown to a developer with no pool account to use", async () => {
|
||||
perms.seesAll = false;
|
||||
m.me.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("tablist")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,19 @@ describe("useTheme", () => {
|
||||
expect(document.documentElement.dataset.theme).toBe("orca-light");
|
||||
});
|
||||
|
||||
it("applies the ai-chat-themes palettes by id, each in its own mode only", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
for (const name of ["sunset-cloud", "terminal", "night-sea"]) {
|
||||
act(() => result.current.setThemeFor("dark", name));
|
||||
expect(document.documentElement.dataset.theme).toBe(name);
|
||||
}
|
||||
act(() => result.current.setThemeFor("dark", "sakura"));
|
||||
expect(result.current.themeFor.dark).toBe("night-sea");
|
||||
act(() => result.current.setThemeFor("light", "sakura"));
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(document.documentElement.dataset.theme).toBe("sakura");
|
||||
});
|
||||
|
||||
it("the default theme carries no data-theme attribute", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
|
||||
@@ -53,12 +53,16 @@ function load(): Promise<void> {
|
||||
}
|
||||
|
||||
function onMessage(msg: WSMessage) {
|
||||
// A workspace shared with this user, or taken back: re-read the list.
|
||||
if (msg.type === "lane_access") return void load();
|
||||
if (msg.type !== "lane_update") return;
|
||||
const payload = msg.data as { lane?: Lane; removed?: number };
|
||||
const lane = payload.lane;
|
||||
if (payload.removed !== undefined || !lane || !lanes) return void load();
|
||||
const i = lanes.findIndex((l) => l.id === lane.id);
|
||||
if (i === -1) return void load();
|
||||
// A broadcast carries no viewer's `access`: a lane seen through a shared
|
||||
// session re-reads it (its access may have changed too).
|
||||
if (i === -1 || lanes[i]?.access) return void load();
|
||||
const next = [...lanes];
|
||||
next[i] = lane;
|
||||
publish(next);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file usePanePresence.ts
|
||||
* @description Who has each of a workspace's panes open (server mode, workspace
|
||||
* sharing phase 4): `GET /api/lanes/:id/presence` once, then every
|
||||
* `pane_presence` frame for that workspace, and a re-read on a WebSocket
|
||||
* reconnect (frames missed while away are never replayed). Local mode has
|
||||
* one user and reads nothing. Returns `{runId: [{id, name}]}`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
|
||||
export type PanePeople = Record<string, { id: number; name: string }[]>;
|
||||
|
||||
export function usePanePresence(laneId: number | null | undefined): PanePeople {
|
||||
const { local } = usePermissions();
|
||||
const [panes, setPanes] = useState<PanePeople>({});
|
||||
useEffect(() => {
|
||||
setPanes({});
|
||||
if (local || laneId == null) return;
|
||||
let alive = true;
|
||||
const load = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.lanes.presence(laneId))
|
||||
.then((r) => alive && setPanes(r.panes))
|
||||
.catch(() => {
|
||||
/* presence is a nicety: no list, no chips */
|
||||
});
|
||||
void load();
|
||||
const offMsg = eventBus.subscribe((msg) => {
|
||||
if (msg.type !== "pane_presence") return;
|
||||
const d = msg.data as { laneId?: number; runId?: string; users?: PanePeople[string] };
|
||||
if (d.laneId !== laneId || !d.runId) return;
|
||||
setPanes((prev) => {
|
||||
const next = { ...prev };
|
||||
if (d.users?.length) next[d.runId!] = d.users;
|
||||
else delete next[d.runId!];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
const offConn = eventBus.onConnection((up) => {
|
||||
if (up) void load();
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
offMsg();
|
||||
offConn();
|
||||
};
|
||||
}, [laneId, local]);
|
||||
return panes;
|
||||
}
|
||||
@@ -22,8 +22,8 @@ export type ThemeMode = Theme | "system";
|
||||
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||
light: ["default", "canvas", "apple-light", "orca-light"],
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark", "sunset-cloud", "terminal", "night-sea"],
|
||||
light: ["default", "canvas", "apple-light", "orca-light", "sakura"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
const MODE_KEY = "theme";
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -386,6 +386,32 @@
|
||||
"menu.copyPath": "Copy path",
|
||||
"menu.reset": "Reset worktree…",
|
||||
"menu.remove": "Delete workspace…",
|
||||
"menu.share": "Share…",
|
||||
"share.title": "Share workspace",
|
||||
"share.what": "People you share it with see this workspace and its sessions, join its open terminals, type into them and commit — like you. Only you rename, reset, delete or share it. A session they start runs on their own account and is billed to them.",
|
||||
"share.warning": "They work as YOUR account on this server: they can read everything in your home folder (your other workspaces, SSH keys and AI logins) and their commits carry your name. Share only with people you would hand your keyboard to.",
|
||||
"share.loading": "Loading…",
|
||||
"share.nobody": "There is no one else to share with.",
|
||||
"share.search": "Search people",
|
||||
"share.cancel": "Cancel",
|
||||
"share.close": "Close",
|
||||
"share.save": "Save",
|
||||
"share.saving": "Saving…",
|
||||
"share.confirmTitle": "Share \"{{name}}\"?",
|
||||
"share.confirmBody": "{{people}} will work in it as your account and can read everything in your home folder.",
|
||||
"share.confirm": "Share",
|
||||
"share.saved_one": "Shared with {{count}} person",
|
||||
"share.saved_other": "Shared with {{count}} people",
|
||||
"share.disabled": "(disabled)",
|
||||
"share.savedNone": "No longer shared with anyone",
|
||||
"shared.ownAccount": "This workspace is shared with you: a session you start here runs on your own account — a pool account or an AI Proxy of yours — and is billed to you.",
|
||||
"share.sessionTitle": "Share session",
|
||||
"share.sessionWhat": "People you share it with see this session and the workspace's changes, join its open tab, type into it, send it messages (on their own account, billed to them) and commit — nothing else of the workspace. Only you share it. On this server they work as YOUR account inside its terminal, so they can still reach the rest of your home from there.",
|
||||
"sessionMenu.share": "Share…",
|
||||
"shared.sessionOnly": "Only a session of this workspace is shared with you: open it from the list to work in it.",
|
||||
"tabs.billedTo": "Billed to {{name}}",
|
||||
"tabs.here": "Also here: {{names}}",
|
||||
"shared.badge": "Shared with you",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Preview",
|
||||
@@ -709,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",
|
||||
|
||||
@@ -1438,18 +1438,17 @@
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Pool account",
|
||||
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
|
||||
"automatic": "Automatic",
|
||||
"select": "{{tool}} pool account",
|
||||
"count_one": "{{count}} account you may use",
|
||||
"count_other": "{{count}} accounts you may use",
|
||||
"pinned": "An admin pinned you to {{account}}",
|
||||
"expired": "expired",
|
||||
"full": "quota full",
|
||||
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||
"nextSession": "{{account}} is used from your next session"
|
||||
"nextSession": "{{account}} is used from your next session",
|
||||
"inUse": "in use",
|
||||
"badge": "Pool",
|
||||
"automaticHint": "Your new sessions run on the least-used pool account",
|
||||
"quota": "Quota {{pct}}%"
|
||||
},
|
||||
"companyHub": {
|
||||
"title": "Company hub",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -386,6 +386,32 @@
|
||||
"menu.copyPath": "Sao chép đường dẫn",
|
||||
"menu.reset": "Đặt lại worktree…",
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"menu.share": "Chia sẻ…",
|
||||
"share.title": "Chia sẻ workspace",
|
||||
"share.what": "Người được chia sẻ thấy workspace này và các session của nó, vào được terminal đang mở, gõ và commit — như bạn. Chỉ bạn được đổi tên, đặt lại, xoá hay chia sẻ nó. Session họ mở chạy bằng tài khoản của chính họ và tính tiền cho họ.",
|
||||
"share.warning": "Họ làm việc bằng TÀI KHOẢN CỦA BẠN trên server này: đọc được mọi thứ trong thư mục home của bạn (các workspace khác, SSH key, đăng nhập AI) và commit của họ mang tên bạn. Chỉ chia sẻ với người bạn tin như đưa bàn phím cho họ.",
|
||||
"share.loading": "Đang tải…",
|
||||
"share.nobody": "Không còn ai khác để chia sẻ.",
|
||||
"share.search": "Tìm người",
|
||||
"share.cancel": "Huỷ",
|
||||
"share.close": "Đóng",
|
||||
"share.save": "Lưu",
|
||||
"share.saving": "Đang lưu…",
|
||||
"share.confirmTitle": "Chia sẻ \"{{name}}\"?",
|
||||
"share.confirmBody": "{{people}} sẽ làm việc trong đó bằng tài khoản của bạn và đọc được mọi thứ trong thư mục home của bạn.",
|
||||
"share.confirm": "Chia sẻ",
|
||||
"share.saved_one": "Đã chia sẻ với {{count}} người",
|
||||
"share.saved_other": "Đã chia sẻ với {{count}} người",
|
||||
"share.disabled": "(đã vô hiệu hoá)",
|
||||
"share.savedNone": "Không còn chia sẻ với ai",
|
||||
"shared.ownAccount": "Workspace này được chia sẻ với bạn: session bạn mở ở đây chạy bằng tài khoản của chính bạn — tài khoản pool hoặc AI Proxy của bạn — và tính tiền cho bạn.",
|
||||
"share.sessionTitle": "Chia sẻ session",
|
||||
"share.sessionWhat": "Người được chia sẻ thấy session này và các thay đổi của workspace, vào tab đang mở, gõ, gửi tin nhắn cho nó (bằng tài khoản của họ, tính tiền cho họ) và commit — không gì khác của workspace. Chỉ bạn chia sẻ được. Trên server này họ làm việc bằng TÀI KHOẢN CỦA BẠN trong terminal của nó, nên từ đó họ vẫn với tới được phần còn lại trong home của bạn.",
|
||||
"sessionMenu.share": "Chia sẻ…",
|
||||
"shared.sessionOnly": "Chỉ một session của workspace này được chia sẻ với bạn: mở nó từ danh sách để làm việc.",
|
||||
"tabs.billedTo": "Tính tiền cho {{name}}",
|
||||
"tabs.here": "Cũng đang mở: {{names}}",
|
||||
"shared.badge": "Được chia sẻ với bạn",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Xem trang",
|
||||
@@ -709,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",
|
||||
|
||||
@@ -1426,18 +1426,17 @@
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Tài khoản pool",
|
||||
"description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.",
|
||||
"automatic": "Tự động",
|
||||
"select": "Tài khoản pool của {{tool}}",
|
||||
"count_one": "{{count}} tài khoản bạn được dùng",
|
||||
"count_other": "{{count}} tài khoản bạn được dùng",
|
||||
"pinned": "Admin đã ghim bạn vào {{account}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "hết quota",
|
||||
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
||||
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo",
|
||||
"inUse": "đang dùng",
|
||||
"badge": "Pool",
|
||||
"automaticHint": "Phiên mới của bạn chạy trên tài khoản pool ít dùng nhất",
|
||||
"quota": "Quota {{pct}}%"
|
||||
},
|
||||
"companyHub": {
|
||||
"title": "Hub công ty",
|
||||
|
||||
@@ -350,6 +350,106 @@
|
||||
--syn-del: 197 0 15; /* #c5000f */
|
||||
}
|
||||
|
||||
/* Sunset Cloud, Terminal, Night Sea (dark) and Sakura (light) — the colour
|
||||
* themes of ai-chat-themes (github.com/Aohkne/ai-chat-themes, MIT), which
|
||||
* gives only a background, a text and an accent colour per theme. Every
|
||||
* other token is derived from those three and held to the bar above: text
|
||||
* ≥ 4.5:1 on a card and on its hover lift, the text accent ≥ 4.5:1 on a card
|
||||
* and on its own /15 chip (Sakura's #e0567f and Sunset's #ff8a6b darkened /
|
||||
* lightened to get there), white ≥ 4.5:1 on `--accent-solid`, each status
|
||||
* label ≥ 4.5:1 on its /20 chip. Their fonts (a monospace UI, Georgia) are
|
||||
* not taken; syntax, diff and `--kept` colours are their mode's defaults.
|
||||
* `--ansi-blue` (read only by `lib/terminalTheme.ts`) keeps a terminal's
|
||||
* blue blue where the accent is green or coral. */
|
||||
.dark[data-theme="sunset-cloud"] {
|
||||
--surface-0: 32 24 36; /* #201824 */
|
||||
--surface-1: 48 37 52; /* #302534 */
|
||||
--surface-2: 54 43 58; /* #362b3a */
|
||||
--surface-3: 60 49 63; /* #3c313f */
|
||||
--surface-4: 67 56 69; /* #433845 */
|
||||
--surface-5: 75 63 76; /* #4b3f4c */
|
||||
--border: 76 64 76; /* #4c404c */
|
||||
--border-light: 88 76 87; /* #584c57 */
|
||||
--accent: 255 159 134; /* #ff9f86 */
|
||||
--accent-hover: 255 188 170; /* #ffbcaa */
|
||||
--accent-solid: 173 94 73; /* #ad5e49 */
|
||||
--accent-solid-hover: 149 81 63; /* #95513f */
|
||||
--fg-primary: 247 232 226; /* #f7e8e2 */
|
||||
--fg-secondary: 214 203 203; /* #d6cbcb */
|
||||
--fg-muted: 176 165 168; /* #b0a5a8 */
|
||||
--status-success: 88 220 156; /* #58dc9c */
|
||||
--status-danger: 255 176 173; /* #ffb0ad */
|
||||
--status-warning: 255 202 22; /* #ffca16 */
|
||||
/* ANSI blue in the terminal: the accent is not blue here. */
|
||||
--ansi-blue: 112 184 255; /* #70b8ff */
|
||||
}
|
||||
|
||||
.dark[data-theme="terminal"] {
|
||||
--surface-0: 8 13 9; /* #080d09 */
|
||||
--surface-1: 14 23 17; /* #0e1711 */
|
||||
--surface-2: 20 30 22; /* #141e16 */
|
||||
--surface-3: 25 36 28; /* #19241c */
|
||||
--surface-4: 31 44 34; /* #1f2c22 */
|
||||
--surface-5: 37 52 41; /* #253429 */
|
||||
--border: 38 54 42; /* #26362a */
|
||||
--border-light: 49 67 53; /* #314335 */
|
||||
--accent: 57 232 122; /* #39e87a */
|
||||
--accent-hover: 116 239 162; /* #74efa2 */
|
||||
--accent-solid: 32 132 70; /* #208446 */
|
||||
--accent-solid-hover: 28 114 60; /* #1c723c */
|
||||
--fg-primary: 185 246 198; /* #b9f6c6 */
|
||||
--fg-secondary: 147 196 157; /* #93c49d */
|
||||
--fg-muted: 117 154 125; /* #759a7d */
|
||||
--status-success: 61 214 140; /* #3dd68c */
|
||||
--status-danger: 255 149 146; /* #ff9592 */
|
||||
--status-warning: 255 202 22; /* #ffca16 */
|
||||
/* ANSI blue in the terminal: the accent is not blue here. */
|
||||
--ansi-blue: 112 184 255; /* #70b8ff */
|
||||
}
|
||||
|
||||
.dark[data-theme="night-sea"] {
|
||||
--surface-0: 11 20 33; /* #0b1421 */
|
||||
--surface-1: 20 31 49; /* #141f31 */
|
||||
--surface-2: 26 37 55; /* #1a2537 */
|
||||
--surface-3: 32 43 62; /* #202b3e */
|
||||
--surface-4: 40 50 69; /* #283245 */
|
||||
--surface-5: 47 58 76; /* #2f3a4c */
|
||||
--border: 48 59 77; /* #303b4d */
|
||||
--border-light: 60 71 89; /* #3c4759 */
|
||||
--accent: 92 175 255; /* #5cafff */
|
||||
--accent-hover: 141 199 255; /* #8dc7ff */
|
||||
--accent-solid: 62 120 176; /* #3e78b0 */
|
||||
--accent-solid-hover: 53 103 151; /* #356797 */
|
||||
--fg-primary: 220 230 250; /* #dce6fa */
|
||||
--fg-secondary: 183 192 210; /* #b7c0d2 */
|
||||
--fg-muted: 146 155 173; /* #929bad */
|
||||
--status-success: 61 214 140; /* #3dd68c */
|
||||
--status-danger: 255 155 153; /* #ff9b99 */
|
||||
--status-warning: 255 202 22; /* #ffca16 */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="sakura"] {
|
||||
--surface-0: 242 230 232; /* #f2e6e8 */
|
||||
--surface-1: 247 235 237; /* #f7ebed */
|
||||
--surface-2: 251 240 242; /* #fbf0f2 */
|
||||
--surface-3: 255 249 250; /* #fff9fa */
|
||||
--surface-4: 255 252 253; /* #fffcfd */
|
||||
--surface-5: 255 255 255; /* #ffffff */
|
||||
--border: 224 210 213; /* #e0d2d5 */
|
||||
--border-light: 237 224 227; /* #ede0e3 */
|
||||
--accent: 167 64 95; /* #a7405f */
|
||||
--accent-hover: 137 52 78; /* #89344e */
|
||||
--accent-solid: 195 75 110; /* #c34b6e */
|
||||
--accent-solid-hover: 168 65 95; /* #a8415f */
|
||||
--fg-primary: 75 42 53; /* #4b2a35 */
|
||||
--fg-secondary: 107 80 89; /* #6b5059 */
|
||||
--fg-muted: 120 97 105; /* #786169 */
|
||||
--status-success: 23 105 45; /* #17692d */
|
||||
--status-danger: 173 28 38; /* #ad1c26 */
|
||||
--status-warning: 124 82 0; /* #7c5200 */
|
||||
--shadow-card: 0 1px 2px rgb(75 42 53 / 0.06), 0 2px 6px rgb(75 42 53 / 0.07);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-weight: 500;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,11 @@ describe("buildTerminalTheme", () => {
|
||||
expect(t.brightWhite).toBe("#edeef0");
|
||||
});
|
||||
|
||||
it("takes ANSI blue from --ansi-blue when a theme's accent is not blue", () => {
|
||||
const t = buildTerminalTheme((n) => ({ ...vars, "--ansi-blue": "112 184 255" })[n] ?? "");
|
||||
expect(t.blue).toBe("#70b8ff");
|
||||
});
|
||||
|
||||
it("makes each bright twin lighter than its base colour", () => {
|
||||
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
||||
const sum = (hex: string) =>
|
||||
|
||||
+123
-24
@@ -505,6 +505,24 @@ export function dashboardToken(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `url` with the dashboard token (if one is configured) in its query string —
|
||||
* for what cannot send the `x-dashboard-token` header: an `<img>`, an
|
||||
* `<a download>`, an iframe, a WebSocket, a bare `fetch`. Unchanged when no
|
||||
* token is set (the loopback default).
|
||||
*/
|
||||
export function withToken(url: string): string {
|
||||
const token = dashboardToken();
|
||||
if (!token) return url;
|
||||
return `${url}${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/** The token header for a bare `fetch` that bypasses {@link request}; empty with no token. */
|
||||
export function tokenHeader(): Record<string, string> {
|
||||
const token = dashboardToken();
|
||||
return token ? { "x-dashboard-token": token } : {};
|
||||
}
|
||||
|
||||
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
|
||||
function ccQuery(scope?: CcScope, cwd?: string): string {
|
||||
const qs = new URLSearchParams();
|
||||
@@ -546,6 +564,36 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
|
||||
*/
|
||||
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
||||
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
|
||||
/** Fired on a 401 EUNAUTHORIZED: DASHBOARD_TOKEN is set and this browser's is missing or wrong. */
|
||||
export const TOKEN_REQUIRED_EVENT = "agenthub:token-required";
|
||||
|
||||
/** Keep `token` as this browser's dashboard token (an empty one forgets it). */
|
||||
export function saveDashboardToken(token: string): void {
|
||||
try {
|
||||
if (token) localStorage.setItem("dashboard_token", token);
|
||||
else localStorage.removeItem("dashboard_token");
|
||||
} catch {
|
||||
/* storage blocked: the prompt will ask again */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `?token=` on the page's own URL (the link `agenthub start` prints): keep
|
||||
* it in this browser and take it out of the address bar and history, so it is
|
||||
* not left in a screenshot or a bookmark. Call once, before the first request.
|
||||
*/
|
||||
export function adoptTokenFromUrl(): void {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const token = url.searchParams.get("token");
|
||||
if (!token) return;
|
||||
saveDashboardToken(token);
|
||||
url.searchParams.delete("token");
|
||||
window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
|
||||
} catch {
|
||||
/* no URL API / history: nothing to adopt */
|
||||
}
|
||||
}
|
||||
|
||||
/** One account in Settings → Users (`GET /api/users`). */
|
||||
export type ServerUserUsage = AuthUser & {
|
||||
@@ -940,6 +988,15 @@ export type PoolProfile = {
|
||||
subscriptionType: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
/** A user a workspace is shared with (`/api/lanes/:id/shares`) — names only. */
|
||||
export type LaneShare = {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
sharedAt: string;
|
||||
/** Disabled since it was shared: kept on the list, granting nothing. */
|
||||
disabled?: boolean;
|
||||
};
|
||||
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
|
||||
export type ToolProfileIdentity = {
|
||||
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
|
||||
@@ -1057,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"
|
||||
@@ -1203,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,
|
||||
@@ -1537,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", {
|
||||
@@ -1570,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 }>(
|
||||
@@ -1932,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) =>
|
||||
@@ -2442,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
|
||||
@@ -2451,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.
|
||||
@@ -2460,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}`);
|
||||
@@ -2769,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.
|
||||
@@ -3358,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) =>
|
||||
@@ -3421,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
|
||||
@@ -3760,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", {
|
||||
@@ -3850,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
|
||||
@@ -3967,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`
|
||||
@@ -4204,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()}`);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4226,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 ─────────────────────────────
|
||||
@@ -4788,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
|
||||
@@ -5095,6 +5166,31 @@ export const api = {
|
||||
}),
|
||||
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
||||
* uploaded file's AgentHub copy is deleted. */
|
||||
/** GET /api/lanes/:id/shares — the users a workspace is shared with (server mode). */
|
||||
shares: (id: number) =>
|
||||
request<{ shares: LaneShare[]; canManage: boolean }>(`/workspaces/${id}/shares`),
|
||||
/** PUT /api/lanes/:id/shares — the WHOLE list (owner or admin; `ENOTOWNER` else). */
|
||||
setShares: (id: number, userIds: number[]) =>
|
||||
request<{ shares: LaneShare[] }>(`/workspaces/${id}/shares`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ userIds }),
|
||||
}),
|
||||
/** GET /api/lanes/:id/presence — who has which pane open (server mode). */
|
||||
presence: (id: number) =>
|
||||
request<{ panes: Record<string, { id: number; name: string }[]> }>(
|
||||
`/workspaces/${id}/presence`
|
||||
),
|
||||
/** GET /api/lanes/:id/sessions/:sid/shares — the users one session is shared with. */
|
||||
sessionShares: (id: number, sid: string) =>
|
||||
request<{ shares: LaneShare[]; canManage: boolean }>(
|
||||
`/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`
|
||||
),
|
||||
/** PUT /api/lanes/:id/sessions/:sid/shares — the WHOLE list (owner or admin). */
|
||||
setSessionShares: (id: number, sid: string, userIds: number[]) =>
|
||||
request<{ shares: LaneShare[] }>(
|
||||
`/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`,
|
||||
{ method: "PUT", body: JSON.stringify({ userIds }) }
|
||||
),
|
||||
removeSource: (id: number, sourceId: number) =>
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
||||
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
|
||||
@@ -6496,6 +6592,9 @@ export interface RunHandle {
|
||||
startedAt: string | null;
|
||||
/** Initial prompt preview (first 500 chars), null when not provided. */
|
||||
promptPreview: string | null;
|
||||
/** Server mode: a user the workspace is shared with opened it — they pay
|
||||
* for it. Null/absent = the workspace's owner. */
|
||||
startedBy?: { id: number; name: string } | null;
|
||||
tool: LaneTool;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { NotifSound } from "./notifPrefs";
|
||||
import { tokenHeader } from "./api";
|
||||
|
||||
const audio = new Map<NotifSound, HTMLAudioElement>();
|
||||
|
||||
@@ -63,7 +64,7 @@ export async function notify(title: string, body: string, url?: string): Promise
|
||||
try {
|
||||
const res = await fetch("/api/push/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...tokenHeader() },
|
||||
body: JSON.stringify({ title, body, url }),
|
||||
});
|
||||
const result = res.ok ? await res.json().catch(() => null) : null;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import type { DragEvent } from "react";
|
||||
import { api } from "./api";
|
||||
import { api, withToken } from "./api";
|
||||
|
||||
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
|
||||
export interface Attachment {
|
||||
@@ -331,7 +331,12 @@ function readRecent(): Recent[] {
|
||||
}
|
||||
|
||||
export function recentUploads(): Attachment[] {
|
||||
return readRecent().map(({ path, name, url }) => ({ path, name, ...(url ? { url } : {}) }));
|
||||
return readRecent().map(({ path, name, url }) => ({
|
||||
path,
|
||||
name,
|
||||
// Stored without the token; added on the way out.
|
||||
...(url ? { url: withToken(url) } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Remember uploads just saved, newest first; an image keeps a thumbnail
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { tokenHeader } from "./api";
|
||||
|
||||
/* =============================================================================
|
||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||||
* =============================================================================
|
||||
@@ -138,7 +141,7 @@ export async function subscribeToPush(): Promise<void> {
|
||||
if (existing) return; // already subscribed -> idempotent no-op
|
||||
|
||||
// Fetch the server's VAPID public key and decode it for `applicationServerKey`.
|
||||
const res = await fetch("/api/push/vapid-public-key");
|
||||
const res = await fetch("/api/push/vapid-public-key", { headers: tokenHeader() });
|
||||
const { publicKey } = await res.json();
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
@@ -149,7 +152,7 @@ export async function subscribeToPush(): Promise<void> {
|
||||
// Register the endpoint + keys with the backend so it can push to this browser.
|
||||
await fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...tokenHeader() },
|
||||
body: JSON.stringify(subscription.toJSON()), // { endpoint, keys: { p256dh, auth } }
|
||||
});
|
||||
}
|
||||
@@ -179,7 +182,7 @@ export async function unsubscribeFromPush(): Promise<void> {
|
||||
// Tell the backend to forget this endpoint so it stops attempting deliveries.
|
||||
await fetch("/api/push/subscribe", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...tokenHeader() },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -324,17 +324,13 @@ export const SETTINGS_INDEX: {
|
||||
"codex",
|
||||
"omp",
|
||||
"pool",
|
||||
"pool account",
|
||||
"automatic",
|
||||
"hồ sơ",
|
||||
"tài khoản",
|
||||
],
|
||||
to: "#tool-profiles",
|
||||
},
|
||||
{
|
||||
section: "agents",
|
||||
titleKey: "poolAccount.title",
|
||||
keywords: ["pool", "account", "switch", "quota", "tài khoản", "pool account"],
|
||||
to: "#pool-account",
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
{ section: "agents/codex", titleKey: "codexHome.title" },
|
||||
|
||||
@@ -40,7 +40,9 @@ function lighten(hex: string | undefined, amount: number): string | undefined {
|
||||
|
||||
/**
|
||||
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
|
||||
* `--status-*`, blue from `--accent`, black/white from the surface/fg ramp.
|
||||
* `--status-*`, blue from `--ansi-blue` when a theme sets one (its accent is
|
||||
* not blue: Terminal's green, Sunset Cloud's coral) else `--accent`,
|
||||
* black/white from the surface/fg ramp.
|
||||
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
|
||||
* theme (darcula's keyword is orange), and a program printing "magenta" should
|
||||
* get magenta.
|
||||
@@ -50,7 +52,7 @@ export function buildTerminalTheme(read: VarReader): ITheme {
|
||||
const red = v("--status-danger");
|
||||
const green = v("--status-success");
|
||||
const yellow = v("--status-warning");
|
||||
const blue = v("--accent");
|
||||
const blue = v("--ansi-blue") ?? v("--accent");
|
||||
const magenta = "#c678dd";
|
||||
const cyan = "#56b6c2";
|
||||
return {
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -1942,6 +1942,10 @@ export interface WSMessage {
|
||||
| "remote_source.status"
|
||||
| "remote_data.updated"
|
||||
| "lane_update"
|
||||
/** A workspace was shared with / taken back from the users named (`userIds`). */
|
||||
| "lane_access"
|
||||
/** Who has a pane open changed: `{laneId, runId, users}` (pane-presence.js). */
|
||||
| "pane_presence"
|
||||
/** One output line from a lane's profile hook, while it runs. */
|
||||
| "lane_hook_output"
|
||||
/** A lane's stack finished coming up (or failed to), with fresh facts. */
|
||||
@@ -2706,6 +2710,8 @@ export interface Lane {
|
||||
owner_display_name?: string | null;
|
||||
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
|
||||
owner_avatar_at?: string | null;
|
||||
/** Server mode: `"session"` when only sessions of it are shared with this viewer. */
|
||||
access?: "session";
|
||||
branch: string | null;
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@ import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsS
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||
import { CompanyHubSettings } from "../components/settings/CompanyHubSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
@@ -255,7 +254,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
};
|
||||
}, [location.key, location.hash]);
|
||||
|
||||
const { isAdmin, local, seesAll } = usePermissions();
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const authState = useAuth().state;
|
||||
const signedIn = authState.mode === "server" && !!authState.user;
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
@@ -310,11 +309,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<AgentInstructionsSettings />
|
||||
<SessionDefaultsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* Every AI tool's login profiles, linked to the account pool: the
|
||||
host's logins, a staff-only read (a developer would get 403s). */}
|
||||
{seesAll && <ToolProfilesSettings />}
|
||||
{/* Every user's own pick of a pool account (server mode; hides itself). */}
|
||||
<PoolAccountSettings />
|
||||
{/* Every AI tool's login profiles with the pool accounts the user may
|
||||
run on as rows of the same list: staff see the host's logins too,
|
||||
a developer only the pool rows (it hides itself with none). */}
|
||||
<ToolProfilesSettings />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -1002,8 +1015,11 @@ export function Workspace() {
|
||||
const showRoot = !inner || inner.root || repos.length === 0;
|
||||
// A file the chat asks for lies in the inner repo whose path prefixes it.
|
||||
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
|
||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||
// The stacked sections are an accordion: ONE is open at a time ("" = the
|
||||
// workspace folder, `repo:<path>` an inner repo, else the folder's id).
|
||||
// `undefined` = the first section (also when the open one is gone); `null`
|
||||
// = every section folded.
|
||||
const [openSource, setOpenSource] = useState<string | null | undefined>(undefined);
|
||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||
const sourceCtx = useContextMenu<string>();
|
||||
@@ -1062,11 +1078,7 @@ export function Workspace() {
|
||||
// Its path is the workspace folder's: unfold that section (or the
|
||||
// inner repo's that holds it).
|
||||
const repo = repoOfRef.current(path);
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(repo ? `repo:${repo.path}` : "");
|
||||
return next;
|
||||
});
|
||||
setOpenSource(repo ? `repo:${repo.path}` : "");
|
||||
setChangesOpen(true);
|
||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
@@ -1270,8 +1282,12 @@ export function Workspace() {
|
||||
// editor: no page padding, no cards, dividers between its regions.
|
||||
const sessionView = !!currentLane && !home;
|
||||
previewHostRef.current = sessionView;
|
||||
// The home is locked to the viewport too: the Changes panel sits beside it
|
||||
// at full height, and the home scrolls inside its own column.
|
||||
const viewportLocked =
|
||||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||
sessionView ||
|
||||
(home && !!currentLane) ||
|
||||
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -1345,27 +1361,41 @@ export function Workspace() {
|
||||
it rather than under it. It collapses to a rail instead of
|
||||
unmounting nothing — there is no live subscription inside it, so
|
||||
unmounting is safe and keeps a closed panel from polling. */}
|
||||
{home && currentLane && (
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
setAddLaneOpen(true);
|
||||
} else if (action === "reset" || action === "remove") {
|
||||
setDestructive({ lane: currentLane, action });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div ref={changesRowRef} className={`relative flex min-h-0 flex-1${home ? " hidden" : ""}`}>
|
||||
<div ref={changesRowRef} className="relative flex min-h-0 flex-1">
|
||||
{/* The home shares the row with the Changes panel, like the session
|
||||
view's console does — so a workspace's changes show before any
|
||||
session is open. */}
|
||||
{home && currentLane && (
|
||||
<div
|
||||
data-testid="workspace-home-column"
|
||||
// `relative`: the home's absolutely placed bits (a row's sr-only
|
||||
// label) must be clipped by THIS scroller — placed against the
|
||||
// row outside it they stretched the page into an empty scroll.
|
||||
// No visible scrollbar: the wheel and touch still scroll it.
|
||||
className={`no-scrollbar relative min-h-0 min-w-0 flex-1 overflow-y-auto${
|
||||
changesOpen && changesFull ? " hidden" : ""
|
||||
}${changesOpen && !changesFull ? " pr-5" : ""}`}
|
||||
>
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
setAddLaneOpen(true);
|
||||
} else if (action === "reset" || action === "remove") {
|
||||
setDestructive({ lane: currentLane, action });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col${
|
||||
currentLane && changesOpen && changesFull ? " hidden" : ""
|
||||
home || (currentLane && changesOpen && changesFull) ? " hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{/* No header row: the console's tab bar is the header. Only a
|
||||
@@ -1460,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"
|
||||
@@ -1792,10 +1824,17 @@ export function Workspace() {
|
||||
// One repo only: the one panel, as it always was.
|
||||
if (sections.length === 1) return panel;
|
||||
// Else every repo is a section, stacked like VS Code's
|
||||
// Source Control; the open ones share the height. A
|
||||
// Source Control — an accordion: opening one folds the
|
||||
// others, so the open one has the whole height. A
|
||||
// folded one stays mounted (CSS), so an unsaved edit in
|
||||
// it survives.
|
||||
const folded = foldedSources.has(key);
|
||||
const openKey =
|
||||
openSource === null
|
||||
? null
|
||||
: sections.some((x) => x.key === openSource)
|
||||
? openSource
|
||||
: sections[0]!.key;
|
||||
const folded = key !== openKey;
|
||||
return (
|
||||
<section
|
||||
key={key}
|
||||
@@ -1811,13 +1850,7 @@ export function Workspace() {
|
||||
type="button"
|
||||
aria-expanded={!folded}
|
||||
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||||
onClick={() =>
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onClick={() => setOpenSource(folded ? key : null)}
|
||||
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{folded ? (
|
||||
@@ -1884,12 +1917,13 @@ export function Workspace() {
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{/* The workspace's context — the home's right column, compact. */}
|
||||
{/* The workspace's context — the home's right column, compact
|
||||
(not on the home itself, which shows that column). */}
|
||||
<section
|
||||
data-testid="panel-workspace-context"
|
||||
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
|
||||
contextOpen ? "max-h-[45%]" : ""
|
||||
}`}
|
||||
className={`-mx-3 shrink-0 flex-col border-t border-border px-3 ${
|
||||
home ? "hidden" : "flex"
|
||||
} ${contextOpen ? "max-h-[45%]" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -196,17 +196,20 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(size()).toHaveAttribute("aria-label", "Full screen");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Full width HIDES the console beside it — hidden, still mounted.
|
||||
// The workspace's home sits beside the panel (no session is open), and
|
||||
// full width HIDES it — hidden, still mounted; same for a console.
|
||||
const beside = () => screen.getByTestId("workspace-home-column");
|
||||
expect(beside().className).not.toContain("hidden");
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-full");
|
||||
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
||||
expect(beside().className).toContain("hidden");
|
||||
expect(size()).toHaveAttribute("aria-label", "Restore size");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Restore brings the console back beside it.
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
|
||||
expect(beside().className).not.toContain("hidden");
|
||||
|
||||
// Collapsing from full screen reopens at 25/75, not back at full width.
|
||||
fireEvent.click(size());
|
||||
@@ -288,11 +291,19 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith(1);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||
// A folded section keeps its panel mounted, only hidden.
|
||||
// An accordion: the first section starts open, the others folded.
|
||||
const toggle = sections[0]!.querySelector("button")!;
|
||||
fireEvent.click(toggle);
|
||||
const shop = sections[1]!.querySelector("button")!;
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(shop).toHaveAttribute("aria-expanded", "false");
|
||||
// Opening one folds the other; a folded section keeps its panel mounted.
|
||||
fireEvent.click(shop);
|
||||
expect(shop).toHaveAttribute("aria-expanded", "true");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
||||
// Clicking the open one folds it too.
|
||||
fireEvent.click(shop);
|
||||
expect(shop).toHaveAttribute("aria-expanded", "false");
|
||||
// A chat's file is the workspace folder's: its section unfolds.
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||
@@ -390,13 +401,13 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
|
||||
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
|
||||
// A chat's file inside an inner repo opens in that repo's section.
|
||||
fireEvent.click(sections[1]!.querySelector("button")!);
|
||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||
act(() => {
|
||||
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(sections[0]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(
|
||||
{ lane: 2, repo: "apps/web" },
|
||||
"src/x.ts",
|
||||
|
||||
@@ -1203,7 +1203,19 @@ describe("Workspace — home and session view", () => {
|
||||
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
|
||||
expect(screen.getByTestId("lane-detail")).toHaveClass("hidden");
|
||||
// The Changes panel (or its rail) sits beside the home, before any session.
|
||||
expect(screen.getByTestId("workspace-home-column").parentElement).not.toHaveClass("hidden");
|
||||
// Its own scroller contains its absolutely placed bits (sr-only labels),
|
||||
// or they stretch the page past the viewport.
|
||||
expect(screen.getByTestId("workspace-home-column")).toHaveClass(
|
||||
"relative",
|
||||
"overflow-y-auto",
|
||||
"no-scrollbar"
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("lane-changes-card") ?? screen.queryByTestId("lane-changes-expand")
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.75 (rebuild client to match)
|
||||
UI build v2.8.80 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+55
-1
@@ -77,6 +77,8 @@ Authentication is **off by default** (the loopback bind is the trust boundary).
|
||||
- `x-dashboard-token: <token>` header
|
||||
- `?token=<token>` query parameter
|
||||
|
||||
Local tools find it without being told: the server writes the active token to `<data dir>/runtime/dashboard-token` (0600) at start and removes the file when no token is set, and the `agenthub` CLI, the MCP server and the statusline send `DASHBOARD_TOKEN` from their own environment, else that file. The browser keeps it in `localStorage` (`dashboard_token`) and adds it as `?token=` to what cannot send a header — the `/ws` and `/ws-pty/*` WebSockets, images, downloads. A refused WebSocket upgrade answers `401`/`403` and the server logs why (`[WS] refused …`, at most one line per reason a minute).
|
||||
|
||||
These paths stay exempt even when a token is configured: `/api/health`, `/api/openapi.json`, `/api/docs`, and `/api/hooks` (local Claude Code hook ingestion). Requests that fail the check get `401` with error code `EUNAUTHORIZED`.
|
||||
|
||||
### Server mode login (`AGENTHUB_MODE=server`)
|
||||
@@ -2098,6 +2100,58 @@ POST /api/locks/:name/release
|
||||
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
|
||||
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
|
||||
|
||||
#### Sharing a workspace (server mode)
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/shares → { shares: [{id, username, name, sharedAt}], canManage }
|
||||
PUT /api/lanes/:id/shares {"userIds": [2, 5]} → { shares }
|
||||
```
|
||||
|
||||
The users a workspace is shared with (`lane_shares`, names only). `PUT` takes the
|
||||
WHOLE list — at most 200 enabled users, never the owner (`400 EBADUSER`) — from the
|
||||
owner or an admin only (`403 ENOTOWNER`), same-origin, each change audited. Local
|
||||
mode: `GET` answers an empty list, `PUT` `400 ELOCALMODE`. A user it is shared with
|
||||
sees and works in it like its owner, but `PATCH {title}`, `reset`/`remove`/`purge`,
|
||||
`DELETE` and `PUT …/shares` answer them `403 ENOTOWNER`. A session they start or
|
||||
resume (`POST /api/lanes/:id/start`, `POST /api/run`) runs on THEIR OWN account —
|
||||
a pool setup-token (Claude) or API key (Codex, Cursor) they may use, or an AI
|
||||
Proxy of theirs — else `409 ENOCOLLABACCOUNT`; resuming a non-Claude session the other side pays
|
||||
for, or a collaborator's `resumeLast`, is `409 ECOLLABRESUME`; their spend quota (`429 EQUOTA`)
|
||||
decides, not the owner's. Every pane they open (a Terminal tab too) records them
|
||||
in `dashboard_runs.started_by`: its pane token acts as them, and an AI pane's
|
||||
session hours are billed to them (`session_payers`; a Terminal tab's stay the
|
||||
owner's). A user disabled since stays on the
|
||||
list (`disabled: true`) and may be kept or removed; only a new one must be enabled. Users taken off
|
||||
get the WebSocket message `lane_access` `{laneId, userIds}` (sent to them only). See
|
||||
`docs/SERVER-MODE.md` → Sharing a workspace. A collaborator's pane token
|
||||
answers only that workspace's reads, the workspace list (filtered to it), its
|
||||
stage / up / down / hook / feature / mcp-sync writes and `/api/health`
|
||||
(`403 EPANESCOPE` — never a start or resume).
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/presence → { panes: { "<run id>": [{id, name}] } }
|
||||
```
|
||||
|
||||
Who has which pane open (server mode; local mode `{panes: {}}`), in memory;
|
||||
changes come as the WebSocket message `pane_presence` `{laneId, runId, users}`
|
||||
to the workspace's owner and full collaborators. A run handle
|
||||
(`GET /api/run`) carries `startedBy: {id, name}` when a collaborator opened
|
||||
it — they pay for it. The first keystroke of a terminal attach into a shared
|
||||
workspace's pane by anyone but its owner is audited (`type into <run id>`).
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/sessions/:sid/shares → { shares, canManage }
|
||||
PUT /api/lanes/:id/sessions/:sid/shares {"userIds": [3]} → { shares }
|
||||
```
|
||||
|
||||
One session shared on its own (`session_shares`), same rules as the workspace
|
||||
list (owner or admin, enabled users, audited `share session <id>`, `404
|
||||
ENOSESSION` for a session of another workspace). The user gets the workspace
|
||||
in `GET /api/lanes` with `access: "session"`, only that session in its session
|
||||
lists, the pane holding it, a resume of it (`start {resumeSessionId}` — no
|
||||
fork, no new session, no shell), and the workspace's git/Files reads and git
|
||||
writes; any other call on the workspace answers `403 ESESSIONSHARE`.
|
||||
|
||||
#### Workspace context: instructions, folders, context files, memory
|
||||
|
||||
```http
|
||||
@@ -3335,7 +3389,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
|
||||
|
||||
```http
|
||||
GET /api/run List all live runs (computed fresh from tmux state)
|
||||
GET /api/run/tmux { available: bool, version, upgrade } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; the Workspace shows it as a sticky toast
|
||||
GET /api/run/tmux { available: bool, version, upgrade, upgradeSeverity } — whether tmux is on PATH, its version, and this platform's upgrade advice when it is older than AgentHub asks for (psmux 3.4.0 on Windows → `winget upgrade marlocarlo.psmux`; tmux 3.7 on macOS → `brew upgrade tmux`, on Linux → build the release), else null; `upgradeSeverity` is `error` for an old psmux (terminals stall — a red toast on every visit) and `warning` for an old tmux (only the pane scrollbar is missing — an amber toast, once per version in a browser)
|
||||
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
|
||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||
GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `agenthub status` | At-a-glance up/down indicator (`●` running / `○` not running); exits `1` when down |
|
||||
| `agenthub start [--port N]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
|
||||
| `agenthub start [--port N] [--host A[,B]\|local] [--allowed-hosts H,..\|none] [--token T\|auto\|off]` | Start the production server **in the background** (detached; survives closing the terminal), wait up to 30 s for `/api/health`, print the URL + PID and the `agenthub stop` hint. Logs append to `data/agenthub-server.log`. No-ops with a pointer when a server is already up. `--host`, `--allowed-hosts` and `--token` are saved in `<data dir>/network.json` (0600) and applied by every later start (a set environment variable wins); a non-loopback `--host` without a token gets one generated. The links to open — every address of a `0.0.0.0` bind, each with `?token=` — are printed. Requires a built client (`npm run build` once). Always ends a Quit (`agenthub stop`); in a plugin install it starts the server the way a session does — by running `scripts/plugin-bootstrap.js` |
|
||||
| `agenthub stop` | **Quit AgentHub** (issue #65): closes every workspace terminal (`agenthub-lane-*` tmux sessions — running agents stop), stops the server — through `systemctl --user stop` / `launchctl bootout` when the autostart unit runs it — and keeps it stopped: Claude Code's `SessionStart` bootstrap starts no server while `<data dir>/runtime/stopped` exists. `agenthub start` (or any server starting, e.g. a boot/login with autostart on) clears it. With the server already down it only sets the flag |
|
||||
| `agenthub uninstall [--yes]` | **Uninstall AgentHub** (`scripts/uninstall.js`; Settings → System has the same button). Without `--yes` it only lists what is there and exits 1. With it, in order: closes every workspace terminal, removes the systemd/launchd unit, stops every dashboard server, the status-area icon and workspace stacks, takes AgentHub's own entries out of `~/.claude/settings.json` (hooks, statusline; backup `settings.json.agenthub-uninstall.bak`), Codex `hooks.json` and Antigravity `hooks.json`, deletes the AI Proxy `$CODEX_HOME/<name>.config.toml` files, runs `claude plugin uninstall` for `agenthub@*` and every plugin of the `agenthub` marketplace and removes that marketplace (never a shared one), then deletes `~/.local/bin/agenthub`, `~/.config/agenthub/`, `~/.claude/.agenthub.json`, `~/.claude/agenthub-{proxies,routers,git-credentials,accounts,profiles}`, `agenthub-lanes/.state` and `.locks`, the plugin's cached copies and the data directory (database included). **Kept:** the AI tools' own conversations (`~/.claude/projects`, `~/.codex/sessions`, …), workspace worktrees and branches, anything in your repositories, config backups. A data directory that is the home, `CLAUDE_HOME`, a checkout or not recognisably AgentHub's is refused. A failing step is logged and the rest still run; log: `$TMPDIR/agenthub-uninstall-<uid>.log`. Needs no running server. Run inside a dashboard terminal, it closes that terminal last. |
|
||||
| `agenthub tray` | Show AgentHub's **status-area icon** now, in the foreground (`scripts/tray.js`): Open AgentHub, Quit AgentHub (a submenu asks again, then runs `agenthub stop`), Start AgentHub after a quit (`agenthub start`), Hide this icon. A production server already keeps the icon up on its own unless Settings → System turned it off; this command does not change that setting. Exits at once when an icon is already running |
|
||||
|
||||
+3
-1
@@ -245,7 +245,9 @@ NODE_ENV=production # Environment mode
|
||||
# The server binds 127.0.0.1 by default and is NOT network-reachable. It reads
|
||||
# transcripts, exports all data, and can spawn `claude`, so only widen the bind
|
||||
# deliberately — and require a token when you do.
|
||||
DASHBOARD_HOST=127.0.0.1 # set 0.0.0.0 ONLY if you must expose it
|
||||
DASHBOARD_HOST=127.0.0.1 # set 0.0.0.0 ONLY if you must expose it; a list
|
||||
# (192.168.1.20,100.64.0.7) binds each, plus 127.0.0.1
|
||||
# (`agenthub start --host` saves it in <data dir>/network.json)
|
||||
DASHBOARD_TOKEN= # required on /api/* + WS when set; use with a non-loopback host
|
||||
DASHBOARD_ALLOWED_HOSTS= # extra Host names (comma-sep) for a LAN bind
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+6
-3
@@ -38,9 +38,12 @@ build, so the registration survives plugin updates.
|
||||
| 3 | `~/.claude/.agenthub.json` — the live-dashboard discovery file the CLI uses (`server/lib/dashboard-url.js`) |
|
||||
| 4 | `http://127.0.0.1:4820` |
|
||||
|
||||
When the dashboard sets `DASHBOARD_TOKEN`, give the MCP process the same
|
||||
variable (`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`); it is
|
||||
sent as `Authorization: Bearer`.
|
||||
When the dashboard sets `DASHBOARD_TOKEN`, the MCP server sends it as
|
||||
`Authorization: Bearer`: its own `DASHBOARD_TOKEN` if set, else the token the
|
||||
running dashboard publishes for local tools in
|
||||
`<data dir>/runtime/dashboard-token` (0600, readable by the same account only).
|
||||
A dashboard of another account needs the variable
|
||||
(`claude mcp add agenthub -e DASHBOARD_TOKEN=… -- agenthub mcp`).
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
+3
-2
@@ -84,8 +84,9 @@ Internal build — all rights reserved.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark,
|
||||
Sunset Cloud, Terminal, Night Sea; light: Default, Apple Light, Canvas, Orca
|
||||
Light, Sakura (the four last from ai-chat-themes, colours only). Each mode
|
||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||
It also picks the workspace terminals' font: JetBrains Mono (default), the
|
||||
rounded Maple Mono, or the browser's own fixed-width
|
||||
|
||||
+126
-6
@@ -152,7 +152,8 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
- A session belongs to the workspace that owns it: `sessions.lane_id`, or the
|
||||
workspace whose pane holds it. Its agents, events, tokens, cost, alerts,
|
||||
snapshots and workflow runs belong to that session.
|
||||
- A **developer** sees only rows of their own workspaces, everywhere: lists,
|
||||
- A **developer** sees only rows of their own workspaces (and of workspaces
|
||||
shared with them — see [Sharing a workspace](#sharing-a-workspace)), everywhere: lists,
|
||||
counts, charts, cost, the Sessions/Events/Agents tables, exports, live
|
||||
WebSocket updates and terminals. A request naming someone else's row (in the
|
||||
path, the query or the body) gets `404 ENOTFOUND`, the same answer as a row
|
||||
@@ -235,6 +236,117 @@ hides them for developers. The command and skill lists show a developer only the
|
||||
own workspaces' project items: `?cwd=` outside one of them answers
|
||||
`403 EFOLDER`, and with no `cwd` they list no project items at all.
|
||||
|
||||
### Sharing a workspace
|
||||
|
||||
A workspace's owner (or an admin) shares it with other users from its `⋯`
|
||||
menu → **Share…** (`WorkspaceShareModal`, `GET/PUT /api/lanes/:id/shares`,
|
||||
table `lane_shares`). There is one level: **collaborate**. A user it is shared
|
||||
with sees the workspace, its sessions, Changes, history and Files like its
|
||||
owner, joins its open terminals, types into them, commits and pushes, edits its
|
||||
instructions and context — every row and live update, through the same
|
||||
`scope.js` predicates (`canSeeLane`, `sessionWhere`, `laneWhere`,
|
||||
`broadcastAudience`'s `viewers`). Only the owner or an admin renames, resets,
|
||||
removes, deletes or shares it (`403 ENOTOWNER`).
|
||||
|
||||
**Whoever starts or resumes a session pays for it.** A session a collaborator
|
||||
starts or resumes runs on THEIR OWN account and never on the owner's login
|
||||
(`spawnRun` `startedBy`): a pool **setup-token** (Claude) or **API key**
|
||||
(Codex, Cursor) their pool group, pin or choice allows (`forCollaborator` —
|
||||
never a pool *login*, which would be written into the owner's home), or an AI
|
||||
Proxy profile of theirs (resolved as them; the gateway relays it). With none,
|
||||
the start is refused (`409 ENOCOLLABACCOUNT`); omp and every other tool, which
|
||||
can only run on the folder account's own login, are refused too. Their own
|
||||
spend quota decides (`EQUOTA`), their pane's hooks read their own flag
|
||||
(`quota-u<id>.json` in the owner's handoff dir under S3, `u<id>.json` on the
|
||||
host), and a quota sweep ends or blocks only the panes THEY pay for
|
||||
(`runPayer`). Money is per hour (`session_payers`, `server/lib/session-payers.js`):
|
||||
a session nobody else ran has no row and is billed exactly as before; a session
|
||||
a collaborator started is theirs from its first hour; a resume changes hands
|
||||
from the NEXT hour (the hour it happens in stays with the previous payer), in
|
||||
either direction. Only a Claude session books its spend by the hour, so only
|
||||
it can change hands: a Codex/Cursor/other session is billed whole at its start
|
||||
hour, and resuming one the other side pays for is refused (`409
|
||||
ECOLLABRESUME`), as is a collaborator's `resumeLast` (it names no session).
|
||||
A Claude session's sub-agent usage is likewise booked at the session's start
|
||||
hour, so after a hand-over it stays with the first payer — an accepted
|
||||
rounding. A Terminal tab stays the owner's money: a `claude` typed there runs
|
||||
on the folder account's own login. The Users page, spend quotas, Today (`/api/pricing/cost?mine=1`)
|
||||
and a developer's budgets all count by payer; the workspace owner's agent
|
||||
instructions still go into every pane of the workspace. `MAX_AI_SESSIONS` counts
|
||||
per Linux account, so a collaborator's sessions count against the owner's
|
||||
account limit.
|
||||
|
||||
**Under S3 a collaborator works as the OWNER's Linux account** — the folder
|
||||
decides the account. They can read everything in the owner's home (the owner's
|
||||
other workspaces, SSH keys, AI logins, the pane tokens of the owner's panes)
|
||||
and their commits carry the owner's git identity; only AgentHub's `audit_log`
|
||||
records who did what. The Share dialog says so. Share only with people you
|
||||
would hand your keyboard to. Two consequences follow and are accepted, not
|
||||
bugs: a collaborator can read the pane token of a pane the OWNER started (and
|
||||
so act as the owner through the CLI from it), and `ENOCOLLABACCOUNT` is a
|
||||
dashboard rule, not a barrier — in a terminal they can run `claude` as the
|
||||
owner's account on the owner's login, and either side can edit the other's
|
||||
quota flag file in that home (the flag is fail-open anyway; the start check is
|
||||
server-side). A collaborator's personal Codex proxy writes no key into the
|
||||
owner's home, only its endpoint. Every pane the collaborator opens records them
|
||||
as its starter (`dashboard_runs.started_by`), so its own pane token acts as
|
||||
THEM, and only while the workspace is still shared with them
|
||||
(`userFromPaneToken`).
|
||||
|
||||
A user already on the list who was disabled since stays listed (marked
|
||||
disabled, granting nothing) and can be taken off; only a NEW addition must be
|
||||
enabled. Every share and unshare is audited (`share workspace <id>` /
|
||||
`unshare workspace <id>`). Taking it back sends the users taken off
|
||||
`lane_access` (their list re-reads) and closes their open terminals on it at
|
||||
the next attach re-check. Deleting the workspace, or the user, drops the rows;
|
||||
giving the workspace to a user it was shared with drops theirs.
|
||||
|
||||
**A collaborator's pane token acts as them inside that workspace only.** The
|
||||
owner's account can read it (it sits in the owner's home), so it answers only
|
||||
reads of `/api/lanes/<that id>/…` (and `/api/workspaces/<id>/…`), the
|
||||
workspace list (which then holds that workspace only), `/api/health`, and its
|
||||
`stage`, `up`, `down`, `hook/<name>`, `features/activate` and `mcp/sync`
|
||||
writes (`paneLane`, `403 EPANESCOPE` for everything else) — never a start,
|
||||
resume or any other call that could bill them. The CLI's workspace commands
|
||||
work from their pane; the MCP server and the statusline's spend line do not.
|
||||
A pane the owner or an admin opens still acts as the owner.
|
||||
|
||||
**Who is here, and who pays.** A pane's tab shows the other people who have
|
||||
it open (`pane_presence`, `GET /api/lanes/:id/presence`, in memory — never
|
||||
the viewer themself), and its tooltip says "Billed to <name>" for a pane a
|
||||
collaborator opened (`startedBy` on the run). The first keystroke of each
|
||||
terminal attach into a SHARED workspace's pane (whole or one session of it)
|
||||
by anyone but its owner is written to the audit log as `type into <run id>` —
|
||||
who typed where, never what (mouse, focus and the terminal's own answers to
|
||||
tmux do not count); the Chat composer types through the same attach, so its
|
||||
prompts are covered too. A terminal that stops answering pings (a sleeping
|
||||
laptop) is ended at the next re-check, and its presence with it.
|
||||
|
||||
#### Sharing one session
|
||||
|
||||
The owner (or an admin) can also share ONE session: right-click it in the
|
||||
sidebar → **Share…** (`GET/PUT /api/lanes/:id/sessions/:sid/shares`, table
|
||||
`session_shares`, audited as `share session <id>`). The user sees the
|
||||
workspace in their list (`access: "session"`) with only that session in it,
|
||||
attaches to the pane holding it, sends it messages (a resume, on their own
|
||||
account and billed to them, as above) and uses the workspace's Changes, git
|
||||
and Files reads and git writes. Everything else of the workspace — another
|
||||
session, a new session, a fork, a Terminal tab, `/api/run`, its instructions,
|
||||
context and settings — answers `403 ESESSIONSHARE` (`sessionShareAllows` in
|
||||
`scope.js`, checked in `scopeGuard`). Their run lists show only runs that held
|
||||
that session, and a pane switching to another session closes their terminal
|
||||
at the next attach re-check. Taking it back, deleting the workspace or the
|
||||
user drops the rows. A resume names a free slot (`slot: "auto"`, never pane
|
||||
0) and a session of THAT workspace; the session's name and archive state stay
|
||||
the owner's (`PATCH /api/sessions/<id>` is `ESESSIONSHARE`), and the
|
||||
workspace's extra folders (`?source=`), its files list for Artifacts and its
|
||||
stage history stay out of reach. **These limits hold for the dashboard's API
|
||||
only.** Under S3 the pane runs as the owner's Linux account: inside it the
|
||||
user can `/resume` any session of the folder, run shell commands and read the
|
||||
owner's home — a session share hands over as much as a workspace share
|
||||
there. The Share dialog says so; share only with people you would hand your
|
||||
keyboard to.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
Each user may keep **personal** AI Proxy profiles beside the **shared** ones:
|
||||
@@ -467,7 +579,10 @@ a login is single use, so the server is the one that refreshes it, before it
|
||||
expires (`claude-pool.refreshLogins`, checked every 60 s; `DASHBOARD_POOL_REFRESH_MS=0` turns it off). It then writes the new pair into every
|
||||
home it placed the login in, and Claude Code picks up tokens another process
|
||||
refreshed. A pair a member's Claude refreshed first is copied back only after
|
||||
Anthropic confirms it is the same account. Removing the account deletes it from
|
||||
Anthropic confirms it is the same account; while Anthropic does not answer, the
|
||||
home is kept and checked again on the next beat. A home still holding one of the
|
||||
account's earlier pairs (its Claude raced the server's refresh) stays the pool's
|
||||
and gets the newest pair, so its member is not asked to `/login`. Removing the account deletes it from
|
||||
the homes that still hold AgentHub's copy. Each
|
||||
change asks for the admin's own password again and is written to the audit log
|
||||
by account name. The token only goes in; the page, like `agenthub accounts list`,
|
||||
@@ -692,15 +807,20 @@ tools**. An account's Edit (pencil) uses the same role / No pool list.
|
||||
### A user's own pool account
|
||||
|
||||
When the pool holds more than one account a user may use, they pick which one
|
||||
their own new sessions run on: **Settings → AI tools → Pool account**, one
|
||||
select per tool (Claude, Codex, Cursor), every role, server mode only
|
||||
(`GET/PUT /api/me/pool-accounts`, `account_choices`, `server/lib/account-pins.js`).
|
||||
their own new sessions run on in **Settings → AI tools → Profiles**: each pool
|
||||
account they may use is a row of that tool's tab (Claude, Codex, Cursor) with
|
||||
its quota and a **Switch to**, beside an **Automatic** row — there is no
|
||||
separate Pool account section. Every role, server mode only; a developer's
|
||||
Profiles shows only these pool rows, staff see the host's saved logins too, and
|
||||
a saved profile with the same email as a pool account is shown once, as the
|
||||
pool row (`GET/PUT /api/me/pool-accounts`, `account_choices`,
|
||||
`server/lib/account-pins.js`).
|
||||
|
||||
- The list is only what their group allows (or the shared pool); a choice
|
||||
outside it is refused (`ENOACCOUNT`), and one later taken from the group
|
||||
falls back to the balancer.
|
||||
- **Automatic** (the default) leaves it to the balancer.
|
||||
- An admin's pin wins and locks the select (`ELOCKED`); the choice comes back
|
||||
- An admin's pin wins and locks the rows (`ELOCKED`); the choice comes back
|
||||
once the pin is removed.
|
||||
- A Claude login goes into the user's home at once, like an admin's switch,
|
||||
so running panes follow it; Codex and Cursor take it at the next session (a
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ stopped dashboard costs one timeout per 30 s, not one per prompt. When the
|
||||
dashboard is down the part is simply left out. The script never throws, stops
|
||||
itself after 2.5 s and always exits 0. The dashboard is found like the CLI finds
|
||||
it (`AGENTHUB_URL`, then `CLAUDE_DASHBOARD_PORT`/`DASHBOARD_PORT`, then
|
||||
`~/.claude/.agenthub.json`); `DASHBOARD_TOKEN` is sent when set.
|
||||
`~/.claude/.agenthub.json`); `DASHBOARD_TOKEN` (else the token the running dashboard publishes in `<data dir>/runtime/dashboard-token`) is sent when there is one.
|
||||
|
||||
## Install / uninstall
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# Workspace & session sharing — plan
|
||||
|
||||
Status: **phases 1–4 built 2026-11-06** (workspace sharing; collaborators run on their own account, billed per payer by hour — `session_payers`; one session can be shared — `session_shares`; pane presence + typing audit; R1 decided as option **(a)** — accept and warn hard).
|
||||
An independent review of phase 1 found and got fixed: a collaborator's Terminal
|
||||
tab acted as the owner through its pane token (now `dashboard_runs.started_by`),
|
||||
`POST /api/run {shell:true}` bypassed `ECOLLABSTART`, a disabled collaborator
|
||||
locked the share list, and the home's prompt box let a collaborator type into a
|
||||
Start that always failed. Accepted under (a): the owner's own panes' tokens are
|
||||
readable by a collaborator, and `ECOLLABSTART` is advisory under S3.
|
||||
|
||||
Server mode only. In local mode there are no users, so there is nothing to share
|
||||
and every piece below is a no-op (`isServerMode()`).
|
||||
|
||||
## What the user asked for
|
||||
|
||||
Members of the same AgentHub install work together on one workspace or one
|
||||
session. Decisions taken with the repo owner (2026-11-06):
|
||||
|
||||
| Question | Decision |
|
||||
| --- | --- |
|
||||
| What may a person it is shared with do? | **Always collaborate** — the same as the owner inside that workspace/session: type into panes, send prompts, start sessions, commit, push. No view-only level. |
|
||||
| Unit of sharing | **Both** — a whole workspace (every session, present and future) or a single session. |
|
||||
| Who pays for a session a collaborator starts? | **The person who opens it** — its tokens, cost and spend quota are theirs, not the workspace owner's. |
|
||||
| Whose AI subscription does a collaborator's run use? | **Their own, or none**: only a pool account of kind `token` that the collaborator may use (passed by env). With a `login`-kind pool or no pool the run is **refused** (`ENOCOLLABACCOUNT`) — a collaborator never spends the owner's subscription. |
|
||||
| Spend quota | **Only the payer's** (the person who started or resumed the run). The owner's quota is not involved. |
|
||||
| A collaborator resumes someone else's session | **The resumer pays from then on** — billing is per RUN, not per session (below). |
|
||||
| Session-only share and git | **Allowed** — commit/push in that session's tree, like everything else. |
|
||||
| Sharing with an analyst | **Allowed** — a shared workspace counts as their own for writes. |
|
||||
|
||||
## The one fact everything else follows from (S3)
|
||||
|
||||
Under S3 a pane runs as the Linux account of its **folder**
|
||||
(`accountForPath`, `server/lib/run-as.js`), and only the folder's account may
|
||||
own a workspace there (`assertOwnerFits`, `EOWNERFOLDER`). Sharing does **not**
|
||||
change that: a collaborator's work in Alice's workspace runs as `ah-alice`.
|
||||
|
||||
What that means, and the UI must say it once, plainly, in the Share dialog:
|
||||
|
||||
- **Git identity, SSH key, HTTPS store, commit signing are the owner's.** A
|
||||
collaborator's commit is authored by the owner (`git config --global` of the
|
||||
owner's account). AgentHub's `audit_log` records who clicked; git does not.
|
||||
- **Files written are the owner's.** Everything a collaborator's session
|
||||
creates belongs to `ah-alice`.
|
||||
- **The owner's `~/.claude` applies** — their skills, memory, MCP servers,
|
||||
settings — to the collaborator's sessions too.
|
||||
- **The owner's account can read the collaborator's pane token** (it is in a
|
||||
launch file in the owner's home, like every pane secret). So the collaborator's
|
||||
token must be scoped to the shared workspace only (below), never their whole
|
||||
account.
|
||||
|
||||
There is no way around these without moving the folder to a shared account —
|
||||
out of scope. Sharing is therefore something you do with people you would
|
||||
also hand your keyboard to; the dialog says so.
|
||||
|
||||
## Data model (migration-safe, `server/db.js`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS lane_shares (
|
||||
lane_id INTEGER NOT NULL REFERENCES lanes(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
PRIMARY KEY (lane_id, user_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
session_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
PRIMARY KEY (session_id, user_id)
|
||||
);
|
||||
-- who started a dashboard run (a collaborator's run is billed to them)
|
||||
ALTER TABLE dashboard_runs ADD COLUMN started_by INTEGER; -- guarded like every ALTER in db.js
|
||||
```
|
||||
|
||||
No `role`/`level` column: there is one level (collaborate). Adding view-only
|
||||
later is one column.
|
||||
|
||||
## Who may do what
|
||||
|
||||
| Action | Owner | Workspace collaborator | Session collaborator | Admin |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| See the workspace, its sessions, Changes, history | ✓ | ✓ | that session + the workspace's Changes panel | ✓ |
|
||||
| Attach to / type into a live pane | ✓ | ✓ every pane | that session's pane | ✓ |
|
||||
| Send a message to an ended session (resume) | ✓ | ✓ | that session | ✓ |
|
||||
| Start a NEW session | ✓ | ✓ (billed to them) | ✗ | ✓ |
|
||||
| Git writes (commit, push, shelve, rewrite…) | ✓ | ✓ | ✓ (the session's tree) | ✓ |
|
||||
| Workspace instructions, context files, extra folders | ✓ | ✓ | ✗ | ✓ |
|
||||
| Rename / Remove / Reset the workspace | ✓ | ✗ | ✗ | ✓ |
|
||||
| Share / unshare | ✓ | ✗ | ✗ | ✓ |
|
||||
| `agenthub stage`, lane runtime `up/down/hook` | ✓ | ✓ | ✗ | ✓ |
|
||||
|
||||
Owner-only actions get an `assertLaneOwner(req, laneId)` (owner or admin);
|
||||
everything else becomes "owner **or** collaborator".
|
||||
|
||||
## Server changes
|
||||
|
||||
1. **`server/lib/scope.js`** — the one place that decides visibility:
|
||||
- `laneWhere` / `canSeeLane`: `owner_id = ? OR id IN (SELECT lane_id FROM lane_shares WHERE user_id = ?)`.
|
||||
- `ownedSessionWhere`: add `OR col IN (SELECT session_id FROM session_shares WHERE user_id = ?)` and sessions of shared lanes (through the same lane predicate).
|
||||
- `PATH_RULES` / `namedIds` need no new rule (they call `canSeeLane`/`canSeeSession`); a new route under a new prefix (`/api/shares`) gets its own rule.
|
||||
- Keep `memberId` as is: an analyst still reads everything and writes only what they own **or collaborate on**.
|
||||
2. **Session ownership & billing ("whoever opens it pays")**
|
||||
- `spawnRun` takes `startedBy = req.user.id`; writes `dashboard_runs.started_by`.
|
||||
- `bindRunSession` (`server/routes/hooks.js`) stamps `sessions.owner_id = started_by` when it differs from the lane owner. `sessions.owner_id` already exists (remote clients) and `scope.js`, `session-account.js`, `users.js` honour it.
|
||||
- `server/routes/users.js` ownership CTE: `sessions.owner_id` must WIN over the lane owner (today it is a UNION — a session could count twice). Same in `user-quota.js` spend (`hourlyTokenRows` + `calculateCost`, never a second formula).
|
||||
- Spend quota: `assertSpawnAllowed(startedBy)` instead of the lane owner, and `hook-handler.js`'s `UserPromptSubmit` check reads the flag of the RUN's payer (the quota file is per Linux account today → per account + payer user id, passed to the pane as `AGENTHUB_PAYER_ID`).
|
||||
- **Billing is per run, not per session.** A session can be paid by several
|
||||
people over time: whoever starts or resumes a run pays for everything that
|
||||
run does, including what anyone types into its live pane (keystrokes cannot
|
||||
be attributed; the run's starter can). New table
|
||||
`session_payers(session_id, user_id, from_at)` — one row per run start whose
|
||||
payer differs from the previous one. `hourlyTokenRows` books a session's
|
||||
tokens per UTC hour already; each hour goes to the payer whose `from_at` is
|
||||
the latest ≤ that hour (a run started mid-hour takes the hour from the next
|
||||
one on — documented rounding, never a second cost formula). With no row the
|
||||
session's owner pays, as today. `sessions.owner_id` keeps meaning "who
|
||||
opened it" (visibility); `session_payers` only moves money.
|
||||
3. **AI login of a collaborator's run — their own or refused.**
|
||||
- The collaborator's pool pick (`pickAccount(startedBy)`, their group, pin
|
||||
and own choice) must be of kind `token`: it goes in as
|
||||
`CLAUDE_CODE_OAUTH_TOKEN` through tmux `-e` / the launch file, overriding
|
||||
the owner's home login for that pane only.
|
||||
- A `login`-kind pick, or no pool account for them: `spawnRun` refuses with
|
||||
`409 ENOCOLLABACCOUNT` before anything starts, for a new session AND for a
|
||||
resume. The home's Start button says why. The owner's login is never used
|
||||
for a collaborator's run.
|
||||
- Codex / other tools: same rule with their own env-only kinds (Codex API key
|
||||
`AGENTHUB_POOL_KEY`, Cursor `CURSOR_API_KEY`); a ChatGPT login (written into
|
||||
the home as `auth.json`) is refused like a Claude `login`.
|
||||
4. **Pane token.** A collaborator's session gets a pane token of the
|
||||
collaborator, **scoped to that lane**: `dashboard_runs.pane_token_hash` plus a
|
||||
`scope_lane_id`, and `userFromPaneToken` refuses anything outside that lane.
|
||||
`peerIsPaneAccount` keeps checking the folder's account (`ah-alice`) — the
|
||||
token is accepted from that uid because the pane runs as it.
|
||||
5. **Live co-working.** tmux already allows several attaches with a shared
|
||||
size (`pty-attach.js`). Add:
|
||||
- presence: who is attached to which pane (in-memory, broadcast to the lane's audience);
|
||||
- an audit line per attach-with-input and per prompt sent from the chat composer (`audit_log`, actor = the real user). Keystrokes are not logged.
|
||||
6. **WebSocket** — `broadcastAudience` (`server/websocket.js`): every lane- or
|
||||
session-scoped message also reaches the lane's/session's collaborators. A new
|
||||
type `shares_changed` (owner + affected user).
|
||||
7. **Routes** (`server/routes/shares.js`, same-origin, audited, server mode only):
|
||||
- `GET /api/lanes/:id/shares`, `PUT /api/lanes/:id/shares {userIds}` (owner/admin)
|
||||
- `GET /api/sessions/:id/shares`, `PUT /api/sessions/:id/shares {userIds}` (owner/admin)
|
||||
- `GET /api/me/shared` — what is shared with me
|
||||
- only enabled users; never the owner themself; a disabled user's shares stay but grant nothing.
|
||||
8. **Revoke.** Removing a share closes that user's live attaches on the lane
|
||||
(their websocket attach ends), revokes their pane tokens' lane scope, and
|
||||
leaves their running sessions running (they run as the owner; the owner can
|
||||
stop them). Deleting a lane cascades its shares.
|
||||
|
||||
## Client changes
|
||||
|
||||
- **Share dialog** from the workspace's `⋯` menu and the session's right-click
|
||||
menu: user picker (enabled users, not me), the current list with Remove, and
|
||||
the S3 warning above. `confirmDialog` on Share, `toast` on the result.
|
||||
- **Sidebar**: a "Shared with me" group (workspaces shared with me, and loose
|
||||
shared sessions under their workspace name, marked). A share icon on rows I
|
||||
shared.
|
||||
- **Presence**: small avatars on a pane's tab of who else is attached.
|
||||
- **Recents / session list**: the opener's avatar on a session a collaborator
|
||||
started; the cost column is the opener's.
|
||||
- Owner-only controls hidden for collaborators (the server refuses anyway —
|
||||
`ENOTOWNER`, 403).
|
||||
|
||||
## Docs to update when built
|
||||
|
||||
`docs/SERVER-MODE.md` (new § Sharing + scope table), `docs/LANES.md`,
|
||||
`docs/API.md`, `CLAUDE.md` (the scope.js rule and the S3 rule gain "or a
|
||||
collaborator"), `README.md` feature list.
|
||||
|
||||
## Phases
|
||||
|
||||
1. **S1 — Workspace sharing, visibility + live panes.** Tables, scope.js,
|
||||
broadcastAudience, Share dialog, sidebar group, attach/type, git writes.
|
||||
Collaborators can join live panes but cannot start sessions yet.
|
||||
2. **S2 — Collaborator runs on their own account, billed per run.**
|
||||
`started_by`, `sessions.owner_id` stamping, `session_payers`, users.js /
|
||||
quota precedence (and the double count fixed), pane token scope, pool
|
||||
`token` env, `ENOCOLLABACCOUNT`.
|
||||
3. **S3 — Session-only sharing.** `session_shares`, its scope rules and UI.
|
||||
4. **S4 — Presence + audit of prompts.**
|
||||
|
||||
Each phase ships on its own; tests per phase: `scope.test.js` (every predicate
|
||||
with and without a share), `server-mode-review-fixes.test.js` style 404s for a
|
||||
non-collaborator, users/quota attribution, websocket audience, and the client
|
||||
dialog/sidebar tests.
|
||||
|
||||
## Self-review against the code (2026-11-06)
|
||||
|
||||
Checked every claim above against `server/lib/scope.js`, `auth.js`
|
||||
(`userFromPaneToken`), `hook-auth.js` (`peerIsPaneAccount`), `pty-run.js`
|
||||
(`spawnRun`), `user-quota.js`, `lane-sources.js` (`launchExtras`),
|
||||
`websocket.js` (pty attach) and `routes/users.js`.
|
||||
|
||||
**R1 — CRITICAL: sharing a workspace hands out a shell as the owner's whole
|
||||
account, not just that workspace.** A collaborator who types into a pane (or
|
||||
whose prompt makes Claude run Bash) runs as `ah-alice`, whose home holds ALL of
|
||||
Alice's workspaces, `~/.ssh` keys, `~/.config/agenthub/` HTTPS store, Claude and
|
||||
Codex logins (`.credentials.json`, `auth.json`) and the env of her live panes.
|
||||
So Bob can read Alice's other projects, copy her SSH key and her Claude
|
||||
subscription token, and — via `$AGENTHUB_PANE_TOKEN` in any of her panes — call
|
||||
the CLI/MCP **as Alice** (`userFromPaneToken` returns the lane owner, and is
|
||||
accepted from Alice's uid, which Bob's typing runs as). This also defeats
|
||||
decision 1 ("never the owner's subscription"): Bob can simply use her login.
|
||||
The section "The one fact everything else follows from" understated this.
|
||||
Options:
|
||||
- **(a) Accept it, warn hard.** Share = "give this person your account". Simple,
|
||||
honest, no isolation.
|
||||
- **(b) Team workspaces in their own Linux account.** A shared workspace lives
|
||||
under a dedicated account (`ah-team-<id>`, provisioned by `agenthub-provision`
|
||||
like a member, its own home, git identity, logins = the pool's `token` kinds
|
||||
only). Every member of the team works there; nobody's personal home is
|
||||
exposed. Real isolation; more work (provisioning, git identity per team,
|
||||
`assertOwnerFits` learns team folders, moving an existing workspace = clone).
|
||||
- **(c) Chat-only collaborators.** No terminal for collaborators — but Claude's
|
||||
own Bash tool still runs as Alice, so this isolates nothing. Not proposed.
|
||||
|
||||
**R2 — Typing into the owner's live pane spends the owner's subscription.**
|
||||
Unavoidable while the pane runs on her login; contradicts the spirit of
|
||||
decision 1. Under (b) it disappears (team panes use pool `token` accounts).
|
||||
|
||||
**R3 — Pane tokens.** `userFromPaneToken` maps a token to the LANE OWNER. Needs
|
||||
`dashboard_runs.started_by` → the token's user. And every pane token of a shared
|
||||
lane (the owner's too, see R1) must be limited to that lane, or one leaked token
|
||||
is a whole account.
|
||||
|
||||
**R4 — Session-only sharing does not fit the current checks.** `canSeeRun` and
|
||||
every `/api/lanes/:id/...` route decide by LANE (`canSeeLane`). Granting a
|
||||
session collaborator `canSeeLane` would show them the whole workspace. Needs a
|
||||
run/session-level predicate and an allowlist of lane routes usable with
|
||||
`?session=<shared id>` only. Recommend: drop session-only sharing from the first
|
||||
release, or limit it to that session's pane + chat (no Changes panel).
|
||||
|
||||
**R5 — Per-account limits are shared.** `MAX_AI_SESSIONS` (default 4) and
|
||||
`member-guard.js` count per Linux account, so collaborators' sessions eat the
|
||||
owner's 4 (no issue under (b): the team account has its own limit).
|
||||
|
||||
**R6 — Spend-quota flag is one file per account** (`quota.json` in the member's
|
||||
handoff dir, `flagEnv`). Payer-based quota needs one file per payer
|
||||
(`quota-u<id>.json`) and `AGENTHUB_QUOTA_FILE` pointing at the payer's.
|
||||
|
||||
**R7 — `launchExtras` puts the lane OWNER's personal instructions** first in
|
||||
every pane; a collaborator's session should get the starter's.
|
||||
|
||||
**R8 — AI Proxy.** `resolveProxy(tool, proxy, laneOwner)` resolves by owner: a
|
||||
collaborator could spend the owner's proxy key. Resolve by the starter — and a
|
||||
collaborator's OWN proxy profile is a valid "own account" besides a `token` pool
|
||||
account (decision 1 should allow it).
|
||||
|
||||
**R9 — Other `owner_id = ?` call sites** to extend or keep owner-only, checked
|
||||
one by one: `scope.js` (`laneWhere`, `canSeeLane`, `visibleLaneIds`,
|
||||
`insideOwnWorkspace`), `auth.js` (demote/disable ends a user's host panes —
|
||||
must also end runs they `started_by` elsewhere), `pool-usage.js`,
|
||||
`member-tmux.js`, `me.js` counts, `users.js`.
|
||||
|
||||
**R10 — Simplification found:** the pty attach socket already re-checks its user
|
||||
periodically ("still owning the pane's workspace", `websocket.js`), so revoking
|
||||
a share closes live attaches by just changing the predicate — no new kill path.
|
||||
|
||||
## Answered questions (2026-11-06)
|
||||
|
||||
1. A collaborator's run uses **their own `token`-kind pool account, or is
|
||||
refused** (`ENOCOLLABACCOUNT`) — never the owner's subscription.
|
||||
2. Spend quota: **only the payer's**.
|
||||
3. Resume by someone else: **the resumer pays from then on** (per-run billing,
|
||||
`session_payers`).
|
||||
4. Session-only share: **commit/push allowed**.
|
||||
5. Analysts: **sharing gives write** in that workspace.
|
||||
|
||||
## Still to settle while building
|
||||
|
||||
- The per-hour rounding of `session_payers` (a run started at 10:40 by Bob in a
|
||||
session Alice paid at 10:05): the hour goes to the earlier payer and Bob pays
|
||||
from 11:00, or token rows get a finer key. Phase 2 measures how often it
|
||||
matters before adding a finer key.
|
||||
- Typing into a live pane started by someone else costs the run's starter. The
|
||||
pane's header shows "billed to <name>" so nobody is surprised.
|
||||
+7
-5
@@ -5,8 +5,8 @@
|
||||
* `tools/list`, `tools/call`). Every tool is one GET against the running
|
||||
* dashboard's HTTP API, so the numbers are the dashboard's own; nothing here
|
||||
* opens the database or writes anything. The base URL is `AGENTHUB_URL`, else
|
||||
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN`, when
|
||||
* set, is sent as a bearer token. Run it as `agenthub mcp` or
|
||||
* the CLI's discovery (`server/lib/dashboard-url.js`); `DASHBOARD_TOKEN` —
|
||||
* else the token the running dashboard published — is sent as a bearer token. Run it as `agenthub mcp` or
|
||||
* `node mcp/server.js`; see docs/MCP.md.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -161,9 +161,11 @@ const TOOLS = {
|
||||
/** GET a dashboard path; resolves to an MCP tool result, never rejects. */
|
||||
async function callApi(pathname) {
|
||||
const url = `${baseUrl()}${pathname}`;
|
||||
const headers = process.env.DASHBOARD_TOKEN
|
||||
? { Authorization: `Bearer ${process.env.DASHBOARD_TOKEN}` }
|
||||
: {};
|
||||
// DASHBOARD_TOKEN, else the token the running server published.
|
||||
const token = require(
|
||||
path.join(__dirname, "..", "server", "lib", "dashboard-url.js")
|
||||
).dashboardToken();
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||
if (process.env.AGENTHUB_PANE_TOKEN)
|
||||
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
|
||||
|
||||
+1143
-48
File diff suppressed because one or more lines are too long
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.75",
|
||||
"version": "2.8.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.75",
|
||||
"version": "2.8.80",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.75",
|
||||
"version": "2.8.80",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -182,6 +182,31 @@ describe("a pool login", () => {
|
||||
assert.equal(diskCreds().refreshToken, other.refreshToken);
|
||||
});
|
||||
|
||||
it("keeps a home whose Claude kept an old pair of the pool's, and rewrites it", async () => {
|
||||
pool.addLoginAccount("team", { oauth: oauth("a", 10 * 60e3), account: ACCOUNT });
|
||||
pool.forSpawn(acct).place();
|
||||
await pool.refreshLogins(); // the server spends a → r1
|
||||
// The member's Claude raced the refresh and wrote the spent pair back.
|
||||
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("a", 10 * 60e3) }));
|
||||
const spawn = pool.forSpawn(acct);
|
||||
assert.equal(spawn?.name, "team", "an old pair of the pool's is still the pool's home");
|
||||
await pool.refreshLogins();
|
||||
assert.ok("mia" in pool.__read("team").placed, "never dropped");
|
||||
assert.equal(diskCreds().refreshToken, oauth("r1").refreshToken, "the newest pair written");
|
||||
});
|
||||
|
||||
it("does not drop a home while Anthropic cannot say whose newer pair it holds", async () => {
|
||||
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||
pool.forSpawn(acct).place();
|
||||
const theirs = oauth("cli", 9 * H);
|
||||
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: theirs }));
|
||||
await pool.refreshLogins(); // fetchOwner answers null: offline
|
||||
assert.ok("mia" in pool.__read("team").placed, "kept for a later beat");
|
||||
owners[theirs.accessToken] = { uuid: "uuid-team" };
|
||||
await pool.refreshLogins();
|
||||
assert.equal(pool.__read("team").oauth.refreshToken, theirs.refreshToken, "copied back");
|
||||
});
|
||||
|
||||
it("is taken out of the homes it is still ours in when removed", () => {
|
||||
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||
pool.forSpawn(acct).place();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file collab-runs.test.js
|
||||
* @description A session started by a user a workspace is shared with
|
||||
* (sharing phase 2, pty-run.js spawnRun `startedBy`): it runs on a pool
|
||||
* setup-token of THEIR OWN through tmux `-e` (never the owner's pick, never a
|
||||
* login), is refused with `ENOCOLLABACCOUNT` when they have none, records
|
||||
* `started_by`, makes them the payer of the new session from its first hour
|
||||
* (session-payers.js), points its hooks at their own quota flag and is
|
||||
* refused while they are over their quota. The owner's own start is unchanged.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "collab-runs-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "claude");
|
||||
|
||||
const { db } = require("../db");
|
||||
const ccUsage = require("../lib/cc-usage");
|
||||
const pool = require("../lib/claude-pool");
|
||||
const tmux = require("../lib/tmux");
|
||||
const pty = require("../lib/pty-run");
|
||||
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
const realFetchUsage = ccUsage.fetchUsage;
|
||||
const realFetchOwner = ccUsage.fetchOwner;
|
||||
let argv = null;
|
||||
let alice;
|
||||
let bob;
|
||||
let lane;
|
||||
|
||||
const envOf = (name) => {
|
||||
const hit = (argv || []).find((x) => String(x).startsWith(`${name}=`));
|
||||
return hit ? String(hit).slice(name.length + 1) : undefined;
|
||||
};
|
||||
|
||||
before(() => {
|
||||
ccUsage.fetchUsage = async () => {
|
||||
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
|
||||
};
|
||||
ccUsage.fetchOwner = async () => null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") argv = args;
|
||||
return "";
|
||||
});
|
||||
const user = (name) =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', 'developer')")
|
||||
.run(name).lastInsertRowid
|
||||
);
|
||||
alice = user("alice");
|
||||
bob = user("bob");
|
||||
lane = Number(
|
||||
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(TMP, alice)
|
||||
.lastInsertRowid
|
||||
);
|
||||
db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(lane, bob);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
ccUsage.fetchUsage = realFetchUsage;
|
||||
ccUsage.fetchOwner = realFetchOwner;
|
||||
tmux.__setExecImpl(null);
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
|
||||
db.prepare("DELETE FROM dashboard_runs").run();
|
||||
db.prepare("DELETE FROM session_payers").run();
|
||||
db.prepare("DELETE FROM account_pins").run();
|
||||
db.prepare("DELETE FROM user_quotas").run();
|
||||
pool.__quotaCache.clear();
|
||||
argv = null;
|
||||
});
|
||||
|
||||
describe("a collaborator's own session", () => {
|
||||
it("is refused without an account of their own, never run on the owner's", () => {
|
||||
assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), {
|
||||
code: "ENOCOLLABACCOUNT",
|
||||
});
|
||||
assert.equal(argv, null, "no pane was started");
|
||||
// The owner's own start is as before.
|
||||
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude" });
|
||||
const row = db.prepare("SELECT started_by FROM dashboard_runs").get();
|
||||
assert.equal(row.started_by, null);
|
||||
});
|
||||
|
||||
it("runs on their own pool token, records them and makes them its payer", () => {
|
||||
pool.addAccount("theirs", TOKEN("b"));
|
||||
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob, slot: 1 });
|
||||
assert.equal(envOf("CLAUDE_CODE_OAUTH_TOKEN"), TOKEN("b"));
|
||||
assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("b")}`) < argv.indexOf("--"));
|
||||
// Their own quota flag, never the owner's.
|
||||
assert.match(envOf("AGENTHUB_QUOTA_FILE"), new RegExp(`u${bob}\\.json$`));
|
||||
const run = db
|
||||
.prepare("SELECT session_id, started_by, account FROM dashboard_runs WHERE id = ?")
|
||||
.get(`agenthub-lane-${lane}-1`);
|
||||
assert.equal(run.started_by, bob);
|
||||
assert.equal(run.account, "theirs");
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT session_id, from_hour, user_id FROM session_payers").all(),
|
||||
[{ session_id: run.session_id, from_hour: "0000", user_id: bob }]
|
||||
);
|
||||
});
|
||||
|
||||
it("starts nothing while they are over their own quota — and the owner still starts", () => {
|
||||
pool.addAccount("theirs", TOKEN("b"));
|
||||
const MODEL = "claude-sonnet-4-5-20250929";
|
||||
const hour = new Date().toISOString().slice(0, 13);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sBob', 'b', 'active', ?, ?, ?)"
|
||||
).run(TMP, lane, new Date().toISOString());
|
||||
db.prepare(
|
||||
"INSERT INTO token_usage (session_id, model, output_tokens) VALUES ('sBob', ?, 10000000)"
|
||||
).run(MODEL);
|
||||
db.prepare(
|
||||
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sBob', ?, ?, 10000000)"
|
||||
).run(hour, MODEL);
|
||||
db.prepare("INSERT INTO session_payers VALUES ('sBob', '0000', ?)").run(bob);
|
||||
const q = require("../lib/user-quota");
|
||||
for (const id of [alice, bob]) {
|
||||
q.setUserQuota(id, { mode: "custom", limit_usd: 1, period: "5h", tz_offset: 0 });
|
||||
}
|
||||
assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), {
|
||||
code: "EQUOTA",
|
||||
});
|
||||
// Those hours are bob's money, not alice's.
|
||||
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", slot: 2 });
|
||||
assert.ok(argv, "the owner's pane started");
|
||||
});
|
||||
|
||||
it("never resumes a non-Claude session across payers, nor the latest one for a collaborator", () => {
|
||||
pool.addAccount("theirs", TOKEN("b"));
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sCodexAAA', 'a', 'completed', ?, ?, 'codex')"
|
||||
).run(TMP, lane);
|
||||
assert.throws(
|
||||
() =>
|
||||
pty.spawnRun({
|
||||
laneId: lane,
|
||||
cwd: TMP,
|
||||
tool: "codex",
|
||||
resumeSessionId: "sCodexAAA",
|
||||
startedBy: bob,
|
||||
}),
|
||||
{ code: "ECOLLABRESUME" }
|
||||
);
|
||||
// Nor the owner a session the collaborator pays for.
|
||||
db.prepare("INSERT INTO session_payers VALUES ('sCodexBBB', '0000', ?)").run(bob);
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "codex", resumeSessionId: "sCodexBBB" }),
|
||||
{ code: "ECOLLABRESUME" }
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", resumeLast: true, startedBy: bob }),
|
||||
{ code: "ECOLLABRESUME" }
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a Terminal tab's session the owner's money", () => {
|
||||
const payers = require("../lib/session-payers");
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by)
|
||||
VALUES ('agenthub-lane-shell', 'sShell', 'interactive', ?, 'running', ?, 'shell', ?)`
|
||||
).run(TMP, lane, bob);
|
||||
payers.noteRun("agenthub-lane-shell");
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM session_payers").get().n, 0);
|
||||
});
|
||||
});
|
||||
@@ -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,527 @@
|
||||
/**
|
||||
* @file lane-shares.test.js
|
||||
* @description Sharing a workspace with other users (server mode,
|
||||
* docs/plans/workspace-sharing/ phase 1): only the owner or an admin manages
|
||||
* the list (`GET/PUT /api/lanes/:id/shares`, audited, enabled users only, never
|
||||
* the owner); the user it is shared with sees the workspace and its sessions
|
||||
* but may not rename, reset, remove or delete it (`ENOTOWNER`); an AI session
|
||||
* they start needs an account of their own (`ENOCOLLABACCOUNT`) and each hour
|
||||
* of a session is billed to whoever ran it then (users page, quotas, Today); a
|
||||
* Terminal tab they open acts as THEM through its pane token, never as the
|
||||
* owner; a collaborator disabled since does not block the list; a user it is
|
||||
* not shared with still gets 404; taking it back ends their access at once.
|
||||
* @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 path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
const TEST_DB = path.join(os.tmpdir(), `lane-shares-${Date.now()}-${process.pid}.db`);
|
||||
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const cookies = {};
|
||||
const users = {};
|
||||
let laneA;
|
||||
|
||||
async function call(method, p, { as, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Origin: BASE,
|
||||
...(as ? { cookie: cookies[as] } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
users.alice = await auth.createUser("alice", "alice-pass-1", "developer");
|
||||
users.bob = await auth.createUser("bob", "bob-pass-1", "developer");
|
||||
users.carol = await auth.createUser("carol", "carol-pass-1", "developer");
|
||||
users.boss = await auth.createUser("boss", "admin-pass-1", "admin");
|
||||
laneA = Number(
|
||||
db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice', 'claude', ?)")
|
||||
.run(users.alice.id).lastInsertRowid
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sA', 'alice one', 'active', '/w/alice', ?)"
|
||||
).run(laneA);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
for (const [name, pass] of [
|
||||
["alice", "alice-pass-1"],
|
||||
["bob", "bob-pass-1"],
|
||||
["carol", "carol-pass-1"],
|
||||
["boss", "admin-pass-1"],
|
||||
]) {
|
||||
const r = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: name, password: pass }),
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
cookies[name] = r.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`, `${TEST_DB}-migrate.lock`]) {
|
||||
fs.rmSync(f, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const laneIds = async (as) => (await call("GET", "/api/lanes", { as })).body.lanes.map((l) => l.id);
|
||||
|
||||
describe("sharing a workspace", () => {
|
||||
it("is managed only by its owner (or an admin), for enabled users other than the owner", async () => {
|
||||
assert.equal((await call("GET", `/api/lanes/${laneA}/shares`, { as: "bob" })).status, 404);
|
||||
const put = (as, userIds) =>
|
||||
call("PUT", `/api/lanes/${laneA}/shares`, { as, body: { userIds } });
|
||||
assert.equal((await put("bob", [users.bob.id])).status, 404);
|
||||
assert.equal((await put("alice", [users.alice.id])).body.error.code, "EBADUSER");
|
||||
assert.equal((await put("alice", [9999])).body.error.code, "EBADUSER");
|
||||
assert.equal((await put("alice", "bob")).body.error.code, "EBADUSER");
|
||||
|
||||
const r = await put("alice", [users.bob.id]);
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.shares.map((u) => u.username),
|
||||
["bob"]
|
||||
);
|
||||
const audit = db
|
||||
.prepare("SELECT user_id, owner_id, path FROM audit_log WHERE path LIKE 'share workspace%'")
|
||||
.all();
|
||||
assert.deepEqual(audit, [
|
||||
{ user_id: users.alice.id, owner_id: users.bob.id, path: `share workspace ${laneA}` },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the workspace and its sessions to the user it is shared with — and nobody else", async () => {
|
||||
assert.ok((await laneIds("bob")).includes(laneA));
|
||||
assert.equal((await call("GET", "/api/sessions/sA", { as: "bob" })).status, 200);
|
||||
const shares = await call("GET", `/api/lanes/${laneA}/shares`, { as: "bob" });
|
||||
assert.equal(shares.status, 200);
|
||||
assert.equal(shares.body.canManage, false);
|
||||
assert.ok(!(await laneIds("carol")).includes(laneA));
|
||||
assert.equal((await call("GET", "/api/sessions/sA", { as: "carol" })).status, 404);
|
||||
});
|
||||
|
||||
it("keeps rename, remove, delete and sharing the owner's", async () => {
|
||||
const code = (r) => r.body?.error?.code;
|
||||
assert.equal(
|
||||
code(await call("PATCH", `/api/lanes/${laneA}`, { as: "bob", body: { title: "mine" } })),
|
||||
"ENOTOWNER"
|
||||
);
|
||||
assert.equal(
|
||||
code(
|
||||
await call("POST", `/api/lanes/${laneA}/remove`, { as: "bob", body: { confirm: true } })
|
||||
),
|
||||
"ENOTOWNER"
|
||||
);
|
||||
assert.equal(code(await call("DELETE", `/api/lanes/${laneA}`, { as: "bob" })), "ENOTOWNER");
|
||||
assert.equal(
|
||||
code(
|
||||
await call("PUT", `/api/lanes/${laneA}/shares`, {
|
||||
as: "bob",
|
||||
body: { userIds: [users.bob.id, users.carol.id] },
|
||||
})
|
||||
),
|
||||
"ENOTOWNER"
|
||||
);
|
||||
// Notes are not owner-only: a collaborator writes them like the owner.
|
||||
const notes = await call("PATCH", `/api/lanes/${laneA}`, { as: "bob", body: { notes: "hi" } });
|
||||
assert.equal(notes.status, 200);
|
||||
// The owner and an admin still rename it.
|
||||
assert.equal(
|
||||
(await call("PATCH", `/api/lanes/${laneA}`, { as: "alice", body: { title: "A" } })).status,
|
||||
200
|
||||
);
|
||||
assert.equal(
|
||||
(await call("PATCH", `/api/lanes/${laneA}`, { as: "boss", body: { title: "B" } })).status,
|
||||
200
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a collaborator's AI session only on an account of their own", async () => {
|
||||
// No pool account and no AI Proxy of theirs: refused, never the owner's login.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lane-shares-cwd-"));
|
||||
db.prepare("UPDATE lanes SET cwd = ? WHERE id = ?").run(dir, laneA);
|
||||
let r;
|
||||
let run;
|
||||
try {
|
||||
r = await call("POST", `/api/lanes/${laneA}/start`, {
|
||||
as: "bob",
|
||||
body: { slot: "auto", prompt: "hi" },
|
||||
});
|
||||
run = await call("POST", "/api/run", {
|
||||
as: "bob",
|
||||
body: { laneId: laneA, initialPrompt: "hi" },
|
||||
});
|
||||
} finally {
|
||||
db.prepare("UPDATE lanes SET cwd = '/w/alice' WHERE id = ?").run(laneA);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
assert.equal(r.status, 409, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "ENOCOLLABACCOUNT");
|
||||
assert.equal(run.status, 409, JSON.stringify(run.body));
|
||||
assert.equal(run.body.error.code, "ENOCOLLABACCOUNT");
|
||||
});
|
||||
|
||||
it("bills each hour of a session to whoever ran it then", async () => {
|
||||
const payers = require("../lib/session-payers");
|
||||
const MODEL = "claude-sonnet-4-5-20250929";
|
||||
const hour = (h) => new Date(Date.now() - h * 3600e3).toISOString().slice(0, 13);
|
||||
const spend = (sid, h, tokens) => {
|
||||
db.prepare(
|
||||
`INSERT INTO token_usage (session_id, model, output_tokens) VALUES (?, ?, ?)
|
||||
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
|
||||
).run(sid, MODEL, tokens);
|
||||
db.prepare(
|
||||
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES (?, ?, ?, ?)"
|
||||
).run(sid, hour(h), MODEL, tokens);
|
||||
};
|
||||
const started = new Date(Date.now() - 3 * 3600e3).toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, name, status, cwd, lane_id, started_at, updated_at)
|
||||
VALUES ('sPaid', 'paid', 'active', '/w/alice', ?, ?, ?)`
|
||||
).run(laneA, started, new Date().toISOString());
|
||||
spend("sPaid", 2, 1_000_000); // alice's hours
|
||||
// Bob resumes it now: his from the next hour on.
|
||||
const runIns = (id, by) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by, started_at)
|
||||
VALUES (?, 'sPaid', 'interactive', '/w/alice', 'completed', ?, 'claude', ?, ?)`
|
||||
)
|
||||
.run(id, laneA, by, new Date().toISOString());
|
||||
runIns("agenthub-lane-x-bob", users.bob.id);
|
||||
const now = Date.now();
|
||||
payers.noteRun("agenthub-lane-x-bob", now);
|
||||
assert.deepEqual(
|
||||
db
|
||||
.prepare(
|
||||
"SELECT from_hour, user_id FROM session_payers WHERE session_id = 'sPaid' ORDER BY from_hour"
|
||||
)
|
||||
.all(),
|
||||
[
|
||||
{ from_hour: "0000", user_id: users.alice.id },
|
||||
{ from_hour: new Date(now + 3600e3).toISOString().slice(0, 13), user_id: users.bob.id },
|
||||
]
|
||||
);
|
||||
// Noting the same payer again changes nothing.
|
||||
payers.noteRun("agenthub-lane-x-bob", now);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM session_payers WHERE session_id = 'sPaid'").get().n,
|
||||
2
|
||||
);
|
||||
// Book bob's hour as the next one (a test cannot wait an hour).
|
||||
db.prepare(
|
||||
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sPaid', ?, ?, 2000000)"
|
||||
).run(new Date(now + 3600e3).toISOString().slice(0, 13), MODEL);
|
||||
db.prepare(
|
||||
"UPDATE token_usage SET output_tokens = output_tokens + 2000000 WHERE session_id = 'sPaid'"
|
||||
).run();
|
||||
|
||||
const resolve = payers.payerResolver();
|
||||
assert.equal(resolve({ session_id: "sPaid", date: hour(2) }), users.alice.id);
|
||||
assert.equal(
|
||||
resolve({ session_id: "sPaid", date: new Date(now + 3600e3).toISOString().slice(0, 13) }),
|
||||
users.bob.id
|
||||
);
|
||||
|
||||
// The users page: each sees only the hours they paid (2:1 tokens).
|
||||
const details = async (u) =>
|
||||
(await call("GET", `/api/users/${u.id}/details`, { as: "boss" })).body.usage.last7d;
|
||||
const a = await details(users.alice);
|
||||
const b = await details(users.bob);
|
||||
assert.ok(a.cost > 0 && b.cost > 0, JSON.stringify({ a, b }));
|
||||
assert.ok(Math.abs(b.cost / a.cost - 2) < 0.01, JSON.stringify({ a, b }));
|
||||
const list = (await call("GET", "/api/users", { as: "boss" })).body.users;
|
||||
const last7 = (name) => list.find((u) => u.username === name).last7d.cost;
|
||||
assert.ok(Math.abs(last7("bob") / last7("alice") - 2) < 0.01, JSON.stringify(list));
|
||||
|
||||
// Spend quotas count the same hours.
|
||||
const q = require("../lib/user-quota");
|
||||
q.setUserQuota(users.bob.id, { mode: "custom", limit_usd: 1000, period: "day", tz_offset: 0 });
|
||||
q.setUserQuota(users.alice.id, {
|
||||
mode: "custom",
|
||||
limit_usd: 1000,
|
||||
period: "day",
|
||||
tz_offset: 0,
|
||||
});
|
||||
const st = q.statusAll({ now: now + 3600e3 + 60e3, userIds: [users.alice.id, users.bob.id] });
|
||||
assert.ok(Math.abs(st.get(users.bob.id).spend - b.cost) < 0.01, JSON.stringify([...st]));
|
||||
q.setUserQuota(users.bob.id, { mode: "default" });
|
||||
q.setUserQuota(users.alice.id, { mode: "default" });
|
||||
|
||||
// Today (`mine=1`) is each one's own money, never the other's hours.
|
||||
const mine = async (as) =>
|
||||
(await call("GET", "/api/pricing/cost?mine=1", { as })).body.total_cost;
|
||||
assert.ok(Math.abs((await mine("bob")) - b.cost) < 0.01);
|
||||
assert.ok(Math.abs((await mine("alice")) - a.cost) < 0.01);
|
||||
db.prepare("DELETE FROM dashboard_runs WHERE id = 'agenthub-lane-x-bob'").run();
|
||||
});
|
||||
|
||||
it("makes a pane token act as the user who opened that pane, never the owner", () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const real = tmux.hasSession;
|
||||
tmux.hasSession = () => true;
|
||||
try {
|
||||
const tokenFor = (runId, startedBy) => {
|
||||
const cred = auth.newPaneToken();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO dashboard_runs (id, mode, cwd, status, lane_id, tool, pane_token_hash, started_by)
|
||||
VALUES (?, 'interactive', '/w/alice', 'running', ?, 'shell', ?, ?)`
|
||||
).run(runId, laneA, cred.hash, startedBy);
|
||||
return cred.token;
|
||||
};
|
||||
const asUser = (token) =>
|
||||
auth.userFromPaneToken({
|
||||
headers: { "x-agenthub-pane-token": token },
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
});
|
||||
assert.equal(asUser(tokenFor(`agenthub-lane-${laneA}-1`, null))?.username, "alice");
|
||||
const bobs = tokenFor(`agenthub-lane-${laneA}-2`, users.bob.id);
|
||||
assert.equal(asUser(bobs)?.username, "bob");
|
||||
// …inside this workspace only: the owner's account can read that token.
|
||||
assert.equal(asUser(bobs)?.paneLane, laneA);
|
||||
const guard = (p, method = "GET") => {
|
||||
let out = null;
|
||||
auth.authGuard(
|
||||
{
|
||||
path: p,
|
||||
method,
|
||||
headers: { "x-agenthub-pane-token": bobs },
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
},
|
||||
{
|
||||
status: (code) => ({ json: (body) => (out = { code, body }) }),
|
||||
},
|
||||
() => (out = "next")
|
||||
);
|
||||
return out;
|
||||
};
|
||||
assert.equal(guard(`/lanes/${laneA}`), "next");
|
||||
assert.equal(guard(`/workspaces/${laneA}/git`), "next");
|
||||
assert.equal(guard("/sessions").body.error.code, "EPANESCOPE");
|
||||
assert.equal(guard(`/lanes/${laneA + 1000}`).body.error.code, "EPANESCOPE");
|
||||
assert.equal(guard("/me").body.error.code, "EPANESCOPE");
|
||||
// The CLI finds its workspace in the list (which answers that one only)…
|
||||
assert.equal(guard("/workspaces"), "next");
|
||||
// …and never starts or bills a session with it.
|
||||
assert.equal(guard(`/lanes/${laneA}/start`, "POST").body.error.code, "EPANESCOPE");
|
||||
assert.equal(guard(`/lanes/${laneA}/reset`, "POST").body.error.code, "EPANESCOPE");
|
||||
assert.equal(guard("/run", "POST").body.error.code, "EPANESCOPE");
|
||||
// No longer shared with him: his pane's token is nobody.
|
||||
db.prepare("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run(
|
||||
laneA,
|
||||
users.bob.id
|
||||
);
|
||||
assert.equal(asUser(bobs), null);
|
||||
db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(
|
||||
laneA,
|
||||
users.bob.id
|
||||
);
|
||||
} finally {
|
||||
tmux.hasSession = real;
|
||||
db.prepare("DELETE FROM dashboard_runs WHERE lane_id = ?").run(laneA);
|
||||
}
|
||||
});
|
||||
|
||||
it("is not blocked by a collaborator disabled since", async () => {
|
||||
const put = (userIds) =>
|
||||
call("PUT", `/api/lanes/${laneA}/shares`, { as: "alice", body: { userIds } });
|
||||
assert.equal((await put([users.bob.id, users.carol.id])).status, 200);
|
||||
db.prepare("UPDATE users SET disabled = 1 WHERE id = ?").run(users.carol.id);
|
||||
try {
|
||||
const listed = await call("GET", `/api/lanes/${laneA}/shares`, { as: "alice" });
|
||||
assert.equal(listed.body.shares.find((u) => u.id === users.carol.id)?.disabled, true);
|
||||
// Kept on the list while another change is saved, then taken off.
|
||||
assert.equal((await put([users.bob.id, users.carol.id])).status, 200);
|
||||
const r = await put([users.bob.id]);
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.shares.map((u) => u.username),
|
||||
["bob"]
|
||||
);
|
||||
// But never newly added while disabled.
|
||||
assert.equal((await put([users.bob.id, users.carol.id])).body.error.code, "EBADUSER");
|
||||
} finally {
|
||||
db.prepare("UPDATE users SET disabled = 0 WHERE id = ?").run(users.carol.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("ends their access as soon as it is taken back, and says so in the audit log", async () => {
|
||||
const r = await call("PUT", `/api/lanes/${laneA}/shares`, {
|
||||
as: "alice",
|
||||
body: { userIds: [] },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.shares, []);
|
||||
assert.ok(!(await laneIds("bob")).includes(laneA));
|
||||
assert.equal((await call("GET", "/api/sessions/sA", { as: "bob" })).status, 404);
|
||||
assert.equal(
|
||||
db
|
||||
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = ? AND owner_id = ?")
|
||||
.get(`unshare workspace ${laneA}`, users.bob.id).n,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it("goes with the workspace when it is deleted", async () => {
|
||||
await call("PUT", `/api/lanes/${laneA}/shares`, {
|
||||
as: "alice",
|
||||
body: { userIds: [users.carol.id] },
|
||||
});
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM lane_shares WHERE lane_id = ?").get(laneA).n,
|
||||
1
|
||||
);
|
||||
assert.equal((await call("DELETE", `/api/lanes/${laneA}`, { as: "alice" })).status, 200);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM lane_shares WHERE lane_id = ?").get(laneA).n,
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharing one session", () => {
|
||||
let laneS;
|
||||
before(() => {
|
||||
laneS = Number(
|
||||
db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice2', 'claude', ?)")
|
||||
.run(users.alice.id).lastInsertRowid
|
||||
);
|
||||
for (const id of ["s1-shared", "s2-private"]) {
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'completed', '/w/alice2', ?)"
|
||||
).run(id, id, laneS);
|
||||
}
|
||||
});
|
||||
const put = (as, userIds, sid = "s1-shared") =>
|
||||
call("PUT", `/api/lanes/${laneS}/sessions/${sid}/shares`, { as, body: { userIds } });
|
||||
|
||||
it("is managed by the workspace's owner only, for a session of that workspace", async () => {
|
||||
assert.equal((await put("carol", [users.carol.id])).status, 404);
|
||||
assert.equal((await put("alice", [users.carol.id], "sA")).status, 404, "not this workspace's");
|
||||
const r = await put("alice", [users.carol.id]);
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(
|
||||
r.body.shares.map((u) => u.username),
|
||||
["carol"]
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path = 'share session s1-shared'").get()
|
||||
.n,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
it("shows that session and its workspace — no other session of it", async () => {
|
||||
const lanes = (await call("GET", "/api/lanes", { as: "carol" })).body.lanes;
|
||||
const mine = lanes.find((l) => l.id === laneS);
|
||||
assert.equal(mine?.access, "session");
|
||||
const list = await call("GET", `/api/sessions?lane=${laneS}`, { as: "carol" });
|
||||
assert.deepEqual(
|
||||
list.body.sessions.map((s) => s.id),
|
||||
["s1-shared"]
|
||||
);
|
||||
assert.equal((await call("GET", "/api/sessions/s1-shared", { as: "carol" })).status, 200);
|
||||
assert.equal((await call("GET", "/api/sessions/s2-private", { as: "carol" })).status, 404);
|
||||
});
|
||||
|
||||
it("allows that session's pane, Changes and a resume of it — nothing else of the workspace", async () => {
|
||||
const scope = require("../lib/scope");
|
||||
const req = { user: { id: users.carol.id, role: "developer" }, method: "POST" };
|
||||
const run = (id, sid) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool)
|
||||
VALUES (?, ?, 'interactive', '/w/alice2', 'running', ?, 'claude')`
|
||||
)
|
||||
.run(id, sid, laneS);
|
||||
run(`agenthub-lane-${laneS}-1`, "s1-shared");
|
||||
run(`agenthub-lane-${laneS}-2`, "s2-private");
|
||||
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}-1`), true);
|
||||
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}-2`), false);
|
||||
assert.equal(scope.canSeeRun(req, `agenthub-lane-${laneS}`), false, "no run there");
|
||||
|
||||
const refused = async (method, p, body) => {
|
||||
const r = await call(method, p, { as: "carol", body });
|
||||
assert.equal(r.status, 403, `${method} ${p} → ${r.status}`);
|
||||
assert.equal(r.body.error.code, "ESESSIONSHARE");
|
||||
};
|
||||
await refused("PATCH", `/api/lanes/${laneS}`, { title: "x" });
|
||||
await refused("GET", `/api/lanes/${laneS}/sources`);
|
||||
await refused("PUT", `/api/lanes/${laneS}/instructions`, { instructions: "x" });
|
||||
await refused("POST", `/api/lanes/${laneS}/start`, { slot: "auto", prompt: "new" });
|
||||
await refused("POST", `/api/lanes/${laneS}/start`, { shell: true });
|
||||
await refused("POST", `/api/lanes/${laneS}/start`, {
|
||||
resumeSessionId: "s1-shared",
|
||||
forkSession: true,
|
||||
});
|
||||
await refused("POST", "/api/run", { laneId: laneS, initialPrompt: "hi" });
|
||||
await refused("POST", `/api/lanes/${laneS}/start`, { resumeSessionId: "s1-shared", slot: 0 });
|
||||
await refused("POST", `/api/lanes/${laneS}/start`, { resumeSessionId: "s1-shared" });
|
||||
await refused("GET", `/api/lanes/${laneS}/git/tree?source=1`);
|
||||
await refused("GET", `/api/artifacts/candidates?laneId=${laneS}`);
|
||||
await refused("PATCH", "/api/sessions/s1-shared", { name: "mine now" });
|
||||
// A shared session resumed only in its own workspace.
|
||||
const laneT = Number(
|
||||
db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/alice3', 'claude', ?)")
|
||||
.run(users.alice.id).lastInsertRowid
|
||||
);
|
||||
db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES ('sT-other', 't', 'completed', '/w/alice3', ?)"
|
||||
).run(laneT);
|
||||
db.prepare("INSERT INTO session_shares (session_id, user_id) VALUES ('sT-other', ?)").run(
|
||||
users.carol.id
|
||||
);
|
||||
await refused("POST", `/api/lanes/${laneT}/start`, {
|
||||
resumeSessionId: "s1-shared",
|
||||
slot: "auto",
|
||||
});
|
||||
db.prepare("DELETE FROM session_shares WHERE session_id = 'sT-other'").run();
|
||||
// Another session of it is not even there.
|
||||
const other = await call("POST", `/api/lanes/${laneS}/start`, {
|
||||
as: "carol",
|
||||
body: { resumeSessionId: "s2-private" },
|
||||
});
|
||||
assert.equal(other.status, 404);
|
||||
// A resume of the shared one gets past the scope (and then needs their own account).
|
||||
const resume = await call("POST", `/api/lanes/${laneS}/start`, {
|
||||
as: "carol",
|
||||
body: { resumeSessionId: "s1-shared", prompt: "hi", slot: "auto" },
|
||||
});
|
||||
assert.ok(![403, 404].includes(resume.status), JSON.stringify(resume.body));
|
||||
// Reads of the workspace's git and its own row pass.
|
||||
assert.equal((await call("GET", `/api/lanes/${laneS}`, { as: "carol" })).status, 200);
|
||||
const git = await call("GET", `/api/lanes/${laneS}/git`, { as: "carol" });
|
||||
assert.notEqual(git.status, 403);
|
||||
db.prepare("DELETE FROM dashboard_runs WHERE lane_id = ?").run(laneS);
|
||||
});
|
||||
|
||||
it("ends their access when it is taken back", async () => {
|
||||
assert.equal((await put("alice", [])).status, 200);
|
||||
assert.ok(!(await laneIds("carol")).includes(laneS));
|
||||
assert.equal((await call("GET", "/api/sessions/s1-shared", { as: "carol" })).status, 404);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @file pane-presence.test.js
|
||||
* @description Who has which pane open (server/lib/pane-presence.js, sharing
|
||||
* phase 4): a user counted once however many attaches they hold, gone with
|
||||
* their last one, listed per workspace; the first keystroke into a SHARED
|
||||
* workspace's pane is audited (who and where, never what) and an unshared
|
||||
* one's is not; `pane_presence` reaches the workspace's owner and the users
|
||||
* it is shared with, nobody else.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pane-presence-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const presence = require("../lib/pane-presence");
|
||||
const scope = require("../lib/scope");
|
||||
|
||||
let alice;
|
||||
let bob;
|
||||
let carol;
|
||||
let lane;
|
||||
let lonely;
|
||||
|
||||
before(() => {
|
||||
const user = (name) =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', 'developer')")
|
||||
.run(name).lastInsertRowid
|
||||
);
|
||||
alice = user("alice");
|
||||
bob = user("bob");
|
||||
carol = user("carol");
|
||||
const mk = () =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)")
|
||||
.run(`/w/${Math.random()}`, alice).lastInsertRowid
|
||||
);
|
||||
lane = mk();
|
||||
lonely = mk();
|
||||
db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(lane, bob);
|
||||
});
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
beforeEach(() => presence.__reset());
|
||||
|
||||
describe("pane presence", () => {
|
||||
it("counts a user once per pane, until their last attach closes", () => {
|
||||
const run = `agenthub-lane-${lane}-1`;
|
||||
presence.join(run, { id: bob, username: "bob", displayName: "Bob B" });
|
||||
presence.join(run, { id: bob, username: "bob" });
|
||||
presence.join(run, { id: alice, username: "alice" });
|
||||
assert.deepEqual(presence.forLane(lane)[run], [
|
||||
{ id: bob, name: "Bob B" },
|
||||
{ id: alice, name: "alice" },
|
||||
]);
|
||||
presence.leave(run, { id: bob });
|
||||
assert.equal(presence.forLane(lane)[run].length, 2, "one of bob's attaches is still open");
|
||||
presence.leave(run, { id: bob });
|
||||
presence.leave(run, { id: alice });
|
||||
assert.deepEqual(presence.forLane(lane), {});
|
||||
presence.join("not-a-run", { id: bob });
|
||||
assert.deepEqual(presence.users("not-a-run"), []);
|
||||
});
|
||||
|
||||
it("audits the first keystroke only in a shared workspace", () => {
|
||||
const count = () =>
|
||||
db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path LIKE 'type into %'").get().n;
|
||||
presence.auditInput(`agenthub-lane-${lonely}`, { id: alice });
|
||||
assert.equal(count(), 0);
|
||||
presence.auditInput(`agenthub-lane-${lane}-2`, { id: bob });
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT user_id, owner_id, method, path FROM audit_log WHERE path LIKE 'type into %'"
|
||||
)
|
||||
.get();
|
||||
assert.deepEqual(row, {
|
||||
user_id: bob,
|
||||
owner_id: alice,
|
||||
method: "PTY",
|
||||
path: `type into agenthub-lane-${lane}-2`,
|
||||
});
|
||||
});
|
||||
|
||||
it("is news for the workspace's owner and collaborators only", () => {
|
||||
const aud = scope.broadcastAudience("pane_presence", { laneId: lane, runId: "x", users: [] });
|
||||
const dev = (id) => ({ id, role: "developer" });
|
||||
assert.equal(scope.audienceIncludes(aud, dev(alice)), true);
|
||||
assert.equal(scope.audienceIncludes(aud, dev(bob)), true);
|
||||
assert.equal(scope.audienceIncludes(aud, dev(carol)), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what counts as typing", () => {
|
||||
const { isTyping } = require("../lib/pty-attach");
|
||||
it("ignores mouse, focus and the terminal answering tmux", () => {
|
||||
for (const s of [
|
||||
"\x1b[<0;10;5M",
|
||||
"\x1b[I",
|
||||
"\x1b[>0;276;0c",
|
||||
"\x1b[?1;2c",
|
||||
"\x1b[12;40R",
|
||||
"\x1b[0n",
|
||||
"\x1b[?2004;1$y",
|
||||
"\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\",
|
||||
"\x1b]10;rgb:ffff/ffff/ffff\x07",
|
||||
"\x1bP>|xterm.js(6.0.0)\x1b\\",
|
||||
]) {
|
||||
assert.equal(isTyping(s), false, JSON.stringify(s));
|
||||
}
|
||||
assert.equal(isTyping("ls\r"), true);
|
||||
assert.equal(isTyping("\x1b[A"), true, "an arrow key is typing");
|
||||
});
|
||||
|
||||
it("never audits the owner's own typing", () => {
|
||||
const before = db.prepare("SELECT COUNT(*) AS n FROM audit_log").get().n;
|
||||
presence.auditInput(`agenthub-lane-${lane}`, { id: alice });
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM audit_log").get().n, before);
|
||||
});
|
||||
});
|
||||
@@ -308,3 +308,62 @@ describe("scopeGuard against the router's own leniency", () => {
|
||||
assert.equal(run({ user: m1, path: "/lanes/pipeline-stats" }), 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a workspace shared with another user (lane_shares)", () => {
|
||||
// Lane 2 (user 2's, session s2) shared with user 1; taken back afterwards so
|
||||
// the suites above keep their own picture.
|
||||
before(() => {
|
||||
db.prepare("INSERT INTO lane_shares (lane_id, user_id, granted_by) VALUES (2, 1, 2)").run();
|
||||
});
|
||||
after(() => db.prepare("DELETE FROM lane_shares").run());
|
||||
|
||||
it("lets the user it is shared with see it, its sessions and its runs", () => {
|
||||
assert.equal(scope.canSeeLane(member(1), 2), true);
|
||||
assert.equal(scope.canSeeSession(member(1), "s2"), true);
|
||||
assert.equal(scope.canSeeRun({ ...member(1), method: "POST" }, "agenthub-lane-2-1"), true);
|
||||
assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1", "s2"]);
|
||||
assert.deepEqual(scope.visibleLaneIds(member(1)).sort(), [1, 2]);
|
||||
const w = scope.laneWhere(member(1));
|
||||
assert.deepEqual(
|
||||
db.prepare(`SELECT id FROM lanes WHERE ${w.where} ORDER BY id`).all(...w.params).map((r) => r.id),
|
||||
[1, 2]
|
||||
);
|
||||
// Nobody else gained anything.
|
||||
assert.equal(scope.canSeeLane(member(3), 2), false);
|
||||
assert.deepEqual(visibleSessions(member(3)), []);
|
||||
});
|
||||
|
||||
it("keeps it out of the user's OWN money (budgets, Today)", () => {
|
||||
const own = (w) =>
|
||||
db.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`).all(...w.params).map((r) => r.id);
|
||||
assert.deepEqual(own(scope.ownedOnlySessionWhere(member(1))), ["s-unstamped", "s1"]);
|
||||
assert.deepEqual(own(scope.ownSessionWhere(member(1))), ["s-unstamped", "s1"]);
|
||||
});
|
||||
|
||||
it("leaves owner-only actions to the owner and admins", () => {
|
||||
assert.equal(scope.isLaneOwner(member(1), 2), false);
|
||||
assert.equal(scope.isLaneOwner(member(2), 2), true);
|
||||
assert.equal(scope.isLaneOwner(admin, 2), true);
|
||||
assert.equal(scope.isLaneOwner(local, 2), true);
|
||||
assert.equal(scope.isLaneOwner({ user: { id: 1, role: "analyst" } }, 2), false);
|
||||
});
|
||||
|
||||
it("sends its news to the user it is shared with, without making it theirs", () => {
|
||||
const a = scope.broadcastAudience("lane_update", { lane: { id: 2 } });
|
||||
assert.equal(scope.audienceIncludes(a, member(1).user), true);
|
||||
assert.equal(a.owners.has(1), false);
|
||||
const s = scope.broadcastAudience("session_updated", { id: "s2" });
|
||||
assert.equal(scope.audienceIncludes(s, member(1).user), true);
|
||||
assert.equal(s.owners.has(1), false);
|
||||
assert.equal(scope.audienceIncludes(s, member(3).user), false);
|
||||
// Taken back: only the users named hear about it.
|
||||
assert.deepEqual([...scope.exactAudience("lane_access", { laneId: 2, userIds: [1] })], [1]);
|
||||
});
|
||||
|
||||
it("stops at once when it is taken back", () => {
|
||||
db.prepare("DELETE FROM lane_shares WHERE lane_id = 2 AND user_id = 1").run();
|
||||
assert.equal(scope.canSeeLane(member(1), 2), false);
|
||||
assert.equal(scope.canSeeRun({ ...member(1), method: "POST" }, "agenthub-lane-2-1"), false);
|
||||
assert.deepEqual(visibleSessions(member(1)), ["s-unstamped", "s1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -379,6 +379,13 @@ describe("tmux wrapper", () => {
|
||||
assert.match(tmux.upgradeAdvice("3.4", "darwin"), /brew upgrade tmux/);
|
||||
assert.match(tmux.upgradeAdvice("3.4", "linux"), /github\.com\/tmux\/tmux\/releases/);
|
||||
assert.equal(tmux.upgradeAdvice("3.7c", "linux"), null);
|
||||
// An old tmux only lacks the pane scrollbar: a warning, and it says so.
|
||||
assert.match(tmux.upgradeAdvice("3.4", "linux"), /only the pane scrollbar is missing/);
|
||||
assert.match(tmux.upgradeAdvice("3.4", "linux"), /--prefix=\$HOME\/\.local/);
|
||||
assert.doesNotMatch(tmux.upgradeAdvice("3.4", "linux"), /sudo make install/);
|
||||
assert.equal(tmux.upgradeSeverity("3.4", "linux"), "warning");
|
||||
assert.equal(tmux.upgradeSeverity("3.3.2", "win32"), "error");
|
||||
assert.equal(tmux.upgradeSeverity("3.7", "linux"), null);
|
||||
assert.equal(tmux.upgradeAdvice("next-3.8", "darwin"), null);
|
||||
// Nothing to compare: no advice rather than a wrong one.
|
||||
assert.equal(tmux.upgradeAdvice(null, "linux"), null);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file websocket-pty-token.test.js
|
||||
* @description With DASHBOARD_TOKEN set, a `/ws-pty/:runId` upgrade is let
|
||||
* through only with the token (`?token=`, appended after the size query the
|
||||
* terminal sends), and a refused one gets a real HTTP status and is counted
|
||||
* by reason — it used to be a silent `socket.destroy()`, so a terminal stuck
|
||||
* "reconnecting" behind a token gave no clue why. Real http.Server, raw
|
||||
* upgrade requests; the run itself does not exist, so a let-through upgrade
|
||||
* ends in the attach failing, never in a 401.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const http = require("node:http");
|
||||
const net = require("node:net");
|
||||
|
||||
process.env.DASHBOARD_TOKEN = "s3cret-token";
|
||||
const ws = require("../websocket");
|
||||
|
||||
function rawUpgrade(port, path) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.write(
|
||||
`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\n` +
|
||||
`Upgrade: websocket\r\nConnection: Upgrade\r\n` +
|
||||
`Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n`
|
||||
);
|
||||
});
|
||||
let body = "";
|
||||
socket.on("data", (d) => {
|
||||
body += d.toString("utf8");
|
||||
// A 101 means the upgrade was accepted: that is all this test needs.
|
||||
if (body.startsWith("HTTP/1.1 101")) socket.destroy();
|
||||
});
|
||||
socket.on("close", () => resolve(body));
|
||||
socket.on("error", () => resolve(body));
|
||||
});
|
||||
}
|
||||
|
||||
describe("/ws-pty behind DASHBOARD_TOKEN", () => {
|
||||
let server;
|
||||
let port;
|
||||
before(async () => {
|
||||
server = http.createServer((req, res) => res.end("ok"));
|
||||
ws.initWebSocket(server);
|
||||
ws.initPtyWebSocket(server);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
port = server.address().port;
|
||||
});
|
||||
after(async () => {
|
||||
ws.closeWebSocket();
|
||||
await new Promise((r) => server.close(r));
|
||||
delete process.env.DASHBOARD_TOKEN;
|
||||
});
|
||||
|
||||
it("refuses an attach without the token with a 401, and counts why", async () => {
|
||||
const before = ws.getUpgradeRefusals().token;
|
||||
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?cols=120&rows=30");
|
||||
assert.match(reply, /^HTTP\/1\.1 401/);
|
||||
assert.equal(ws.getUpgradeRefusals().token, before + 1);
|
||||
});
|
||||
|
||||
it("refuses a wrong token", async () => {
|
||||
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?cols=120&rows=30&token=nope");
|
||||
assert.match(reply, /^HTTP\/1\.1 401/);
|
||||
});
|
||||
|
||||
it("lets the attach through with the token after the size query", async () => {
|
||||
const before = ws.getUpgradeRefusals().token;
|
||||
const reply = await rawUpgrade(
|
||||
port,
|
||||
"/ws-pty/agenthub-lane-1?cols=120&rows=30&token=s3cret-token"
|
||||
);
|
||||
assert.match(reply, /^HTTP\/1\.1 101/);
|
||||
assert.equal(ws.getUpgradeRefusals().token, before);
|
||||
});
|
||||
|
||||
it("lets a sizeless attach through with ?token=", async () => {
|
||||
const reply = await rawUpgrade(port, "/ws-pty/agenthub-lane-1?token=s3cret-token");
|
||||
assert.match(reply, /^HTTP\/1\.1 101/);
|
||||
});
|
||||
});
|
||||
+55
-1
@@ -998,6 +998,51 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_artifact_shares_user ON artifact_shares(user_id);
|
||||
`);
|
||||
|
||||
// A workspace shared with another user (docs/plans/workspace-sharing/, phase 1):
|
||||
// they see it and work in it like its owner, except owner-only actions
|
||||
// (rename, reset, remove, sharing). No foreign keys, like artifact_shares — a
|
||||
// table rebuild of lanes/users must not cascade them away; deleteLane and
|
||||
// deleteUser remove the rows themselves.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS lane_shares (
|
||||
lane_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
granted_by INTEGER,
|
||||
shared_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (lane_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lane_shares_user ON lane_shares(user_id);
|
||||
`);
|
||||
|
||||
// A single session shared with another user (sharing phase 3): they see that
|
||||
// session and its workspace's Changes, join its pane and resume it — nothing
|
||||
// else of the workspace. No foreign keys, like lane_shares.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
session_id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
granted_by INTEGER,
|
||||
shared_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (session_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_shares_user ON session_shares(user_id);
|
||||
`);
|
||||
|
||||
// Who pays for a session from which UTC hour on (sharing phase 2,
|
||||
// server/lib/session-payers.js): written only once someone other than the
|
||||
// workspace's owner runs it — a session with no row is its owner's. `from_hour`
|
||||
// is `YYYY-MM-DDTHH`, or `0000` for the whole session. Kept when a user goes:
|
||||
// it is money history.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_payers (
|
||||
session_id TEXT NOT NULL,
|
||||
from_hour TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_id, from_hour)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_payers_user ON session_payers(user_id);
|
||||
`);
|
||||
|
||||
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
||||
// who, when, and the folders it was written to — `artifacts.installs` only
|
||||
// counts. Installs before this table existed stay counted, not listed.
|
||||
@@ -1857,6 +1902,15 @@ try {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN pane_token_hash TEXT").run();
|
||||
}
|
||||
|
||||
// Who started a run, when it is not the workspace's owner (a user the
|
||||
// workspace is shared with opening a Terminal tab): its pane token acts as
|
||||
// THEM, never as the owner (server/lib/auth.js `userFromPaneToken`). NULL = the owner.
|
||||
try {
|
||||
db.prepare("SELECT started_by FROM dashboard_runs LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE dashboard_runs ADD COLUMN started_by INTEGER").run();
|
||||
}
|
||||
|
||||
// A realtime chat run's bridge secret (`AGENTHUB_CHAT_TOKEN`), SHA-256: only the
|
||||
// pane's own bridge may stream that run's events (server/routes/chat.js).
|
||||
// Never listed.
|
||||
@@ -2089,7 +2143,7 @@ try {
|
||||
}
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_id)`);
|
||||
// A user's OWN choice of one pool account per tool (Settings → AI tools →
|
||||
// Pool account, account-pins.js `setChoice`): below an admin's pin, above the
|
||||
// Profiles, account-pins.js `setChoice`): below an admin's pin, above the
|
||||
// balancer, and only among the accounts the user's group may use.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS account_choices (
|
||||
|
||||
+28
-5
@@ -99,7 +99,9 @@ const { writeServerInfo, removeServerInfo, peersSharingDataDir } = require("./li
|
||||
const { getDataDir } = require("./lib/claude-home");
|
||||
const {
|
||||
resolveHost,
|
||||
isLoopbackHostname,
|
||||
extraListenHosts,
|
||||
isExposedBind,
|
||||
accessUrls,
|
||||
corsOptions,
|
||||
hostGuard,
|
||||
tokenGuard,
|
||||
@@ -158,6 +160,8 @@ const APP_VERSION = (() => {
|
||||
})();
|
||||
|
||||
function createApp() {
|
||||
// Saved network settings first: the CORS options below are read once here.
|
||||
require("./lib/network-config").applyNetworkConfig();
|
||||
const app = express();
|
||||
// Express 5 parses a query string with node's `querystring` by default;
|
||||
// keep Express 4's `qs` parsing, which every route and guard (`namedIds`
|
||||
@@ -293,6 +297,9 @@ function createApp() {
|
||||
}
|
||||
|
||||
function startServer(app, port) {
|
||||
// `agenthub start --host/--allowed-hosts/--token`, saved for every start;
|
||||
// a variable set in the environment wins.
|
||||
require("./lib/network-config").applyNetworkConfig();
|
||||
const server = http.createServer(app);
|
||||
initWebSocket(server);
|
||||
initPtyWebSocket(server);
|
||||
@@ -368,8 +375,12 @@ function startServer(app, port) {
|
||||
// Bind to loopback by default so the dashboard is not network-reachable out
|
||||
// of the box (GHSA-gr74-4xfh-6jw9). Operators opt into a wider bind with
|
||||
// DASHBOARD_HOST=0.0.0.0 — and are warned to set DASHBOARD_TOKEN when they do.
|
||||
const host = resolveHost();
|
||||
const boundLoopback = isLoopbackHostname(host);
|
||||
// DASHBOARD_HOST may list several addresses; the first is `server`'s own
|
||||
// bind, every other one (127.0.0.1 included whenever no address covers it,
|
||||
// so hooks and the CLI keep working) a listener handing its connections to
|
||||
// `server` — the same app, WebSockets and peer checks, the real socket kept.
|
||||
const host = resolveHost().replace(/^\[(.*)\]$/, "$1");
|
||||
const boundLoopback = !isExposedBind();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// A port another program holds fails here, not as an uncaught 'error'
|
||||
@@ -377,10 +388,22 @@ function startServer(app, port) {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
const boundPort = server.address().port;
|
||||
const extras = extraListenHosts().map((h) => {
|
||||
const l = require("node:net").createServer((sock) => server.emit("connection", sock));
|
||||
l.on("error", (e) =>
|
||||
console.warn(`⚠️ Could not also listen on ${h}:${boundPort} — ${e.code || e.message}`)
|
||||
);
|
||||
l.listen(boundPort, h);
|
||||
return l;
|
||||
});
|
||||
if (extras.length) server.on("close", () => extras.forEach((l) => l.close()));
|
||||
// Publish the live port so the Claude Code hook handler can find this
|
||||
// server even when it bound a non-default port (the desktop app falls
|
||||
// back off 4820 when that port is already taken).
|
||||
writeServerInfo(port);
|
||||
// The token (if any) for a local CLI/MCP/statusline that never exported it.
|
||||
require("./lib/security").publishDashboardToken();
|
||||
// The AI Proxy gateway URL a server-mode pane is given names this port.
|
||||
require("./lib/proxy-gateway").setListenPort(server.address().port);
|
||||
// S3: what members' panes run — this install's scripts and every
|
||||
@@ -413,8 +436,8 @@ function startServer(app, port) {
|
||||
);
|
||||
}
|
||||
const mode = isProduction ? "production" : "development";
|
||||
const shown = boundLoopback ? "localhost" : host;
|
||||
console.log(`Agent Dashboard server running on http://${shown}:${port} (${mode})`);
|
||||
const urls = accessUrls(boundPort);
|
||||
console.log(`Agent Dashboard server running on ${urls.join(" ")} (${mode})`);
|
||||
if (isServerMode()) {
|
||||
console.log("Server mode: every page and API call needs a login.");
|
||||
if (!hasAdmin()) {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
|
||||
*
|
||||
* A user may also CHOOSE an account of their own (`account_choices`, Settings
|
||||
* → AI tools → Pool account, `/api/me/pool-accounts`): `preferredFor` — the
|
||||
* → AI tools → Profiles, `/api/me/pool-accounts`): `preferredFor` — the
|
||||
* balancers' question — answers the admin's pin first, then that choice. A
|
||||
* choice is only ever among the accounts the user's group may use
|
||||
* (pool-groups.js `allowed`), checked when it is made and again at every
|
||||
@@ -109,7 +109,7 @@ function setChoice(userId, tool, account) {
|
||||
}
|
||||
|
||||
/**
|
||||
* What Settings → AI tools → Pool account shows `userId`: per tool with at
|
||||
* What Settings → AI tools → Profiles shows `userId`: per tool with at
|
||||
* least one account they may use, those accounts (never a token), the admin's
|
||||
* pin and their own choice.
|
||||
*/
|
||||
|
||||
+79
-8
@@ -258,9 +258,36 @@ function validateProfile(body) {
|
||||
|
||||
// Latin look-alikes from other scripts, so "Vĩ" in Cyrillic still collides.
|
||||
const CONFUSABLE = {
|
||||
а: "a", в: "b", е: "e", к: "k", м: "m", н: "h", о: "o", р: "p", с: "c", т: "t",
|
||||
у: "y", х: "x", і: "i", ј: "j", ԁ: "d", ѕ: "s", ӏ: "l", α: "a", β: "b", ε: "e",
|
||||
η: "n", ι: "i", κ: "k", ν: "v", ο: "o", ρ: "p", τ: "t", υ: "u", χ: "x", "ı": "i",
|
||||
а: "a",
|
||||
в: "b",
|
||||
е: "e",
|
||||
к: "k",
|
||||
м: "m",
|
||||
н: "h",
|
||||
о: "o",
|
||||
р: "p",
|
||||
с: "c",
|
||||
т: "t",
|
||||
у: "y",
|
||||
х: "x",
|
||||
і: "i",
|
||||
ј: "j",
|
||||
ԁ: "d",
|
||||
ѕ: "s",
|
||||
ӏ: "l",
|
||||
α: "a",
|
||||
β: "b",
|
||||
ε: "e",
|
||||
η: "n",
|
||||
ι: "i",
|
||||
κ: "k",
|
||||
ν: "v",
|
||||
ο: "o",
|
||||
ρ: "p",
|
||||
τ: "t",
|
||||
υ: "u",
|
||||
χ: "x",
|
||||
ı: "i",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -517,6 +544,9 @@ function deleteUser(username) {
|
||||
const lanesLib = require("./lanes");
|
||||
for (const lane of lanes) lanesLib.deleteLane(lane.id);
|
||||
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
|
||||
// Workspaces other users shared with them (lane_shares has no foreign key).
|
||||
sql("DELETE FROM lane_shares WHERE user_id = ?").run(u.id);
|
||||
sql("DELETE FROM session_shares WHERE user_id = ?").run(u.id);
|
||||
require("./avatars").removeAvatar(u.id);
|
||||
try {
|
||||
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
|
||||
@@ -545,6 +575,11 @@ function assignWorkspace(laneId, username) {
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, u.id);
|
||||
const r = sql("UPDATE lanes SET owner_id = ? WHERE id = ?").run(u.id, Number(laneId));
|
||||
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
|
||||
// Its new owner no longer needs it shared with them.
|
||||
sql("DELETE FROM lane_shares WHERE lane_id = ? AND user_id = ?").run(Number(laneId), u.id);
|
||||
sql(
|
||||
"DELETE FROM session_shares WHERE user_id = ? AND session_id IN (SELECT id FROM sessions WHERE lane_id = ?)"
|
||||
).run(u.id, Number(laneId));
|
||||
}
|
||||
|
||||
/** Take a workspace back from the user who owns it: it is left with no owner
|
||||
@@ -725,25 +760,58 @@ function newPaneToken() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace owner a request from inside a live pane acts as: its
|
||||
* The user a request from inside a live pane acts as: its
|
||||
* `x-agenthub-pane-token` names a run that is still running in tmux, on a
|
||||
* workspace someone owns, and the request came straight from this machine.
|
||||
* That user is the workspace owner — or, for a pane a user the workspace is
|
||||
* shared with started (`dashboard_runs.started_by`), THAT user, and only while
|
||||
* it is still shared with them: their Terminal tab never acts as the owner.
|
||||
*/
|
||||
function userFromPaneToken(req) {
|
||||
const t = req.headers[PANE_HEADER];
|
||||
if (typeof t !== "string" || !t.startsWith("ahpane_") || t.length > 200) return null;
|
||||
if (!isLocalRequest(req)) return null;
|
||||
const row = sql(
|
||||
`SELECT r.id, r.lane_id, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
`SELECT r.id, r.lane_id, r.session_id, r.started_by, l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE r.pane_token_hash = ? AND r.status IN ('running', 'spawning') AND instr(r.id, '@') = 0`
|
||||
).get(sha256(t));
|
||||
if (!row || row.owner_id == null) return null;
|
||||
// S3: only from the account that pane runs as (any local uid could post it).
|
||||
if (!require("./hook-auth").peerIsPaneAccount(req, row.lane_id)) return null;
|
||||
if (!require("./tmux").hasSession(row.id)) return null;
|
||||
return (
|
||||
publicUser(sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(row.owner_id)) || null
|
||||
);
|
||||
const uid = row.started_by ?? row.owner_id;
|
||||
const user = sql("SELECT * FROM users WHERE id = ? AND disabled = 0").get(uid);
|
||||
if (!user) return null;
|
||||
if (
|
||||
uid !== row.owner_id &&
|
||||
user.role !== "admin" &&
|
||||
!require("./scope").sharedWith(row.lane_id, uid) &&
|
||||
!require("./scope").sessionSharedWith(row.session_id, uid)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const pub = publicUser(user);
|
||||
if (!pub) return null;
|
||||
// A collaborator's pane runs as the OWNER's Linux account, which can read
|
||||
// its token: it acts as the collaborator inside this workspace only
|
||||
// (`paneLane`, authGuard), never across their account.
|
||||
return uid !== row.owner_id ? { ...pub, paneLane: row.lane_id } : pub;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a collaborator's pane token may call — the CLI's workspace commands,
|
||||
* nothing that starts or bills a session (the owner's account can read the
|
||||
* token): reads of its own workspace (and the workspace list, which answers
|
||||
* that one only), health, and its stage / stack / hook / feature writes.
|
||||
*/
|
||||
function paneLaneAllows(method, lp, laneId) {
|
||||
const read = method === "GET" || method === "HEAD";
|
||||
if (lp === "/health") return true;
|
||||
if (read && /^\/(?:lanes|workspaces)\/?$/i.test(lp)) return true;
|
||||
const m = /^\/(?:lanes|workspaces)\/(\d+)(\/.*)?$/i.exec(lp);
|
||||
if (!m || Number(m[1]) !== laneId) return false;
|
||||
if (read) return true;
|
||||
return /^\/(stage|up|down|hook\/[^/]+|features\/activate|mcp\/sync)$/i.test(m[2] || "");
|
||||
}
|
||||
|
||||
// ── Gates ───────────────────────────────────────────────────────────────────
|
||||
@@ -799,6 +867,9 @@ function authGuard(req, res, next) {
|
||||
const cookieUser = userFromRequest(req);
|
||||
const user = cookieUser || userFromPaneToken(req);
|
||||
if (!user) return deny(res, 401, "EAUTH", "log in first");
|
||||
if (!cookieUser && user.paneLane != null && !paneLaneAllows(req.method, lp, user.paneLane)) {
|
||||
return deny(res, 403, "EPANESCOPE", "this pane's token acts only inside its own workspace");
|
||||
}
|
||||
req.user = user;
|
||||
// A password an admin gave: the login may only read its own account and
|
||||
// pick its own password until it has.
|
||||
|
||||
+22
-8
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
const { stmts } = require("../db");
|
||||
const { memberId, sessionWhere } = require("./scope");
|
||||
const { memberId, ownedOnlySessionWhere } = require("./scope");
|
||||
const { calculateCost, dailyTokenRows, costWhere } = require("../routes/pricing");
|
||||
|
||||
const PERIODS = ["day", "week", "month"];
|
||||
@@ -71,15 +71,29 @@ function budgetSpend(cfg, { now = Date.now(), back = 0, rules, req } = {}) {
|
||||
// A session that spent in the period was touched in it, so older ones are
|
||||
// skipped before their hours are read.
|
||||
const extra = [{ clause: "s.updated_at >= ?", params: [b.startUtc] }];
|
||||
if (memberId(req) != null) {
|
||||
const own = sessionWhere(req, "s.id", "s.lane_id");
|
||||
extra.push({ clause: own.where, params: own.params });
|
||||
}
|
||||
const scope = cfg.tool ? { where: "tool = ?", params: [cfg.tool] } : null;
|
||||
let paid = [];
|
||||
if (memberId(req) != null) {
|
||||
// Their OWN money: their workspaces' sessions, by the hours they paid
|
||||
// where a collaborator ran one — and the hours they paid for in
|
||||
// workspaces shared with them (server/lib/session-payers.js).
|
||||
const own = ownedOnlySessionWhere(req, "s.id", "s.lane_id");
|
||||
const payers = require("./session-payers");
|
||||
extra.push({ clause: `(${own.where}) AND s.id NOT IN (${payers.SPLIT})`, params: own.params });
|
||||
const split = costWhere({
|
||||
scope,
|
||||
lane: cfg.lane,
|
||||
extra: [
|
||||
{ clause: "s.updated_at >= ?", params: [b.startUtc] },
|
||||
{ clause: `s.id IN (${payers.SPLIT})`, params: [] },
|
||||
],
|
||||
});
|
||||
paid = payers.paidDailyRows(req.user.id, split.whereClause, split.params, `${-tz} minutes`);
|
||||
}
|
||||
const where = costWhere({ scope, lane: cfg.lane, extra });
|
||||
const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params).filter(
|
||||
(r) => r.date >= b.start && r.date <= b.end
|
||||
);
|
||||
const rows = dailyTokenRows(`${-tz} minutes`, where.whereClause, where.params)
|
||||
.concat(paid)
|
||||
.filter((r) => r.date >= b.start && r.date <= b.end);
|
||||
const spend = calculateCost(rows, rules || stmts.listPricing.all()).total_cost;
|
||||
// The pace so far, carried to the period's end; at least an hour of pace so
|
||||
// the first minutes of a period do not project a fortune.
|
||||
|
||||
@@ -46,12 +46,17 @@
|
||||
* "another process landed". A home whose Claude refreshed first is copied
|
||||
* back, but only after Anthropic confirms the token is the same account
|
||||
* (`fetchOwner`): a member could otherwise plant a login every other
|
||||
* member's panes would get.
|
||||
* member's panes would get — and a home is never dropped while Anthropic
|
||||
* does not answer. A home still holding one of the account's PAST pairs
|
||||
* (hashes in `retired`: its Claude raced the server's refresh) stays the
|
||||
* pool's and gets the newest pair, rather than being left on a dead
|
||||
* login that asked its member to /login again.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
@@ -249,9 +254,36 @@ function placedHomes(a) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether `member`'s credentials are still the pair AgentHub last wrote there for `a`. */
|
||||
/** How many of a login's past refresh tokens are remembered (as hashes). */
|
||||
const RETIRED_KEEP = 20;
|
||||
const rtHash = (rt) =>
|
||||
crypto
|
||||
.createHash("sha256")
|
||||
.update(String(rt || ""))
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
|
||||
/**
|
||||
* `a` with `old`'s refresh token remembered as one of its own past pairs. A
|
||||
* home still holding such a pair (its Claude raced the server's refresh, or a
|
||||
* spawn wrote a pair the server spent a moment later) is still the pool's:
|
||||
* nobody but this account ever held that token.
|
||||
*/
|
||||
function retire(a, old) {
|
||||
if (!old?.refreshToken || old.refreshToken === a.oauth?.refreshToken) return a;
|
||||
const h = rtHash(old.refreshToken);
|
||||
return { ...a, retired: [h, ...(a.retired || []).filter((x) => x !== h)].slice(0, RETIRED_KEEP) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `member`'s credentials are the pool's `a`: the pair AgentHub last
|
||||
* wrote there, or one of `a`'s own past pairs (stale — the next write fixes it).
|
||||
*/
|
||||
const ownsHome = (a, member, disk) =>
|
||||
a.kind === "login" && Boolean(disk) && disk.refreshToken === a.placed?.[member];
|
||||
a.kind === "login" &&
|
||||
Boolean(disk?.refreshToken) &&
|
||||
(disk.refreshToken === a.placed?.[member] ||
|
||||
(a.retired || []).includes(rtHash(disk.refreshToken)));
|
||||
|
||||
/**
|
||||
* Write `a`'s login into a placement — a member's home as the member, the
|
||||
@@ -297,10 +329,19 @@ async function refreshLogins() {
|
||||
isOauth(disk) &&
|
||||
Number(disk.expiresAt) > Number(a.oauth.expiresAt) &&
|
||||
Number(disk.expiresAt) <= Date.now() + 24 * 3600e3 + FUTURE_SLACK_MS;
|
||||
const owner = newer ? await usage.fetchOwner(disk.accessToken) : null;
|
||||
let owner = null;
|
||||
if (newer) {
|
||||
owner = await usage.fetchOwner(disk.accessToken);
|
||||
// A spawn may have placed the login somewhere while we waited.
|
||||
a = readAccount(a.name) || a;
|
||||
// Anthropic did not answer (offline, a hiccup): decide on a later
|
||||
// beat. Dropping the home now would strand it on a pair only it
|
||||
// holds, and no spawn would ever write the pool's login there again.
|
||||
if (!owner) continue;
|
||||
}
|
||||
const placed = { ...a.placed };
|
||||
if (owner && owner.uuid === a.account?.accountUuid) {
|
||||
a = { ...a, oauth: { ...a.oauth, ...disk } };
|
||||
a = retire({ ...a, oauth: { ...a.oauth, ...disk } }, a.oauth);
|
||||
placed[member] = disk.refreshToken;
|
||||
} else {
|
||||
delete placed[member]; // their own login now: never ours to write over
|
||||
@@ -310,11 +351,19 @@ async function refreshLogins() {
|
||||
}
|
||||
// 2. The server refreshes ahead of every home.
|
||||
if (!(Number(a.oauth.expiresAt) > Date.now() + REFRESH_AHEAD_MS)) {
|
||||
const old = a.oauth;
|
||||
let fresh = null;
|
||||
let refreshError = null;
|
||||
try {
|
||||
a = { ...a, oauth: await usage.exchangeRefresh(a.oauth), refreshError: null };
|
||||
fresh = await usage.exchangeRefresh(old);
|
||||
} catch (e) {
|
||||
a = { ...a, refreshError: e.code || "EUPSTREAM" };
|
||||
refreshError = e.code || "EUPSTREAM";
|
||||
}
|
||||
// Re-read: a spawn's place() may have saved a new placement meanwhile.
|
||||
a = readAccount(a.name) || a;
|
||||
a = fresh
|
||||
? retire({ ...a, oauth: fresh, refreshError: null }, old)
|
||||
: { ...a, refreshError };
|
||||
save(a);
|
||||
quotaCache.delete(a.name);
|
||||
}
|
||||
@@ -627,6 +676,22 @@ function forSpawn(member = null, ownerId = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pane a user runs in a workspace SHARED with them needs (sharing
|
||||
* phase 2): a setup-token of THEIR OWN — their group's, their pin or choice,
|
||||
* else the balancer's pick — by env, or null. Never a login: one would be
|
||||
* written into the owner's home (`place()`), and the owner's own login there
|
||||
* is the owner's money.
|
||||
*/
|
||||
function forCollaborator(userId) {
|
||||
const allowed = require("./pool-groups").allowed(userId, "claude");
|
||||
const usable = allAccounts().filter((a) => a.kind === "token" && !expired(a) && allowed(a.name));
|
||||
if (usable.length === 0) return null;
|
||||
const pin = require("./account-pins").preferredFor(userId, "claude");
|
||||
const a = (pin && usable.find((x) => x.name === pin)) || rankAccounts(usable);
|
||||
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
|
||||
}
|
||||
|
||||
/** Is `name` an account in the pool (any state)? */
|
||||
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||||
|
||||
@@ -640,6 +705,7 @@ module.exports = {
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
forCollaborator,
|
||||
forRemote,
|
||||
switchHome,
|
||||
refreshQuotas,
|
||||
|
||||
@@ -478,7 +478,28 @@ function pickAccount(member = null, ownerId = null) {
|
||||
* (a member's launch file); `place()` runs just before the pane starts.
|
||||
*/
|
||||
function forSpawn(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
return spawnOf(pick(member, ownerId), member);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pane a user runs in a workspace SHARED with them needs (sharing
|
||||
* phase 2): an API key of THEIR OWN by env, or null — never a ChatGPT login,
|
||||
* which would be written into the owner's CODEX_HOME.
|
||||
*/
|
||||
function forCollaborator(userId) {
|
||||
const allowed = require("./pool-groups").allowed(userId, "codex");
|
||||
const usable = allAccounts().filter((a) => a.kind === "apikey" && allowed(a.name));
|
||||
if (usable.length === 0) return null;
|
||||
const pin = require("./account-pins").preferredFor(userId, "codex");
|
||||
const a =
|
||||
(pin && usable.find((x) => x.name === pin)) ||
|
||||
usable
|
||||
.map((x) => ({ x, n: livePanes(x.name) }))
|
||||
.sort((p, q) => p.n - q.n || p.x.name.localeCompare(q.x.name))[0].x;
|
||||
return spawnOf(a, null);
|
||||
}
|
||||
|
||||
function spawnOf(a, member) {
|
||||
if (!a) return null;
|
||||
if (a.kind === "apikey") {
|
||||
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
|
||||
@@ -522,6 +543,7 @@ module.exports = {
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
forCollaborator,
|
||||
syncBack,
|
||||
poolDir,
|
||||
__read: readAccount,
|
||||
|
||||
@@ -34,11 +34,11 @@ const insertStmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO dashboard_runs (
|
||||
id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code, started_at, ended_at,
|
||||
lane_id, tool, worktree, proxy, account, pane_token_hash
|
||||
lane_id, tool, worktree, proxy, account, pane_token_hash, started_by
|
||||
) VALUES (
|
||||
@id, @session_id, @mode, @cwd, @model, @permission_mode, @effort,
|
||||
@resume_session_id, @prompt_preview, @status, @exit_code, @started_at, @ended_at,
|
||||
@lane_id, @tool, @worktree, @proxy, @account, @pane_token_hash
|
||||
@lane_id, @tool, @worktree, @proxy, @account, @pane_token_hash, @started_by
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -53,7 +53,7 @@ const updateStmt = db.prepare(`
|
||||
|
||||
const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort,
|
||||
resume_session_id, prompt_preview, status, exit_code,
|
||||
started_at, ended_at, lane_id, tool, worktree, proxy, account`;
|
||||
started_at, ended_at, lane_id, tool, worktree, proxy, account, started_by`;
|
||||
|
||||
const listStmt = db.prepare(`
|
||||
SELECT ${RUN_COLUMNS}
|
||||
@@ -128,7 +128,11 @@ function recordRun(handle) {
|
||||
proxy: handle.proxy || null,
|
||||
account: handle.account || null,
|
||||
pane_token_hash: handle.paneTokenHash || null,
|
||||
started_by: Number.isInteger(handle.startedBy) ? handle.startedBy : null,
|
||||
});
|
||||
// Who pays for the session it holds (session-payers.js) — a no-op for
|
||||
// the owner's own run of a session nobody else ran.
|
||||
if (handle.sessionId) require("./session-payers").noteRun(handle.id);
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
// but not silent: a swallowed insert leaves the runs modal showing rows with
|
||||
@@ -151,6 +155,7 @@ function patchRun({ id, sessionId, status, exitCode, endedAt }) {
|
||||
exit_code: typeof exitCode === "number" ? exitCode : null,
|
||||
ended_at: endedAt ? new Date(endedAt).toISOString() : null,
|
||||
});
|
||||
if (sessionId) require("./session-payers").noteRun(id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -250,7 +255,15 @@ const claimRunStmt = db.prepare(`
|
||||
*/
|
||||
function claimToolSessions(sessionId = null) {
|
||||
try {
|
||||
if (sessionId) claimRunStmt.run({ id: sessionId });
|
||||
if (sessionId && claimRunStmt.run({ id: sessionId }).changes) {
|
||||
// The run just claimed it: the newest holding it.
|
||||
const r = db
|
||||
.prepare(
|
||||
"SELECT id FROM dashboard_runs WHERE session_id = ? ORDER BY started_at DESC LIMIT 1"
|
||||
)
|
||||
.get(sessionId);
|
||||
if (r) require("./session-payers").noteRun(r.id);
|
||||
}
|
||||
return claimToolStmt.run({ id: sessionId }).changes;
|
||||
} catch {
|
||||
return 0;
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* statusline (`statusline/statusline.js`). An explicit CLAUDE_DASHBOARD_PORT /
|
||||
* DASHBOARD_PORT wins (the hook handler's contract), then the live-server
|
||||
* discovery file `~/.claude/.agenthub.json` (`server-info.js`, PID-checked),
|
||||
* then the default port. Never throws.
|
||||
* then the default port. `dashboardToken()` is the token such a process sends
|
||||
* when the dashboard requires one: `DASHBOARD_TOKEN`, else the one the running
|
||||
* server published (`<data dir>/runtime/dashboard-token`, 0600 — see
|
||||
* `security.publishDashboardToken`), so a shell that never exported it still
|
||||
* reaches a token-gated dashboard. Never throws.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,4 +28,23 @@ function dashboardUrl() {
|
||||
return "http://127.0.0.1:4820";
|
||||
}
|
||||
|
||||
module.exports = { dashboardUrl };
|
||||
/** The dashboard token to send, or null when none is configured or readable. */
|
||||
function dashboardToken() {
|
||||
const env = process.env.DASHBOARD_TOKEN;
|
||||
if (typeof env === "string" && env) return env;
|
||||
try {
|
||||
const { tokenFilePath } = require(path.join(__dirname, "security.js"));
|
||||
const t = require("node:fs").readFileSync(tokenFilePath(), "utf8").trim();
|
||||
return t || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The headers carrying `dashboardToken()` (both spellings the server takes); {} with none. */
|
||||
function tokenHeaders() {
|
||||
const t = dashboardToken();
|
||||
return t ? { "x-dashboard-token": t } : {};
|
||||
}
|
||||
|
||||
module.exports = { dashboardUrl, dashboardToken, tokenHeaders };
|
||||
|
||||
@@ -331,6 +331,13 @@ function setProvisioningFacts(id, facts = {}) {
|
||||
|
||||
function deleteLane(id) {
|
||||
const gone = db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
|
||||
// Who it was shared with goes with it (lane_shares has no foreign key).
|
||||
if (gone) {
|
||||
db.prepare("DELETE FROM lane_shares WHERE lane_id = ?").run(id);
|
||||
db.prepare(
|
||||
"DELETE FROM session_shares WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?)"
|
||||
).run(id);
|
||||
}
|
||||
// Its `lane_sources` rows cascade; AgentHub's own copies of its uploaded
|
||||
// context files go with them. A folder source is the user's and stays.
|
||||
// Lazy: lane-sources.js reads the db directly and must not load at import.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file pane-presence.js
|
||||
* @description Who has which pane open right now (server mode, workspace
|
||||
* sharing phase 4): every terminal attach (`/ws-pty/:runId`, websocket.js)
|
||||
* adds its logged-in user to its pane, and its close takes them off. In
|
||||
* memory only — a restart drops every attach anyway. A change is broadcast as
|
||||
* `pane_presence` `{laneId, runId, users}` to the workspace's owner and the
|
||||
* users it is shared with; `GET /api/lanes/:id/presence` gives the current
|
||||
* state. The first keystroke of an attach into a SHARED workspace's pane by
|
||||
* anyone but its owner is written to `audit_log` (`type into <run id>`) — who
|
||||
* typed, never what.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** runId → Map(userId → {id, name, n}) — `n` attaches of that user. */
|
||||
const panes = new Map();
|
||||
|
||||
const laneOf = (runId) => {
|
||||
const m = /^agenthub-lane-(\d+)(?:-\d+)?$/.exec(String(runId || ""));
|
||||
return m ? Number(m[1]) : null;
|
||||
};
|
||||
|
||||
const nameOf = (user) => user.displayName || user.display_name || user.username;
|
||||
|
||||
function users(runId) {
|
||||
return [...(panes.get(runId)?.values() || [])].map(({ id, name }) => ({ id, name }));
|
||||
}
|
||||
|
||||
function announce(runId) {
|
||||
try {
|
||||
require("../websocket").broadcast("pane_presence", {
|
||||
laneId: laneOf(runId),
|
||||
runId,
|
||||
users: users(runId),
|
||||
});
|
||||
} catch {
|
||||
/* no websocket server (tests) */
|
||||
}
|
||||
}
|
||||
|
||||
/** `user` opened pane `runId`. */
|
||||
function join(runId, user) {
|
||||
if (!user || laneOf(runId) == null) return;
|
||||
if (!panes.has(runId)) panes.set(runId, new Map());
|
||||
const here = panes.get(runId);
|
||||
const me = here.get(user.id);
|
||||
if (me) {
|
||||
me.n += 1;
|
||||
return;
|
||||
}
|
||||
here.set(user.id, { id: user.id, name: nameOf(user), n: 1 });
|
||||
announce(runId);
|
||||
}
|
||||
|
||||
/** `user` closed one attach of pane `runId`. */
|
||||
function leave(runId, user) {
|
||||
const here = user && panes.get(runId);
|
||||
const me = here?.get(user.id);
|
||||
if (!me) return;
|
||||
me.n -= 1;
|
||||
if (me.n > 0) return;
|
||||
here.delete(user.id);
|
||||
if (!here.size) panes.delete(runId);
|
||||
announce(runId);
|
||||
}
|
||||
|
||||
/** `{runId: [{id, name}]}` for workspace `laneId`'s panes. */
|
||||
function forLane(laneId) {
|
||||
const out = {};
|
||||
for (const runId of panes.keys()) {
|
||||
if (laneOf(runId) === Number(laneId)) out[runId] = users(runId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether workspace `laneId` is shared with anyone, whole or a session of it. */
|
||||
function isShared(laneId) {
|
||||
const { db } = require("../db");
|
||||
return Boolean(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT 1 FROM lane_shares WHERE lane_id = ?
|
||||
UNION ALL SELECT 1 FROM session_shares
|
||||
WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?) LIMIT 1`
|
||||
)
|
||||
.get(laneId, laneId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first keystroke of an attach: in a shared workspace, write who typed
|
||||
* into which pane (`audit_log`, owner = the workspace's owner). Best effort.
|
||||
*/
|
||||
function auditInput(runId, user) {
|
||||
const laneId = laneOf(runId);
|
||||
if (!user || laneId == null) return;
|
||||
try {
|
||||
if (!isShared(laneId)) return;
|
||||
const { db } = require("../db");
|
||||
const owner = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id;
|
||||
if (owner === user.id) return; // the owner in their own workspace
|
||||
require("./audit").recordAccountChange(user.id, owner ?? null, "PTY", `type into ${runId}`);
|
||||
} catch (err) {
|
||||
console.warn("[presence] could not audit:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
const __reset = () => panes.clear();
|
||||
|
||||
module.exports = { join, leave, forLane, auditInput, users, __reset };
|
||||
@@ -82,6 +82,17 @@ const PASTE_HOLD_MS = 700;
|
||||
// keystroke first leaves copy-mode (`tmux.leaveCopyMode`), then is written.
|
||||
const NOT_TYPING_RE = /\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M[\s\S]{3}|\x1b\[[IO]/g;
|
||||
|
||||
// What the browser's terminal sends back on its own when tmux asks it
|
||||
// something on attach (device attributes, cursor/status reports, mode
|
||||
// reports, colour queries): no one typed it.
|
||||
const TERMINAL_REPLY_RE =
|
||||
/\x1b\[[?>=]?[\d;]*[cRn]|\x1b\[\?[\d;]*\$y|\x1bP[\s\S]*?\x1b\\|\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
|
||||
|
||||
/** Whether `text` from a browser is someone typing (not a mouse, focus or terminal reply). */
|
||||
function isTyping(text) {
|
||||
return text.replace(NOT_TYPING_RE, "").replace(TERMINAL_REPLY_RE, "") !== "";
|
||||
}
|
||||
|
||||
// runId -> { driver, viewers: Set<{ ws, pty, cols, rows }>, scrolled }
|
||||
// (`scrolled`: a mouse report reached the pane since the last keystroke — per
|
||||
// pane, since one browser may scroll and another type)
|
||||
@@ -333,4 +344,11 @@ __setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args));
|
||||
|
||||
const __resetGroups = () => groups.clear();
|
||||
|
||||
module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl, __resetGroups };
|
||||
module.exports = {
|
||||
attach,
|
||||
attachBinary,
|
||||
validateRunId,
|
||||
isTyping,
|
||||
__setSpawnImpl,
|
||||
__resetGroups,
|
||||
};
|
||||
|
||||
+76
-10
@@ -419,6 +419,7 @@ function spawnRun(args) {
|
||||
bypass,
|
||||
initialPrompt,
|
||||
proxy,
|
||||
startedBy,
|
||||
} = args || {};
|
||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
@@ -497,10 +498,16 @@ function spawnRun(args) {
|
||||
// their .mcp.json) or a member in someone else's.
|
||||
if (lane) require("./run-as").assertOwnerFits(lane.cwd, laneOwner); // EOWNERFOLDER
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
// Server mode: an owner over their spend quota starts nothing new (user-quota.js).
|
||||
require("./user-quota").assertSpawnAllowed(laneOwner); // EQUOTA
|
||||
// Who pays (sharing phase 2): a user the workspace is shared with runs on
|
||||
// their OWN account — a pool token or API key, or an AI Proxy of theirs —
|
||||
// under their own quota, never on the owner's login (session-payers.js).
|
||||
const payer =
|
||||
lane && Number.isInteger(startedBy) && startedBy !== laneOwner ? startedBy : laneOwner;
|
||||
const collab = payer !== laneOwner;
|
||||
// Server mode: a payer over their spend quota starts nothing new (user-quota.js).
|
||||
require("./user-quota").assertSpawnAllowed(payer); // EQUOTA
|
||||
assertSessionRoom(laneId); // ESESSIONLIMIT
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
const proxyProfile = resolveProxy(tool, proxy, payer); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js) — or the one the
|
||||
// workspace owner is pinned to (account-pins.js); an AI Proxy brings its
|
||||
@@ -509,21 +516,56 @@ function spawnRun(args) {
|
||||
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
|
||||
const poolAccount =
|
||||
entry.launcher === "claude" && !proxyProfile
|
||||
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
? collab
|
||||
? require("./claude-pool").forCollaborator(payer)
|
||||
: require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
||||
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
||||
const codexPool =
|
||||
entry.launcher === "codex" && !proxyProfile
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
? collab
|
||||
? require("./codex-pool").forCollaborator(payer)
|
||||
: require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
// Cursor's pool is API keys only (cursor-pool.js): CURSOR_API_KEY through `-e`.
|
||||
const cursorPool = tool === "cursor" ? require("./cursor-pool").forSpawn(laneOwner) : null;
|
||||
const cursorPool = tool === "cursor" ? require("./cursor-pool").forSpawn(payer) : null;
|
||||
// Only a Claude session books its spend by the hour, so only it can change
|
||||
// hands mid-way (session-payers.js): any other tool's whole session is
|
||||
// billed at its start hour, and a resume across payers would bill the wrong
|
||||
// one. `resumeLast` names no session, so its payer could not be known.
|
||||
if (collab && resumeLast === true) {
|
||||
throw makeErr(
|
||||
"ECOLLABRESUME",
|
||||
"in a workspace shared with you, resume a session by picking it — not the latest one"
|
||||
);
|
||||
}
|
||||
if (
|
||||
resumeSessionId &&
|
||||
laneOwner != null &&
|
||||
entry.launcher !== "claude" &&
|
||||
require("./session-payers").currentPayer(resumeSessionId, laneOwner) !== payer
|
||||
) {
|
||||
throw makeErr(
|
||||
"ECOLLABRESUME",
|
||||
`${tool} books a session's spend at its start, so only the user who pays for it may resume it — start a new session instead`
|
||||
);
|
||||
}
|
||||
// Any other tool, or no account of their own, would run on the owner's
|
||||
// login in the owner's home: refused.
|
||||
if (collab && !proxyProfile && !poolAccount && !codexPool && !cursorPool) {
|
||||
throw makeErr(
|
||||
"ENOCOLLABACCOUNT",
|
||||
"this workspace is shared with you, so a session you start must run on your own account — " +
|
||||
"a pool account an admin gave you, or an AI Proxy of yours — and you have none for " +
|
||||
`${tool}. The owner's own login is never used for you.`
|
||||
);
|
||||
}
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
// A member's omp on the host's shared omp login (omp-share.js): its
|
||||
// models.yml points at the loopback gateway, with a key already listed there.
|
||||
const ompShare =
|
||||
tool === "omp"
|
||||
tool === "omp" && !collab
|
||||
? require("./omp-share").forSpawn(
|
||||
require("./run-as").accountForLane(laneId),
|
||||
runIdForLane(laneId, slot)
|
||||
@@ -651,6 +693,7 @@ function spawnRun(args) {
|
||||
proxy: proxyProfile ? proxyProfile.name : null,
|
||||
account: (poolAccount || codexPool || cursorPool)?.name ?? null,
|
||||
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||
startedBy: collab ? payer : null,
|
||||
prompt: initialPrompt || "",
|
||||
status: "running",
|
||||
startedAt,
|
||||
@@ -734,9 +777,9 @@ function spawnRun(args) {
|
||||
for (const k of Object.keys(env)) delete env[k];
|
||||
Object.assign(env, own);
|
||||
}
|
||||
// Server mode: where this pane's hooks look for its owner's quota flag
|
||||
// Server mode: where this pane's hooks look for its payer's quota flag
|
||||
// (user-quota.js) — a refused prompt is decided by the hook, locally.
|
||||
Object.assign(env, require("./user-quota").flagEnv(laneOwner, member));
|
||||
Object.assign(env, require("./user-quota").flagEnv(payer, member, laneOwner));
|
||||
|
||||
// `after-start` tools take no prompt on their command line: paste it once
|
||||
// the CLI has had `delayMs` to draw its input box. ponytail: fixed delay,
|
||||
@@ -759,6 +802,9 @@ function spawnRun(args) {
|
||||
const prepareHome = () => {
|
||||
codexPool?.place();
|
||||
poolAccount?.place();
|
||||
// A collaborator's flag sits in the owner's handoff dir: write its state
|
||||
// now, so one left from an earlier share never says the wrong thing.
|
||||
if (collab) require("./user-quota").writeCollabFlag(payer, member);
|
||||
ompShare?.place();
|
||||
if (member && entry.launcher === "claude" && (poolAccount || proxyProfile)) {
|
||||
require("./run-as").markClaudeOnboarded(member);
|
||||
@@ -808,7 +854,9 @@ function spawnRun(args) {
|
||||
* `shell` so the tab can say what it is. The caller has already made sure the
|
||||
* slot holds no live session.
|
||||
*/
|
||||
function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
/** `startedBy`: the user opening it when that is not the workspace's owner (a
|
||||
* workspace shared with them) — its pane token then acts as them. */
|
||||
function spawnShell({ laneId, slot = 0, cwd, startedBy = null }) {
|
||||
if (!Number.isInteger(laneId)) throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
|
||||
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
|
||||
@@ -844,6 +892,8 @@ function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
worktree: false,
|
||||
proxy: null,
|
||||
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||
startedBy:
|
||||
lane && Number.isInteger(startedBy) && startedBy !== lane.owner_id ? startedBy : null,
|
||||
prompt: "",
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
@@ -884,9 +934,25 @@ function publicRun(id, live = tmux.hasSession(id)) {
|
||||
worktree: !!row?.worktree,
|
||||
startedAt: row?.started_at || null,
|
||||
promptPreview: row?.prompt_preview || null,
|
||||
// Server mode: a pane a user the workspace is shared with opened — whose
|
||||
// money it is (session-payers.js). Null = the workspace's owner.
|
||||
startedBy: row?.started_by != null ? starterOf(row.started_by) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function starterOf(userId) {
|
||||
try {
|
||||
const u = require("../db")
|
||||
.db.prepare(
|
||||
"SELECT id, COALESCE(NULLIF(display_name, ''), username) AS name FROM users WHERE id = ?"
|
||||
)
|
||||
.get(userId);
|
||||
return u ? { id: u.id, name: u.name } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRun(id) {
|
||||
if (laneIdFromRunId(id) === null) return null;
|
||||
return publicRun(id);
|
||||
|
||||
+235
-26
@@ -3,7 +3,10 @@
|
||||
* @description Server mode's row ownership (phase S2, docs/SERVER-MODE.md) —
|
||||
* the ONE place that decides which rows a request may see.
|
||||
*
|
||||
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session
|
||||
* A workspace (`lanes.owner_id`) belongs to the user who created it, and may be
|
||||
* SHARED with other users (`lane_shares`): they see it and work in it like its
|
||||
* owner — visibility here — while owner-only actions check `isLaneOwner`, and
|
||||
* personal figures (Today, budgets) stay on owned rows only. A session
|
||||
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
|
||||
* that workspace holding it (a session not stamped yet). A session no workspace owns
|
||||
* belongs to the user of the Linux account it ran as (`sessions.account`,
|
||||
@@ -41,7 +44,16 @@ const ownerFor = (req) => (req?.user ? req.user.id : null);
|
||||
* unrestricted. `col` is the session id expression, `laneCol` its lane id.
|
||||
*/
|
||||
function sessionWhere(req, col = "id", laneCol = "lane_id") {
|
||||
return ownedSessionWhere(memberId(req), col, laneCol);
|
||||
return ownedSessionWhere(memberId(req), col, laneCol, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* `sessionWhere` without shared workspaces: what this request's user OWNS —
|
||||
* for their own money (budgets), never another user's spend in a workspace
|
||||
* shared with them. "1" when unrestricted, like `sessionWhere`.
|
||||
*/
|
||||
function ownedOnlySessionWhere(req, col = "id", laneCol = "lane_id") {
|
||||
return ownedSessionWhere(memberId(req), col, laneCol, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,15 +65,32 @@ function ownSessionWhere(req, col = "id", laneCol = "lane_id") {
|
||||
return ownedSessionWhere(req?.user ? req.user.id : null, col, laneCol);
|
||||
}
|
||||
|
||||
function ownedSessionWhere(uid, col, laneCol) {
|
||||
// The workspaces a user owns or that are shared with them (2 params: uid, uid).
|
||||
const MY_LANES =
|
||||
"SELECT id FROM lanes WHERE owner_id = ? UNION SELECT lane_id FROM lane_shares WHERE user_id = ?";
|
||||
// The sessions shared with a user one by one (1 param: uid), and the
|
||||
// workspaces holding them (sharing phase 3: seen, but only that session).
|
||||
const MY_SESSIONS = "SELECT session_id FROM session_shares WHERE user_id = ?";
|
||||
const SESSION_LANES = `SELECT lane_id FROM sessions WHERE lane_id IS NOT NULL AND id IN (${MY_SESSIONS})`;
|
||||
// Every workspace a user may open (3 params: uid ×3).
|
||||
const SEEN_LANES = `${MY_LANES} UNION ${SESSION_LANES}`;
|
||||
|
||||
function ownedSessionWhere(uid, col, laneCol, shared = false) {
|
||||
if (uid == null) return { where: "1", params: [] };
|
||||
const lanes = shared ? MY_LANES : "SELECT id FROM lanes WHERE owner_id = ?";
|
||||
const lp = shared ? [uid, uid] : [uid];
|
||||
return {
|
||||
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
|
||||
WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
|
||||
where: `(${laneCol} IN (${lanes})
|
||||
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r
|
||||
WHERE r.lane_id IN (${lanes}) AND r.session_id IS NOT NULL)
|
||||
OR ${col} IN (SELECT id FROM sessions WHERE owner_id = ?)
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
|
||||
params: [uid, uid, uid, uid],
|
||||
OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?)${
|
||||
shared
|
||||
? `
|
||||
OR ${col} IN (${MY_SESSIONS})`
|
||||
: ""
|
||||
})`,
|
||||
params: [...lp, ...lp, uid, uid, ...(shared ? [uid] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +106,56 @@ function bySessionWhere(req, col = "session_id") {
|
||||
function laneWhere(req, col = "id") {
|
||||
const uid = memberId(req);
|
||||
if (uid == null) return { where: "1", params: [] };
|
||||
return { where: `${col} IN (SELECT id FROM lanes WHERE owner_id = ?)`, params: [uid] };
|
||||
return { where: `${col} IN (${SEEN_LANES})`, params: [uid, uid, uid] };
|
||||
}
|
||||
|
||||
/** Whether session `sessionId` is shared with `userId` on its own (phase 3). */
|
||||
function sessionSharedWith(sessionId, userId) {
|
||||
if (!sessionId || userId == null) return false;
|
||||
return Boolean(
|
||||
sql("SELECT 1 FROM session_shares WHERE session_id = ? AND user_id = ?").get(
|
||||
String(sessionId),
|
||||
userId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of workspace `laneId` this request may use: `all` (local mode, an
|
||||
* admin or analyst read, its owner, a user it is shared with), `session`
|
||||
* (only sessions shared with them one by one), or null.
|
||||
*/
|
||||
function laneAccess(req, laneId) {
|
||||
const uid = memberId(req);
|
||||
if (uid == null) return "all";
|
||||
const v = typeof laneId === "number" ? laneId : String(laneId);
|
||||
const row = sql("SELECT id, owner_id FROM lanes WHERE id = ?").get(v);
|
||||
if (!row) return null;
|
||||
if (row.owner_id === uid || sharedWith(row.id, uid)) return "all";
|
||||
const one = sql(
|
||||
`SELECT 1 FROM sessions WHERE lane_id = ? AND id IN (${MY_SESSIONS}) LIMIT 1`
|
||||
).get(row.id, uid);
|
||||
return one ? "session" : null;
|
||||
}
|
||||
|
||||
/** Whether `userId` is one of the users workspace `laneId` is shared with. */
|
||||
function sharedWith(laneId, userId) {
|
||||
if (laneId == null || userId == null) return false;
|
||||
return Boolean(
|
||||
sql("SELECT 1 FROM lane_shares WHERE lane_id = ? AND user_id = ?").get(Number(laneId), userId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-only actions on a workspace (rename, reset, remove, sharing): its
|
||||
* owner or an admin; always true in local mode. A user it is shared with may
|
||||
* do everything else, which `canSeeLane` decides.
|
||||
*/
|
||||
function isLaneOwner(req, laneId) {
|
||||
const u = req?.user;
|
||||
if (!u || u.role === "admin") return true;
|
||||
const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
|
||||
return Boolean(row) && row.owner_id === u.id;
|
||||
}
|
||||
|
||||
function canSeeLane(req, laneId) {
|
||||
@@ -86,9 +164,7 @@ function canSeeLane(req, laneId) {
|
||||
// Bound as given, like getLane binds a route param: SQLite's integer
|
||||
// affinity reads '9002.0' or ' 9002' as 9002 for both, so the row checked
|
||||
// here is the row the route would load.
|
||||
const v = typeof laneId === "number" ? laneId : String(laneId);
|
||||
const row = sql("SELECT owner_id FROM lanes WHERE id = ?").get(v);
|
||||
return Boolean(row) && row.owner_id === uid;
|
||||
return laneAccess(req, laneId) != null;
|
||||
}
|
||||
|
||||
function canSeeSession(req, sessionId) {
|
||||
@@ -109,7 +185,13 @@ function canSeeAgent(req, agentId) {
|
||||
function canSeeRun(req, runId) {
|
||||
if (memberId(req) == null) return true;
|
||||
const m = /^agenthub-lane-(\d+)(?:-\d+)?(?:@.*)?$/.exec(String(runId || ""));
|
||||
if (m) return canSeeLane(req, m[1]);
|
||||
if (m) {
|
||||
const access = laneAccess(req, m[1]);
|
||||
if (access !== "session") return access != null;
|
||||
// A session shared on its own: only the pane holding that session.
|
||||
const row = sql("SELECT session_id FROM dashboard_runs WHERE id = ?").get(String(runId));
|
||||
return Boolean(row?.session_id) && sessionSharedWith(row.session_id, memberId(req));
|
||||
}
|
||||
const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(runId));
|
||||
return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id);
|
||||
}
|
||||
@@ -118,9 +200,9 @@ function canSeeRun(req, runId) {
|
||||
function visibleLaneIds(req) {
|
||||
const uid = memberId(req);
|
||||
if (uid == null) return null;
|
||||
return sql("SELECT id FROM lanes WHERE owner_id = ?")
|
||||
.all(uid)
|
||||
.map((r) => r.id);
|
||||
return sql(SEEN_LANES)
|
||||
.all(uid, uid, uid)
|
||||
.map((r) => r.lane_id ?? r.id);
|
||||
}
|
||||
|
||||
/** A row of `table` (`id` = `rowId`) whose `session_id` this request may see. */
|
||||
@@ -133,8 +215,11 @@ function canSeeRowSession(req, table, rowId, idCol = "id") {
|
||||
/** A `dashboard_runs` row (`/api/run/history/:id`) of one of this request's workspaces. */
|
||||
function canSeeRunRow(req, id) {
|
||||
if (memberId(req) == null) return true;
|
||||
const row = sql("SELECT lane_id FROM dashboard_runs WHERE id = ?").get(String(id));
|
||||
return Boolean(row && row.lane_id != null) && canSeeLane(req, row.lane_id);
|
||||
const row = sql("SELECT lane_id, session_id FROM dashboard_runs WHERE id = ?").get(String(id));
|
||||
if (!row || row.lane_id == null) return false;
|
||||
const access = laneAccess(req, row.lane_id);
|
||||
if (access !== "session") return access != null;
|
||||
return Boolean(row.session_id) && sessionSharedWith(row.session_id, memberId(req));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,6 +320,28 @@ function scopeGuard(req, res, next) {
|
||||
if (bad) return notFound();
|
||||
if (lanes.some((id) => !/^\d+$/.test(id) || !canSeeLane(req, Number(id)))) return notFound();
|
||||
if (sessions.some((id) => !canSeeSession(req, id))) return notFound();
|
||||
// A workspace seen only through a session shared on its own (phase 3):
|
||||
// that session, its pane and the workspace's Changes — nothing else of it.
|
||||
const write = req.method !== "GET" && req.method !== "HEAD";
|
||||
const pm = /^\/(?:lanes|workspaces)\/(\d+)(\/.*)?$/i.exec(p);
|
||||
if (
|
||||
pm &&
|
||||
laneAccess(req, Number(pm[1])) === "session" &&
|
||||
!sessionShareAllows(req, pm[2] || "", Number(pm[1]))
|
||||
) {
|
||||
return sessionOnly(res);
|
||||
}
|
||||
const viaSession = lanes.some((id) => laneAccess(req, Number(id)) === "session");
|
||||
// Workspace-wide reads by `?laneId=`: files and skills of every session, stage history.
|
||||
if (
|
||||
viaSession &&
|
||||
(write || /^\/(artifacts|lanes\/pipeline-stats|workspaces\/pipeline-stats)(\/|$)/i.test(p))
|
||||
) {
|
||||
return sessionOnly(res);
|
||||
}
|
||||
// The shared session itself: open it, never rename or archive it.
|
||||
const sm = /^\/sessions\/([^/]+)/i.exec(p);
|
||||
if (write && sm && sessionOnlyVia(req, sm[1])) return sessionOnly(res);
|
||||
// S3: a WRITE to a workspace re-checks that its owner may still own that
|
||||
// folder, by the LIVE role — an admin demoted since keeps owning their
|
||||
// host-folder workspaces, and `/up`, `/hook`, a commit (git hooks) or a
|
||||
@@ -255,6 +362,55 @@ function scopeGuard(req, res, next) {
|
||||
return next();
|
||||
}
|
||||
|
||||
/** Whether this request sees session `sid` only because it is shared with them on its own. */
|
||||
function sessionOnlyVia(req, sid) {
|
||||
const uid = memberId(req);
|
||||
if (uid == null || !sessionSharedWith(sid, uid)) return false;
|
||||
const row = sql("SELECT lane_id FROM sessions WHERE id = ?").get(String(sid));
|
||||
return !row?.lane_id || laneAccess(req, row.lane_id) === "session";
|
||||
}
|
||||
|
||||
const sessionOnly = (res) =>
|
||||
res.status(403).json({
|
||||
error: {
|
||||
code: "ESESSIONSHARE",
|
||||
message: "only a session of this workspace is shared with you: open that session",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* What a user with only a session of workspace (rest = the path after
|
||||
* `/lanes/<id>`) may call there: reads of the workspace, its git, files and
|
||||
* share list (not its instructions or context); git writes; and a `start` that
|
||||
* resumes a session shared with them — no new session, fork or terminal.
|
||||
*/
|
||||
function sessionShareAllows(req, rest, laneId) {
|
||||
const r = rest.toLowerCase();
|
||||
// A workspace's extra folders are its own, not the session's.
|
||||
if (req.query?.source !== undefined) return false;
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
return r === "" || r === "/" || /^\/(git|fs|shares)(\/|$)/.test(r);
|
||||
}
|
||||
if (/^\/git\//.test(r)) return true;
|
||||
if (r === "/start") {
|
||||
const b = req.body && typeof req.body === "object" ? req.body : {};
|
||||
return (
|
||||
typeof b.resumeSessionId === "string" &&
|
||||
b.forkSession !== true &&
|
||||
b.shell !== true &&
|
||||
b.resume !== true &&
|
||||
// A free slot the server picks (no slot means pane 0, the workspace's
|
||||
// own run — another user's live agent there would answer ERUNLIVE).
|
||||
b.slot === "auto" &&
|
||||
sessionSharedWith(b.resumeSessionId, memberId(req)) &&
|
||||
Boolean(
|
||||
sql("SELECT 1 FROM sessions WHERE id = ? AND lane_id = ?").get(b.resumeSessionId, laneId)
|
||||
)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a workspace's owner may still own its folder (S3, live role). */
|
||||
function ownerStillFits(laneId) {
|
||||
const lane = sql("SELECT cwd, owner_id FROM lanes WHERE id = ?").get(laneId);
|
||||
@@ -292,6 +448,42 @@ function laneOwners(laneId) {
|
||||
return new Set(row && row.owner_id != null ? [row.owner_id] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The users a workspace is shared with — they get its news, but it is not
|
||||
* "theirs". `sessions`: also those with only a session of it (its row
|
||||
* changes, never its stack's output).
|
||||
*/
|
||||
function laneViewers(laneId, { sessions = false } = {}) {
|
||||
if (laneId == null) return new Set();
|
||||
return new Set(
|
||||
sql(
|
||||
`SELECT user_id FROM lane_shares WHERE lane_id = ?${
|
||||
sessions
|
||||
? ` UNION SELECT user_id FROM session_shares
|
||||
WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?)`
|
||||
: ""
|
||||
}`
|
||||
)
|
||||
.all(...(sessions ? [Number(laneId), Number(laneId)] : [Number(laneId)]))
|
||||
.map((r) => r.user_id)
|
||||
);
|
||||
}
|
||||
|
||||
/** The users the workspace holding a session is shared with. */
|
||||
function sessionViewers(sessionId) {
|
||||
if (!sessionId) return new Set();
|
||||
return new Set(
|
||||
sql(
|
||||
`SELECT user_id FROM lane_shares WHERE lane_id IN (
|
||||
SELECT lane_id FROM sessions WHERE id = ? AND lane_id IS NOT NULL
|
||||
UNION SELECT lane_id FROM dashboard_runs WHERE session_id = ? AND lane_id IS NOT NULL)
|
||||
UNION SELECT user_id FROM session_shares WHERE session_id = ?`
|
||||
)
|
||||
.all(String(sessionId), String(sessionId), String(sessionId))
|
||||
.map((r) => r.user_id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a broadcast may reach: `null` = every client, `{owners: Set<userId>}` =
|
||||
* admin/analyst plus those members, `{staffOnly: true}` = admin/analyst only.
|
||||
@@ -303,25 +495,31 @@ function broadcastAudience(type, data) {
|
||||
switch (type) {
|
||||
case "session_created":
|
||||
case "session_updated":
|
||||
return { owners: sessionOwners(d.id) };
|
||||
return { owners: sessionOwners(d.id), viewers: sessionViewers(d.id) };
|
||||
case "agent_created":
|
||||
case "agent_updated":
|
||||
case "new_event":
|
||||
case "workflow_upserted":
|
||||
case "transcript_changed":
|
||||
return { owners: sessionOwners(d.session_id) };
|
||||
return { owners: sessionOwners(d.session_id), viewers: sessionViewers(d.session_id) };
|
||||
case "alert_triggered":
|
||||
case "alert_updated":
|
||||
if (d.acked_all) return null;
|
||||
return d.session_id ? { owners: sessionOwners(d.session_id) } : STAFF;
|
||||
return d.session_id
|
||||
? { owners: sessionOwners(d.session_id), viewers: sessionViewers(d.session_id) }
|
||||
: STAFF;
|
||||
case "lane_update":
|
||||
// A removal names an id that no longer resolves; it reveals nothing.
|
||||
if (d.removed != null && !d.lane) return null;
|
||||
return { owners: laneOwners(d.lane?.id) };
|
||||
return {
|
||||
owners: laneOwners(d.lane?.id),
|
||||
viewers: laneViewers(d.lane?.id, { sessions: true }),
|
||||
};
|
||||
case "pane_presence":
|
||||
case "lane_hook_output":
|
||||
case "lane_hook_result":
|
||||
case "lane_runtime":
|
||||
return { owners: laneOwners(d.laneId) };
|
||||
return { owners: laneOwners(d.laneId), viewers: laneViewers(d.laneId) };
|
||||
// A shared artifact is everyone's news; a private one, its owner's and
|
||||
// the people it is shared with by name.
|
||||
case "artifact_changed":
|
||||
@@ -359,6 +557,8 @@ function broadcastAudience(type, data) {
|
||||
function exactAudience(type, data) {
|
||||
const d = data && typeof data === "object" ? data : {};
|
||||
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
|
||||
// A workspace no longer shared with these users: only they re-read their list.
|
||||
if (type === "lane_access" && Array.isArray(d.userIds)) return new Set(d.userIds);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -366,7 +566,10 @@ function exactAudience(type, data) {
|
||||
function audienceIncludes(audience, user) {
|
||||
if (!user || user.role !== "developer") return true;
|
||||
if (audience === null || audience.strip) return true;
|
||||
return Boolean(audience.owners && audience.owners.has(user.id));
|
||||
return Boolean(
|
||||
(audience.owners && audience.owners.has(user.id)) ||
|
||||
(audience.viewers && audience.viewers.has(user.id))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,8 +640,8 @@ function insideOwnWorkspace(req, dir) {
|
||||
const u = req?.user;
|
||||
if (!u || u.role === "admin") return true;
|
||||
return insideLanes(
|
||||
sql("SELECT id FROM lanes WHERE owner_id = ?")
|
||||
.all(u.id)
|
||||
sql(MY_LANES)
|
||||
.all(u.id, u.id)
|
||||
.map((r) => r.id),
|
||||
dir
|
||||
);
|
||||
@@ -488,12 +691,18 @@ module.exports = {
|
||||
PATH_RULES,
|
||||
canSeeRowSession,
|
||||
canSeeRunRow,
|
||||
laneAccess,
|
||||
sessionSharedWith,
|
||||
folderAllowed,
|
||||
ownFolder,
|
||||
memberId,
|
||||
ownerFor,
|
||||
sessionWhere,
|
||||
ownSessionWhere,
|
||||
ownedOnlySessionWhere,
|
||||
isLaneOwner,
|
||||
sharedWith,
|
||||
laneViewers,
|
||||
bySessionWhere,
|
||||
laneWhere,
|
||||
canSeeLane,
|
||||
|
||||
+101
-3
@@ -18,16 +18,77 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Hostnames that count as "this machine". "0.0.0.0" is included because a
|
||||
// browser may resolve a 0.0.0.0 bind via localhost; an empty Host is treated as
|
||||
// loopback (HTTP/1.0 / local tooling).
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", ""]);
|
||||
|
||||
/** The interface to bind. Loopback unless the operator opts into a wider bind. */
|
||||
/**
|
||||
* Every address to bind, in order: `DASHBOARD_HOST` may list several,
|
||||
* comma-separated (`192.168.1.20,100.64.0.7`). Loopback unless the operator
|
||||
* opts into a wider bind.
|
||||
*/
|
||||
function resolveHosts() {
|
||||
const list = (process.env.DASHBOARD_HOST || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return list.length ? [...new Set(list)] : ["127.0.0.1"];
|
||||
}
|
||||
|
||||
/** The first (main) address to bind. */
|
||||
function resolveHost() {
|
||||
const h = (process.env.DASHBOARD_HOST || "").trim();
|
||||
return h || "127.0.0.1";
|
||||
return resolveHosts()[0];
|
||||
}
|
||||
|
||||
/** An all-interfaces bind (it answers on loopback too). */
|
||||
const isWildcardHost = (h) => ["0.0.0.0", "::", "[::]"].includes(String(h || "").trim());
|
||||
|
||||
/**
|
||||
* The addresses to listen on besides the main one: the rest of the list, plus
|
||||
* 127.0.0.1 when no address in it already answers there. The hook handler,
|
||||
* the CLI, the MCP server and the statusline all call 127.0.0.1 — a dashboard
|
||||
* bound only to a LAN address (WSL in mirrored networking, where Windows
|
||||
* cannot reach the VM's loopback) would otherwise lose every hook.
|
||||
*/
|
||||
function extraListenHosts() {
|
||||
const hosts = resolveHosts();
|
||||
const extra = hosts.slice(1).map((h) => h.replace(/^\[(.*)\]$/, "$1"));
|
||||
const coversLoopback = hosts.some(
|
||||
(h) => isWildcardHost(h) || ["127.0.0.1", "localhost"].includes(h.toLowerCase())
|
||||
);
|
||||
if (!coversLoopback) extra.push("127.0.0.1");
|
||||
return [...new Set(extra)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The URLs a browser can open this dashboard at, for the start-up line and the
|
||||
* CLI: `localhost` first, then each bound address — every non-internal IPv4
|
||||
* address of this machine for a wildcard bind.
|
||||
*/
|
||||
function accessUrls(port) {
|
||||
const os = require("node:os");
|
||||
const urls = new Set([`http://localhost:${port}`]);
|
||||
for (const h of resolveHosts()) {
|
||||
if (isWildcardHost(h)) {
|
||||
for (const addrs of Object.values(os.networkInterfaces())) {
|
||||
for (const a of addrs || []) {
|
||||
if (a.family === "IPv4" && !a.internal) urls.add(`http://${a.address}:${port}`);
|
||||
}
|
||||
}
|
||||
} else if (!isLoopbackHostname(h)) {
|
||||
urls.add(`http://${h.includes(":") && !h.startsWith("[") ? `[${h}]` : h}:${port}`);
|
||||
}
|
||||
}
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
/** Whether any bind is reachable from beyond this machine. */
|
||||
function isExposedBind() {
|
||||
return resolveHosts().some((h) => isWildcardHost(h) || !isLoopbackHostname(h));
|
||||
}
|
||||
|
||||
function isLoopbackHostname(name) {
|
||||
@@ -101,6 +162,36 @@ function getDashboardToken() {
|
||||
return typeof t === "string" && t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
/** Where the running server publishes its token for the CLI, MCP server and statusline. */
|
||||
function tokenFilePath() {
|
||||
const { getDataDir } = require("./claude-home");
|
||||
return path.join(getDataDir(), "runtime", "dashboard-token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the active token (0600, this account only) so a local CLI/MCP/
|
||||
* statusline started from a shell that never exported DASHBOARD_TOKEN can
|
||||
* still call a token-gated dashboard; remove a stale one when no token is
|
||||
* set. Best effort: never throws.
|
||||
*/
|
||||
function publishDashboardToken() {
|
||||
const file = tokenFilePath();
|
||||
const token = getDashboardToken();
|
||||
try {
|
||||
if (!token) {
|
||||
fs.rmSync(file, { force: true });
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, token, { mode: 0o600 });
|
||||
fs.chmodSync(tmp, 0o600);
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (e) {
|
||||
console.warn(`[security] could not publish the dashboard token: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function tokensMatch(provided, expected) {
|
||||
if (typeof provided !== "string" || provided.length === 0) return false;
|
||||
const a = Buffer.from(provided);
|
||||
@@ -162,6 +253,11 @@ function isWebSocketAuthorized(req) {
|
||||
module.exports = {
|
||||
LOOPBACK_HOSTS,
|
||||
resolveHost,
|
||||
resolveHosts,
|
||||
extraListenHosts,
|
||||
isWildcardHost,
|
||||
isExposedBind,
|
||||
accessUrls,
|
||||
isLoopbackHostname,
|
||||
allowedHostnames,
|
||||
hostnameOf,
|
||||
@@ -169,6 +265,8 @@ module.exports = {
|
||||
hostGuard,
|
||||
corsOptions,
|
||||
getDashboardToken,
|
||||
tokenFilePath,
|
||||
publishDashboardToken,
|
||||
tokenGuard,
|
||||
isWebSocketAuthorized,
|
||||
// exported for tests
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @file session-payers.js
|
||||
* @description Who pays for a session, hour by hour (server mode, workspace
|
||||
* sharing phase 2 — docs/plans/workspace-sharing/). A session is its
|
||||
* workspace OWNER's money until someone else runs it: a user the workspace is
|
||||
* shared with starts or resumes it (`dashboard_runs.started_by`), and from
|
||||
* then on it is theirs — until the owner (or another collaborator) runs it
|
||||
* again. Recorded in `session_payers` (session, from UTC hour, user); a
|
||||
* session with no row is billed exactly as before.
|
||||
*
|
||||
* Hours are the booking unit (`hourlyTokenRows`), so a switch takes effect
|
||||
* from the NEXT hour: the hour a resume happens in stays with the previous
|
||||
* payer, while a session the run itself started is the starter's from its
|
||||
* first hour (`0000`). The rest of a session's total that no hour holds is
|
||||
* booked at its start hour, like every other report.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const db = () => require("../db").db;
|
||||
|
||||
const WHOLE = "0000";
|
||||
const HOUR_MS = 3_600_000;
|
||||
const hourOf = (ms) => new Date(ms).toISOString().slice(0, 13);
|
||||
/** How far before its run a session may have started and still be the run's own (clock skew). */
|
||||
const NEW_SESSION_SLACK_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Note the payer of run `runId`'s session — its starter, else the workspace
|
||||
* owner — once the run knows its session. Best effort: a run never fails
|
||||
* because this did.
|
||||
*/
|
||||
function noteRun(runId, now = Date.now()) {
|
||||
try {
|
||||
const run = db()
|
||||
.prepare(
|
||||
`SELECT r.session_id, r.started_by, r.started_at, r.tool, l.owner_id
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.id = ?`
|
||||
)
|
||||
.get(runId);
|
||||
// A Terminal tab is no account of its starter's: a `claude` typed there
|
||||
// runs on the folder account's own login, so it stays the owner's money.
|
||||
if (!run?.session_id || run.owner_id == null || run.tool === "shell") return;
|
||||
const payer = run.started_by ?? run.owner_id;
|
||||
const put = db().prepare(
|
||||
"INSERT OR REPLACE INTO session_payers (session_id, from_hour, user_id) VALUES (?, ?, ?)"
|
||||
);
|
||||
db().transaction(() => {
|
||||
const rows = timeline(run.session_id);
|
||||
const current = rows.length ? rows[rows.length - 1].user_id : run.owner_id;
|
||||
if (current === payer) return;
|
||||
// The run's own new session (started with it, or not seen yet) is the
|
||||
// starter's whole; a resumed one changes hands from the hour after the
|
||||
// run started (a hook may tell us late).
|
||||
const s = db().prepare("SELECT started_at FROM sessions WHERE id = ?").get(run.session_id);
|
||||
const startedMs = Date.parse(run.started_at);
|
||||
const fresh = !s || Date.parse(s.started_at) >= startedMs - NEW_SESSION_SLACK_MS;
|
||||
const from =
|
||||
fresh && !rows.length
|
||||
? WHOLE
|
||||
: hourOf((Number.isFinite(startedMs) ? Math.min(startedMs, now) : now) + HOUR_MS);
|
||||
// The first change of hands: the owner's part becomes a row too, so the
|
||||
// timeline alone answers for every hour.
|
||||
if (!rows.length && from !== WHOLE) put.run(run.session_id, WHOLE, run.owner_id);
|
||||
put.run(run.session_id, from, payer);
|
||||
})();
|
||||
} catch (err) {
|
||||
console.warn(`[session-payers] run ${runId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Who pays for `sessionId` now: its timeline's last payer, else `ownerId`. */
|
||||
function currentPayer(sessionId, ownerId) {
|
||||
const rows = timeline(sessionId);
|
||||
return rows.length ? rows[rows.length - 1].user_id : ownerId;
|
||||
}
|
||||
|
||||
function timeline(sessionId) {
|
||||
return db()
|
||||
.prepare(
|
||||
"SELECT from_hour, user_id FROM session_payers WHERE session_id = ? ORDER BY from_hour"
|
||||
)
|
||||
.all(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every split session's timeline: `{has(sessionId), payer(sessionId, hour)}`.
|
||||
* `hour` is an hourly row's `date` (`YYYY-MM-DDTHH`).
|
||||
*/
|
||||
function index() {
|
||||
const map = new Map();
|
||||
for (const r of db()
|
||||
.prepare("SELECT session_id, from_hour, user_id FROM session_payers ORDER BY from_hour")
|
||||
.all()) {
|
||||
if (!map.has(r.session_id)) map.set(r.session_id, []);
|
||||
map.get(r.session_id).push(r);
|
||||
}
|
||||
return {
|
||||
has: (sid) => map.has(sid),
|
||||
payer(sid, hour) {
|
||||
const rows = map.get(sid);
|
||||
if (!rows) return null;
|
||||
let uid = rows[0].user_id;
|
||||
for (const r of rows) {
|
||||
if (r.from_hour <= hour) uid = r.user_id;
|
||||
else break;
|
||||
}
|
||||
return uid;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** SQL: the split sessions (`s.id IN (…)` / `NOT IN`). */
|
||||
const SPLIT = "SELECT session_id FROM session_payers";
|
||||
|
||||
/**
|
||||
* Daily-shaped token rows (`date` = local date for `tzModifier`, as
|
||||
* `dailyTokenRows`) of the hours `uid` pays for in split sessions matching
|
||||
* `whereClause` — which must already narrow to `s.id IN (SPLIT)`.
|
||||
*/
|
||||
function paidDailyRows(uid, whereClause, params, tzModifier = "+0 minutes") {
|
||||
const { hourlyTokenRows } = require("../routes/pricing");
|
||||
const shift = Number.parseInt(String(tzModifier), 10) || 0;
|
||||
const idx = index();
|
||||
const out = [];
|
||||
for (const r of hourlyTokenRows(whereClause, params)) {
|
||||
if (idx.payer(r.session_id, r.date) !== uid) continue;
|
||||
const { session_id: _sid, ...row } = r;
|
||||
row.date = new Date(Date.parse(`${r.date}:00:00Z`) + shift * 60_000).toISOString().slice(0, 10);
|
||||
out.push(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map(session id → first-wins owner) of `OWNED_SESSIONS`, with each hour's payer laid over it. */
|
||||
function payerResolver() {
|
||||
const { OWNED_SESSIONS } = require("../routes/users");
|
||||
const owner = new Map();
|
||||
for (const r of db().prepare(OWNED_SESSIONS).all()) {
|
||||
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
|
||||
}
|
||||
const idx = index();
|
||||
/** The user an hourly row's money is (null: nobody's). */
|
||||
return (row) =>
|
||||
idx.has(row.session_id)
|
||||
? idx.payer(row.session_id, row.date)
|
||||
: (owner.get(row.session_id) ?? null);
|
||||
}
|
||||
|
||||
module.exports = { noteRun, currentPayer, index, paidDailyRows, payerResolver, SPLIT, WHOLE };
|
||||
+25
-4
@@ -1003,16 +1003,30 @@ const UPGRADES = {
|
||||
win32: {
|
||||
min: [3, 4, 0],
|
||||
name: "psmux",
|
||||
// An older psmux stalls a dashboard terminal: a real fault.
|
||||
severity: "error",
|
||||
impact: "dashboard terminals can stall until a page refresh",
|
||||
how: "close every AgentHub terminal pane, then run: winget upgrade marlocarlo.psmux",
|
||||
},
|
||||
darwin: { min: [3, 7, 0], name: "tmux", how: "brew upgrade tmux" },
|
||||
darwin: {
|
||||
min: [3, 7, 0],
|
||||
name: "tmux",
|
||||
severity: "warning",
|
||||
impact: "only the pane scrollbar is missing; terminals work",
|
||||
how: "brew upgrade tmux",
|
||||
},
|
||||
linux: {
|
||||
min: [3, 7, 0],
|
||||
name: "tmux",
|
||||
// Only the pane scrollbar is missing: never a reason a terminal fails.
|
||||
severity: "warning",
|
||||
impact: "only the pane scrollbar is missing; terminals work",
|
||||
how:
|
||||
"distros ship older ones (Ubuntu 24.04 has 3.4), so build the release from " +
|
||||
"https://github.com/tmux/tmux/releases (needs libevent-dev libncurses-dev bison: " +
|
||||
"./configure && make && sudo make install)",
|
||||
"https://github.com/tmux/tmux/releases into your own ~/.local, leaving the " +
|
||||
"system's tmux alone (needs libevent-dev libncurses-dev bison: " +
|
||||
"./configure --prefix=$HOME/.local && make && make install, with ~/.local/bin " +
|
||||
"first in PATH; then restart AgentHub once every terminal pane is closed)",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1026,7 +1040,13 @@ function upgradeAdvice(version = clientVersion(), platform = process.platform) {
|
||||
const have = [m[1], m[2], m[3] || 0].map(Number);
|
||||
const i = have.findIndex((n, k) => n !== want.min[k]);
|
||||
if (i === -1 || have[i] > want.min[i]) return null;
|
||||
return `${want.name} ${version} is older than ${want.min.join(".")} — ${want.how}`;
|
||||
return `${want.name} ${version} is older than ${want.min.join(".")} (${want.impact}) — ${want.how}`;
|
||||
}
|
||||
|
||||
/** How much an old version matters here: `error` (psmux: terminals stall) or
|
||||
* `warning` (tmux: no pane scrollbar); null when there is nothing to say. */
|
||||
function upgradeSeverity(version = clientVersion(), platform = process.platform) {
|
||||
return upgradeAdvice(version, platform) ? UPGRADES[platform].severity : null;
|
||||
}
|
||||
|
||||
/** The RUNNING server's version, null when none runs. A server started before
|
||||
@@ -1063,6 +1083,7 @@ module.exports = {
|
||||
clientVersion,
|
||||
serverVersion,
|
||||
upgradeAdvice,
|
||||
upgradeSeverity,
|
||||
serverPid,
|
||||
memberAttach,
|
||||
memberServerInfo,
|
||||
|
||||
+106
-27
@@ -8,8 +8,10 @@
|
||||
* browser's getTimezoneOffset() when they saved it) or `5h` (a sliding
|
||||
* window: the current UTC hour and the four before it — spend booked in hour
|
||||
* H counts until H + 5 h). Spend is the users page's own booking
|
||||
* (`hourlyTokenRows` over the sessions a user OWNS through a workspace,
|
||||
* priced by `calculateCost`), never a second formula.
|
||||
* (`hourlyTokenRows` over the sessions a user OWNS through a workspace, each
|
||||
* hour counted for its payer — session-payers.js — priced by `calculateCost`),
|
||||
* never a second formula. A pane a collaborator opened in a workspace shared
|
||||
* with them is THEIR quota's: its start, its flag and the sweep go by payer.
|
||||
*
|
||||
* A user over their quota cannot chat on until it resets:
|
||||
* - `assertSpawnAllowed` refuses every new pane (`EQUOTA`, 429) — start,
|
||||
@@ -203,10 +205,9 @@ function statusAll({ now = Date.now(), userIds = null } = {}) {
|
||||
const { OWNED_SESSIONS } = require("../routes/users");
|
||||
const rules = require("../db").stmts.listPricing.all();
|
||||
const oldest = quotas.reduce((a, q) => (q.win.since < a ? q.win.since : a), quotas[0].win.since);
|
||||
const owner = new Map();
|
||||
for (const r of db().prepare(OWNED_SESSIONS).all()) {
|
||||
if (!owner.has(r.session_id)) owner.set(r.session_id, r.uid);
|
||||
}
|
||||
// Each hour's money is its payer's: the owner's, or a collaborator's who
|
||||
// ran the session then (session-payers.js).
|
||||
const payerOf = require("./session-payers").payerResolver();
|
||||
// A session that spent in the window was touched in it.
|
||||
const rows = hourlyTokenRows(
|
||||
`WHERE COALESCE(NULLIF(s.updated_at, ''), s.started_at) >= ? AND s.id IN (SELECT session_id FROM (${OWNED_SESSIONS}))`,
|
||||
@@ -214,7 +215,7 @@ function statusAll({ now = Date.now(), userIds = null } = {}) {
|
||||
);
|
||||
const byUser = new Map();
|
||||
for (const r of rows) {
|
||||
const uid = owner.get(r.session_id);
|
||||
const uid = payerOf(r);
|
||||
if (uid == null) continue;
|
||||
if (!byUser.has(uid)) byUser.set(uid, []);
|
||||
byUser.get(uid).push(r);
|
||||
@@ -275,7 +276,7 @@ function message(s, now = Date.now()) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Throws EQUOTA when the workspace owner is over their quota. No-op in local mode. */
|
||||
/** Throws EQUOTA when the payer (the workspace owner, or a collaborator) is over their quota. No-op in local mode. */
|
||||
function assertSpawnAllowed(ownerId) {
|
||||
if (!isServerMode() || ownerId == null) return;
|
||||
const s = statusFor(ownerId);
|
||||
@@ -288,14 +289,65 @@ const FLAG = "quota.json";
|
||||
const hostDir = () => path.join(require("./claude-home").getDataDir(), "user-quota");
|
||||
const hostFlag = (userId) => path.join(hostDir(), `u${userId}.json`);
|
||||
|
||||
/** A collaborator's flag in the workspace owner's handoff dir (sharing phase 2). */
|
||||
const collabFlag = (userId) => `quota-u${userId}.json`;
|
||||
|
||||
/**
|
||||
* The env a pane of `ownerId` gets: where its hooks look for the flag —
|
||||
* the member's handoff dir (S3: the pane runs as them) or the host's data dir.
|
||||
* The env a pane paid for by `payerId` gets: where its hooks look for the
|
||||
* flag — the pane account's handoff dir (S3: the pane runs as the workspace
|
||||
* owner's account; a collaborator's own file there) or the host's data dir.
|
||||
*/
|
||||
function flagEnv(ownerId, member) {
|
||||
if (!isServerMode() || ownerId == null) return {};
|
||||
const file = member ? path.join(require("./run-as").handoffDir(member), FLAG) : hostFlag(ownerId);
|
||||
return { AGENTHUB_QUOTA_FILE: file };
|
||||
function flagEnv(payerId, member, ownerId = payerId) {
|
||||
if (!isServerMode() || payerId == null) return {};
|
||||
if (!member) return { AGENTHUB_QUOTA_FILE: hostFlag(payerId) };
|
||||
const name = payerId === ownerId ? FLAG : collabFlag(payerId);
|
||||
return { AGENTHUB_QUOTA_FILE: path.join(require("./run-as").handoffDir(member), name) };
|
||||
}
|
||||
|
||||
function flagText(s, now) {
|
||||
const body = s?.exceeded
|
||||
? { blocked: true, until: s.resetsAt, message: message(s, now) }
|
||||
: { blocked: false };
|
||||
return `${JSON.stringify(body)}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write collaborator `userId`'s flag in pane account `member`'s handoff dir —
|
||||
* at spawn, so a file left from an earlier share never says the wrong thing.
|
||||
* (The host's per-user flag is the sweep's.) Best effort.
|
||||
*/
|
||||
function writeCollabFlag(userId, member, now = Date.now()) {
|
||||
if (!isServerMode() || !member) return;
|
||||
try {
|
||||
require("./run-as").handoffWrite(
|
||||
member,
|
||||
collabFlag(userId),
|
||||
flagText(statusFor(userId, { now }), now)
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: collaborator flag u${userId} not written:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/** The pane accounts of the workspaces shared with `userId` (not their own). */
|
||||
function sharedAccounts(userId) {
|
||||
const runAs = require("./run-as");
|
||||
const out = new Map();
|
||||
for (const { cwd } of db()
|
||||
.prepare(
|
||||
`SELECT l.cwd FROM lane_shares ls JOIN lanes l ON l.id = ls.lane_id WHERE ls.user_id = ?
|
||||
UNION SELECT l.cwd FROM session_shares ss JOIN sessions s ON s.id = ss.session_id
|
||||
JOIN lanes l ON l.id = s.lane_id WHERE ss.user_id = ?`
|
||||
)
|
||||
.all(userId, userId)) {
|
||||
try {
|
||||
const acct = runAs.accountForPath(cwd);
|
||||
if (acct) out.set(acct.name, acct);
|
||||
} catch {
|
||||
/* EMEMBERGONE: no pane runs there */
|
||||
}
|
||||
}
|
||||
return [...out.values()];
|
||||
}
|
||||
|
||||
function writeHostFlag(userId, text) {
|
||||
@@ -308,10 +360,7 @@ function writeHostFlag(userId, text) {
|
||||
|
||||
/** Write a user's flag everywhere their panes may read it. Best effort per place. */
|
||||
function writeFlags(user, s, now) {
|
||||
const body = s?.exceeded
|
||||
? { blocked: true, until: s.resetsAt, message: message(s, now) }
|
||||
: { blocked: false };
|
||||
const text = `${JSON.stringify(body)}\n`;
|
||||
const text = flagText(s, now);
|
||||
try {
|
||||
writeHostFlag(user.id, text);
|
||||
} catch (err) {
|
||||
@@ -323,11 +372,30 @@ function writeFlags(user, s, now) {
|
||||
} catch {
|
||||
acct = null;
|
||||
}
|
||||
if (!acct) return;
|
||||
if (acct) {
|
||||
try {
|
||||
require("./run-as").handoffWrite(acct, FLAG, text);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: ${user.username}'s flag not written:`, err.message);
|
||||
}
|
||||
}
|
||||
// Their panes in workspaces shared with them run as the owner's account.
|
||||
let shared = [];
|
||||
try {
|
||||
require("./run-as").handoffWrite(acct, FLAG, text);
|
||||
} catch (err) {
|
||||
console.warn(`user quota: ${user.username}'s flag not written:`, err.message);
|
||||
shared = sharedAccounts(user.id);
|
||||
} catch {
|
||||
shared = [];
|
||||
}
|
||||
for (const other of shared) {
|
||||
if (acct && other.name === acct.name) continue;
|
||||
try {
|
||||
require("./run-as").handoffWrite(other, collabFlag(user.id), text);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`user quota: ${user.username}'s flag at ${other.name} not written:`,
|
||||
err.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,15 +406,25 @@ let blocked = new Set();
|
||||
/** user id → the flag text last written, so an unchanged state writes nothing. */
|
||||
const written = new Map();
|
||||
|
||||
function laneOwner(laneId) {
|
||||
/** Who pays for run `runId`: its starter when someone shared the workspace with them, else its owner. */
|
||||
function runPayer(runId) {
|
||||
const r = db()
|
||||
.prepare(
|
||||
`SELECT COALESCE(r.started_by, l.owner_id) AS uid
|
||||
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.id = ?`
|
||||
)
|
||||
.get(runId);
|
||||
if (r) return r.uid ?? null;
|
||||
const laneId = require("./pty-run").laneIdFromRunId(runId);
|
||||
if (laneId == null) return null;
|
||||
return db().prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id ?? null;
|
||||
}
|
||||
|
||||
/** Is run `runId`'s workspace owner over quota (by the last sweep)? omp-share asks. */
|
||||
/** Is run `runId`'s payer over quota (by the last sweep)? omp-share asks. */
|
||||
function isRunBlocked(runId) {
|
||||
if (!blocked.size) return false;
|
||||
const laneId = require("./pty-run").laneIdFromRunId(runId);
|
||||
return laneId != null && blocked.has(laneOwner(laneId));
|
||||
const uid = runPayer(runId);
|
||||
return uid != null && blocked.has(uid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,7 +462,7 @@ function endUnstoppable(userIds) {
|
||||
const runAs = require("./run-as");
|
||||
const ended = [];
|
||||
for (const run of runs.listRuns()) {
|
||||
if (run.status !== "running" || !userIds.has(laneOwner(run.laneId))) continue;
|
||||
if (run.status !== "running" || !userIds.has(runPayer(run.id))) continue;
|
||||
if (hookCanStop(run, runAs.accountForLane(run.laneId))) continue;
|
||||
try {
|
||||
if (runs.killRun(run.id)) ended.push(run.id);
|
||||
@@ -460,6 +538,7 @@ module.exports = {
|
||||
message,
|
||||
assertSpawnAllowed,
|
||||
flagEnv,
|
||||
writeCollabFlag,
|
||||
isRunBlocked,
|
||||
hookCanStop,
|
||||
sweep,
|
||||
|
||||
@@ -701,6 +701,97 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/shares": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The users a workspace is shared with",
|
||||
description:
|
||||
"Server mode (local mode: `{shares: [], canManage: false}`). `{shares:[{id, username, name, sharedAt}], canManage}` — names only; `canManage` is whether this caller (the owner or an admin) may change the list. A user it is shared with may read it too.",
|
||||
operationId: "getLaneShares",
|
||||
parameters: [idParam],
|
||||
responses: { 200: { description: "`{shares, canManage}`.", ...json(obj) }, 404: notFound },
|
||||
},
|
||||
put: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Set the users a workspace is shared with",
|
||||
description:
|
||||
"`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; each user added or taken off is written to the audit log. A user it is shared with sees it and works in it like its owner — under S3 as the owner's Linux account — except rename, reset, remove, delete and sharing (`ENOTOWNER`), and a session they start runs on their own account (else `409 ENOCOLLABACCOUNT`) and is billed to them. Users taken off get `lane_access` and lose access at once.",
|
||||
operationId: "setLaneShares",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["userIds"],
|
||||
properties: { userIds: { type: "array", items: { type: "integer" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{shares}`.", ...json(obj) },
|
||||
400: { description: "EBADUSER or ELOCALMODE." },
|
||||
403: { description: "ENOTOWNER." },
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/presence": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Who has which of a workspace's panes open",
|
||||
description:
|
||||
"Server mode (local mode: `{panes: {}}`). `{panes: {<run id>: [{id, name}]}}` — the users with a terminal attached to each pane, from memory; changes arrive as the WebSocket message `pane_presence` `{laneId, runId, users}` (the workspace's owner and the users it is shared with).",
|
||||
operationId: "getLanePresence",
|
||||
parameters: [idParam],
|
||||
responses: { 200: { description: "`{panes}`.", ...json(obj) }, 404: notFound },
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/sessions/{sid}/shares": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The users one session of a workspace is shared with",
|
||||
description:
|
||||
"Server mode (local mode: `{shares: [], canManage: false}`). `{shares:[{id, username, name, sharedAt}], canManage}` for session `sid` of this workspace (`404 ENOSESSION` otherwise).",
|
||||
operationId: "getSessionShares",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "sid", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: { 200: { description: "`{shares, canManage}`.", ...json(obj) }, 404: notFound },
|
||||
},
|
||||
put: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Set the users one session of a workspace is shared with",
|
||||
description:
|
||||
"`{userIds:[…]}` is the WHOLE list (≤ 200 enabled users, never the workspace's owner). The owner or an admin only (`403 ENOTOWNER`); same-origin; audited as `share session <id>` / `unshare session <id>`. A user it is shared with sees that session and its workspace (`access: \"session\"` in the workspace list), joins the pane holding it, resumes it on their own account and uses the workspace's git — every other call on the workspace answers them `403 ESESSIONSHARE`.",
|
||||
operationId: "setSessionShares",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "sid", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["userIds"],
|
||||
properties: { userIds: { type: "array", items: { type: "integer" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{shares}`.", ...json(obj) },
|
||||
400: { description: "EBADUSER or ELOCALMODE." },
|
||||
403: { description: "ENOTOWNER." },
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/memory": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
|
||||
+213
-6
@@ -229,11 +229,23 @@ function broadcastLane(id) {
|
||||
router.get("/", (req, res) => {
|
||||
// Server mode: a member lists (and counts) only their own workspaces.
|
||||
const visible = scope.visibleLaneIds(req);
|
||||
const rows = lanesLib.listLanes().filter((l) => visible === null || visible.includes(l.id));
|
||||
// A collaborator's pane token: its own workspace only (auth.js paneLaneAllows).
|
||||
const paneLane = req.user?.paneLane ?? null;
|
||||
const rows = lanesLib
|
||||
.listLanes()
|
||||
.filter(
|
||||
(l) => (visible === null || visible.includes(l.id)) && (paneLane == null || l.id === paneLane)
|
||||
);
|
||||
// ONE list-sessions answers every lane's run, instead of a synchronous
|
||||
// has-session per lane (null when tmux could not be asked: per-lane then).
|
||||
const live = rows.some((l) => l.run_id) ? tmux.liveSessions() : null;
|
||||
const lanes = rows.map((l) => payload(l, live));
|
||||
// `access: "session"`: only sessions of it are shared with this user (phase 3).
|
||||
const lanes = rows.map((l) => {
|
||||
const p = payload(l, live);
|
||||
return visible !== null && scope.laneAccess(req, l.id) === "session"
|
||||
? { ...p, access: "session" }
|
||||
: p;
|
||||
});
|
||||
res.json({
|
||||
lanes,
|
||||
counts: {
|
||||
@@ -426,6 +438,13 @@ router.get("/branches", async (req, res) => {
|
||||
/** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */
|
||||
const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]);
|
||||
|
||||
/** A shared workspace's owner-only action (rename, reset, remove, sharing). */
|
||||
function notOwner(res) {
|
||||
return res.status(403).json({
|
||||
error: { code: "ENOTOWNER", message: "only the workspace's owner (or an admin) may do this" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Server mode: the folder is, holds or lies in another user's workspace. */
|
||||
function refuseOwned(res) {
|
||||
return res.status(409).json({
|
||||
@@ -1356,6 +1375,8 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
// that tie a workspace to a pane or a session (`run_id` pointed at another
|
||||
// user's pane would let stop/reset kill it).
|
||||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||
// A workspace's name is its owner's: a user it is shared with renames nothing.
|
||||
if ("title" in body && !scope.isLaneOwner(req, req.params.id)) return notOwner(res);
|
||||
if (scope.memberId(req) != null) {
|
||||
const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k));
|
||||
if (off.length) {
|
||||
@@ -1386,6 +1407,168 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
res.json({ lane: payload(lane) });
|
||||
});
|
||||
|
||||
// ── Sharing a workspace with other users (server mode) ─────────────────────
|
||||
// docs/plans/workspace-sharing/: a user it is shared with sees it and works in
|
||||
// it like its owner — under S3 AS the owner's Linux account — except the
|
||||
// owner-only actions (`scope.isLaneOwner`). Names only, never a password.
|
||||
|
||||
const MAX_SHARES = 200;
|
||||
|
||||
/** The users a workspace (`lane_shares`) or one session (`session_shares`) is shared with. */
|
||||
function sharesOf(table, keyCol, key) {
|
||||
return require("../db")
|
||||
.db.prepare(
|
||||
`SELECT u.id, u.username, COALESCE(NULLIF(u.display_name, ''), u.username) AS name,
|
||||
s.shared_at AS sharedAt, u.disabled
|
||||
FROM ${table} s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.${keyCol} = ? ORDER BY name COLLATE NOCASE`
|
||||
)
|
||||
.all(key)
|
||||
.map((u) => ({ ...u, disabled: Boolean(u.disabled) }));
|
||||
}
|
||||
const laneShares = (laneId) => sharesOf("lane_shares", "lane_id", Number(laneId));
|
||||
const sessionShares = (sessionId) => sharesOf("session_shares", "session_id", String(sessionId));
|
||||
|
||||
/**
|
||||
* Replace the WHOLE share list of a workspace or a session with `req.body.userIds`:
|
||||
* server mode, the workspace's owner or an admin, enabled users only (one
|
||||
* already listed may stay though disabled since), never the owner; every
|
||||
* change audited as `<verb> <what> <id>`. Answers the new list, or sends the
|
||||
* error and answers null.
|
||||
*/
|
||||
function replaceShares(req, res, { lane, table, keyCol, key, what }) {
|
||||
const auth = require("../lib/auth");
|
||||
if (!auth.isServerMode()) {
|
||||
res.status(400).json({ error: { code: "ELOCALMODE", message: "sharing needs server mode" } });
|
||||
return null;
|
||||
}
|
||||
if (!scope.isLaneOwner(req, lane.id)) {
|
||||
notOwner(res);
|
||||
return null;
|
||||
}
|
||||
const raw = req.body?.userIds;
|
||||
if (
|
||||
!Array.isArray(raw) ||
|
||||
raw.length > MAX_SHARES ||
|
||||
!raw.every((x) => Number.isInteger(x) && x > 0)
|
||||
) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADUSER", message: "userIds must be a list of user ids" } });
|
||||
return null;
|
||||
}
|
||||
const { db } = require("../db");
|
||||
const wanted = [...new Set(raw)];
|
||||
const before = new Set(sharesOf(table, keyCol, key).map((u) => u.id));
|
||||
const user = db.prepare("SELECT id, disabled FROM users WHERE id = ?");
|
||||
for (const id of wanted) {
|
||||
const u = user.get(id);
|
||||
// A user already on the list may since have been disabled: keeping them
|
||||
// must not block every other change (it grants nothing while disabled).
|
||||
if (!u || (u.disabled && !before.has(id))) {
|
||||
res.status(400).json({ error: { code: "EBADUSER", message: `no enabled user ${id}` } });
|
||||
return null;
|
||||
}
|
||||
if (id === lane.owner_id) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADUSER", message: `a ${what} is not shared with its owner` } });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const added = wanted.filter((id) => !before.has(id));
|
||||
const removed = [...before].filter((id) => !wanted.includes(id));
|
||||
const actor = req.user?.id ?? null;
|
||||
db.transaction(() => {
|
||||
const del = db.prepare(`DELETE FROM ${table} WHERE ${keyCol} = ? AND user_id = ?`);
|
||||
const ins = db.prepare(
|
||||
`INSERT OR IGNORE INTO ${table} (${keyCol}, user_id, granted_by) VALUES (?, ?, ?)`
|
||||
);
|
||||
for (const id of removed) del.run(key, id);
|
||||
for (const id of added) ins.run(key, id, actor);
|
||||
})();
|
||||
const audit = require("../lib/audit");
|
||||
for (const id of added) audit.recordAccountChange(actor, id, "PUT", `share ${what} ${key}`);
|
||||
for (const id of removed) audit.recordAccountChange(actor, id, "PUT", `unshare ${what} ${key}`);
|
||||
// The owner and everyone it is shared with now re-read it; the users taken
|
||||
// off re-read their list, and their open terminals on it close at the next
|
||||
// attach re-check (websocket.js).
|
||||
broadcastLane(lane.id);
|
||||
if (removed.length) broadcast("lane_access", { laneId: lane.id, userIds: removed });
|
||||
return sharesOf(table, keyCol, key);
|
||||
}
|
||||
|
||||
router.get("/:id/shares", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
if (!require("../lib/auth").isServerMode()) return res.json({ shares: [], canManage: false });
|
||||
res.json({ shares: laneShares(lane.id), canManage: scope.isLaneOwner(req, lane.id) });
|
||||
});
|
||||
|
||||
/** `{userIds}` — the WHOLE list of users the workspace is shared with. */
|
||||
router.put("/:id/shares", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const shares = replaceShares(req, res, {
|
||||
lane,
|
||||
table: "lane_shares",
|
||||
keyCol: "lane_id",
|
||||
key: lane.id,
|
||||
what: "workspace",
|
||||
});
|
||||
if (shares) res.json({ shares });
|
||||
});
|
||||
|
||||
// Who has which of this workspace's panes open (pane-presence.js): server
|
||||
// mode's `{panes: {runId: [{id, name}]}}`; changes come as `pane_presence`.
|
||||
router.get("/:id/presence", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
if (!require("../lib/auth").isServerMode()) return res.json({ panes: {} });
|
||||
res.json({ panes: require("../lib/pane-presence").forLane(lane.id) });
|
||||
});
|
||||
|
||||
/** A session of this workspace (`sessions.lane_id`), or null after sending 404. */
|
||||
function laneSession(req, res, lane) {
|
||||
const row = require("../db")
|
||||
.db.prepare("SELECT id FROM sessions WHERE id = ? AND lane_id = ?")
|
||||
.get(String(req.params.sid), lane.id);
|
||||
if (!row) res.status(404).json({ error: { code: "ENOSESSION", message: "session not found" } });
|
||||
return row;
|
||||
}
|
||||
|
||||
// One session shared on its own (sharing phase 3): the users see that session
|
||||
// and its workspace's Changes, join its pane and resume it (on their own
|
||||
// account) — nothing else of the workspace (scope.js sessionShareAllows).
|
||||
router.get("/:id/sessions/:sid/shares", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const row = laneSession(req, res, lane);
|
||||
if (!row) return;
|
||||
if (!require("../lib/auth").isServerMode()) return res.json({ shares: [], canManage: false });
|
||||
res.json({ shares: sessionShares(row.id), canManage: scope.isLaneOwner(req, lane.id) });
|
||||
});
|
||||
|
||||
router.put("/:id/sessions/:sid/shares", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const row = laneSession(req, res, lane);
|
||||
if (!row) return;
|
||||
const shares = replaceShares(req, res, {
|
||||
lane,
|
||||
table: "session_shares",
|
||||
keyCol: "session_id",
|
||||
key: row.id,
|
||||
what: "session",
|
||||
});
|
||||
if (shares) res.json({ shares });
|
||||
});
|
||||
|
||||
router.post("/:id/stage", sameOriginGuard, (req, res) => {
|
||||
const current = lanesLib.getLane(req.params.id);
|
||||
if (!current) {
|
||||
@@ -3240,6 +3423,11 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const body = req.body || {};
|
||||
// A workspace shared with this user: they may not destroy it. A session
|
||||
// they start or resume runs on their own account and is billed to them
|
||||
// (pty-run.js spawnRun `startedBy`, server/lib/session-payers.js).
|
||||
const startedBy = scope.isLaneOwner(req, lane.id) ? null : (req.user?.id ?? null);
|
||||
if (startedBy != null && DESTRUCTIVE_ACTIONS.has(action)) return notOwner(res);
|
||||
|
||||
if (DESTRUCTIVE_ACTIONS.has(action)) {
|
||||
if (body.confirm !== true) {
|
||||
@@ -3382,7 +3570,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
if (picked === null) return { allBusy: true };
|
||||
slot = picked;
|
||||
if (isLive(current.id, slot)) return { conflict: true };
|
||||
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
|
||||
startedRunId = runs.spawnShell({
|
||||
laneId: current.id,
|
||||
slot,
|
||||
cwd: current.cwd,
|
||||
// A user it is shared with: their pane token acts as them
|
||||
// (the owner's or an admin's acts as the owner, as before).
|
||||
startedBy,
|
||||
}).id;
|
||||
return {};
|
||||
});
|
||||
if (shellOutcome.missing) {
|
||||
@@ -3447,7 +3642,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
const wantProxy =
|
||||
(body.proxy ?? body.router) !== undefined
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(resumeId, lane.owner_id ?? null);
|
||||
: resumeProxy(resumeId, startedBy ?? lane.owner_id ?? null);
|
||||
if (wantProxy === require("../lib/ai-proxies").COMPANY_NAME) {
|
||||
try {
|
||||
await require("../lib/remote-client").ping();
|
||||
@@ -3506,7 +3701,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
? (body.proxy ?? body.router)
|
||||
: resumeProxy(
|
||||
body.resumeSessionId || (body.resume ? current.session_id : null),
|
||||
current.owner_id ?? null
|
||||
startedBy ?? current.owner_id ?? null
|
||||
),
|
||||
model: body.model,
|
||||
permissionMode: body.permissionMode,
|
||||
@@ -3517,6 +3712,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
bypass: body.bypass === true,
|
||||
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
|
||||
forkSession: body.forkSession === true,
|
||||
startedBy,
|
||||
});
|
||||
startedRunId = handle.id;
|
||||
// An extra pane's session is not the lane's run: `run_id` stays the
|
||||
@@ -3587,7 +3783,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// ESESSIONLIMIT (S3): the member already runs the most AI sessions allowed.
|
||||
const status = /^(EBAD|EUNSUPPORTED$|ENOHUB$)/.test(err.code || "")
|
||||
? 400
|
||||
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
||||
: [
|
||||
"ENOTSHARED",
|
||||
"EMEMBERGONE",
|
||||
"EOWNERFOLDER",
|
||||
"ECWDACCOUNT",
|
||||
"ENOCOLLABACCOUNT",
|
||||
"ECOLLABRESUME",
|
||||
].includes(err.code)
|
||||
? 409
|
||||
: err.code === "EQUOTA" || err.code === "ESESSIONLIMIT"
|
||||
? 429
|
||||
@@ -3603,6 +3806,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
if (lanesLib.getLane(req.params.id) && !scope.isLaneOwner(req, req.params.id)) {
|
||||
return notOwner(res);
|
||||
}
|
||||
const ok = lanesLib.deleteLane(req.params.id);
|
||||
if (!ok)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
@@ -3613,4 +3819,5 @@ router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
module.exports = router;
|
||||
module.exports.broadcastLane = broadcastLane;
|
||||
module.exports.healAllRunIds = healAllRunIds;
|
||||
module.exports.notOwner = notOwner;
|
||||
module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;
|
||||
|
||||
@@ -579,11 +579,25 @@ function costScope(req, res) {
|
||||
// `mine=1`: the caller's own sessions even for an admin/analyst, who would
|
||||
// otherwise get every user's sum (the status bar's Today is a personal figure).
|
||||
const extra = [];
|
||||
let payer = null;
|
||||
if (req.query.mine === "1") {
|
||||
const own = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id");
|
||||
if (own.where !== "1") extra.push({ clause: own.where, params: own.params });
|
||||
if (own.where !== "1") {
|
||||
// Money is per payer: a session a collaborator ran (in either
|
||||
// direction) counts by the hours each paid (server/lib/session-payers.js).
|
||||
const { SPLIT } = require("../lib/session-payers");
|
||||
extra.push({ clause: `(${own.where}) AND s.id NOT IN (${SPLIT})`, params: own.params });
|
||||
payer = {
|
||||
uid: req.user.id,
|
||||
...costWhere({
|
||||
scope: parseScope(req),
|
||||
lane,
|
||||
extra: [{ clause: `s.id IN (${SPLIT})`, params: [] }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return costWhere({ scope: parseScope(req), lane, extra });
|
||||
return { ...costWhere({ scope: parseScope(req), lane, extra }), payer };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,11 +624,22 @@ function costByTool(dailyTokens, rules) {
|
||||
* the spend pace keep their own windows. Also used by scheduled reports.
|
||||
*/
|
||||
function computeCost(
|
||||
{ whereClause = "", params = [] } = {},
|
||||
{ whereClause = "", params = [], payer = null } = {},
|
||||
range = null,
|
||||
tzModifier = "+0 minutes"
|
||||
) {
|
||||
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
// `payer` (`?mine=1` in server mode): plus the hours of split sessions this
|
||||
// user paid for, the scope itself having left those sessions out.
|
||||
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params).concat(
|
||||
payer
|
||||
? require("../lib/session-payers").paidDailyRows(
|
||||
payer.uid,
|
||||
payer.whereClause,
|
||||
payer.params,
|
||||
tzModifier
|
||||
)
|
||||
: []
|
||||
);
|
||||
const inside = range ? dailyTokens.filter((r) => inRange(range, r.date)) : dailyTokens;
|
||||
const rules = stmts.listPricing.all();
|
||||
// Price the date-split rows so each day's usage bills at the rate effective on
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user