feat: choose your own pool account; skills with "token" in a doc name upload; System Health off Linux

- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc,
  .credentials.json…) is always refused, but a word like token/secret/
  password/credential only in a data file's name — design-tokens.md or
  count_tokens.py is skill content, not a key.
- System Health: off Linux the History, CPU threads and Busiest processes
  cards (all read from /proc) are left out instead of shown empty; the CPU
  bar without a sample and Windows' always-zero Load row go too.
- Users pick their own pool account per tool (/api/me/pool-accounts,
  Settings → AI tools), within their groups, refused while pinned.
- Admin user detail, 9router update, session replay and Antigravity
  catalog changes from parallel sessions.
This commit is contained in:
2026-10-28 10:41:53 +07:00
parent 4999325210
commit 0f17efe3b4
39 changed files with 1306 additions and 230 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+201 -187
View File
@@ -9,7 +9,8 @@
* CPU / memory history (`GET /api/host/history`, kept in memory since the
* server started); then CPU (every thread as a compact cell, temperatures,
* GPUs), disks (how full, read / write rate) with network interfaces, and the
* busiest processes with who each counts for; and — server mode — each user's
* busiest processes with who each counts for (history, CPU threads and
* processes are Linux-only reads, so off Linux those cards are left out); and — server mode — each user's
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
* Administration users table shares for each home's share of the homes disk.
@@ -250,19 +251,23 @@ export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
</span>
</Tip>
</Row>
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{t("host.load")}</span>
<div className="flex gap-1 ml-auto">
{s.cpu.load.slice(0, 3).map((load, i) => (
<span
key={i}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
>
{load.toFixed(2)}
</span>
))}
{s.os.platform !== "win32" && (
<div className="flex items-center gap-3">
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">
{t("host.load")}
</span>
<div className="flex gap-1 ml-auto">
{s.cpu.load.slice(0, 3).map((load, i) => (
<span
key={i}
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
>
{load.toFixed(2)}
</span>
))}
</div>
</div>
</div>
)}
{s.process && (
<Row label={t("host.agenthub")}>
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
@@ -274,11 +279,9 @@ export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
)}
</div>
<div className="border-t border-border/40 pt-3 space-y-3">
<HealthBar
label={t("host.cpu")}
value={s.usage ? s.usage.cpu : null}
tip={t("host.cpuHelp")}
/>
{s.usage && (
<HealthBar label={t("host.cpu")} value={s.usage.cpu} tip={t("host.cpuHelp")} />
)}
<HealthBar
label={t("host.memory")}
value={mem ? pct(mem.used, mem.total) : null}
@@ -375,6 +378,11 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
const topProcs = stats.topProcesses ?? [];
const others = groups.filter((g) => g.kind !== "user");
const people = groups.filter((g) => g.kind === "user");
// History, per-thread CPU and processes are read from /proc: on Windows and
// macOS their cards would only ever be empty, so they are left out.
const linux = stats.os.platform === "linux";
const showCores = perCore.length > 0 || temps.length > 0 || stats.gpus.length > 0;
const showProcs = !!stats.usage || others.length > 0;
// A whole disk's I/O goes on the first mounted file system that lives on it
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
@@ -408,132 +416,136 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
<HostCard host={host} heap={heap} />
{/* CPU and memory over time. */}
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
<CardHead
icon={Activity}
tone="text-accent"
title={t("host.historyTitle")}
right={
<div
role="group"
aria-label={t("host.range")}
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
>
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
range === r
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{t(`host.range${r}`)}
</button>
))}
{linux && (
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
<CardHead
icon={Activity}
tone="text-accent"
title={t("host.historyTitle")}
right={
<div
role="group"
aria-label={t("host.range")}
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
>
{RANGES.map((r) => (
<button
key={r}
type="button"
onClick={() => setRange(r)}
aria-pressed={range === r}
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
range === r
? "bg-surface-4 text-fg-secondary"
: "text-fg-muted hover:text-fg-secondary"
}`}
>
{t(`host.range${r}`)}
</button>
))}
</div>
}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
<MiniChart
kind="line"
points={histPoints("cpu")}
max={100}
format={fmtPct}
label={t("host.historyCpu")}
emptyText={t("host.historyNone")}
/>
</div>
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
<MiniChart
kind="line"
points={histPoints("memory")}
max={100}
format={fmtPct}
label={t("host.historyMemory")}
emptyText={t("host.historyNone")}
/>
</div>
}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
<MiniChart
kind="line"
points={histPoints("cpu")}
max={100}
format={fmtPct}
label={t("host.historyCpu")}
emptyText={t("host.historyNone")}
/>
</div>
<div>
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
<MiniChart
kind="line"
points={histPoints("memory")}
max={100}
format={fmtPct}
label={t("host.historyMemory")}
emptyText={t("host.historyNone")}
/>
</div>
{history?.startedAt && (
<p className="text-[10px] text-fg-muted">
{t("host.historyHelp", {
s: history.intervalMs / 1000,
at: formatDateTime(history.startedAt),
})}
</p>
)}
</div>
{history?.startedAt && (
<p className="text-[10px] text-fg-muted">
{t("host.historyHelp", {
s: history.intervalMs / 1000,
at: formatDateTime(history.startedAt),
})}
</p>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 empty:hidden">
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
<CardHead
icon={Cpu}
tone="text-syntax-keyword"
title={t("host.cpu")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
</span>
}
/>
{perCore.length > 0 && (
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
{perCore.map((v, i) => (
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
))}
</div>
)}
{temps.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-temperatures"
>
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
<Thermometer className="w-3 h-3 text-status-warning" />
{t("host.temperatures")}
{showCores && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
<CardHead
icon={Cpu}
tone="text-syntax-keyword"
title={t("host.cpu")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
</span>
}
/>
{perCore.length > 0 && (
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
{perCore.map((v, i) => (
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
))}
</div>
{temps.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
{s.label} <span className="text-fg-muted">· {s.chip}</span>
</span>
<span
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
s.celsius >= 90
? "bg-status-danger/20 text-status-danger"
: s.celsius >= 75
? "bg-status-warning/20 text-status-warning"
: "bg-surface-3 text-fg-secondary"
}`}
>
{s.celsius.toFixed(0)} °C
</span>
)}
{temps.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-2"
data-testid="server-temperatures"
>
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
<Thermometer className="w-3 h-3 text-status-warning" />
{t("host.temperatures")}
</div>
))}
</div>
)}
{stats.gpus.length > 0 && (
<div className="border-t border-border/40 pt-3 space-y-3">
{stats.gpus.map((g, i) => (
<HealthBar
key={i}
label={g.name}
value={g.utilization}
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
/>
))}
</div>
)}
</div>
{temps.map((s, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
{s.label} <span className="text-fg-muted">· {s.chip}</span>
</span>
<span
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
s.celsius >= 90
? "bg-status-danger/20 text-status-danger"
: s.celsius >= 75
? "bg-status-warning/20 text-status-warning"
: "bg-surface-3 text-fg-secondary"
}`}
>
{s.celsius.toFixed(0)} °C
</span>
</div>
))}
</div>
)}
{stats.gpus.length > 0 && (
<div className="border-t border-border/40 pt-3 space-y-3">
{stats.gpus.map((g, i) => (
<HealthBar
key={i}
label={g.name}
value={g.utilization}
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
/>
))}
</div>
)}
</div>
)}
{/* Disks (with their I/O) and network interfaces. */}
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
@@ -587,60 +599,62 @@ export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo
)}
{/* The busiest processes, and what belongs to no user. */}
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
<CardHead
icon={ListTree}
tone="text-syntax-function"
title={t("host.topProcesses")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
</span>
}
/>
{!stats.usage ? (
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
) : (
<div className="space-y-2">
{topProcs.map((p) => (
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
<div className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate text-xs">
<span className="font-mono text-fg-secondary">{p.name}</span>
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
{showProcs && (
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
<CardHead
icon={ListTree}
tone="text-syntax-function"
title={t("host.topProcesses")}
right={
<span className="text-[10px] font-mono text-fg-muted">
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
</span>
}
/>
{!stats.usage ? (
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
) : (
<div className="space-y-2">
{topProcs.map((p) => (
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
<div className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate text-xs">
<span className="font-mono text-fg-secondary">{p.name}</span>
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
</span>
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(p.memory)}
</span>
<span
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
>
{fmtPct(p.cpu)}
</span>
</div>
</Tip>
))}
</div>
)}
{others.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-1.5"
data-testid="server-usage-table"
>
{others.map((g) => (
<div key={g.key} className="flex items-center gap-3 text-[10px]">
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
{groupName(g)}
</span>
<span className="text-[10px] font-mono text-fg-muted">
{formatBytes(p.memory)}
</span>
<span
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
>
{fmtPct(p.cpu)}
<span className="font-mono text-fg-secondary">
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
{t("host.processCount", { n: g.processes })}
</span>
</div>
</Tip>
))}
</div>
)}
{others.length > 0 && (
<div
className="border-t border-border/40 pt-3 space-y-1.5"
data-testid="server-usage-table"
>
{others.map((g) => (
<div key={g.key} className="flex items-center gap-3 text-[10px]">
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
{groupName(g)}
</span>
<span className="font-mono text-fg-secondary">
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
{t("host.processCount", { n: g.processes })}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Each user's live share of the machine (server mode). */}
@@ -7,7 +7,8 @@
* rate and each network interface, the CPU / memory history with its range
* switch (`GET /api/host/history`), and the busiest processes with who each
* counts for, the processes of no user in a short list of their own, and
* each user's live CPU / RAM as a tile (their busiest processes on click).
* each user's live CPU / RAM as a tile (their busiest processes on click);
* off Linux the cards it can never fill are left out.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -179,4 +180,33 @@ describe("HostHealth", () => {
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
expect(within(tile).getByText("claude")).toBeTruthy();
});
it("leaves out what Windows cannot report instead of empty cards", async () => {
api.host.stats.mockResolvedValue({
...stats,
os: { platform: "win32", release: "10.0.26300", distro: null, arch: "x64" },
cpu: { ...stats.cpu, load: [0, 0, 0], perCore: [] },
disks: [],
usage: null,
});
api.host.history.mockResolvedValue({
range: "1h",
intervalMs: 30000,
running: false,
points: [],
});
render(<Tab />);
const card = await screen.findByTestId("server-facts");
expect(await within(card).findByText(/win32/)).toBeTruthy();
expect(within(card).queryByRole("meter", { name: "CPU" })).toBeNull();
expect(card.textContent).not.toContain("Load");
expect(within(card).getByRole("meter", { name: "Memory" })).toBeTruthy();
for (const id of [
"server-history",
"server-cores",
"server-top-processes",
"server-disks-table",
])
expect(screen.queryByTestId(id)).toBeNull();
});
});
@@ -10,7 +10,7 @@
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude, Codex and a catalog tool with
* `resumeById` (omp) can resume a session by id — any other tool's session
* `resumeById` (omp, Cursor, Antigravity) can resume a session by id — any other tool's session
* offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder
@@ -5,7 +5,8 @@
* Default badge or Set default, and Update whenever the catalog names an
* updater — no version lookup, the updater itself knows whether it has work.
* The ones available to install fold behind a disclosure, with Install where
* the catalog knows how. Install/Update/Update all confirm the exact command
* the catalog knows how (an admin's; Update all is anyone's — auth.js
* MEMBER_WRITE_EXCEPTIONS). Install/Update/Update all confirm the exact command
* first, run as ONE server job at a time and show its log under the row
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* A right-click on an installed row opens its Update / Set default /
@@ -231,7 +232,8 @@ export function AiToolsSettings({
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{isAdmin && updateAllTools.length > 0 && (
{/* Every signed-in user may run Update all (auth.js MEMBER_WRITE_EXCEPTIONS). */}
{updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
@@ -1,6 +1,6 @@
/**
* @file NineRouterUpdate.tsx
* @description **Update 9router** on the AI Proxy page (an admin's; beside
* @description **Update 9router** on the AI Proxy page (every user's; beside
* Config 9router): a button — dotted when npm has a newer 9router — and a
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
@@ -0,0 +1,120 @@
/**
* @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,85 @@
/**
* @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();
});
});
+5 -3
View File
@@ -167,9 +167,11 @@ export function UserDetail({ userId }: { userId: number }) {
·{" "}
{p.pin
? t("detail.poolPinned", { account: p.pin })
: p.group
? t("detail.poolGroup", { name: p.group.name })
: t("detail.poolShared")}
: p.choice
? t("detail.poolChosen", { account: p.choice })
: p.group
? t("detail.poolGroup", { name: p.group.name })
: t("detail.poolShared")}
</span>
</div>
{p.accounts.map((a) => {
+14
View File
@@ -1410,5 +1410,19 @@
"apikey": "API key",
"credentials": "Logins"
}
},
"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"
}
}
+1
View File
@@ -343,6 +343,7 @@
"noUsage": "No spend in the last 30 days.",
"pool": "Account pool & quota",
"poolPinned": "pinned to {{account}}",
"poolChosen": "chose {{account}}",
"poolGroup": "group {{name}}",
"poolShared": "shared pool",
"poolNone": "No account in this tool's pool.",
+14
View File
@@ -1398,5 +1398,19 @@
"apikey": "API key",
"credentials": "Đăng nhập"
}
},
"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"
}
}
+1
View File
@@ -343,6 +343,7 @@
"noUsage": "Không có chi phí trong 30 ngày qua.",
"pool": "Account pool & quota",
"poolPinned": "ghim vào {{account}}",
"poolChosen": "tự chọn {{account}}",
"poolGroup": "nhóm {{name}}",
"poolShared": "pool dùng chung",
"poolNone": "Pool của tool này chưa có tài khoản nào.",
+27
View File
@@ -616,6 +616,8 @@ export type UserDetails = {
PoolTool,
{
pin: string | null;
/** The user's own choice (below a pin); absent from an older server. */
choice?: string | null;
group: { id: number; name: string } | null;
accounts: UserPoolAccount[];
}
@@ -865,6 +867,22 @@ export type PoolGroupPatch = {
accounts?: Partial<Record<PoolTool, string[]>>;
};
export type PoolTool = "claude" | "codex" | "cursor";
/** One tool of `GET /api/me/pool-accounts`: the accounts this user may use — never a token. */
export type MyPoolTool = {
tool: PoolTool;
/** An admin's pin: while set, the user cannot choose. */
pin: string | null;
/** The user's own choice; null = the balancer picks. */
choice: string | null;
accounts: {
name: string;
kind: string;
email: string | null;
expired: boolean;
quotaPercent: number | null;
full: boolean;
}[];
};
/** A saved switch-user profile that can be moved into the pool (no token). */
export type PoolProfile = {
name: string;
@@ -1226,6 +1244,15 @@ export const api = {
request<MyAccount>("/me/avatar/url", { method: "POST", body: JSON.stringify({ url }) }),
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
/** GET /api/me/pool-accounts — per tool, the pool accounts this account may use (server mode). */
poolAccounts: () => request<{ tools: MyPoolTool[] }>("/me/pool-accounts"),
/** PUT /api/me/pool-accounts — run new sessions of `tool` on `account` (`null` = automatic). */
choosePoolAccount: (tool: PoolTool, account: string | null) =>
request<{
tool: PoolTool;
choice: string | null;
switched: "switched" | "kept" | "own-login" | "expired" | "next-session" | null;
}>("/me/pool-accounts", { method: "PUT", body: JSON.stringify({ tool, account }) }),
},
/** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */
host: {
+6
View File
@@ -329,6 +329,12 @@ export const SETTINGS_INDEX: {
],
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" },
+2 -2
View File
@@ -30,7 +30,7 @@
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
* It is shown only when the server finds a `9router` binary installed.
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
* Beside it every user gets **Update 9router** (`NineRouterUpdate`): shut it
* down, `npm install -g 9router@latest`, start it again hidden.
*
* **Test** asks the endpoint itself, once as Claude Code would
@@ -274,7 +274,7 @@ export function AiProxyConfig() {
{t("aiProxy.config9router")}
</a>
)}
{nineRouter && isAdmin && <NineRouterUpdate />}
{nineRouter && <NineRouterUpdate />}
<button
type="button"
onClick={startAdd}
+3
View File
@@ -73,6 +73,7 @@ import { AgentInstructionsSettings } from "../components/settings/AgentInstructi
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -310,6 +311,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
{/* 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 />
{/* 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. */}
+6 -4
View File
@@ -105,6 +105,8 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `PUT /api/me/prefs` `{prefs: {<key>: "<value>" \| null}}` | merged (`null` deletes a key), the same shape back; `400 EBADPREFS` for a key outside the list below or a value that is not a string of at most 8 KB |
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
| `GET /api/me/pool-accounts` | server mode: `{tools: [{tool, pin, choice, accounts: [{name, kind, email, expired, quotaPercent, full}]}]}` — per tool (`claude`, `codex`, `cursor`) with at least one account the account's [pool group](SERVER-MODE.md#a-users-own-pool-account) allows; never a token. Local mode: `{tools: []}` |
| `PUT /api/me/pool-accounts` `{tool, account: "<name>" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
@@ -3010,7 +3012,7 @@ GET /api/ai-tools/:id/config/:i { path, format, text, sha }
PUT /api/ai-tools/:id/config/:i Body: { text, baseSha } → { sha, backup }
POST /api/ai-tools/:id/install Start the catalog's install job → 202 { job }
POST /api/ai-tools/:id/update Start the catalog's update job → 202 { job }
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater
POST /api/ai-tools/update-all One job: every npm-managed tool with a newer version + every self updater (any signed-in user; other /ai-tools writes are an admin's)
GET /api/ai-tools/job The current or last job, or null
GET /api/ai-tools/versions { [id]: { npmManaged, installed, latest, updateAvailable } }
GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the server; 204 → letter avatar
@@ -3038,7 +3040,7 @@ The login profiles of the AI tools other than Claude Code (`server/lib/tool-prof
GET /api/ai-proxies Every saved profile, the built-in `native` one first
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
GET /api/ai-proxies/9router/update versions, running state, `blocker`, current job (`?fresh=1` re-asks npm)
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (admin)
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (any signed-in user)
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
PATCH /api/ai-proxies/:name Update the same fields, all optional
@@ -3061,7 +3063,7 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, an admin's only — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, shown to every user — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`, but their `POST …/update` is in `MEMBER_WRITE_EXCEPTIONS`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
1. **shutdown** — a running `9router.service` user unit is `systemctl --user stop`ped; otherwise this account's processes whose argv names the install's `cli.js` get SIGTERM, then they and their descendants SIGKILL after 5 s; the step waits until the port is free. Skipped when nothing ran.
2. **update** — `npm install -g --prefix <the install's own prefix> --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n` with `--cache /var/cache/agenthub-9router-npm` (root's HOME is inaccessible inside the AgentHub unit; the sudoers rule must name that exact argv), only when `sudo -n <npm> --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`).
3. **relaunch** — only when 9router was running before: `systemctl --user start 9router.service`, or `node cli.js --tray --no-browser --skip-update -p <old port> [-H <old host>]` detached in its own `systemd-run --user --scope` (plain detached spawn without one); done once the port answers. Runs even after a failed update.
@@ -3165,7 +3167,7 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
+26 -1
View File
@@ -109,7 +109,11 @@ start (`server/db.js`), and `member` is no longer accepted.
`/api/settings`, `/updates`, `/codex-config`,
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`) — except
the two update buttons any signed-in user may press, `POST
/api/ai-tools/update-all` and `POST /api/ai-proxies/9router/update`
(`MEMBER_WRITE_EXCEPTIONS`; both run only what the catalog / the fixed 9router
job names) — plus a write
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)) and the
host-wide parts of `/cc-config` (see
[Your own Claude Code config](#your-own-claude-code-config)). A
@@ -609,6 +613,27 @@ by team (Users page → **Account pool → Groups**, server mode only,
an account takes it out of every group; deleting a group sends its members
back to the shared pool and removes no account.
### 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`).
- 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
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
ChatGPT login still waits while another holds the home).
- It is the user's own change: the login cookie only (a pane token cannot),
audited, no password step-up. The Users page's user detail shows it as
"chose <account>". Oh My Pi has no pool to choose from (its sharing is the
host's one login).
The tab bar also shows Oh My Pi (its login sharing, below) and every other
installed tool, which says it has no pool (Antigravity keeps its Google login
in the system keyring; there is no key AgentHub could hand a session).
+139 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.28
version: 2.8.29
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -14540,6 +14540,144 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/pool-accounts:
get:
tags:
- Auth
summary: The account pool as this account may use it (server mode)
description: 'Per tool with at least one account the account''s pool group allows: those accounts (never a token), an admin''s pin and the account''s own choice. Local mode: {tools: []}.'
responses:
'200':
description: Tools
content:
application/json:
schema:
type: object
required:
- tools
properties:
tools:
type: array
items:
type: object
required:
- tool
- pin
- choice
- accounts
properties:
tool:
type: string
enum:
- claude
- codex
- cursor
pin:
type: string
nullable: true
choice:
type: string
nullable: true
accounts:
type: array
items:
type: object
properties:
name:
type: string
kind:
type: string
email:
type: string
nullable: true
expired:
type: boolean
quotaPercent:
type: number
nullable: true
full:
type: boolean
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
put:
tags:
- Auth
summary: Run this account's new sessions of a tool on one pool account (null = automatic)
description: Below an admin's pin, above the balancer. A Claude login goes into the account's home at once (`switched`); Codex and Cursor take it at the next session. Login cookie only, audited.
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- tool
properties:
tool:
type: string
enum:
- claude
- codex
- cursor
account:
type: string
nullable: true
responses:
'200':
description: Chosen
content:
application/json:
schema:
type: object
properties:
tool:
type: string
choice:
type: string
nullable: true
switched:
type: string
nullable: true
enum:
- switched
- kept
- own-login
- expired
- next-session
'400':
description: EBADTOOL
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: EAUTH
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOACCOUNT — not in the pool, or not one the account's group may use
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ELOCKED — an admin pinned this account's sessions of the tool
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/me/onboarded:
post:
tags:
+12
View File
@@ -623,6 +623,18 @@ describe("skills from files or written", () => {
["s/.env", "K=1"],
]);
assert.equal(secret.body.error.code, "ESECRET");
const word = await upload("/api/artifacts/skill/upload", [
["w/SKILL.md", "---\ndescription: W\n---\n"],
["w/api-token.json", "{}"],
]);
assert.equal(word.body.error.code, "ESECRET");
// The word alone in a doc or a script is skill content, not a key.
const docs = await upload("/api/artifacts/skill/upload", [
["tokens/SKILL.md", "---\ndescription: Design tokens\n---\n"],
["tokens/design-tokens.md", "# tokens"],
["tokens/scripts/count_tokens.py", "print(1)"],
]);
assert.equal(docs.status, 201, JSON.stringify(docs.body));
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], {
description: "d",
});
@@ -0,0 +1,194 @@
/**
* @file pool-account-choice.test.js
* @description A user's own choice of pool account (account-pins.js
* `setChoice`/`preferredFor`, `/api/me/pool-accounts`), fake accounts only:
* the balancer runs the user's new panes on the chosen account; an admin's pin
* wins and locks the choice (ELOCKED); only accounts the user's group allows
* may be chosen (ENOACCOUNT), and one later taken from the group falls back
* to the balancer; a removed account drops its choices; the routes list no
* token, need a login cookie and refuse in local mode.
* @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(), "pool-choice-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const groups = require("../lib/pool-groups");
const cursorPool = require("../lib/cursor-pool");
const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
const KEY = (c) => `key_${c.repeat(40)}`;
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
let server;
let BASE;
let MIA;
let LEO;
async function call(method, p, { cookie, body } = {}) {
const res = await fetch(BASE + p, {
method,
headers: {
"Content-Type": "application/json",
Origin: BASE,
...(cookie ? { cookie } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: await res.json().catch(() => null) };
}
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.headers.get("set-cookie").split(";")[0];
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("boss", "admin-pass-1", "admin");
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
db.prepare("DELETE FROM pool_groups").run();
db.prepare("DELETE FROM account_pins").run();
db.prepare("DELETE FROM account_choices").run();
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
fs.rmSync(dir, { recursive: true, force: true });
}
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
});
describe("pool account choice: the balancer", () => {
it("runs the user's new panes on the account they chose, others unchanged", () => {
assert.equal(cursorPool.pickAccount(MIA).name, "a");
pins.setChoice(MIA, "cursor", "c");
assert.equal(cursorPool.pickAccount(MIA).name, "c");
assert.equal(cursorPool.pickAccount(LEO).name, "a");
claudePool.addAccount("one", TOKEN("o"));
claudePool.addAccount("two", TOKEN("t"));
pins.setChoice(MIA, "claude", "two");
assert.equal(claudePool.pickAccount(null, MIA).name, "two");
pins.setChoice(MIA, "cursor", null);
assert.equal(cursorPool.pickAccount(MIA).name, "a", "automatic again");
});
it("lets an admin's pin win, and refuses a choice while pinned", () => {
pins.setChoice(MIA, "cursor", "b");
pins.setPin(MIA, "cursor", "c");
assert.equal(cursorPool.pickAccount(MIA).name, "c");
assert.throws(() => pins.setChoice(MIA, "cursor", "a"), { code: "ELOCKED" });
pins.setPin(MIA, "cursor", null);
assert.equal(cursorPool.pickAccount(MIA).name, "b", "the choice comes back");
});
it("allows only the group's accounts, and falls back once one leaves it", () => {
const g = groups.createGroup("A");
groups.setGroupUsers(g.id, [MIA]);
groups.setGroupAccounts(g.id, "cursor", ["b", "c"]);
assert.throws(() => pins.setChoice(MIA, "cursor", "a"), { code: "ENOACCOUNT" });
assert.throws(() => pins.setChoice(MIA, "cursor", "zz"), { code: "ENOACCOUNT" });
pins.setChoice(MIA, "cursor", "c");
assert.equal(cursorPool.pickAccount(MIA).name, "c");
groups.setGroupAccounts(g.id, "cursor", ["b"]);
assert.equal(pins.preferredFor(MIA, "cursor"), null);
assert.equal(cursorPool.pickAccount(MIA).name, "b");
});
it("drops a removed account's choices", () => {
pins.setChoice(MIA, "cursor", "b");
cursorPool.removeAccount("b");
assert.equal(pins.choiceFor(MIA, "cursor"), null);
});
});
describe("pool account choice: /api/me/pool-accounts", () => {
it("lists only the tools and accounts the user may use, never a token", async () => {
const g = groups.createGroup("A");
groups.setGroupUsers(g.id, [MIA]);
groups.setGroupAccounts(g.id, "cursor", ["b"]);
const cookie = await login("mia", "member-pass-1");
const r = await call("GET", "/api/me/pool-accounts", { cookie });
assert.equal(r.status, 200);
assert.deepEqual(
r.body.tools.map((t) => t.tool),
["cursor"]
);
assert.deepEqual(
r.body.tools[0].accounts.map((a) => a.name),
["b"]
);
assert.ok(!JSON.stringify(r.body).includes(KEY("b")));
});
it("chooses, goes back to automatic, refuses while pinned, and is audited", async () => {
const cookie = await login("leo", "member-pass-2");
let r = await call("PUT", "/api/me/pool-accounts", {
cookie,
body: { tool: "cursor", account: "c" },
});
assert.equal(r.status, 200);
assert.equal(r.body.choice, "c");
assert.equal(r.body.switched, "next-session");
assert.equal(cursorPool.pickAccount(LEO).name, "c");
r = await call("PUT", "/api/me/pool-accounts", {
cookie,
body: { tool: "cursor", account: "nope" },
});
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOACCOUNT");
r = await call("PUT", "/api/me/pool-accounts", { cookie, body: { tool: "x", account: "c" } });
assert.equal(r.status, 400);
pins.setPin(LEO, "cursor", "a");
r = await call("PUT", "/api/me/pool-accounts", {
cookie,
body: { tool: "cursor", account: "b" },
});
assert.equal(r.status, 409);
assert.equal(r.body.error.code, "ELOCKED");
pins.setPin(LEO, "cursor", null);
r = await call("PUT", "/api/me/pool-accounts", {
cookie,
body: { tool: "cursor", account: null },
});
assert.equal(r.status, 200);
assert.equal(r.body.choice, null);
const audited = db
.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path LIKE '%pool account%'")
.get().n;
assert.ok(audited >= 2);
});
it("needs a login", async () => {
const r = await call("PUT", "/api/me/pool-accounts", {
body: { tool: "cursor", account: "a" },
});
assert.equal(r.status, 401);
});
});
+20 -1
View File
@@ -782,6 +782,25 @@ describe("pty-run", () => {
})
);
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
// A named conversation: agy's `--conversation <id>`, not `--continue`.
tmux.__reset();
const named = captureNewSession(() =>
pty.spawnRun({
laneId: 14,
cwd: "/tmp/repo",
tool: "antigravity",
resumeSessionId: "70785283-bcc4-43a4-a1ea-02d844d5df5b",
initialPrompt: "go on",
})
);
assert.deepEqual(paneArgv(named), [
at("agy"),
"--conversation",
"70785283-bcc4-43a4-a1ea-02d844d5df5b",
"-i",
"go on",
]);
});
// A catalog tool has no "resume THIS session" argv; starting it fresh under
@@ -792,7 +811,7 @@ describe("pty-run", () => {
pty.spawnRun({
laneId: 15,
cwd: "/tmp/repo",
tool: "antigravity",
tool: "septool",
resumeSessionId: "abc12345",
}),
{ code: "EUNSUPPORTED" }
+41
View File
@@ -137,6 +137,47 @@ describe("server mode", () => {
assert.equal(a.status, 404);
});
it("lets any user press Update all AI tools and Update 9router, nothing else there", async () => {
// The guard alone, called directly: a real request would start the job.
const member = await loginAs("mia", "member-pass-1");
const guard = (method, p) => {
let passed = false;
let status = null;
const req = { method, path: p, headers: { cookie: member }, socket: {} };
const res = {
status(s) {
status = s;
return this;
},
json() {
return this;
},
};
auth.authGuard(req, res, () => {
passed = true;
});
return passed ? "next" : status;
};
for (const p of [
"/ai-tools/update-all",
"/AI-Tools/update-all/",
"/ai-proxies/9router/update",
"/ai-routers/9router/update",
]) {
assert.equal(guard("POST", p), "next", p);
}
for (const [method, p] of [
["POST", "/ai-tools/claude/update"],
["POST", "/ai-tools/claude/install"],
["PUT", "/ai-tools/claude"],
["PUT", "/ai-tools/omp-sharing"],
["DELETE", "/ai-tools/update-all"],
["POST", "/ai-proxies/9router/update/x"],
]) {
assert.equal(guard(method, p), 403, `${method} ${p}`);
}
});
it("takes hooks from this machine only", async () => {
const remote = await call("POST", "/api/hooks/event", {
body: {},
+1 -1
View File
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
`resume` when a start carries `resumeSessionId`, and only for an id that starts
with a letter or digit. A tool without it (and without a `launcher`) refuses a
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
Built-in: omp and Cursor (`--resume`), Antigravity (`--conversation`: an agy session's id is its conversation id). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
## Install / update (A2)
+3
View File
@@ -15,6 +15,9 @@
"resume": [
"--continue"
],
"resumeById": [
"--conversation"
],
"bypass": [
"--dangerously-skip-permissions"
],
+12
View File
@@ -1972,6 +1972,18 @@ db.exec(`
PRIMARY KEY (user_id, tool)
);
`);
// 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
// balancer, and only among the accounts the user's group may use.
db.exec(`
CREATE TABLE IF NOT EXISTS account_choices (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tool TEXT NOT NULL,
account TEXT NOT NULL,
chosen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (user_id, tool)
);
`);
// Account pool groups (server/lib/pool-groups.js): a team's members run on the
// team's own pool accounts. One group per user; an account, named like a pin,
// may serve several groups. Accounts no group lists are the shared pool.
+105 -3
View File
@@ -13,6 +13,14 @@
*
* `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
* 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
* spawn, so an account later taken from the group falls back to the balancer.
* A pinned user cannot choose (`ELOCKED`): the admin's pin is the decision.
*
* Server mode only: in local mode there are no users and nothing is pinned.
* Removing the account drops its pins (`dropAccount`); deleting the user drops
* theirs (ON DELETE CASCADE, and `auth.deleteUser`).
@@ -45,6 +53,95 @@ function pinFor(ownerId, tool) {
}
}
/** The account `userId` chose for `tool` themselves, or null. */
function choiceFor(userId, tool) {
if (!require("./auth").isServerMode() || !Number.isInteger(userId)) return null;
try {
return (
db()
.prepare("SELECT account FROM account_choices WHERE user_id = ? AND tool = ?")
.get(userId, tool)?.account ?? null
);
} catch {
return null; // a spawn never fails over a choice
}
}
/**
* The account the balancer should prefer for `ownerId`'s new `tool` pane: the
* admin's pin, else the owner's own choice while their group still allows it.
*/
function preferredFor(ownerId, tool) {
const pin = pinFor(ownerId, tool);
if (pin) return pin;
const choice = choiceFor(ownerId, tool);
if (!choice) return null;
return require("./pool-groups").allowed(ownerId, tool)(choice) ? choice : null;
}
/**
* `userId`'s own choice of `account` for `tool`, or back to the balancer
* (`account` null). Refused while an admin pins them (`ELOCKED`), and for an
* account not in the pool or not their group's (`ENOACCOUNT`).
*/
function setChoice(userId, tool, account) {
validTool(tool);
if (!Number.isInteger(userId)) throw err("ENOUSER", `no user ${userId}`);
if (pinFor(userId, tool)) {
throw err("ELOCKED", `an admin has pinned your ${tool} sessions to one account`);
}
if (account == null || account === "") {
db().prepare("DELETE FROM account_choices WHERE user_id = ? AND tool = ?").run(userId, tool);
return null;
}
const name = String(account);
if (!poolOf(tool).hasAccount(name) || !require("./pool-groups").allowed(userId, tool)(name)) {
throw err("ENOACCOUNT", `no ${tool} account ${name} you may use`);
}
db()
.prepare(
`INSERT INTO account_choices (user_id, tool, account) VALUES (?, ?, ?)
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
chosen_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
)
.run(userId, tool, name);
return name;
}
/**
* What Settings → AI tools → Pool account 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.
*/
function myAccounts(userId) {
const groups = require("./pool-groups");
const out = [];
for (const tool of TOOLS) {
let status = [];
try {
status = poolOf(tool).poolStatus();
} catch {
status = [];
}
const ok = groups.allowed(userId, tool);
const pin = pinFor(userId, tool);
const accounts = status
.filter((a) => ok(a.name))
.map((a) => ({
name: a.name,
kind: a.kind,
email: a.email ?? null,
expired: Boolean(a.expired),
quotaPercent: a.quotaPercent ?? null,
full: Boolean(a.full),
}));
if (accounts.length === 0) continue;
const choice = choiceFor(userId, tool);
out.push({ tool, pin, choice: choice && ok(choice) ? choice : null, accounts });
}
return out;
}
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
function listPins(tool) {
return db()
@@ -120,23 +217,24 @@ function setAccountUsers(tool, account, userIds) {
}
/**
* Make a pin take effect now where it can: a Claude login is written into
* Make a pin — or the user's own choice — take effect now where it can: a Claude login is written into
* the user's home at once (claude-pool `switchHome`), so their running panes
* move to it without a restart. Anything else goes in at the next spawn.
* Answers what happened (see `switchHome`).
*/
async function applyPin(userId, tool) {
const pin = pinFor(userId, tool);
const pin = preferredFor(userId, tool);
if (!pin || tool !== "claude") return "next-session";
const user = db().prepare("SELECT username FROM users WHERE id = ?").get(userId);
const member = user ? require("./run-as").accountForUser(user.username) : null;
return poolOf(tool).switchHome(member, pin);
}
/** A removed account takes its pins — and its place in every group — with it. */
/** A removed account takes its pins, choices — and its place in every group — with it. */
function dropAccount(tool, account) {
try {
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
db().prepare("DELETE FROM account_choices WHERE tool = ? AND account = ?").run(tool, account);
} catch {
/* no database (a bare CLI test): nothing pinned */
}
@@ -147,6 +245,10 @@ module.exports = {
TOOLS,
poolOf,
pinFor,
choiceFor,
preferredFor,
setChoice,
myAccounts,
listPins,
setPin,
setAccountUsers,
+12 -3
View File
@@ -76,9 +76,18 @@ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
const PLUGIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}@[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
const SNAPSHOT_RE = /^[A-Za-z0-9_-]{32}$/;
// A skill may not carry a login or a key: refused, never copied.
const SECRET_NAME_RE =
/(^\.env(\..*)?$|credential|secret|password|token|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|\.netrc$)/i;
// A skill may not carry a login or a key: refused, never copied. A key file's
// name alone proves it; a word like "token" does not — `design-tokens.md` or
// `tokenizer.py` is skill content — so the word rule skips docs and source.
const SECRET_FILE_RE =
/(^\.env(\..*)?$|\.pem$|\.key$|\.p12$|\.pfx$|^id_(rsa|ed25519|ecdsa|dsa)$|^\.?netrc$|^\.credentials\.json$|^\.git-credentials$)/i;
const SECRET_WORD_RE = /(credential|secret|password|token)/i;
const CONTENT_EXT_RE =
/\.(md|mdx|markdown|rst|html?|css|js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|bash|zsh|ps1|lua|sql|svg|png|jpe?g|gif|webp)$/i;
const SECRET_NAME_RE = {
test: (name) =>
SECRET_FILE_RE.test(name) || (SECRET_WORD_RE.test(name) && !CONTENT_EXT_RE.test(name)),
};
const root = () => path.join(getDataDir(), "artifacts");
const installsOffFlag = () => path.join(root(), "installs-off");
+25 -3
View File
@@ -13,7 +13,8 @@
* hook routes only from this machine (`isLocalRequest`; `ingest-batch` keeps
* its own bearer token), and everything else only with a valid login.
* Writes under `ADMIN_WRITE_PREFIXES` — the host account's own
* configuration — are an `admin`'s; an `analyst` and a `developer` write
* configuration — are an `admin`'s (bar `MEMBER_WRITE_EXCEPTIONS`: Update
* all AI tools and Update 9router); an `analyst` and a `developer` write
* only their own rows (`scope.js`).
* - Accounts are made from the host's shell (`agenthub users …`) or by an
* admin on the Users page (`server/routes/users.js`), where every change
@@ -80,11 +81,27 @@ const ADMIN_WRITE_PREFIXES = [
"/accounts",
// Alert rules and budgets fire for every user's sessions.
"/alerts/rules",
// Stopping, updating and restarting the host's 9router (both mounts).
// Stopping and restarting the host's 9router (both mounts) — its Update is
// in MEMBER_WRITE_EXCEPTIONS.
"/ai-proxies/9router",
"/ai-routers/9router",
];
/**
* The writes under `ADMIN_WRITE_PREFIXES` every signed-in user may still make,
* matched exactly (method + lower-cased path, a trailing slash ignored): the
* two "bring the host's tools up to date" buttons. Each runs only what a
* release catalog file / the fixed 9router job names — nothing from the
* request — so letting a member press it cannot widen what it runs. Install,
* a single tool's update, enable/default and every other `/ai-tools` write
* stay an admin's.
*/
const MEMBER_WRITE_EXCEPTIONS = new Set([
"POST /ai-tools/update-all",
"POST /ai-proxies/9router/update",
"POST /ai-routers/9router/update",
]);
/**
* API prefixes a developer may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
@@ -738,7 +755,11 @@ function authGuard(req, res, next) {
return deny(res, 403, "EBADORIGIN", "cross-origin requests are not allowed");
}
// An analyst writes like a developer: only their own rows (scope.js).
if (user.role !== "admin" && ADMIN_WRITE_PREFIXES.some(under)) {
if (
user.role !== "admin" &&
ADMIN_WRITE_PREFIXES.some(under) &&
!MEMBER_WRITE_EXCEPTIONS.has(`${req.method} ${lp.replace(/\/+$/, "")}`)
) {
return deny(res, 403, "EADMIN", "only an admin may change this");
}
return next();
@@ -771,6 +792,7 @@ module.exports = {
COOKIE,
ROLES,
ADMIN_WRITE_PREFIXES,
MEMBER_WRITE_EXCEPTIONS,
STAFF_ONLY_PREFIXES,
apiPath,
newPaneToken,
+3 -3
View File
@@ -507,9 +507,9 @@ function pick(member, ownerId = null) {
// in their home stays: one .credentials.json per home.
const allowed = require("./pool-groups").allowed(ownerId, "claude");
usable = usable.filter((a) => allowed(a.name));
// A pinned token goes in by env and wins outright; a pinned login waits
// while another login holds this home.
const pin = require("./account-pins").pinFor(ownerId, "claude");
// A pinned (or the owner's own chosen) token goes in by env and wins
// outright; a pinned login waits while another login holds this home.
const pin = require("./account-pins").preferredFor(ownerId, "claude");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
if (live) return live;
+3 -3
View File
@@ -362,9 +362,9 @@ function pick(member, ownerId = null) {
// in their home stays: one auth.json per home.
const allowed = require("./pool-groups").allowed(ownerId, "codex");
usable = usable.filter((a) => allowed(a.name));
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
// waits while another login holds this home.
const pin = require("./account-pins").pinFor(ownerId, "codex");
// A pinned (or the owner's own chosen) API key goes in by env and wins
// outright; a pinned ChatGPT login waits while another login holds this home.
const pin = require("./account-pins").preferredFor(ownerId, "codex");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
if (live) return live;
+11 -1
View File
@@ -176,6 +176,16 @@ function readChunk(file, offset, size) {
return { text: buf.toString("utf8", 0, end), bytes: end, more: offset + end < size && end > 0 };
}
/** A regular file, by `stat`: `existsSync` is access(2), blind to the
* CAP_DAC_READ_SEARCH that reaches a member's 0700 CODEX_HOME (S3). */
function isFile(p) {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
/** Every total vector in a parent journal: the history a fork copied. Streamed. */
async function parentTotals(file) {
const keys = new Set();
@@ -498,7 +508,7 @@ function ingestCodexUsage({
if (walkedAll) return null;
const known = threadPath.get(id)?.path;
const root = path.join(home || getCodexHome(), "sessions") + path.sep;
if (known && known.startsWith(root) && fs.existsSync(known)) return known;
if (known && known.startsWith(root) && isFile(known)) return known;
walkedAll = true;
for (const f of allRolloutFiles(home)) {
const m = FILE_NAME.exec(path.basename(f.path));
+1 -1
View File
@@ -118,7 +118,7 @@ function pick(ownerId = null) {
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
const usable = allAccounts().filter((a) => allowed(a.name));
if (usable.length === 0) return null;
const pin = require("./account-pins").pinFor(ownerId, "cursor");
const pin = require("./account-pins").preferredFor(ownerId, "cursor");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned) return pinned;
return usable
+98
View File
@@ -14,6 +14,7 @@
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
* - GET/PUT /api/me/pool-accounts (Auth) — the account's own pick of a pool account per tool
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
* (Auth) — Settings → Account: the account's own profile, password and logins
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
@@ -243,6 +244,103 @@ const paths = {
},
},
},
"/api/me/pool-accounts": {
get: {
tags: ["Auth"],
summary: "The account pool as this account may use it (server mode)",
description:
"Per tool with at least one account the account's pool group allows: those accounts (never a token), an admin's pin and the account's own choice. Local mode: {tools: []}.",
responses: {
200: {
description: "Tools",
content: {
"application/json": {
schema: {
type: "object",
required: ["tools"],
properties: {
tools: {
type: "array",
items: {
type: "object",
required: ["tool", "pin", "choice", "accounts"],
properties: {
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
pin: { type: "string", nullable: true },
choice: { type: "string", nullable: true },
accounts: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
kind: { type: "string" },
email: { type: "string", nullable: true },
expired: { type: "boolean" },
quotaPercent: { type: "number", nullable: true },
full: { type: "boolean" },
},
},
},
},
},
},
},
},
},
},
},
401: err("EAUTH"),
},
},
put: {
tags: ["Auth"],
summary: "Run this account's new sessions of a tool on one pool account (null = automatic)",
description:
"Below an admin's pin, above the balancer. A Claude login goes into the account's home at once (`switched`); Codex and Cursor take it at the next session. Login cookie only, audited.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool"],
properties: {
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
account: { type: "string", nullable: true },
},
},
},
},
},
responses: {
200: {
description: "Chosen",
content: {
"application/json": {
schema: {
type: "object",
properties: {
tool: { type: "string" },
choice: { type: "string", nullable: true },
switched: {
type: "string",
nullable: true,
enum: ["switched", "kept", "own-login", "expired", "next-session"],
},
},
},
},
},
},
400: err("EBADTOOL"),
401: err("EAUTH"),
403: err("ELOCALMODE"),
404: err("ENOACCOUNT — not in the pool, or not one the account's group may use"),
409: err("ELOCKED — an admin pinned this account's sessions of the tool"),
},
},
},
"/api/me/onboarded": {
post: {
tags: ["Auth"],
+3 -4
View File
@@ -112,11 +112,10 @@ router.get("/9router/update", async (req, res) => {
});
// POST /api/ai-proxies/9router/update — start the job; 202 with it. Poll the
// GET above for its steps. Admin only (also in auth.js ADMIN_WRITE_PREFIXES).
router.post("/9router/update", sameOriginGuard, async (req, res) => {
// GET above for its steps. Any signed-in user (auth.js MEMBER_WRITE_EXCEPTIONS):
// the job is fixed, nothing from the request reaches it.
router.post("/9router/update", sameOriginGuard, async (_req, res) => {
try {
if (req.user && req.user.role !== "admin")
throw Object.assign(new Error("only an admin may update 9router"), { code: "EADMIN" });
res.status(202).json(await nineRouterUpdate.start());
} catch (e) {
return fail(res, e);
+54
View File
@@ -30,6 +30,12 @@
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` stores an https link
* instead, which `GET /avatar` redirects to (server/lib/avatar-url.js) and
* DELETE removes it — all writes are account writes like the profile's. The sidebar shows it beside the name.
* `/pool-accounts` (server mode) is the account pool as this account may use
* it (server/lib/account-pins.js `myAccounts`): GET lists, per tool, the
* accounts its group allows — never a token — with the admin's pin and its
* own choice; PUT `{tool, account}` chooses one (`null` = automatic) — refused
* while an admin pins it (`ELOCKED`) or for an account it may not use
* (`ENOACCOUNT`). An account write like the profile's: cookie only, audited.
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
@@ -188,6 +194,10 @@ const ACCOUNT_STATUS = {
ETOOBIG: 413,
EREAUTH: 403,
ETOOMANY: 429,
EBADTOOL: 400,
ENOUSER: 404,
ENOACCOUNT: 404,
ELOCKED: 409,
};
function accountView(userId) {
@@ -316,6 +326,50 @@ router.delete(
})
);
// ── The account pool: the user's own choice of account per tool ───────────
const pins = require("../lib/account-pins");
/** Every tool with an account this user may use: those accounts, the admin's pin, their choice. */
router.get("/pool-accounts", (req, res) => {
if (!auth.isServerMode()) return res.json({ tools: [] });
const user = req.user || auth.userFromRequest(req);
if (!user) return unauth(res);
try {
res.json({ tools: pins.myAccounts(user.id) });
} catch (e) {
res.status(500).json({ error: { code: e.code || "EPOOL", message: e.message } });
}
});
/**
* `{tool, account}` — run this user's new sessions of `tool` on `account`
* (`null`: the balancer picks). A Claude login goes into their home at once,
* like an admin's switch (`switched`); anything else at the next session.
*/
router.put(
"/pool-accounts",
sameOriginGuard,
accountWrite(async (_req, user, body) => {
const choice = pins.setChoice(user.id, body.tool, body.account ?? null);
let switched = null;
if (choice) {
try {
switched = await pins.applyPin(user.id, body.tool);
} catch (e) {
console.warn(`[me] switch ${user.username} → ${choice}: ${e.message}`);
switched = "next-session";
}
}
return {
out: { tool: body.tool, choice, switched },
action: choice
? `own ${body.tool} pool account ${choice}`
: `own ${body.tool} pool account: automatic`,
};
})
);
// An avatar past its limit reaches here as express.raw's 413.
router.use((e, _req, res, next) => {
if (e && e.type === "entity.too.large")
+16 -2
View File
@@ -35,6 +35,20 @@ const {
const router = Router();
/**
* Whether `p` is a regular file, by `stat`. Not `fs.existsSync`: that is
* access(2), which checks the REAL uid with capabilities cleared, so it says
* "no" for a member's journal behind a 0700 folder (`~/.omp/agent`, S3) that
* the server itself reads through its CAP_DAC_READ_SEARCH.
*/
function isFile(p) {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
// JSONL entry types the transcript reader turns into renderable messages.
// `user`/`assistant` are the conversation. `custom-title` is the metadata line
// written by /rename, `claude -n`, and the picker's Ctrl+R — surfaced as an
@@ -864,7 +878,7 @@ router.get("/:id/transcripts", async (req, res) => {
? require("../lib/cursor-usage").transcriptPath(req.params.id)
: require("../lib/antigravity-usage").transcriptPath(req.params.id);
const list =
file && fs.existsSync(file)
file && isFile(file)
? [
{
id: "main",
@@ -1142,7 +1156,7 @@ router.get("/:id/transcript", async (req, res) => {
getSnapshotTranscriptPath(req.params.id);
}
if (!jsonlPath || !fs.existsSync(jsonlPath)) {
if (!jsonlPath || !isFile(jsonlPath)) {
return res.json({ messages: [], total: 0, has_more: false, last_line: 0, first_line: 0 });
}
+3
View File
@@ -292,10 +292,13 @@ async function poolFor(uid) {
}
const ok = groups.allowed(uid, tool);
const pin = pins.pinFor(uid, tool);
const choice = pins.choiceFor(uid, tool);
const ranOn = used.filter((u) => u.tool === tool);
const wholeSession = tool !== "claude";
out[tool] = {
pin,
// The user's own pick (Settings → AI tools → Pool account), below a pin.
choice,
group: group ? { id: group.id, name: group.name } : null,
accounts: status
.filter((a) => ok(a.name) || ranOn.some((u) => u.account === a.name))