Compare commits

..

8 Commits

Author SHA1 Message Date
nntrivi2001 0c8782ea75 chore: v2.8.11 — switch a pool account's users from its Edit 2026-10-19 11:03:54 +07:00
nntrivi2001 c042adc51b ui(pool): pick an account's users from its own Edit
Each account row in the Account pool card has a pencil: tick the users
whose sessions run on it (one moves off any other account, unticking sends
one back to the balancer). PUT /api/accounts/:name/users saves the set in one
transaction, audits each change and switches a Claude login into each added
user's home at once. The card-level switch button and pinned list are gone.
2026-10-19 10:45:54 +07:00
nntrivi2001 776ad6466f feat(pool): an admin's switch moves a user's Claude login at once
PUT /api/accounts/pins (and agenthub accounts pin) now writes a pinned
Claude login into the user's home right away (claude-pool.switchHome):
every home's newer pair is copied back first, the member's own login is
never written over, and no pane is restarted — running sessions take the
new pair at their next credentials read. A live home stays on the login it
holds, so unpinning does not flip it back. The Users page calls it
"Switch a user's account" and says what happened.
2026-10-19 10:23:44 +07:00
nntrivi2001 9c98638ec0 feat(pool): pin a user to one Claude/Codex pool account
An admin pins a server-mode user to one pool account per tool
(account_pins; PUT /api/accounts/pins with the password step-up and audit,
the Users page's Pin a user, agenthub accounts pin|unpin). The workspace
owner's pin wins over the balancer whenever the pane may use the account;
a pinned login still waits while another pool login holds the home.
2026-10-19 10:05:19 +07:00
nntrivi2001 aa8ac6cad9 fix: v2.8.10 — the server starts again on plugin installs (js-yaml is a runtime dependency)
v2.8.9 crashed at start on every plugin install with "Cannot find module
'js-yaml'": server/lib/lane-detect.js requires it, but it was only a
devDependency that redoc used to pull in, and v2.8.8 dropped redoc. The
plugin installs with --omit=dev. This box hid it: Debian's global
/usr/share/nodejs has js-yaml. Reproduced with a pruned install and the
global paths off; fixed by moving js-yaml (and argparse in the lock) to
dependencies. New runtime-deps.test.js fails on any server/ require that is
not a dependency.
2026-10-16 14:54:43 +07:00
nntrivi2001 3237ce4774 chore: v2.8.9 — /tui works in panes with workspace instructions; clearer Startup buttons 2026-10-16 13:39:31 +07:00
nntrivi2001 7c5f30df7d fix(panes): /tui works in panes with workspace instructions
Claude refused `/tui` ("Cannot switch renderers in this session — it has
restrictions a restart can't carry over") in every pane started with
--append-system-prompt-file, i.e. any workspace with instructions (member
panes usually have the owner's). The instructions are now written as the only
CLAUDE.md of a folder of their own (<data dir>/workspace-prompts/<id>/, a
member's prompt-<id>/ in the handoff dir via write-as), passed as one more
--add-dir with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1. Verified with
Claude Code 2.1.301: the file is listed in /memory and /tui switches. Codex
is unchanged (its pointer line names the same file).
2026-10-16 13:38:09 +07:00
nntrivi2001 5e0a9b526b ui(settings): Quit/Uninstall as real buttons with icons and a busy spinner; code in row descriptions; dimmed unavailable rows; smoother switch 2026-10-16 10:58:14 +07:00
33 changed files with 1387 additions and 121 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+87 -6
View File
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
], ],
[ [
"accounts", "accounts",
"list|add|remove [name] [--tool codex]", "list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts", "Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
], ],
["repl", "", "Open the interactive shell (also: shell, i)"], ["repl", "", "Open the interactive shell (also: shell, i)"],
], ],
@@ -3814,7 +3814,7 @@ const SUBCOMMANDS = {
service: ["install", "uninstall", "status"], service: ["install", "uninstall", "status"],
server: ["setup", "off", "status"], server: ["setup", "off", "status"],
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"], users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
accounts: ["list", "add", "remove"], accounts: ["list", "add", "remove", "pin", "unpin"],
pricing: ["set", "delete", "reset"], pricing: ["set", "delete", "reset"],
webhooks: ["test"], webhooks: ["test"],
import: ["rescan", "path"], import: ["rescan", "path"],
@@ -4286,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
} }
pool.resetQuota(); pool.resetQuota();
table( table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"], [
"codex account",
"kind",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota",
"state",
"pinned",
],
pool pool
.poolStatus() .poolStatus()
.map((a) => [ .map((a) => [
@@ -4301,10 +4312,64 @@ async function cmdCodexAccounts(sub, name, flags) {
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ") ? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
: "–", : "–",
a.full ? "full" : "ok", a.full ? "full" : "ok",
pinnedUsers("codex", a.name),
]) ])
); );
} }
/**
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
* that user's new sessions run on that account (server/lib/account-pins.js).
*/
async function cmdAccountPin(sub, positional, tool) {
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
const [first, second] = positional;
const username = sub === "pin" ? second : first;
if (!username || (sub === "pin" && !first)) {
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
}
const user = require(path.join(REPO_ROOT, "server", "db.js"))
.db.prepare("SELECT id FROM users WHERE username = ?")
.get(username);
if (!user) throw new Error(`no user ${username}`);
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
console.log(c.dim(" Pins apply in server mode only."));
}
if (!pin.account) {
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
return;
}
const switched = await pins.applyPin(user.id, tool);
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
}
/** What `applyPin` did, for the CLI. */
const SWITCH_NOTE = {
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
kept: "Their home already holds it.",
"own-login":
"They signed in with their own login, which is left alone: no pool login is written over it.",
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
"next-session": "New sessions use it; running ones keep their account until they end.",
};
/** The `pinned` column: who an admin pinned to `account`. */
function pinnedUsers(tool, account) {
try {
return (
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
.listPins(tool)
.filter((p) => p.account === account)
.map((p) => p.username)
.join(",") || "–"
);
} catch {
return "–";
}
}
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */ /** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
async function cmdAccounts(sub, positional, flags = {}) { async function cmdAccounts(sub, positional, flags = {}) {
serverAuth(); // the same settings + database the server reads serverAuth(); // the same settings + database the server reads
@@ -4316,6 +4381,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") { if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
throw new Error("--tool is claude (the default) or codex"); throw new Error("--tool is claude (the default) or codex");
} }
if (sub === "pin" || sub === "unpin")
return cmdAccountPin(sub, positional, flags.tool || "claude");
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags); if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (sub === "add") { if (sub === "add") {
console.log( console.log(
@@ -4337,7 +4404,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
await pool.refreshQuotas({ force: true }); await pool.refreshQuotas({ force: true });
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–"); const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
table( table(
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"], [
"account",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota 5h",
"quota wk",
"state",
"pinned",
],
pool pool
.poolStatus() .poolStatus()
.map((a) => [ .map((a) => [
@@ -4356,6 +4434,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
: a.quota?.error : a.quota?.error
? `no quota (${a.quota.error})` ? `no quota (${a.quota.error})`
: "ok", : "ok",
pinnedUsers("claude", a.name),
]) ])
); );
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js")); const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
@@ -4363,7 +4442,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
return; return;
} }
console.error( console.error(
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]") c.red(
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
)
); );
process.exitCode = 1; process.exitCode = 1;
} }
+6 -3
View File
@@ -33,13 +33,16 @@ export function Switch({
// On: the solid accent (white knob clears 3:1 in every theme). Off: a // On: the solid accent (white knob clears 3:1 in every theme). Off: a
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge // border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
// is visible on any surface (surface-4 alone was ~1:1 on a light card). // is visible on any surface (surface-4 alone was ~1:1 on a light card).
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${ // Hover darkens the track (enabled only), so it reads as clickable.
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80" className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 ${
checked
? "bg-accent-solid enabled:hover:bg-accent-solid-hover"
: "bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
}`} }`}
> >
<span <span
aria-hidden aria-hidden
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${ className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out ${
checked ? "translate-x-[18px]" : "translate-x-0.5" checked ? "translate-x-[18px]" : "translate-x-0.5"
}`} }`}
/> />
@@ -9,17 +9,24 @@
* boot/login while the switch is on. Between them, the status-area icon switch * boot/login while the switch is on. Between them, the status-area icon switch
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last, * (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from * **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
* the machine, database included (scripts/uninstall.js). * the machine, database included (scripts/uninstall.js). Quit is a plain
* button and Uninstall a danger one, both the same width with an icon, and
* each spins while its request runs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Loader2, Power, Trash2 } from "lucide-react";
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api"; import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow"; import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
// The two action buttons share one width, so they line up under the switches.
const ACTION = "min-w-[6.5rem] justify-center text-xs";
const ICON = "h-3.5 w-3.5 shrink-0";
export function AutostartSettings() { export function AutostartSettings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const [status, setStatus] = useState<AutostartStatus | null>(null); const [status, setStatus] = useState<AutostartStatus | null>(null);
@@ -126,9 +133,11 @@ export function AutostartSettings() {
type="button" type="button"
data-testid="quit-agenthub" data-testid="quit-agenthub"
disabled={quitting} disabled={quitting}
aria-busy={quitting}
onClick={() => void quit()} onClick={() => void quit()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40" className={`btn-secondary ${ACTION}`}
> >
{quitting ? <Loader2 className={ICON + " animate-spin"} /> : <Power className={ICON} />}
{t("quit.button")} {t("quit.button")}
</button> </button>
} }
@@ -141,9 +150,15 @@ export function AutostartSettings() {
type="button" type="button"
data-testid="uninstall-agenthub" data-testid="uninstall-agenthub"
disabled={uninstalling} disabled={uninstalling}
aria-busy={uninstalling}
onClick={() => void uninstall()} onClick={() => void uninstall()}
className="btn-ghost text-xs text-status-danger disabled:opacity-40" className={`btn-danger ${ACTION}`}
> >
{uninstalling ? (
<Loader2 className={ICON + " animate-spin"} />
) : (
<Trash2 className={ICON} />
)}
{t("uninstall.button")} {t("uninstall.button")}
</button> </button>
} }
+31 -4
View File
@@ -4,7 +4,8 @@
* switch row (`SettingsSwitchRow`) and a segmented radio group * switch row (`SettingsSwitchRow`) and a segmented radio group
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide * (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
* themselves with a hairline, none after the last, so a section is just its * themselves with a hairline, none after the last, so a section is just its
* rows in order. * rows in order. A string description shows its `backticked` words as code,
* and a `disabled` row dims its label, so an unavailable control reads as one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -32,30 +33,55 @@ export function SettingsSection({
); );
} }
/** `text` with every `backticked` run drawn as inline code (a command to type). */
export function withCode(text: string): ReactNode {
const parts = text.split(/`([^`]+)`/);
if (parts.length === 1) return text;
return parts.map((p, i) =>
i % 2 ? (
<code
key={i}
className="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
>
{p}
</code>
) : (
p
)
);
}
export function SettingsRow({ export function SettingsRow({
label, label,
description, description,
control, control,
alignTop = false, alignTop = false,
disabled = false,
id, id,
}: { }: {
label: ReactNode; label: ReactNode;
description?: ReactNode; description?: ReactNode;
control: ReactNode; control: ReactNode;
alignTop?: boolean; alignTop?: boolean;
/** The control cannot be used here: the label is dimmed with it. */
disabled?: boolean;
/** A deep-link anchor (`#id`) for one row inside a grouped section. */ /** A deep-link anchor (`#id`) for one row inside a grouped section. */
id?: string; id?: string;
}) { }) {
return ( return (
<div <div
id={id} id={id}
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${ className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-6 border-b border-border/60 last:border-b-0 ${
description ? "py-3" : "py-2" description ? "py-3" : "py-2"
} ${alignTop ? "items-start" : "items-center"}`} } ${alignTop ? "items-start" : "items-center"}`}
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-fg-secondary">{label}</p> <p className={`text-sm ${disabled ? "text-fg-muted" : "text-fg-secondary"}`}>{label}</p>
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>} {description && (
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted">
{typeof description === "string" ? withCode(description) : description}
</p>
)}
</div> </div>
<div className="shrink-0">{control}</div> <div className="shrink-0">{control}</div>
</div> </div>
@@ -82,6 +108,7 @@ export function SettingsSwitchRow({
id={id} id={id}
label={label} label={label}
description={description} description={description}
disabled={disabled}
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />} control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
/> />
); );
+183 -4
View File
@@ -8,26 +8,31 @@
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI * `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
* API key or a pasted `auth.json` — or removes one, each time with their own * API key or a pasted `auth.json` — or removes one, each time with their own
* password again; an analyst only reads. The secret only goes IN: the server * password again; an analyst only reads. The secret only goes IN: the server
* never sends it back. * never sends it back. An account's Edit (pencil) picks the users who run on
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
* a Claude login goes into their home at once — no restart.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react"; import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { import {
api, api,
type AccountPin,
type AccountPool, type AccountPool,
type PoolAccountInput, type PoolAccountInput,
type PoolProfile, type PoolProfile,
type PoolTool, type PoolTool,
type ServerUserUsage,
} from "../../lib/api"; } from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format"; import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin"; import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal"; import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
type Row = { type Row = {
tool: PoolTool; tool: PoolTool;
@@ -59,6 +64,8 @@ function rowsOf(pool: AccountPool): Row[] {
]; ];
} }
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
function errorText(t: TFunction<"users">, e: unknown): string { function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string }; const err = e as Error & { code?: string };
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" }) return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
@@ -72,6 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<Row | null>(null); const [removing, setRemoving] = useState<Row | null>(null);
const [editing, setEditing] = useState<Row | null>(null);
const load = useCallback( const load = useCallback(
(force = false) => (force = false) =>
@@ -148,6 +156,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<div className="text-[11px] text-fg-muted"> <div className="text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })} {t("pool.added", { at: formatDateTime(r.addedAt) })}
</div> </div>
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
<div className="text-[11px] text-fg-secondary">
{t("pool.pinned", {
users: pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username)
.join(", "),
})}
</div>
)}
</td> </td>
<td className="px-5 py-2.5 text-sm text-fg-secondary"> <td className="px-5 py-2.5 text-sm text-fg-secondary">
{t(`pool.kind.${r.kind}`)} {t(`pool.kind.${r.kind}`)}
@@ -188,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</div> </div>
</td> </td>
{isAdmin && ( {isAdmin && (
<td className="px-5 py-2 text-right"> <td className="px-5 py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => setEditing(r)}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button <button
type="button" type="button"
onClick={() => setRemoving(r)} onClick={() => setRemoving(r)}
@@ -206,7 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</table> </table>
</div> </div>
)} )}
{adding && ( {adding && (
<AddAccountDialog <AddAccountDialog
onClose={() => setAdding(false)} onClose={() => setAdding(false)}
@@ -228,10 +254,163 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
}} }}
/> />
)} )}
{editing && pool && (
<EditAccountDialog
pool={pool}
row={editing}
onClose={() => setEditing(null)}
onDone={(r) => {
const left = r.added.filter(
(u) => u.switched === "own-login" || u.switched === "expired"
);
toast.success(t("pool.editDone", { name: editing.name }));
for (const u of left) {
toast.info(
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
);
}
setEditing(null);
void load();
}}
/>
)}
</section> </section>
); );
} }
/**
* One account's Edit: tick the users whose sessions run on it. A user already
* on another account of the tool moves here when ticked; unticking one sends
* them back to the least-used account. Saved in one request, password again.
*/
function EditAccountDialog({
pool,
row,
onClose,
onDone,
}: {
pool: AccountPool;
row: Row;
onClose: () => void;
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
}) {
const { t } = useTranslation("users");
const pins = pinsOf(pool, row.tool);
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0;
const toggle = (id: number, on: boolean) =>
setChosen((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.edit", { name: row.name })}
message={t("pool.editHint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
return (
<div key={u.id} className="px-3 py-2">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => toggle(u.id, on)}
label={
<span>
{u.username}
{u.disabled && (
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
)}
{elsewhere && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.onAccount", { account: elsewhere.account })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
type ClaudeSource = "profile" | "credentials" | "token"; type ClaudeSource = "profile" | "credentials" | "token";
function AddAccountDialog({ function AddAccountDialog({
@@ -5,7 +5,8 @@
* admin adds an account only with their own password typed (the token goes * admin adds an account only with their own password typed (the token goes
* in once and is not shown back) — a saved login moved in (the server's own * in once and is not shown back) — a saved login moved in (the server's own
* included), or a setup-token — and removes one the same way, and an analyst * included), or a setup-token — and removes one the same way, and an analyst
* sees no add or remove control. * sees no add or remove control. An account's Edit ticks the users who run on
* it, saved with the admin's own password; the users show on the account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
add: vi.fn(), add: vi.fn(),
remove: vi.fn(), remove: vi.fn(),
profiles: vi.fn(), profiles: vi.fn(),
setUsers: vi.fn(),
})); }));
const users = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => { vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>(); const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { accountPool } }; return { ...actual, api: { accountPool, users } };
}); });
import { AccountPoolCard } from "../AccountPoolCard"; import { AccountPoolCard } from "../AccountPoolCard";
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
beforeEach(() => { beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset(); for (const fn of Object.values(accountPool)) fn.mockReset();
accountPool.list.mockResolvedValue(POOL); accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({
users: [
{ id: 7, username: "mia", disabled: false },
{ id: 8, username: "leo", disabled: false },
],
});
accountPool.profiles.mockResolvedValue({ accountPool.profiles.mockResolvedValue({
profiles: [ profiles: [
{ {
@@ -158,4 +167,43 @@ describe("AccountPoolCard", () => {
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass") expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
); );
}); });
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
accountPool.list.mockResolvedValue({
...POOL,
pins: {
claude: [
{ userId: 8, username: "leo", account: "team-1" },
{ userId: 7, username: "mia", account: "team-0" },
],
codex: [],
},
});
accountPool.setUsers.mockResolvedValue({
added: [{ userId: 7, username: "mia", switched: "switched" }],
removed: [{ userId: 8, username: "leo" }],
});
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
const dialog = await screen.findByRole("dialog");
const leo = await screen.findByRole("checkbox", { name: /leo/ });
const mia = screen.getByRole("checkbox", { name: /mia/ });
expect(leo).toHaveAttribute("aria-checked", "true");
expect(mia).toHaveAttribute("aria-checked", "false");
expect(screen.getByText("now on team-0")).toBeInTheDocument();
await user.click(mia);
await user.click(leo);
const save = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Save"
)!;
expect(save).toBeDisabled();
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(save);
await waitFor(() =>
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
);
});
}); });
+16 -2
View File
@@ -166,7 +166,8 @@
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.", "EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
"ENOTFOUND": "That saved login no longer exists.", "ENOTFOUND": "That saved login no longer exists.",
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.", "ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
"EUPSTREAM": "Anthropic could not be reached. Try again." "EUPSTREAM": "Anthropic could not be reached. Try again.",
"ENOUSER": "That user no longer exists."
}, },
"source": "From", "source": "From",
"sources": { "sources": {
@@ -196,7 +197,20 @@
"token": "The sk-ant-… that `claude setup-token` printed.", "token": "The sk-ant-… that `claude setup-token` printed.",
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account." "codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
}, },
"refreshFailed": "refresh failed" "refreshFailed": "refresh failed",
"pinned": "Used by: {{users}}",
"loadingUsers": "Loading…",
"pinDone": {
"own-login": "{{user}} signed in with their own login, which was left alone",
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
},
"edit": "Edit {{name}}",
"editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.",
"save": "Save",
"users": "Users on this account",
"noUsers": "No users yet.",
"onAccount": "now on {{account}}",
"editDone": "{{name}} saved"
}, },
"delete": { "delete": {
"button": "Delete user", "button": "Delete user",
+16 -2
View File
@@ -166,7 +166,8 @@
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.", "EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
"ENOTFOUND": "Login đã lưu đó không còn nữa.", "ENOTFOUND": "Login đã lưu đó không còn nữa.",
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.", "ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại." "EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
"ENOUSER": "User đó không còn tồn tại."
}, },
"source": "Nguồn", "source": "Nguồn",
"sources": { "sources": {
@@ -196,7 +197,20 @@
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.", "token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT." "codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
}, },
"refreshFailed": "làm mới lỗi" "refreshFailed": "làm mới lỗi",
"pinned": "Đang dùng: {{users}}",
"loadingUsers": "Đang tải…",
"pinDone": {
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
},
"edit": "Sửa {{name}}",
"editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.",
"save": "Lưu",
"users": "User dùng tài khoản này",
"noUsers": "Chưa có user nào.",
"onAccount": "đang ở {{account}}",
"editDone": "Đã lưu {{name}}"
}, },
"delete": { "delete": {
"button": "Xoá user", "button": "Xoá user",
+23
View File
@@ -676,9 +676,18 @@ export type CodexPoolAccount = {
quotaPercent: number | null; quotaPercent: number | null;
full: boolean; full: boolean;
}; };
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
export type AccountPin = {
userId: number;
username: string;
account: string;
pinnedAt?: string;
};
export type AccountPool = { export type AccountPool = {
accounts: PoolAccount[]; accounts: PoolAccount[];
codex: CodexPoolAccount[]; codex: CodexPoolAccount[];
/** Per tool; absent from an older server, empty in local mode. */
pins?: { claude: AccountPin[]; codex: AccountPin[] };
blockPercent: number; blockPercent: number;
}; };
export type PoolTool = "claude" | "codex"; export type PoolTool = "claude" | "codex";
@@ -1052,6 +1061,20 @@ export const api = {
method: "POST", method: "POST",
body: JSON.stringify({ ...body, currentPassword }), body: JSON.stringify({ ...body, currentPassword }),
}), }),
/** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */
setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) =>
request<{
/** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */
added: {
userId: number;
username: string;
switched: "switched" | "kept" | "own-login" | "expired" | "next-session";
}[];
removed: { userId: number; username: string }[];
}>(`/accounts/${encodeURIComponent(name)}/users`, {
method: "PUT",
body: JSON.stringify({ tool, userIds, currentPassword }),
}),
/** DELETE /api/accounts/:name — admin, with their OWN password again. */ /** DELETE /api/accounts/:name — admin, with their OWN password again. */
remove: (tool: PoolTool, name: string, currentPassword: string) => remove: (tool: PoolTool, name: string, currentPassword: string) =>
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, { request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
@@ -7022,7 +7022,7 @@ exports[`screen snapshots > Settings — about 1`] = `
class="mt-2" class="mt-2"
> >
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7033,7 +7033,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Start AgentHub automatically Start AgentHub automatically
</p> </p>
<p <p
class="mt-0.5 text-xs text-fg-muted" class="mt-0.5 text-xs leading-relaxed text-fg-muted"
> >
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up. Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
</p> </p>
@@ -7044,19 +7044,19 @@ exports[`screen snapshots > Settings — about 1`] = `
<button <button
aria-checked="false" aria-checked="false"
aria-label="Start AgentHub automatically" aria-label="Start AgentHub automatically"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80" class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
role="switch" role="switch"
type="button" type="button"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5" class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
/> />
</button> </button>
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7067,7 +7067,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Show AgentHub icon in the status area Show AgentHub icon in the status area
</p> </p>
<p <p
class="mt-0.5 text-xs text-fg-muted" class="mt-0.5 text-xs leading-relaxed text-fg-muted"
> >
An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit. An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.
</p> </p>
@@ -7078,19 +7078,19 @@ exports[`screen snapshots > Settings — about 1`] = `
<button <button
aria-checked="true" aria-checked="true"
aria-label="Show AgentHub icon in the status area" aria-label="Show AgentHub icon in the status area"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-accent-solid" class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-accent-solid enabled:hover:bg-accent-solid-hover"
role="switch" role="switch"
type="button" type="button"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]" class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-[18px]"
/> />
</button> </button>
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7101,25 +7101,51 @@ exports[`screen snapshots > Settings — about 1`] = `
Quit AgentHub Quit AgentHub
</p> </p>
<p <p
class="mt-0.5 text-xs text-fg-muted" class="mt-0.5 text-xs leading-relaxed text-fg-muted"
> >
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run \`agenthub start\` to bring it back. Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run
<code
class="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
>
agenthub start
</code>
to bring it back.
</p> </p>
</div> </div>
<div <div
class="shrink-0" class="shrink-0"
> >
<button <button
class="btn-ghost text-xs text-status-danger disabled:opacity-40" aria-busy="false"
class="btn-secondary min-w-[6.5rem] justify-center text-xs"
data-testid="quit-agenthub" data-testid="quit-agenthub"
type="button" type="button"
> >
<svg
class="lucide lucide-power h-3.5 w-3.5 shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2v10"
/>
<path
d="M18.4 6.6a9 9 0 1 1-12.77.04"
/>
</svg>
Quit Quit
</button> </button>
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7130,7 +7156,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Uninstall AgentHub Uninstall AgentHub
</p> </p>
<p <p
class="mt-0.5 text-xs text-fg-muted" class="mt-0.5 text-xs leading-relaxed text-fg-muted"
> >
Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept. Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.
</p> </p>
@@ -7139,10 +7165,45 @@ exports[`screen snapshots > Settings — about 1`] = `
class="shrink-0" class="shrink-0"
> >
<button <button
class="btn-ghost text-xs text-status-danger disabled:opacity-40" aria-busy="false"
class="btn-danger min-w-[6.5rem] justify-center text-xs"
data-testid="uninstall-agenthub" data-testid="uninstall-agenthub"
type="button" type="button"
> >
<svg
class="lucide lucide-trash2 h-3.5 w-3.5 shrink-0"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6h18"
/>
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
/>
<path
d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"
/>
<line
x1="10"
x2="10"
y1="11"
y2="17"
/>
<line
x1="14"
x2="14"
y1="11"
y2="17"
/>
</svg>
Uninstall Uninstall
</button> </button>
</div> </div>
@@ -7162,7 +7223,7 @@ exports[`screen snapshots > Settings — about 1`] = `
class="mt-2" class="mt-2"
> >
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7173,12 +7234,12 @@ exports[`screen snapshots > Settings — about 1`] = `
Release Release
</p> </p>
<p <p
class="mt-0.5 text-xs text-fg-muted" class="mt-0.5 text-xs leading-relaxed text-fg-muted"
> >
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.8 (rebuild client to match) UI build v2.8.11 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
@@ -7193,7 +7254,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7215,7 +7276,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7240,7 +7301,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7262,7 +7323,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7284,7 +7345,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7295,7 +7356,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Live connections Live connections
</p> </p>
<p <p
class="mt-0.5 text-xs text-fg-muted" class="mt-0.5 text-xs leading-relaxed text-fg-muted"
> >
Browser tabs and tools receiving live updates Browser tabs and tools receiving live updates
</p> </p>
@@ -7325,7 +7386,7 @@ exports[`screen snapshots > Settings — about 1`] = `
class="mt-2" class="mt-2"
> >
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7355,7 +7416,7 @@ exports[`screen snapshots > Settings — about 1`] = `
class="mt-2" class="mt-2"
> >
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7366,7 +7427,7 @@ exports[`screen snapshots > Settings — about 1`] = `
Database file Database file
</p> </p>
<p <p
class="mt-0.5 text-xs text-fg-muted" class="mt-0.5 text-xs leading-relaxed text-fg-muted"
> >
<span <span
class="font-mono break-all" class="font-mono break-all"
@@ -7386,7 +7447,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -7408,7 +7469,7 @@ exports[`screen snapshots > Settings — about 1`] = `
</div> </div>
</div> </div>
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -8960,7 +9021,7 @@ exit 0
class="mt-2" class="mt-2"
> >
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-3 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -8971,7 +9032,7 @@ exit 0
Redact secrets before storing Redact secrets before storing
</p> </p>
<p <p
class="mt-0.5 text-xs text-fg-muted" class="mt-0.5 text-xs leading-relaxed text-fg-muted"
> >
API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed. API keys and tokens of known shapes, bearer headers, private keys and password=/TOKEN= values in event payloads and summaries become [redacted]. The transcripts on disk are the CLIs' own files and are not changed.
</p> </p>
@@ -8982,13 +9043,13 @@ exit 0
<button <button
aria-checked="false" aria-checked="false"
aria-label="Redact secrets before storing" aria-label="Redact secrets before storing"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80" class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
role="switch" role="switch"
type="button" type="button"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5" class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
/> />
</button> </button>
</div> </div>
@@ -9739,7 +9800,7 @@ exports[`screen snapshots > Settings — notifications 1`] = `
class="min-w-0 flex-1" class="min-w-0 flex-1"
> >
<div <div
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center" class="flex justify-between gap-6 border-b border-border/60 last:border-b-0 py-2 items-center"
> >
<div <div
class="min-w-0" class="min-w-0"
@@ -9756,13 +9817,13 @@ exports[`screen snapshots > Settings — notifications 1`] = `
<button <button
aria-checked="false" aria-checked="false"
aria-label="Enable Browser Notifications" aria-label="Enable Browser Notifications"
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80" class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
role="switch" role="switch"
type="button" type="button"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5" class="inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out translate-x-0.5"
/> />
</button> </button>
</div> </div>
+3 -3
View File
@@ -89,9 +89,9 @@ In server mode a **developer** sees only rows of the workspaces they own (`lanes
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log. An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login). `GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`): **The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
@@ -1598,7 +1598,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content. - **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`. - Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped. At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then that workspace's instructions folder `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir) as one more `--add-dir`, with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane's env so Claude reads the `CLAUDE.md` there as memory — never `--append-system-prompt-file`: Claude cannot carry a custom system prompt over a restart and refused `/tui` in such a pane. That `CLAUDE.md` is rewritten at every spawn and holds the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
#### Projects (workspaces grouped by repository) #### Projects (workspaces grouped by repository)
+1 -1
View File
@@ -108,7 +108,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) | | `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared | | `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count | | `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) | | `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). `pin <account> <user>` / `unpin <user>` (add `--tool codex` for Codex) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin | | `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
### Interactive REPL ### Interactive REPL
+1
View File
@@ -621,6 +621,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`).
| --- | --- | | --- | --- |
| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` | | `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` |
| `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) | | `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) |
| `account_pins` | `user_id` → `users.id` (cascade), `tool` (`claude`\|`codex`), `account` (a pool account's name), `pinned_at`; PK (`user_id`, `tool`) — an admin's pin of a user to one pool account (`server/lib/account-pins.js`) |
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database. `dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
+1 -1
View File
File diff suppressed because one or more lines are too long
+24
View File
@@ -355,6 +355,7 @@ claude setup-token
agenthub accounts add team-1 agenthub accounts add team-1
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
agenthub accounts remove team-1 agenthub accounts remove team-1
agenthub accounts pin team-1 mia # switch mia to team-1 (unpin mia = automatic again)
``` ```
In server mode an admin can do the same on the **Users** page, in the In server mode an admin can do the same on the **Users** page, in the
@@ -423,6 +424,28 @@ How a new session picks its account (`server/lib/claude-pool.js`):
pane still needs that member's own `/login`. pane still needs that member's own `/login`.
- A token more than a year old is skipped. - A token more than a year old is skipped.
- A resumed session picks an account again, like a new one. - A resumed session picks an account again, like a new one.
- **Switching a user to one account.** An admin can take one user off the
ranking: the pencil (**Edit**) on that account's row in the Account pool
card — tick the users who run on it, untick to send one back to automatic (or
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; their
own password again, each change audited). A user ticked on one account
moves off any other. Every session in a
workspace that user OWNS then runs on that account, even when it is full;
other users are not affected, and the account is not reserved for them.
- **A Claude login switches at once, without a restart.** The server first
copies every home's newer pair back into the pool (so the login leaving
the home keeps its only live refresh token), then writes the new login
into the user's `~/.claude/.credentials.json` as them. New sessions start
on it; sessions already running keep their process and move to it the
next time Claude Code re-reads its credentials (it does so before it
refreshes its access token, so at the latest when that token expires).
A login the user made themselves (`/login`) is never written over — the
card says so. Their sessions are booked to the account they started on.
- A setup-token, a Codex account, or a host pane gets the account at its
next spawn; running ones keep theirs.
- An expired account falls back to the ranking. Removing the account drops
the switch; deleting the user drops theirs. Server mode only
(`server/lib/account-pins.js`).
- Quota is read with each account's own token, in the background, at most every - Quota is read with each account's own token, in the background, at most every
5 minutes. If the usage endpoint refuses a setup-token, the state column shows 5 minutes. If the usage endpoint refuses a setup-token, the state column shows
`no quota (…)` and ranking uses spend alone. `no quota (…)` and ranking uses spend alone.
@@ -447,6 +470,7 @@ CODEX_HOME=$(mktemp -d) codex login
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
agenthub accounts list --tool codex agenthub accounts list --tool codex
agenthub accounts remove team-key --tool codex agenthub accounts remove team-key --tool codex
agenthub accounts pin team-key mia --tool codex # pins work as for Claude
``` ```
- **API key**: any number of panes at once. The pane gets the key through - **API key**: any number of panes at once. The pane gets the key through
+146 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.8.8 version: 2.8.11
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
@@ -4785,6 +4785,22 @@ components:
description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows. description: Events attributed to this run's inner agents, ordered by created_at then id. Capped at 5000 rows.
items: items:
$ref: '#/components/schemas/DashboardEvent' $ref: '#/components/schemas/DashboardEvent'
AccountPin:
type: object
required:
- userId
- username
- account
properties:
userId:
type: integer
username:
type: string
account:
type: string
description: The pool account's name
pinnedAt:
type: string
UserPrefs: UserPrefs:
type: object type: object
required: required:
@@ -15141,6 +15157,18 @@ paths:
nullable: true nullable: true
full: full:
type: boolean type: boolean
pins:
type: object
description: Per tool, the users an admin pinned to one account (empty in local mode)
properties:
claude:
type: array
items:
$ref: '#/components/schemas/AccountPin'
codex:
type: array
items:
$ref: '#/components/schemas/AccountPin'
codex: codex:
type: array type: array
description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry description: The Codex account pool (server/lib/codex-pool.js), same fields plus `kind`; no expiry
@@ -15241,6 +15269,62 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/api/accounts/pins:
put:
tags:
- Auth
operationId: pinPoolAccount
summary: 'Admin: switch one user''s sessions of a tool to one pool account (`account: null` = automatic), with their own password again. A Claude login is written into their home at once; running panes take it without a restart'
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- tool
- userId
- currentPassword
properties:
tool:
type: string
enum:
- claude
- codex
userId:
type: integer
account:
type: string
nullable: true
currentPassword:
type: string
responses:
'200':
description: '`{pin: {userId, username, account}, switched}` — `account` null when unpinned; `switched`: switched | kept | own-login | expired | next-session | null'
'400':
description: EBADTOOL
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER, ENOACCOUNT
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — too many wrong passwords
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/accounts/profiles: /api/accounts/profiles:
get: get:
tags: tags:
@@ -15250,6 +15334,67 @@ paths:
responses: responses:
'200': '200':
description: '`{profiles: [{name, label, email, subscriptionType, active}]}`' description: '`{profiles: [{name, label, email, subscriptionType, active}]}`'
/api/accounts/{name}/users:
put:
tags:
- Auth
operationId: setPoolAccountUsers
summary: 'Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer), with their own password again. A Claude login is written into each added user''s home at once'
parameters:
- name: name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- tool
- userIds
- currentPassword
properties:
tool:
type: string
enum:
- claude
- codex
userIds:
type: array
items:
type: integer
currentPassword:
type: string
responses:
'200':
description: '`{added: [{userId, username, switched}], removed: [{userId, username}]}` — `switched`: switched | kept | own-login | expired | next-session'
'400':
description: EBADTOOL, EBADUSER
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER, ENOACCOUNT
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — too many wrong passwords
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/accounts/{name}: /api/accounts/{name}:
delete: delete:
tags: tags:
+3 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.8", "version": "2.8.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.8", "version": "2.8.11",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
@@ -14,6 +14,7 @@
"adm-zip": "^0.6.1", "adm-zip": "^0.6.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"js-yaml": "^4.1.0",
"multer": "^2.0.0", "multer": "^2.0.0",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"systray2": "^2.1.4", "systray2": "^2.1.4",
@@ -27,7 +28,6 @@
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"js-yaml": "^4.1.0",
"prettier": "^3.8.1" "prettier": "^3.8.1"
}, },
"engines": { "engines": {
@@ -215,7 +215,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/array-flatten": { "node_modules/array-flatten": {
@@ -1099,7 +1098,6 @@
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.8", "version": "2.8.11",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
@@ -75,6 +75,7 @@
"adm-zip": "^0.6.1", "adm-zip": "^0.6.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"js-yaml": "^4.1.0",
"multer": "^2.0.0", "multer": "^2.0.0",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"systray2": "^2.1.4", "systray2": "^2.1.4",
@@ -88,7 +89,6 @@
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"js-yaml": "^4.1.0",
"prettier": "^3.8.1" "prettier": "^3.8.1"
}, },
"engines": { "engines": {
+48 -1
View File
@@ -9,7 +9,9 @@
* same account; removing the account takes it out of those homes; and the * same account; removing the account takes it out of those homes; and the
* Users-page route moves a saved switch-user profile in — the one this * Users-page route moves a saved switch-user profile in — the one this
* machine's own Claude Code is logged into with its LIVE login, the host's * machine's own Claude Code is logged into with its LIVE login, the host's
* home then being one more placement the server refreshes. * home then being one more placement the server refreshes. A pin of the
* owner to another login waits while a live pane holds the first, until the
* admin's switch writes it into the home (never over the member's own login).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -188,6 +190,51 @@ describe("a pool login", () => {
assert.equal(pool.__read("team"), null); assert.equal(pool.__read("team"), null);
}); });
it("follows the owner's pin, and an admin's switch moves the home's login now", async () => {
const { db } = require("../db");
const pins = require("../lib/account-pins");
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
pool.addLoginAccount("alpha", { oauth: oauth("a"), account: ACCOUNT });
pool.addLoginAccount("beta", {
oauth: oauth("b"),
account: { accountUuid: "uuid-beta", emailAddress: "beta@example.com" },
});
assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "unpinned: the balancer's pick");
pins.setPin(miaId, "claude", "beta");
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
assert.equal(pool.forSpawn(acct, null).name, "alpha", "another owner is not pinned");
// A pane of mia's runs on alpha: beta waits until it ends.
pool.forSpawn(acct).place();
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
const lane = db
.prepare("INSERT INTO lanes (cwd, owner_id) VALUES (?, ?)")
.run(path.join(HOME, "proj"), miaId).lastInsertRowid;
db.prepare(
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, lane_id, tool)
VALUES ('pin-run', 'interactive', ?, 'running', ?, 'alpha', ?, 'claude')`
).run(path.join(HOME, "proj"), new Date().toISOString(), lane);
assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "a bare pin waits for the pane");
// The admin's switch writes beta into the home now; the pane is not touched.
assert.equal(await pins.applyPin(miaId, "claude"), "switched");
assert.equal(diskCreds().refreshToken, oauth("b").refreshToken);
assert.equal(pool.__read("alpha").placed.mia, undefined, "alpha no longer holds mia's home");
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
assert.equal(await pins.applyPin(miaId, "claude"), "kept");
// Unpinned with the pane still live: the home's login stays, no flip back.
pins.setPin(miaId, "claude", null);
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
// A login the member made themselves is never written over.
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("own") }));
pins.setPin(miaId, "claude", "alpha");
assert.equal(await pins.applyPin(miaId, "claude"), "own-login");
assert.equal(diskCreds().refreshToken, oauth("own").refreshToken);
pins.setPin(miaId, "claude", "beta");
pool.removeAccount("beta");
assert.equal(pins.pinFor(miaId, "claude"), null, "a removed account drops its pins");
db.prepare("DELETE FROM dashboard_runs WHERE id = 'pin-run'").run();
db.prepare("DELETE FROM lanes WHERE id = ?").run(lane);
});
it("refuses a second copy of one Claude account", () => { it("refuses a second copy of one Claude account", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT }); pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), { assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
+15 -7
View File
@@ -181,12 +181,16 @@ describe("lane-sources instructions and launch", () => {
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese"); assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
}); });
it("starts a claude pane with --add-dir first and the instructions from a file", () => { it("starts a claude pane with --add-dir first and the instructions as a CLAUDE.md there", () => {
let argv = null; let argv = null;
let newSession = null;
tmux.__setExecImpl((args) => { tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 }); if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
// The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js). // The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js).
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 6); if (args[0] === "new-session") {
newSession = args;
argv = args.slice(args.indexOf("--") + 6);
}
return ""; return "";
}); });
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" }); pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
@@ -194,11 +198,15 @@ describe("lane-sources instructions and launch", () => {
assert.equal(argv[0], "claude"); assert.equal(argv[0], "claude");
assert.equal(argv[1], "--add-dir"); assert.equal(argv[1], "--add-dir");
assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]); assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]);
assert.equal(argv[4], "--permission-mode"); // The instructions' own folder, read as memory: a custom system prompt
const flag = argv.find((a) => a.startsWith("--append-system-prompt-file=")); // (`--append-system-prompt-file`) made Claude refuse `/tui` in the pane.
const file = flag.slice("--append-system-prompt-file=".length); const dir = argv[4];
assert.ok(file.startsWith(process.env.DASHBOARD_DATA_DIR)); assert.ok(dir.startsWith(process.env.DASHBOARD_DATA_DIR));
const text = fs.readFileSync(file, "utf8"); assert.equal(argv[5], "--permission-mode");
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
assert.match(text, /^- always answer in Vietnamese/); assert.match(text, /^- always answer in Vietnamese/);
assert.match(text, /pass wd\.md/); assert.match(text, /pass wd\.md/);
// A prompt that looks like flags stays one positional, never `claude -c`. // A prompt that looks like flags stays one positional, never `claude -c`.
+68
View File
@@ -0,0 +1,68 @@
/**
* @file runtime-deps.test.js
* @description Every package the server code requires is a runtime dependency.
* A plugin install runs `npm install --omit=dev`, so a package listed only in
* devDependencies — or one that arrived only as some other package's
* dependency — is missing there. v2.8.9 crashed at start on every plugin
* install: `js-yaml` (server/lib/lane-detect.js) was a devDependency that
* `redoc` used to pull in, and dropping `redoc` took it away. Machines with
* Debian's global /usr/share/nodejs never saw it, so this checks the manifest
* instead of trusting a local `require` to fail.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { builtinModules } = require("node:module");
const ROOT = path.join(__dirname, "..", "..");
const pkg = require(path.join(ROOT, "package.json"));
// Required only behind a runtime check, never on a plain Node server.
const GUARDED = new Set(["electron"]);
function sourceFiles(dir) {
const out = [];
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!["node_modules", "__tests__", "vendor"].includes(e.name)) out.push(...sourceFiles(p));
} else if (/\.(c?js|mjs)$/.test(e.name)) out.push(p);
}
return out;
}
/** Bare package names `file` requires, comments left out. */
function requiredPackages(file) {
const code = fs
.readFileSync(file, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:"'`\\])\/\/.*$/gm, "$1");
const names = new Set();
for (const m of code.matchAll(/\brequire\(\s*["']([^"'./][^"']*)["']\s*\)/g)) {
const spec = m[1];
if (spec.startsWith("node:")) continue;
const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
if (!builtinModules.includes(name)) names.add(name);
}
return names;
}
describe("runtime dependencies", () => {
it("every package server/ requires is in dependencies or optionalDependencies", () => {
const runtime = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
]);
const missing = [];
for (const file of sourceFiles(path.join(ROOT, "server"))) {
for (const name of requiredPackages(file)) {
if (!runtime.has(name) && !GUARDED.has(name))
missing.push(`${path.relative(ROOT, file)}: ${name}`);
}
}
assert.deepEqual(missing, []);
});
});
@@ -4,7 +4,8 @@
* admin may add or remove an account, each change needs the admin's OWN * admin may add or remove an account, each change needs the admin's OWN
* password again (EREAUTH), the token only goes in — no answer (the add, the * password again (EREAUTH), the token only goes in — no answer (the add, the
* list) ever carries it — the audit log names the account, not its secret, and * list) ever carries it — the audit log names the account, not its secret, and
* in local mode the routes refuse. * in local mode the routes refuse. An admin pins a user to one account
* (`PUT /api/accounts/pins`): that user's new panes skip the balancer.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -144,3 +145,112 @@ describe("the account pool from the Users page", () => {
assert.ok(claudePool.pickAccount(), "team-1 is still there"); assert.ok(claudePool.pickAccount(), "team-1 is still there");
}); });
}); });
describe("pinning a user to one account", () => {
const pins = require("../lib/account-pins");
const pin = (cookie, body) =>
call("PUT", "/api/accounts/pins", {
cookie,
body: { tool: "claude", currentPassword: BOSS_PASS, ...body },
});
it("lets an admin pin and unpin, audited, and new panes follow the pin", async () => {
const boss = await login("boss", BOSS_PASS);
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
claudePool.addAccount("team-z", `sk-ant-oat01-${"z".repeat(40)}`);
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1", "the balancer's pick");
const r = await pin(boss, { userId: miaId, account: "team-z" });
assert.equal(r.status, 200);
assert.deepEqual(r.body.pin, { userId: miaId, username: "mia", account: "team-z" });
assert.equal(r.body.switched, "next-session", "a setup-token goes in at the next spawn");
assert.equal(claudePool.pickAccount(null, miaId).name, "team-z");
assert.equal(claudePool.pickAccount(null, null).name, "team-1", "nobody else moves");
const audit = db.prepare("SELECT owner_id, path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.equal(audit.owner_id, miaId);
assert.match(audit.path, /pin mia to claude account team-z/);
const list = await call("GET", "/api/accounts", { cookie: boss });
assert.deepEqual(
list.body.pins.claude.map((p) => [p.username, p.account]),
[["mia", "team-z"]]
);
assert.deepEqual(list.body.pins.codex, []);
const off = await pin(boss, { userId: miaId, account: null });
assert.equal(off.body.pin.account, null);
assert.equal(claudePool.pickAccount(null, miaId).name, "team-1");
claudePool.removeAccount("team-z");
});
it("refuses an unknown account or user, a wrong password, and a non-admin", async () => {
const boss = await login("boss", BOSS_PASS);
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
assert.equal(
(await pin(boss, { userId: miaId, account: "nope" })).body.error.code,
"ENOACCOUNT"
);
assert.equal((await pin(boss, { userId: 9999, account: "team-1" })).body.error.code, "ENOUSER");
assert.equal(
(await pin(boss, { userId: miaId, account: "team-1", currentPassword: "x" })).body.error.code,
"EREAUTH"
);
const ana = await login("ana", "analyst-pass-1");
const w = await call("PUT", "/api/accounts/pins", {
cookie: ana,
body: { tool: "claude", userId: miaId, account: "team-1", currentPassword: "analyst-pass-1" },
});
assert.equal(w.status, 403);
assert.equal(pins.pinFor(miaId, "claude"), null);
});
it("sets exactly who runs on one account from its Edit", async () => {
const boss = await login("boss", BOSS_PASS);
const id = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
claudePool.addAccount("team-y", `sk-ant-oat01-${"y".repeat(40)}`);
pins.setPin(id("ana"), "claude", "team-1");
const edit = (account, userIds, extra = {}) =>
call("PUT", `/api/accounts/${account}/users`, {
cookie: boss,
body: { tool: "claude", userIds, currentPassword: BOSS_PASS, ...extra },
});
const r = await edit("team-y", [id("mia"), id("ana")]);
assert.equal(r.status, 200);
assert.deepEqual(r.body.added.map((u) => [u.username, u.switched]).sort(), [
["ana", "next-session"],
["mia", "next-session"],
]);
assert.equal(pins.pinFor(id("ana"), "claude"), "team-y", "moved off team-1");
const again = await edit("team-y", [id("mia")]);
assert.deepEqual(again.body.added, []);
assert.deepEqual(
again.body.removed.map((u) => u.username),
["ana"]
);
assert.equal(pins.pinFor(id("ana"), "claude"), null);
assert.equal(pins.pinFor(id("mia"), "claude"), "team-y");
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
assert.match(audit.path, /unpin ana from claude/);
assert.equal((await edit("team-y", [9999])).body.error.code, "ENOUSER");
assert.equal((await edit("team-y", "x")).body.error.code, "EBADUSER");
assert.equal((await edit("nope", [])).body.error.code, "ENOACCOUNT");
assert.equal((await edit("team-y", [], { currentPassword: "x" })).body.error.code, "EREAUTH");
assert.equal(pins.pinFor(id("mia"), "claude"), "team-y", "a refused edit changes nothing");
await edit("team-y", []);
claudePool.removeAccount("team-y");
});
it("is ignored in local mode and dropped with its user", async () => {
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
pins.setPin(miaId, "claude", "team-1");
delete process.env.AGENTHUB_MODE;
try {
assert.equal(pins.pinFor(miaId, "claude"), null);
} finally {
process.env.AGENTHUB_MODE = "server";
}
assert.equal(pins.pinFor(miaId, "claude"), "team-1");
db.prepare("DELETE FROM users WHERE id = ?").run(miaId);
assert.equal(
db.prepare("SELECT COUNT(*) AS n FROM account_pins WHERE user_id = ?").get(miaId).n,
0
);
});
});
+1 -1
View File
@@ -114,7 +114,7 @@ describe("a member pane's files live in the member's handoff dir", () => {
assert.deepEqual(x.addDirs, [ctx]); assert.deepEqual(x.addDirs, [ctx]);
assert.equal(fs.readFileSync(path.join(ctx, "spec.md"), "utf8"), "# spec\n"); assert.equal(fs.readFileSync(path.join(ctx, "spec.md"), "utf8"), "# spec\n");
assert.equal(mode(path.join(ctx, "spec.md")), 0o644); assert.equal(mode(path.join(ctx, "spec.md")), 0o644);
assert.equal(x.systemPromptFile, path.join(HANDOFF, `prompt-${laneId}.md`)); assert.equal(x.systemPromptFile, path.join(HANDOFF, `prompt-${laneId}`, "CLAUDE.md"));
assert.equal(mode(x.systemPromptFile), 0o644); assert.equal(mode(x.systemPromptFile), 0o644);
const text = fs.readFileSync(x.systemPromptFile, "utf8"); const text = fs.readFileSync(x.systemPromptFile, "utf8");
assert.match(text, /Be brief\./); assert.match(text, /Be brief\./);
+12
View File
@@ -1873,6 +1873,18 @@ for (const col of ["display_name", "email", "avatar_at"]) {
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run(); db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
} }
} }
// An admin's pin of one server-mode user to one pool account per tool
// (server/lib/account-pins.js): that user's new sessions skip the balancer.
// The account is named, not referenced — the pool lives in files.
db.exec(`
CREATE TABLE IF NOT EXISTS account_pins (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tool TEXT NOT NULL CHECK(tool IN ('claude','codex')),
account TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (user_id, tool)
);
`);
// An existing database made before auto_vacuum: the setting is stored only by // An existing database made before auto_vacuum: the setting is stored only by
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here, // a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
+144
View File
@@ -0,0 +1,144 @@
/**
* @file account-pins.js
* @description Account pins: an admin's choice that one server-mode user's
* new sessions of a tool run on one named pool account instead of the one the
* balancer would pick (claude-pool.js / codex-pool.js `pick`) — the Users
* page's "switch a user's account". One pin per user and tool
* (`account_pins`); the user is the workspace's OWNER
* (`lanes.owner_id`), never whoever clicked Start. A pin is a preference the
* pool still bounds: an account a pane may not use (expired, or a login the
* pane's home cannot take) falls back to the balancer. A Claude login is
* written into the user's home right away (`applyPin`), over the pool login
* their running panes use, so those move to it without a restart.
*
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
*
* 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`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const TOOLS = ["claude", "codex"];
const err = (code, message) => Object.assign(new Error(message), { code });
const db = () => require("../db").db;
const poolOf = (tool) => require(tool === "codex" ? "./codex-pool" : "./claude-pool");
function validTool(tool) {
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
return tool;
}
/** The account name `ownerId`'s new `tool` sessions are pinned to, or null. */
function pinFor(ownerId, tool) {
if (!require("./auth").isServerMode() || !Number.isInteger(ownerId)) return null;
try {
return (
db()
.prepare("SELECT account FROM account_pins WHERE user_id = ? AND tool = ?")
.get(ownerId, tool)?.account ?? null
);
} catch {
return null; // a spawn never fails over a pin
}
}
/** Every pin of `tool`, with its user's name: `[{userId, username, account, pinnedAt}]`. */
function listPins(tool) {
return db()
.prepare(
`SELECT p.user_id AS userId, u.username, p.account, p.pinned_at AS pinnedAt
FROM account_pins p JOIN users u ON u.id = p.user_id
WHERE p.tool = ? ORDER BY u.username`
)
.all(validTool(tool));
}
/**
* Pin `userId`'s new `tool` sessions to `account`, or unpin them (`account`
* null). Returns `{userId, username, account}`.
*/
function setPin(userId, tool, account) {
validTool(tool);
const user = Number.isInteger(userId)
? db().prepare("SELECT id, username FROM users WHERE id = ?").get(userId)
: null;
if (!user) throw err("ENOUSER", `no user ${userId}`);
if (account == null || account === "") {
db().prepare("DELETE FROM account_pins WHERE user_id = ? AND tool = ?").run(user.id, tool);
return { userId: user.id, username: user.username, account: null };
}
const name = String(account);
if (!poolOf(tool).hasAccount(name)) {
throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`);
}
db()
.prepare(
`INSERT INTO account_pins (user_id, tool, account) VALUES (?, ?, ?)
ON CONFLICT(user_id, tool) DO UPDATE SET account = excluded.account,
pinned_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
)
.run(user.id, tool, name);
return { userId: user.id, username: user.username, account: name };
}
/**
* The Users page's Edit of one account: exactly `userIds` run on `account`
* afterwards. A user pinned to another account of the tool moves here; one
* pinned here but left out goes back to the balancer. One transaction.
* Returns `{added, removed}` (each `[{userId, username}]`).
*/
function setAccountUsers(tool, account, userIds) {
validTool(tool);
const name = String(account ?? "");
if (!poolOf(tool).hasAccount(name)) {
throw err("ENOACCOUNT", `no ${tool} account ${name} in the pool`);
}
if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) {
throw err("EBADUSER", "userIds: an array of user ids");
}
const want = [...new Set(userIds)];
const users = want.map((id) => {
const u = db().prepare("SELECT id, username FROM users WHERE id = ?").get(id);
if (!u) throw err("ENOUSER", `no user ${id}`);
return u;
});
const before = listPins(tool);
const here = new Set(before.filter((p) => p.account === name).map((p) => p.userId));
const added = users.filter((u) => !here.has(u.id));
const removed = before.filter((p) => p.account === name && !want.includes(p.userId));
db().transaction(() => {
for (const u of added) setPin(u.id, tool, name);
for (const p of removed) setPin(p.userId, tool, null);
})();
return {
added: added.map((u) => ({ userId: u.id, username: u.username })),
removed: removed.map((p) => ({ userId: p.userId, username: p.username })),
};
}
/**
* Make a pin 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);
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 with it. */
function dropAccount(tool, account) {
try {
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
} catch {
/* no database (a bare CLI test): nothing pinned */
}
}
module.exports = { TOOLS, pinFor, listPins, setPin, setAccountUsers, applyPin, dropAccount };
+1
View File
@@ -367,6 +367,7 @@ function deleteUser(username) {
} catch { } catch {
/* no personal profiles */ /* no personal profiles */
} }
sql("DELETE FROM account_pins WHERE user_id = ?").run(u.id);
sql("DELETE FROM users WHERE id = ?").run(u.id); sql("DELETE FROM users WHERE id = ?").run(u.id);
return { id: u.id, username: u.username, workspaces: lanes.length }; return { id: u.id, username: u.username, workspaces: lanes.length };
} }
+66 -9
View File
@@ -18,6 +18,10 @@
* 3. least $ spent in the last 5 hours, then in the last 7 days — the * 3. least $ spent in the last 5 hours, then in the last 7 days — the
* sessions its runs (`dashboard_runs.account`) held, over the hours they * sessions its runs (`dashboard_runs.account`) held, over the hours they
* held them (pool-usage.js). * held them (pool-usage.js).
* An admin may switch a user to one account (account-pins.js): that user's
* panes skip the ranking whenever they may use it, and a login is written
* into their home at once (`switchHome`) — running panes take it at their
* next credentials read, without a restart.
* The quota is read with the account's own token in the background, at most * The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no * every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend. * reading, and ranking falls back to spend.
@@ -193,6 +197,7 @@ function removeAccount(name) {
} }
} }
fs.rmSync(fileOf(n)); fs.rmSync(fileOf(n));
require("./account-pins").dropAccount("claude", n);
quotaCache.delete(n); quotaCache.delete(n);
} }
@@ -332,6 +337,40 @@ async function refreshLogins() {
} }
} }
/**
* An admin's switch (account-pins.js): put pool login `name` into `member`'s
* home NOW, over the pool login it holds — never over a login the member made
* themselves. Every home's newer pair is copied back first (refreshLogins),
* so the login leaving the home keeps its only live refresh token. Running
* panes are not restarted: Claude Code takes the new pair when it next
* re-reads its credentials (before a refresh), new panes at once. Answers
* `switched`, `kept` (already there), `own-login` (left alone), `expired`, or
* `next-session` (a setup-token, or no member home: it goes in at spawn).
*/
async function switchHome(member, name) {
const first = readAccount(validName(name));
if (!first) throw err("ENOACCOUNT", `no account ${name} in the pool`);
if (first.kind !== "login" || !member || member.host) return "next-session";
await refreshLogins();
const a = readAccount(first.name);
if (!a || expired(a)) return "expired";
const disk = homeCreds(member);
if (disk && ownsHome(a, member.name, disk)) return "kept";
const holder = disk && allAccounts().find((x) => ownsHome(x, member.name, disk));
if (disk && !holder) return "own-login";
if (holder) {
const placed = { ...holder.placed };
delete placed[member.name];
save({ ...holder, placed });
}
save(writeLogin(a, member));
runAs().markClaudeOnboarded(member, {
...(a.account ? { oauthAccount: a.account } : {}),
...(a.userID ? { userID: a.userID } : {}),
});
return "switched";
}
// ── Quota, read in the background ────────────────────────────────────────── // ── Quota, read in the background ──────────────────────────────────────────
/** name → { at, fiveHour?, sevenDay?, error? } */ /** name → { at, fiveHour?, sevenDay?, error? } */
@@ -434,10 +473,15 @@ function liveInHome(name, member) {
}); });
} }
/** The full record a new Claude pane should run on, or null. `member` = run-as account. */ /**
function pick(member) { * The full record a new Claude pane should run on, or null. `member` = run-as
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
* over the ranking when this pane may use that account.
*/
function pick(member, ownerId = null) {
let usable = allAccounts().filter((a) => !expired(a)); let usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null; if (usable.length === 0) return null;
let live = null;
if (!member) { if (!member) {
// A host pane's home holds the host's own login — a pool account only when // A host pane's home holds the host's own login — a pool account only when
// it was moved in from there (`hostHolds`) and is still the pair on disk: // it was moved in from there (`hostHolds`) and is still the pair on disk:
@@ -453,10 +497,18 @@ function pick(member) {
} else { } else {
// One .credentials.json per home: while a pane of theirs runs on a // One .credentials.json per home: while a pane of theirs runs on a
// pool login, the next pane stays on that one. // pool login, the next pane stays on that one.
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member)); // The login in the home is the one they run on — after an admin's
// switch (switchHome) it is no longer the one those panes started on.
const liveLogin = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
live = liveLogin ? placed || liveLogin : null;
}
}
// 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");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "token" || !live || live === pinned)) return pinned;
if (live) return live; if (live) return live;
}
}
if (usable.length === 0) return null; if (usable.length === 0) return null;
refreshQuotas().catch(() => {}); refreshQuotas().catch(() => {});
const ranked = usable const ranked = usable
@@ -489,8 +541,8 @@ function pick(member) {
* background re-read. A `login` is offered for a member, and for a host pane * background re-read. A `login` is offered for a member, and for a host pane
* only when it is the host's own login (`hostHolds`), which stays as it is. * only when it is the host's own login (`hostHolds`), which stays as it is.
*/ */
function pickAccount(member = null) { function pickAccount(member = null, ownerId = null) {
const a = pick(member); const a = pick(member, ownerId);
if (!a) return null; if (!a) return null;
return a.kind === "login" return a.kind === "login"
? { name: a.name, kind: "login" } ? { name: a.name, kind: "login" }
@@ -502,8 +554,8 @@ function pickAccount(member = null) {
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just * `env` goes through tmux `-e` (a member's launch file); `place()` runs just
* before the pane starts and writes a login into the member's home. * before the pane starts and writes a login into the member's home.
*/ */
function forSpawn(member = null) { function forSpawn(member = null, ownerId = null) {
const a = pick(member); const a = pick(member, ownerId);
if (!a) return null; if (!a) return null;
if (a.kind === "token") { if (a.kind === "token") {
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} }; return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
@@ -524,8 +576,12 @@ function forSpawn(member = null) {
}; };
} }
/** Is `name` an account in the pool (any state)? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = { module.exports = {
BLOCK_PERCENT, BLOCK_PERCENT,
hasAccount,
REFRESH_AHEAD_MS, REFRESH_AHEAD_MS,
addAccount, addAccount,
addLoginAccount, addLoginAccount,
@@ -533,6 +589,7 @@ module.exports = {
poolStatus, poolStatus,
pickAccount, pickAccount,
forSpawn, forSpawn,
switchHome,
refreshQuotas, refreshQuotas,
refreshLogins, refreshLogins,
__read: readAccount, __read: readAccount,
+25 -9
View File
@@ -29,6 +29,7 @@
* the last 5 h, then 7 d. The reading is the newest `rate_limits` in the * the last 5 h, then 7 d. The reading is the newest `rate_limits` in the
* rollouts of the sessions that account's runs started (no network), each * rollouts of the sessions that account's runs started (no network), each
* from the CODEX_HOME of the account its workspace runs as. * from the CODEX_HOME of the account its workspace runs as.
* A user an admin pinned to one account (account-pins.js) skips the ranking.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
@@ -139,6 +140,7 @@ function removeAccount(name) {
const n = validName(name); const n = validName(name);
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`); if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
fs.rmSync(fileOf(n)); fs.rmSync(fileOf(n));
require("./account-pins").dropAccount("codex", n);
quotaCache = null; quotaCache = null;
} }
@@ -332,10 +334,15 @@ function syncBack() {
// ── Picking ──────────────────────────────────────────────────────────────── // ── Picking ────────────────────────────────────────────────────────────────
/** The full record a new Codex pane should run on, or null. `member` = run-as account. */ /**
function pick(member) { * The full record a new Codex pane should run on, or null. `member` = run-as
* account; `ownerId` = the workspace owner, whose pin (account-pins.js) wins
* over the ranking when this pane may use that account.
*/
function pick(member, ownerId = null) {
let usable = allAccounts(); let usable = allAccounts();
if (usable.length === 0) return null; if (usable.length === 0) return null;
let live = null;
if (!member) { if (!member) {
// A host pane's CODEX_HOME holds the host's own login. // A host pane's CODEX_HOME holds the host's own login.
usable = usable.filter((a) => a.kind === "apikey"); usable = usable.filter((a) => a.kind === "apikey");
@@ -348,10 +355,15 @@ function pick(member) {
} else { } else {
// One auth.json per home: while a pane of theirs runs on a pool // One auth.json per home: while a pane of theirs runs on a pool
// login, the next pane stays on that one. // login, the next pane stays on that one.
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)); live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
}
}
// 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");
const pinned = pin ? usable.find((a) => a.name === pin) : null;
if (pinned && (pinned.kind === "apikey" || !live || live === pinned)) return pinned;
if (live) return live; if (live) return live;
}
}
if (usable.length === 0) return null; if (usable.length === 0) return null;
const ranked = usable const ranked = usable
.map((a) => { .map((a) => {
@@ -395,8 +407,8 @@ function liveInHome(name, member) {
} }
/** `{name, kind}` of the account a new Codex pane would get, or null. */ /** `{name, kind}` of the account a new Codex pane would get, or null. */
function pickAccount(member = null) { function pickAccount(member = null, ownerId = null) {
const a = pick(member); const a = pick(member, ownerId);
return a ? { name: a.name, kind: a.kind } : null; return a ? { name: a.name, kind: a.kind } : null;
} }
@@ -405,8 +417,8 @@ function pickAccount(member = null) {
* this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e` * this pane may use): `{name, argv, env, place}`. `env` goes through tmux `-e`
* (a member's launch file); `place()` runs just before the pane starts. * (a member's launch file); `place()` runs just before the pane starts.
*/ */
function forSpawn(member = null) { function forSpawn(member = null, ownerId = null) {
const a = pick(member); const a = pick(member, ownerId);
if (!a) return null; if (!a) return null;
if (a.kind === "apikey") { if (a.kind === "apikey") {
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`; const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
@@ -439,8 +451,12 @@ function forSpawn(member = null) {
}; };
} }
/** Is `name` an account in the pool? */
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
module.exports = { module.exports = {
BLOCK_PERCENT, BLOCK_PERCENT,
hasAccount,
addAccount, addAccount,
removeAccount, removeAccount,
poolStatus, poolStatus,
+16 -9
View File
@@ -7,7 +7,7 @@
* workspace's own folder and not be or contain the OS home; an uploaded file * workspace's own folder and not be or contain the OS home; an uploaded file
* is stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a * is stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a
* name it chooses, so a request never names a path on disk. Only Claude * name it chooses, so a request never names a path on disk. Only Claude
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`, * (`--add-dir`, the instructions as a CLAUDE.md in a folder of their own) and Codex (`--add-dir`,
* `-c developer_instructions=`) can take them; every other tool is reported * `-c developer_instructions=`) can take them; every other tool is reported
* unsupported and its panes get nothing. The workspace OWNER's own agent * unsupported and its panes get nothing. The workspace OWNER's own agent
* instructions (`user_prefs.instructions`, Settings → My agent instructions) * instructions (`user_prefs.instructions`, Settings → My agent instructions)
@@ -184,7 +184,9 @@ function removeSource(laneId, sourceId) {
function removeLaneContext(laneId) { function removeLaneContext(laneId) {
const dir = contextDir(laneId); const dir = contextDir(laneId);
if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, force: true }); if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true }); const prompts = path.join(getDataDir(), "workspace-prompts");
fs.rmSync(path.join(prompts, String(Number(laneId))), { recursive: true, force: true });
fs.rmSync(path.join(prompts, `${Number(laneId)}.md`), { force: true }); // before v2.8.9
} }
/** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */ /** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */
@@ -286,18 +288,23 @@ function launchExtras(laneId, tool) {
// A data dir that cannot be written costs the pane its instructions (all of // A data dir that cannot be written costs the pane its instructions (all of
// Claude's, Codex's past the inline budget), never its start — same as a // Claude's, Codex's past the inline budget), never its start — same as a
// vanished source. // vanished source.
// The file is a `CLAUDE.md` alone in a folder of its own: Claude reads it as
// memory from that folder (`--add-dir` + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_
// CLAUDE_MD, pty-run.js), not as `--append-system-prompt-file` — a custom
// system prompt is a launch flag Claude cannot carry over a restart, so it
// refused `/tui` (switching renderer restarts the session) in every pane
// that had instructions. Codex names the same file in its pointer line.
try { try {
const sub = `prompt-${Number(laneId)}`;
if (member) { if (member) {
const file = require("./run-as").handoffWrite( const runAs = require("./run-as");
member, runAs.mkdirAs(member, path.join(runAs.handoffDir(member), sub));
`prompt-${Number(laneId)}.md`, const file = runAs.handoffWrite(member, path.join(sub, "CLAUDE.md"), systemPrompt);
systemPrompt
);
return { addDirs, systemPrompt, systemPromptFile: file }; return { addDirs, systemPrompt, systemPromptFile: file };
} }
const dir = path.join(getDataDir(), "workspace-prompts"); const dir = path.join(getDataDir(), "workspace-prompts", String(Number(laneId)));
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const systemPromptFile = path.join(dir, `${Number(laneId)}.md`); const systemPromptFile = path.join(dir, "CLAUDE.md");
fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 }); fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
return { addDirs, systemPrompt, systemPromptFile }; return { addDirs, systemPrompt, systemPromptFile };
} catch (err) { } catch (err) {
+13 -7
View File
@@ -202,7 +202,10 @@ function buildClaudeArgv({
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following // `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
// argument up to the next option, so it goes first, where `--permission-mode` // argument up to the next option, so it goes first, where `--permission-mode`
// always ends it — after the prompt it would eat the prompt as a directory. // always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs); // The instructions' own folder rides along: its CLAUDE.md is read as memory
// (see lane-sources.js launchExtras), so `/tui` can still restart the pane.
const dirs = systemPromptFile ? [...addDirs, path.dirname(systemPromptFile)] : addDirs;
if (dirs.length) argv.push("--add-dir", ...dirs);
argv.push("--permission-mode", permissionMode || "auto"); argv.push("--permission-mode", permissionMode || "auto");
// Inside a pane Claude Code sees $TMUX and opens agent-team teammates with // Inside a pane Claude Code sees $TMUX and opens agent-team teammates with
// `split-window -P -F '#{pane_id}'`. psmux ignores -F, so the id it reads // `split-window -P -F '#{pane_id}'`. psmux ignores -F, so the id it reads
@@ -220,9 +223,6 @@ function buildClaudeArgv({
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow // `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
// the prompt as the worktree's name. // the prompt as the worktree's name.
if (worktreeName) argv.push(`--worktree=${worktreeName}`); if (worktreeName) argv.push(`--worktree=${worktreeName}`);
// From a file: the instructions can be long, and the whole argv has to fit
// in one tmux message. `=` form, so the path is never read as an option.
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning // Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
// it on: `claude --help` — "Enable bypassing all permission checks as an // it on: `claude --help` — "Enable bypassing all permission checks as an
// option, without it being enabled by default." Without it the cycle is only // option, without it being enabled by default." Without it the cycle is only
@@ -461,19 +461,20 @@ function spawnRun(args) {
assertCwdAccount(laneId, cwd); // ECWDACCOUNT assertCwdAccount(laneId, cwd); // ECWDACCOUNT
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
// A Claude session on the vendor's own API runs on the least-loaded pool // A Claude session on the vendor's own API runs on the least-loaded pool
// account, if the admin added any (claude-pool.js); an AI Proxy brings its // account, if the admin added any (claude-pool.js) — or the one the
// workspace owner is pinned to (account-pins.js); an AI Proxy brings its
// own key, and an empty pool leaves the machine's login in charge. // own key, and an empty pool leaves the machine's login in charge.
// A real login (kind `login`) is written into a MEMBER's home by place(); // A real login (kind `login`) is written into a MEMBER's home by place();
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN. // a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
const poolAccount = const poolAccount =
entry.launcher === "claude" && !proxyProfile entry.launcher === "claude" && !proxyProfile
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId)) ? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
: null; : null;
// Codex likewise, from its own pool (codex-pool.js): an API key through // Codex likewise, from its own pool (codex-pool.js): an API key through
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place(). // `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
const codexPool = const codexPool =
entry.launcher === "codex" && !proxyProfile entry.launcher === "codex" && !proxyProfile
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId)) ? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
: null; : null;
const paneCred = require("./auth").newPaneToken(); const paneCred = require("./auth").newPaneToken();
if (forkSession === true) { if (forkSession === true) {
@@ -626,6 +627,11 @@ function spawnRun(args) {
// journals and the proxy layer `-p <name>` loads live there — and the // journals and the proxy layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME. // tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}), ...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
// The workspace's instructions are a CLAUDE.md in an `--add-dir` folder,
// which Claude reads only with this set (lane-sources.js launchExtras).
...(entry.launcher === "claude" && systemPromptFile
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
: {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its // No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and // output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own // pushes lines into history its redraw cannot clear). The user's own
+91
View File
@@ -5,6 +5,8 @@
* - POST /api/auth/logout (Auth) * - POST /api/auth/logout (Auth)
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens * - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one * - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
* - GET /api/users (Auth) — every account's usage (admin/analyst) * - GET /api/users (Auth) — every account's usage (admin/analyst)
* - GET /api/users/audit (Auth) — staff reads of developers' sessions * - 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/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
@@ -30,6 +32,16 @@ const tags = [
]; ];
const schemas = { const schemas = {
AccountPin: {
type: "object",
required: ["userId", "username", "account"],
properties: {
userId: { type: "integer" },
username: { type: "string" },
account: { type: "string", description: "The pool account's name" },
pinnedAt: { type: "string" },
},
},
UserPrefs: { UserPrefs: {
type: "object", type: "object",
required: ["prefs", "onboardedAt"], required: ["prefs", "onboardedAt"],
@@ -633,6 +645,15 @@ const paths = {
}, },
}, },
}, },
pins: {
type: "object",
description:
"Per tool, the users an admin pinned to one account (empty in local mode)",
properties: {
claude: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
codex: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
},
},
codex: { codex: {
type: "array", type: "array",
description: description:
@@ -706,6 +727,41 @@ const paths = {
}, },
}, },
}, },
"/api/accounts/pins": {
put: {
tags: ["Auth"],
operationId: "pinPoolAccount",
summary:
"Admin: switch one user's sessions of a tool to one pool account (`account: null` = automatic), with their own password again. A Claude login is written into their home at once; running panes take it without a restart",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "userId", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
userId: { type: "integer" },
account: { type: "string", nullable: true },
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: {
description:
"`{pin: {userId, username, account}, switched}` — `account` null when unpinned; `switched`: switched | kept | own-login | expired | next-session | null",
},
400: err("EBADTOOL"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOUSER, ENOACCOUNT"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/accounts/profiles": { "/api/accounts/profiles": {
get: { get: {
tags: ["Auth"], tags: ["Auth"],
@@ -716,6 +772,41 @@ const paths = {
}, },
}, },
}, },
"/api/accounts/{name}/users": {
put: {
tags: ["Auth"],
operationId: "setPoolAccountUsers",
summary:
"Admin: exactly these users run on this pool account (a user on another account moves here; one left out goes back to the balancer), with their own password again. A Claude login is written into each added user's home at once",
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["tool", "userIds", "currentPassword"],
properties: {
tool: { type: "string", enum: ["claude", "codex"] },
userIds: { type: "array", items: { type: "integer" } },
currentPassword: { type: "string" },
},
},
},
},
},
responses: {
200: {
description:
"`{added: [{userId, username, switched}], removed: [{userId, username}]}` — `switched`: switched | kept | own-login | expired | next-session",
},
400: err("EBADTOOL, EBADUSER"),
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
404: err("ENOUSER, ENOACCOUNT"),
429: err("ETOOMANY — too many wrong passwords"),
},
},
},
"/api/accounts/{name}": { "/api/accounts/{name}": {
delete: { delete: {
tags: ["Auth"], tags: ["Auth"],
+67 -1
View File
@@ -16,6 +16,12 @@
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS * refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex * Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
* one is an OpenAI API key or the text of an `auth.json` (`token`). * one is an OpenAI API key or the text of an `auth.json` (`token`).
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
* user's sessions of a tool to one pool account (account-pins.js) — a Claude
* login goes into their home at once, so running panes move without a
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
* Users page's Edit of one account — exactly those users run on it; `GET /`
* lists the pins under `pins`.
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change * `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
* they need the admin's own password again (`currentPassword`), are audited * they need the admin's own password again (`currentPassword`), are audited
* (the account NAME only), and refuse in local mode. The secret only goes IN: * (the account NAME only), and refuse in local mode. The secret only goes IN:
@@ -29,6 +35,7 @@ const codexPool = require("../lib/codex-pool");
const auth = require("../lib/auth"); const auth = require("../lib/auth");
const { recordAccountChange } = require("../lib/audit"); const { recordAccountChange } = require("../lib/audit");
const profiles = require("../lib/cc-profiles"); const profiles = require("../lib/cc-profiles");
const pins = require("../lib/account-pins");
const router = Router(); const router = Router();
@@ -39,6 +46,9 @@ router.get("/", async (req, res) => {
res.json({ res.json({
accounts: pool.poolStatus(), accounts: pool.poolStatus(),
codex: codexPool.poolStatus(), codex: codexPool.poolStatus(),
pins: auth.isServerMode()
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
: { claude: [], codex: [] },
blockPercent: pool.BLOCK_PERCENT, blockPercent: pool.BLOCK_PERCENT,
}); });
} catch (e) { } catch (e) {
@@ -69,9 +79,11 @@ const STATUS = {
ELOCALMODE: 403, ELOCALMODE: 403,
EREAUTH: 403, EREAUTH: 403,
EBADTOOL: 400, EBADTOOL: 400,
EBADUSER: 400,
EBADNAME: 400, EBADNAME: 400,
EBADTOKEN: 400, EBADTOKEN: 400,
ENOACCOUNT: 404, ENOACCOUNT: 404,
ENOUSER: 404,
EEXISTS: 409, EEXISTS: 409,
ETOOMANY: 429, ETOOMANY: 429,
ESTALE: 409, // a pasted login whose refresh token was already spent ESTALE: 409, // a pasted login whose refresh token was already spent
@@ -93,7 +105,9 @@ function poolChange(handler) {
} }
await auth.confirmPassword(req.user, body.currentPassword); await auth.confirmPassword(req.user, body.currentPassword);
const out = await handler(req, body); const out = await handler(req, body);
recordAccountChange(req.user.id, null, req.method, out.action); for (const a of out.audits || [{ target: out.target, action: out.action }]) {
recordAccountChange(req.user.id, a.target ?? null, req.method, a.action);
}
res.status(out.status || 200).json(out.json); res.status(out.status || 200).json(out.json);
} catch (e) { } catch (e) {
const status = STATUS[e.code]; const status = STATUS[e.code];
@@ -203,6 +217,58 @@ router.post(
}) })
); );
router.put(
"/pins",
poolChange(async (_req, body) => {
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
const switched = pin.account
? await applyNow(pin.userId, pin.username, body.tool, pin.account)
: null;
return {
json: { pin, switched },
target: pin.userId,
action: pin.account
? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}`
: `pool: unpin ${pin.username} from ${body.tool}`,
};
})
);
/** Switch an added user now; a failed switch still leaves the pin for the next spawn. */
async function applyNow(userId, username, tool, account) {
try {
return await pins.applyPin(userId, tool);
} catch (e) {
console.warn(`[accounts] switch ${username} → ${account}: ${e.message}`);
return "next-session";
}
}
router.put(
"/:name/users",
poolChange(async (req, body) => {
const account = req.params.name;
const { added, removed } = pins.setAccountUsers(body.tool, account, body.userIds);
const switched = [];
for (const u of added) {
switched.push({ ...u, switched: await applyNow(u.userId, u.username, body.tool, account) });
}
return {
json: { added: switched, removed },
audits: [
...added.map((u) => ({
target: u.userId,
action: `pool: pin ${u.username} to ${body.tool} account ${account}`,
})),
...removed.map((u) => ({
target: u.userId,
action: `pool: unpin ${u.username} from ${body.tool}`,
})),
],
};
})
);
router.delete( router.delete(
"/:name", "/:name",
poolChange((req, body) => { poolChange((req, body) => {