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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"resume": [
|
||||
"--continue"
|
||||
],
|
||||
"resumeById": [
|
||||
"--conversation"
|
||||
],
|
||||
"bypass": [
|
||||
"--dangerously-skip-permissions"
|
||||
],
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user