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.
This commit is contained in:
+76
-6
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name] [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -3814,7 +3814,7 @@ const SUBCOMMANDS = {
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove"],
|
||||
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
import: ["rescan", "path"],
|
||||
@@ -4286,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
}
|
||||
pool.resetQuota();
|
||||
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
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4301,10 +4312,53 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
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).
|
||||
*/
|
||||
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);
|
||||
console.log(
|
||||
pin.account
|
||||
? `${c.green("✔")} ${pin.username}'s new ${tool} sessions run on ${pin.account}`
|
||||
: `${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`
|
||||
);
|
||||
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
|
||||
console.log(c.dim(" Pins apply in server mode only."));
|
||||
}
|
||||
console.log(c.dim(" Running sessions 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). */
|
||||
async function cmdAccounts(sub, positional, flags = {}) {
|
||||
serverAuth(); // the same settings + database the server reads
|
||||
@@ -4316,6 +4370,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "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 (sub === "add") {
|
||||
console.log(
|
||||
@@ -4337,7 +4393,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||
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
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4356,6 +4423,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
: a.quota?.error
|
||||
? `no quota (${a.quota.error})`
|
||||
: "ok",
|
||||
pinnedUsers("claude", a.name),
|
||||
])
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
@@ -4363,7 +4431,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,20 +8,23 @@
|
||||
* `.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
|
||||
* password again; an analyst only reads. The secret only goes IN: the server
|
||||
* never sends it back.
|
||||
* never sends it back. An admin can also pin a user to one account
|
||||
* (`PUT /api/accounts/pins`): that user's new sessions skip the balancer.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { KeyRound, Pin, PinOff, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
type AccountPool,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -59,6 +62,8 @@ function rowsOf(pool: AccountPool): Row[] {
|
||||
];
|
||||
}
|
||||
|
||||
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||
@@ -72,6 +77,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
/** The pin dialog, opened empty or on one existing pin (to change or unpin it). */
|
||||
const [pinning, setPinning] = useState<{ tool: PoolTool; userId?: number } | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
@@ -91,6 +98,11 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
}, [load]);
|
||||
|
||||
const rows = pool ? rowsOf(pool) : [];
|
||||
const pins = pool
|
||||
? (["claude", "codex"] as const).flatMap((tool) =>
|
||||
pinsOf(pool, tool).map((p) => ({ ...p, tool }))
|
||||
)
|
||||
: [];
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
@@ -112,6 +124,15 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && rows.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPinning({ tool: "claude" })}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<Pin className="w-4 h-4" /> {t("pool.pinUser")}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
||||
@@ -148,6 +169,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</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 className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{t(`pool.kind.${r.kind}`)}
|
||||
@@ -206,6 +237,33 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{pins.length > 0 && (
|
||||
<div className="border-t border-border px-5 py-3" data-testid="account-pins">
|
||||
<h3 className="text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("pool.pinsTitle")}
|
||||
</h3>
|
||||
<ul className="mt-1.5 flex flex-wrap gap-2">
|
||||
{pins.map((p) => (
|
||||
<li key={`${p.tool}:${p.userId}`} className="badge gap-1.5 text-xs text-fg-secondary">
|
||||
<Pin className="h-3 w-3" />
|
||||
{p.username} → {p.account}
|
||||
<span className="text-fg-muted">({t(`pool.toolName.${p.tool}`)})</span>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPinning({ tool: p.tool, userId: p.userId })}
|
||||
title={t("pool.unpin", { name: p.username })}
|
||||
aria-label={t("pool.unpin", { name: p.username })}
|
||||
className="text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<PinOff className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
@@ -228,10 +286,172 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pinning && pool && (
|
||||
<PinUserDialog
|
||||
pool={pool}
|
||||
initial={pinning}
|
||||
onClose={() => setPinning(null)}
|
||||
onDone={(user, account) => {
|
||||
toast.success(
|
||||
account
|
||||
? t("pool.pinDone.pinned", { user, account })
|
||||
: t("pool.pinDone.unpinned", { user })
|
||||
);
|
||||
setPinning(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin a user to one account of a tool, or unpin them ("None"). Opened on an
|
||||
* existing pin it starts on that user with "None", so one click unpins.
|
||||
*/
|
||||
function PinUserDialog({
|
||||
pool,
|
||||
initial,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
pool: AccountPool;
|
||||
initial: { tool: PoolTool; userId?: number };
|
||||
onClose: () => void;
|
||||
onDone: (user: string, account: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>(initial.tool);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [userId, setUserId] = useState<number | null>(initial.userId ?? null);
|
||||
const [account, setAccount] = useState("");
|
||||
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 accounts = tool === "codex" ? pool.codex : pool.accounts;
|
||||
const pinOf = (id: number | null, of: PoolTool) =>
|
||||
pinsOf(pool, of).find((p) => p.userId === id)?.account ?? "";
|
||||
const ready = userId != null && current.length > 0 && account !== pinOf(userId, tool);
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy || userId == null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.accountPool.pin(tool, userId, account || null, current);
|
||||
onDone(r.pin.username, r.pin.account);
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.pinTitle")}
|
||||
message={t("pool.pinHint")}
|
||||
confirmLabel={t("pool.pinButton")}
|
||||
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 (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("pool.tool")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as PoolTool;
|
||||
setTool(next);
|
||||
setAccount(pinOf(userId, next));
|
||||
}}
|
||||
aria-label={t("pool.tool")}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("pool.user")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={userId ?? ""}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value ? Number(e.target.value) : null;
|
||||
setUserId(id);
|
||||
setAccount(pinOf(id, tool));
|
||||
}}
|
||||
aria-label={t("pool.user")}
|
||||
>
|
||||
<option value="">
|
||||
{users === null ? t("pool.loadingUsers") : t("pool.chooseUser")}
|
||||
</option>
|
||||
{users?.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
{u.disabled ? ` (${t("disabled")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("pool.pinAccount")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={account}
|
||||
onChange={(e) => setAccount(e.target.value)}
|
||||
aria-label={t("pool.pinAccount")}
|
||||
disabled={userId == null}
|
||||
>
|
||||
<option value="">{t("pool.noPin")}</option>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.name} value={a.name}>
|
||||
{a.name}
|
||||
{"email" in a && a.email ? ` — ${a.email}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<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";
|
||||
|
||||
function AddAccountDialog({
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 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
|
||||
* 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 admin pins a user to one account and
|
||||
* unpins them, each with their own password; the pins show on the accounts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
}));
|
||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { accountPool } };
|
||||
return { ...actual, api: { accountPool, users } };
|
||||
});
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
users.list.mockResolvedValue({
|
||||
users: [
|
||||
{ id: 7, username: "mia", disabled: false },
|
||||
{ id: 8, username: "leo", disabled: false },
|
||||
],
|
||||
});
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
@@ -158,4 +167,48 @@ describe("AccountPoolCard", () => {
|
||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||
);
|
||||
});
|
||||
|
||||
it("pins a user to an account and unpins them, with the admin's own password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] },
|
||||
});
|
||||
accountPool.pin.mockResolvedValue({ pin: { userId: 7, username: "mia", account: "team-1" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
expect(await screen.findByText("Pinned: leo")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /pin a user/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: "mia" })).toBeInTheDocument());
|
||||
await user.selectOptions(screen.getByLabelText("User"), "7");
|
||||
await user.selectOptions(screen.getByLabelText("Account"), "team-1");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save pin"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.pin).toHaveBeenCalledWith("claude", 7, "team-1", "boss-pass")
|
||||
);
|
||||
});
|
||||
|
||||
it("unpins from the pinned list", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
pins: { claude: [{ userId: 8, username: "leo", account: "team-1" }], codex: [] },
|
||||
});
|
||||
accountPool.pin.mockResolvedValue({ pin: { userId: 8, username: "leo", account: null } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await user.click(await screen.findByRole("button", { name: "Unpin leo" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Save pin")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.pin).toHaveBeenCalledWith("claude", 8, null, "boss-pass")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,8 @@
|
||||
"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.",
|
||||
"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",
|
||||
"sources": {
|
||||
@@ -196,7 +197,27 @@
|
||||
"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."
|
||||
},
|
||||
"refreshFailed": "refresh failed"
|
||||
"refreshFailed": "refresh failed",
|
||||
"pinned": "Pinned: {{users}}",
|
||||
"pinUser": "Pin a user",
|
||||
"pinTitle": "Pin a user to an account",
|
||||
"pinHint": "That user's new sessions always run on the chosen account instead of the least-used one — even when it is full. Sessions already running keep their account; a login waits until the user's sessions on another login have ended.",
|
||||
"pinButton": "Save pin",
|
||||
"user": "User",
|
||||
"chooseUser": "Choose a user…",
|
||||
"loadingUsers": "Loading…",
|
||||
"pinAccount": "Account",
|
||||
"noPin": "None — the least-used account",
|
||||
"pinsTitle": "Pinned users",
|
||||
"unpin": "Unpin {{name}}",
|
||||
"pinDone": {
|
||||
"pinned": "{{user}} now runs on {{account}}",
|
||||
"unpinned": "{{user}} is back on the least-used account"
|
||||
},
|
||||
"toolName": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
|
||||
@@ -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.",
|
||||
"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.",
|
||||
"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",
|
||||
"sources": {
|
||||
@@ -196,7 +197,27 @@
|
||||
"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."
|
||||
},
|
||||
"refreshFailed": "làm mới lỗi"
|
||||
"refreshFailed": "làm mới lỗi",
|
||||
"pinned": "Đã ghim: {{users}}",
|
||||
"pinUser": "Ghim user",
|
||||
"pinTitle": "Ghim user vào một tài khoản",
|
||||
"pinHint": "Phiên mới của user này luôn chạy trên tài khoản đã chọn thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. Phiên đang chạy giữ nguyên tài khoản; một login sẽ chờ tới khi các phiên của user trên login khác kết thúc.",
|
||||
"pinButton": "Lưu ghim",
|
||||
"user": "User",
|
||||
"chooseUser": "Chọn user…",
|
||||
"loadingUsers": "Đang tải…",
|
||||
"pinAccount": "Tài khoản",
|
||||
"noPin": "Không ghim — tài khoản ít tải nhất",
|
||||
"pinsTitle": "User đã ghim",
|
||||
"unpin": "Bỏ ghim {{name}}",
|
||||
"pinDone": {
|
||||
"pinned": "{{user}} giờ chạy trên {{account}}",
|
||||
"unpinned": "{{user}} quay lại dùng tài khoản ít tải nhất"
|
||||
},
|
||||
"toolName": {
|
||||
"claude": "Claude",
|
||||
"codex": "Codex"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
|
||||
@@ -676,9 +676,18 @@ export type CodexPoolAccount = {
|
||||
quotaPercent: number | null;
|
||||
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 = {
|
||||
accounts: PoolAccount[];
|
||||
codex: CodexPoolAccount[];
|
||||
/** Per tool; absent from an older server, empty in local mode. */
|
||||
pins?: { claude: AccountPin[]; codex: AccountPin[] };
|
||||
blockPercent: number;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex";
|
||||
@@ -1052,6 +1061,15 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** PUT /api/accounts/pins — admin: pin a user to one account (`null` unpins), password again. */
|
||||
pin: (tool: PoolTool, userId: number, account: string | null, currentPassword: string) =>
|
||||
request<{ pin: { userId: number; username: string; account: string | null } }>(
|
||||
"/accounts/pins",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ tool, userId, account, currentPassword }),
|
||||
}
|
||||
),
|
||||
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
||||
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
`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}}` pins that user's new sessions of `tool` to one pool account (`account: null` unpins; `404 ENOUSER`/`ENOACCOUNT`), with the same password step-up and audit (the user as `owner_id`). 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`):
|
||||
|
||||
|
||||
+1
-1
@@ -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 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 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) makes one user's new sessions always run on that account (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 |
|
||||
|
||||
### Interactive REPL
|
||||
|
||||
@@ -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` |
|
||||
| `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.
|
||||
|
||||
|
||||
@@ -355,6 +355,7 @@ claude setup-token
|
||||
agenthub accounts add team-1
|
||||
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
|
||||
agenthub accounts remove team-1
|
||||
agenthub accounts pin team-1 mia # mia's new sessions always run on team-1 (unpin mia to undo)
|
||||
```
|
||||
|
||||
In server mode an admin can do the same on the **Users** page, in the
|
||||
@@ -423,6 +424,19 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
||||
pane still needs that member's own `/login`.
|
||||
- A token more than a year old is skipped.
|
||||
- A resumed session picks an account again, like a new one.
|
||||
- **Pinning a user to one account.** An admin can skip the ranking for one
|
||||
user: **Pin a user** in the Account pool card (or
|
||||
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>` to
|
||||
undo; their own password again, audited). Every new 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. The pin is a
|
||||
preference the pool still bounds: an expired account, or a login a host pane
|
||||
may not take, falls back to the ranking; and since a home holds one
|
||||
`.credentials.json`, a pinned login waits until that user's panes on another
|
||||
pool login have ended (a setup-token or API key pin goes in by env and
|
||||
applies at once). Running sessions keep their account. Removing the account
|
||||
drops its pins; deleting the user drops theirs. Pins apply in server mode only
|
||||
(`server/lib/account-pins.js`).
|
||||
- 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
|
||||
`no quota (…)` and ranking uses spend alone.
|
||||
@@ -447,6 +461,7 @@ CODEX_HOME=$(mktemp -d) codex login
|
||||
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
|
||||
agenthub accounts list --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
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* 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
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -188,6 +189,38 @@ describe("a pool login", () => {
|
||||
assert.equal(pool.__read("team"), null);
|
||||
});
|
||||
|
||||
it("follows the owner's pin, but never over another login a live pane holds", () => {
|
||||
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");
|
||||
db.prepare("UPDATE dashboard_runs SET status = 'completed' WHERE id = 'pin-run'").run();
|
||||
assert.equal(pool.forSpawn(acct, miaId).name, "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", () => {
|
||||
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -144,3 +145,75 @@ describe("the account pool from the Users page", () => {
|
||||
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(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("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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1873,6 +1873,18 @@ for (const col of ["display_name", "email", "avatar_at"]) {
|
||||
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
|
||||
// a VACUUM, which rewrites the whole file under an exclusive lock. Done here,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @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`). 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, and a login never
|
||||
* replaces another one a live pane in the same home runs on.
|
||||
*
|
||||
* 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 };
|
||||
}
|
||||
|
||||
/** 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, dropAccount };
|
||||
@@ -367,6 +367,7 @@ function deleteUser(username) {
|
||||
} catch {
|
||||
/* no personal profiles */
|
||||
}
|
||||
sql("DELETE FROM account_pins WHERE user_id = ?").run(u.id);
|
||||
sql("DELETE FROM users WHERE id = ?").run(u.id);
|
||||
return { id: u.id, username: u.username, workspaces: lanes.length };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
* 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
|
||||
* held them (pool-usage.js).
|
||||
* An admin may pin a user to one account (account-pins.js): that user's
|
||||
* panes skip the ranking whenever they may use it.
|
||||
* 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
|
||||
* reading, and ranking falls back to spend.
|
||||
@@ -193,6 +195,7 @@ function removeAccount(name) {
|
||||
}
|
||||
}
|
||||
fs.rmSync(fileOf(n));
|
||||
require("./account-pins").dropAccount("claude", n);
|
||||
quotaCache.delete(n);
|
||||
}
|
||||
|
||||
@@ -434,10 +437,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));
|
||||
if (usable.length === 0) return null;
|
||||
let live = null;
|
||||
if (!member) {
|
||||
// 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:
|
||||
@@ -453,10 +461,15 @@ function pick(member) {
|
||||
} else {
|
||||
// One .credentials.json per home: while a pane of theirs runs on a
|
||||
// pool login, the next pane stays on that one.
|
||||
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
|
||||
if (live) return live;
|
||||
live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member)) || 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 (usable.length === 0) return null;
|
||||
refreshQuotas().catch(() => {});
|
||||
const ranked = usable
|
||||
@@ -489,8 +502,8 @@ function pick(member) {
|
||||
* 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.
|
||||
*/
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
function pickAccount(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
return a.kind === "login"
|
||||
? { name: a.name, kind: "login" }
|
||||
@@ -502,8 +515,8 @@ function pickAccount(member = null) {
|
||||
* `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.
|
||||
*/
|
||||
function forSpawn(member = null) {
|
||||
const a = pick(member);
|
||||
function forSpawn(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
if (a.kind === "token") {
|
||||
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
|
||||
@@ -524,8 +537,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 = {
|
||||
BLOCK_PERCENT,
|
||||
hasAccount,
|
||||
REFRESH_AHEAD_MS,
|
||||
addAccount,
|
||||
addLoginAccount,
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -139,6 +140,7 @@ function removeAccount(name) {
|
||||
const n = validName(name);
|
||||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||
fs.rmSync(fileOf(n));
|
||||
require("./account-pins").dropAccount("codex", n);
|
||||
quotaCache = null;
|
||||
}
|
||||
|
||||
@@ -332,10 +334,15 @@ function syncBack() {
|
||||
|
||||
// ── 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();
|
||||
if (usable.length === 0) return null;
|
||||
let live = null;
|
||||
if (!member) {
|
||||
// A host pane's CODEX_HOME holds the host's own login.
|
||||
usable = usable.filter((a) => a.kind === "apikey");
|
||||
@@ -348,10 +355,15 @@ function pick(member) {
|
||||
} else {
|
||||
// One auth.json per home: while a pane of theirs runs on a pool
|
||||
// login, the next pane stays on that one.
|
||||
const live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member));
|
||||
if (live) return live;
|
||||
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 (usable.length === 0) return null;
|
||||
const ranked = usable
|
||||
.map((a) => {
|
||||
@@ -395,8 +407,8 @@ function liveInHome(name, member) {
|
||||
}
|
||||
|
||||
/** `{name, kind}` of the account a new Codex pane would get, or null. */
|
||||
function pickAccount(member = null) {
|
||||
const a = pick(member);
|
||||
function pickAccount(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
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`
|
||||
* (a member's launch file); `place()` runs just before the pane starts.
|
||||
*/
|
||||
function forSpawn(member = null) {
|
||||
const a = pick(member);
|
||||
function forSpawn(member = null, ownerId = null) {
|
||||
const a = pick(member, ownerId);
|
||||
if (!a) return null;
|
||||
if (a.kind === "apikey") {
|
||||
const provider = `{name="OpenAI (AgentHub pool)",base_url="https://api.openai.com/v1",env_key="${KEY_ENV}",wire_api="responses"}`;
|
||||
@@ -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 = {
|
||||
BLOCK_PERCENT,
|
||||
hasAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
poolStatus,
|
||||
|
||||
@@ -461,19 +461,20 @@ function spawnRun(args) {
|
||||
assertCwdAccount(laneId, cwd); // ECWDACCOUNT
|
||||
const proxyProfile = resolveProxy(tool, proxy, laneOwner); // EBADPROXY, EUNSUPPORTED
|
||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||
// account, if the admin added any (claude-pool.js); 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.
|
||||
// A real login (kind `login`) is written into a MEMBER's home by place();
|
||||
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
|
||||
const poolAccount =
|
||||
entry.launcher === "claude" && !proxyProfile
|
||||
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
||||
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
||||
const codexPool =
|
||||
entry.launcher === "codex" && !proxyProfile
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
if (forkSession === true) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* - POST /api/auth/logout (Auth)
|
||||
* - 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
|
||||
* - PUT /api/accounts/pins (Auth) — an admin pins a user to one pool account
|
||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||
* - 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
|
||||
@@ -30,6 +31,16 @@ const tags = [
|
||||
];
|
||||
|
||||
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: {
|
||||
type: "object",
|
||||
required: ["prefs", "onboardedAt"],
|
||||
@@ -633,6 +644,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: {
|
||||
type: "array",
|
||||
description:
|
||||
@@ -706,6 +726,38 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/pins": {
|
||||
put: {
|
||||
tags: ["Auth"],
|
||||
operationId: "pinPoolAccount",
|
||||
summary:
|
||||
"Admin: pin one user's new sessions of a tool to one pool account (`account: null` unpins), with their own password again",
|
||||
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}}` — `account` null when unpinned" },
|
||||
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": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
|
||||
* 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`).
|
||||
* `PUT /pins` (`{tool, userId, account}`, `account: null` unpins) pins one
|
||||
* user's new sessions of a tool to one pool account (account-pins.js); `GET /`
|
||||
* lists the pins under `pins`.
|
||||
* `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
|
||||
* (the account NAME only), and refuse in local mode. The secret only goes IN:
|
||||
@@ -29,6 +32,7 @@ const codexPool = require("../lib/codex-pool");
|
||||
const auth = require("../lib/auth");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -39,6 +43,9 @@ router.get("/", async (req, res) => {
|
||||
res.json({
|
||||
accounts: pool.poolStatus(),
|
||||
codex: codexPool.poolStatus(),
|
||||
pins: auth.isServerMode()
|
||||
? { claude: pins.listPins("claude"), codex: pins.listPins("codex") }
|
||||
: { claude: [], codex: [] },
|
||||
blockPercent: pool.BLOCK_PERCENT,
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -72,6 +79,7 @@ const STATUS = {
|
||||
EBADNAME: 400,
|
||||
EBADTOKEN: 400,
|
||||
ENOACCOUNT: 404,
|
||||
ENOUSER: 404,
|
||||
EEXISTS: 409,
|
||||
ETOOMANY: 429,
|
||||
ESTALE: 409, // a pasted login whose refresh token was already spent
|
||||
@@ -93,7 +101,7 @@ function poolChange(handler) {
|
||||
}
|
||||
await auth.confirmPassword(req.user, body.currentPassword);
|
||||
const out = await handler(req, body);
|
||||
recordAccountChange(req.user.id, null, req.method, out.action);
|
||||
recordAccountChange(req.user.id, out.target ?? null, req.method, out.action);
|
||||
res.status(out.status || 200).json(out.json);
|
||||
} catch (e) {
|
||||
const status = STATUS[e.code];
|
||||
@@ -203,6 +211,20 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/pins",
|
||||
poolChange((_req, body) => {
|
||||
const pin = pins.setPin(Number(body.userId), body.tool, body.account ?? null);
|
||||
return {
|
||||
json: { pin },
|
||||
target: pin.userId,
|
||||
action: pin.account
|
||||
? `pool: pin ${pin.username} to ${body.tool} account ${pin.account}`
|
||||
: `pool: unpin ${pin.username} from ${body.tool}`,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:name",
|
||||
poolChange((req, body) => {
|
||||
|
||||
Reference in New Issue
Block a user