Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dd97a369c | |||
| 62b4520507 | |||
| 241e19b713 | |||
| 685eb748e8 | |||
| 44426faf16 | |||
| 26952ae0d9 | |||
| 649f0599f8 | |||
| 8d2765313c | |||
| 57f02f0d89 | |||
| 50ac0f0daf |
+9
-13
File diff suppressed because one or more lines are too long
+47
-14
@@ -2845,18 +2845,18 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"setup|off|status [--hostname name] [--admin name]",
|
||||
"setup|install-system|tools|off|status [--hostname name] [--admin name]",
|
||||
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||
],
|
||||
[
|
||||
"users",
|
||||
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||
"list|add|passwd|role|disable|enable|provision|assign|audit [name]",
|
||||
"Server mode's accounts (add: --role admin|analyst|developer)",
|
||||
],
|
||||
[
|
||||
"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",
|
||||
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex|cursor]",
|
||||
"Claude/Codex/Cursor account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
@@ -3812,8 +3812,8 @@ const COMMANDS = (() => {
|
||||
const SUBCOMMANDS = {
|
||||
alerts: ["ack", "ack-all"],
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
server: ["setup", "install-system", "tools", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "provision", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
@@ -3905,7 +3905,7 @@ async function newPassword(label) {
|
||||
return first;
|
||||
}
|
||||
|
||||
/** `agenthub server setup|off|status` — turn server mode on or off. */
|
||||
/** `agenthub server setup|install-system|tools|off|status` — turn server mode on or off. */
|
||||
async function cmdServer(sub, flags) {
|
||||
const { settingsEnv, auth } = serverAuth();
|
||||
const file = settingsEnv.settingsFilePath();
|
||||
@@ -4062,7 +4062,7 @@ async function installSystem(flags, settingsEnv) {
|
||||
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
|
||||
}
|
||||
const which = (bin) =>
|
||||
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
|
||||
spawnSync("sh", ["-c", 'command -v "$1"', "sh", bin], { encoding: "utf8" }).stdout.trim();
|
||||
const host = os.userInfo().username;
|
||||
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||
const data = getDataDir();
|
||||
@@ -4328,8 +4328,36 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub accounts … --tool cursor` — the Cursor account pool (server/lib/cursor-pool.js). */
|
||||
async function cmdCursorAccounts(sub, name) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Create an API key in the Cursor dashboard (Integrations), then paste it.")
|
||||
);
|
||||
const added = pool.addAccount(name, {
|
||||
apiKey: await askPassword(`Cursor API key for ${name}: `),
|
||||
});
|
||||
console.log(`${c.green("✔")} ${added.name} added`);
|
||||
console.log(c.dim(" New Cursor sessions now spread over the pool's accounts."));
|
||||
return;
|
||||
}
|
||||
if (sub === "remove") {
|
||||
pool.removeAccount(name);
|
||||
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||
return;
|
||||
}
|
||||
if (sub !== undefined && sub !== "list") {
|
||||
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool cursor");
|
||||
}
|
||||
table(
|
||||
["cursor account", "live", "pinned"],
|
||||
pool.poolStatus().map((a) => [a.name, a.livePanes, pinnedUsers("cursor", a.name)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
|
||||
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex|cursor] —
|
||||
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||
*/
|
||||
async function cmdAccountPin(sub, positional, tool) {
|
||||
@@ -4337,7 +4365,9 @@ async function cmdAccountPin(sub, positional, tool) {
|
||||
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]");
|
||||
throw new Error(
|
||||
"usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
);
|
||||
}
|
||||
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||
@@ -4387,14 +4417,15 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||
const name = positional[0];
|
||||
if (sub === "add" || sub === "remove") {
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex]`);
|
||||
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name> [--tool codex|cursor]`);
|
||||
}
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
if (flags.tool != null && !["claude", "codex", "cursor"].includes(flags.tool)) {
|
||||
throw new Error("--tool is claude (the default), codex or cursor");
|
||||
}
|
||||
if (sub === "pin" || sub === "unpin")
|
||||
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
if (flags.tool === "cursor") return cmdCursorAccounts(sub, name);
|
||||
if (sub === "add") {
|
||||
console.log(
|
||||
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||
@@ -4450,11 +4481,13 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||
const cursor = require(path.join(REPO_ROOT, "server", "lib", "cursor-pool.js"));
|
||||
if (cursor.poolStatus().length) await cmdCursorAccounts("list", null);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
|
||||
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex|cursor]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
|
||||
Generated
+38
-26
@@ -29,7 +29,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1251,15 +1251,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2740,6 +2731,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
@@ -5472,35 +5475,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||
"license": "MIT",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4"
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||
"license": "MIT",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
||||
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.4",
|
||||
"react-router": "6.30.6"
|
||||
"react-router": "7.18.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
@@ -5766,6 +5773,11 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
||||
* layer or a header drawn over a pane can cover it — opened from inside a
|
||||
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
||||
* layer, under anything stacked above it.
|
||||
* layer, under anything stacked above it. `useDialog` takes the focus while it
|
||||
* is open and gives it back, and its Escape closes only this, the top dialog.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
|
||||
export function ImageLightbox({
|
||||
src,
|
||||
@@ -20,18 +22,18 @@ export function ImageLightbox({
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panel, onClose);
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
data-testid="image-lightbox"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8 outline-none"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
|
||||
@@ -82,6 +82,7 @@ import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
type Mode = "rescan" | "path" | "upload" | "backup";
|
||||
|
||||
@@ -247,13 +248,9 @@ export function ImportHistory() {
|
||||
|
||||
const copyArchiveCmd = async () => {
|
||||
if (!guide) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(guide.archive_command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
if (!(await copyText(guide.archive_command, true))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const progressText = (() => {
|
||||
|
||||
@@ -88,6 +88,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import type { ScopeMode } from "../lib/dataScope";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
/** Data-scope choices in display order (the radio group's arrow-key order). */
|
||||
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
|
||||
@@ -863,7 +864,7 @@ export function RemoteSources() {
|
||||
: t("remoteSources.enable", "Enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
||||
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
|
||||
<MenuItem onSelect={pick((s) => void copyText(s.host))}>
|
||||
{t("rowMenu.copyHost")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
@@ -70,7 +71,7 @@ export function PetNameButton({
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { api } from "../lib/api";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { CopyButton } from "./event-views/primitives";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
export function snapshotUrl(token: string): string {
|
||||
return `${window.location.origin}/snapshot/${token}`;
|
||||
@@ -33,7 +34,7 @@ export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
||||
try {
|
||||
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||
setToken(snapshot.token);
|
||||
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
||||
void copyText(snapshotUrl(snapshot.token));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only (never while applying); "Skip this version" is the one
|
||||
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
||||
* this page load only — also while an update applies: it keeps running in the
|
||||
* background (the page still reloads itself once the new server is up), and a
|
||||
* failure re-opens the modal with its error. While hidden, a small chip in the
|
||||
* corner keeps showing the update — with the plugin chain's current step from
|
||||
* `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one
|
||||
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
|
||||
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||
* and starts a Claude Code session there with the error as its prompt — the
|
||||
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||
@@ -182,6 +186,8 @@ export function UpdateNotifier() {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
/** The running plugin chain's step, from the polls while it runs. */
|
||||
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
@@ -246,10 +252,13 @@ export function UpdateNotifier() {
|
||||
);
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
// Closing while an update applies only hides it: the apply request and the
|
||||
// poll that reloads the page keep running, and a failure re-opens it.
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
if (!dismissKey) return;
|
||||
if (applying || restarting) toast.info(t("backgroundNote"));
|
||||
setClosedKey(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
}, [applying, restarting, dismissKey, t]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
@@ -261,8 +270,8 @@ export function UpdateNotifier() {
|
||||
setDismissedSha(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
// Escape closes for now — never while an update is applying.
|
||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
||||
// Escape closes for now — an applying update carries on in the background.
|
||||
useDialog(show, panelRef, closeForNow);
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!status?.manual_command) return;
|
||||
@@ -307,6 +316,7 @@ export function UpdateNotifier() {
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
setProgress(s.plugin_update_progress ?? null);
|
||||
const v = s.last_plugin_update;
|
||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||
@@ -315,6 +325,7 @@ export function UpdateNotifier() {
|
||||
syncFromPayload(s);
|
||||
setRestarting(false);
|
||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||
else setClosedKey(null); // closed while it ran: show why it failed
|
||||
return;
|
||||
}
|
||||
// A new version answering is back too, even if a poll never
|
||||
@@ -336,6 +347,7 @@ export function UpdateNotifier() {
|
||||
const applyNow = async () => {
|
||||
if (applying || restarting) return;
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
setApplying(true);
|
||||
try {
|
||||
const result = await api.updates.apply();
|
||||
@@ -359,6 +371,8 @@ export function UpdateNotifier() {
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
// Not applied: re-open a modal closed while it ran, so the error is seen.
|
||||
setClosedKey(null);
|
||||
};
|
||||
|
||||
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||
@@ -384,6 +398,53 @@ export function UpdateNotifier() {
|
||||
}
|
||||
};
|
||||
|
||||
const stepText = progress?.step
|
||||
? t(`pluginStep.${progress.step}`, {
|
||||
index: progress.index,
|
||||
total: progress.total,
|
||||
defaultValue: progress.step,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Hidden while it runs: a chip keeps it in sight and brings the modal back.
|
||||
if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="update-chip"
|
||||
onClick={() => setClosedKey(null)}
|
||||
title={t("chipHint")}
|
||||
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||||
<span className="truncate">
|
||||
{status?.latest_release
|
||||
? t("chipTitle", { version: status.latest_release })
|
||||
: t("updating")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||||
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||||
</span>
|
||||
{progress && progress.total > 0 ? (
|
||||
<span
|
||||
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress.index}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={progress.total}
|
||||
>
|
||||
<span
|
||||
className="block h-full bg-accent-solid transition-all"
|
||||
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!show || !status) return null;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -436,7 +497,6 @@ export function UpdateNotifier() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
aria-label={t("later")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
>
|
||||
@@ -513,7 +573,14 @@ export function UpdateNotifier() {
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
seconds — saying "restarting" there would look hung. */}
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
<span>
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
{stepText ? (
|
||||
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||||
{stepText}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ describe("StatusBar", () => {
|
||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
expect(personal).toHaveTextContent("Check quota in Settings → AI tools → Profiles");
|
||||
expect(personal).not.toHaveTextContent("in use");
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
|
||||
@@ -298,12 +298,69 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores Escape while an update is applying", async () => {
|
||||
applyMock.mockReturnValue(new Promise(() => {}));
|
||||
it("closes while an update applies, and re-opens if it then fails", async () => {
|
||||
let fail: (e: Error) => void = () => {};
|
||||
applyMock.mockReturnValue(new Promise((_, reject) => (fail = reject)));
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
fail(new Error("Command failed: npm run build"));
|
||||
expect(await screen.findByText("Command failed: npm run build")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — hidden while it runs", () => {
|
||||
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
|
||||
const PLUGIN: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
};
|
||||
statusMock.mockResolvedValue(PLUGIN);
|
||||
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||
statusMock.mockResolvedValue({
|
||||
...PLUGIN,
|
||||
plugin_update_progress: {
|
||||
step: "plugin_update",
|
||||
index: 2,
|
||||
total: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
|
||||
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const chip = await screen.findByTestId("update-chip");
|
||||
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
|
||||
expect(chip).toHaveTextContent(step);
|
||||
await userEvent.click(chip);
|
||||
expect(
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no chip when nothing is running", async () => {
|
||||
render(<UpdateNotifier />);
|
||||
await screen.findByText(i18n.t("updates:title"));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
// ───────────────────────── Copy button ─────────────────────────
|
||||
|
||||
@@ -111,13 +112,9 @@ export function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard API can fail in insecure contexts - silently ignore.
|
||||
}
|
||||
if (!(await copyText(text, true))) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||
* A running clone shows `CloneProgress` and never holds the page: closing the
|
||||
* dialog only hides it behind a small progress chip (click to reopen), the
|
||||
* request keeps going, and its outcome arrives as a toast — a failure is kept
|
||||
* for the next open so a login row is still there to fill in.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +22,8 @@ import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -26,12 +32,15 @@ export function CloneHereDialog({
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
onReopen,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
/** Show the dialog again from the background chip. */
|
||||
onReopen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,10 +51,23 @@ export function CloneHereDialog({
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
/** Where the running clone goes (the open folder may change meanwhile). */
|
||||
const [target, setTarget] = useState("");
|
||||
// Read by the request's own continuation and by the reset below.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const busyRef = useRef(false);
|
||||
/** A clone failed while hidden: the next open shows that, not a blank form. */
|
||||
const keepRef = useRef(false);
|
||||
useDialog(open, panelRef, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || busyRef.current) return;
|
||||
if (keepRef.current) {
|
||||
keepRef.current = false;
|
||||
return;
|
||||
}
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
@@ -54,14 +76,39 @@ export function CloneHereDialog({
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
if (!open) return null;
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
if (!open) {
|
||||
if (!busy || !token) return null;
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="fx-clone-chip"
|
||||
onClick={onReopen}
|
||||
title={into}
|
||||
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("clone.cloning")} {into.split("/").pop()}
|
||||
</span>
|
||||
</span>
|
||||
<CloneProgress token={token} />
|
||||
</button>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
const progressToken = newCloneToken();
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setToken(progressToken);
|
||||
setTarget(into);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
@@ -69,6 +116,7 @@ export function CloneHereDialog({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
progressToken,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
@@ -79,12 +127,16 @@ export function CloneHereDialog({
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
onCloned(result.lane, result.path);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
let message: string;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
@@ -93,18 +145,25 @@ export function CloneHereDialog({
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
message = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
setError(message);
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
if (!openRef.current) {
|
||||
keepRef.current = true;
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={busy ? undefined : onClose}
|
||||
onClick={onClose}
|
||||
// A portal still bubbles through React: keep keys away from the File
|
||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
@@ -119,7 +178,10 @@ export function CloneHereDialog({
|
||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
{t("fx.cloneRepo")}
|
||||
</h2>
|
||||
@@ -162,7 +224,10 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
</div>
|
||||
{login && (
|
||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
||||
<div
|
||||
data-testid="fx-clone-login"
|
||||
className="space-y-2 rounded-md border border-border p-2"
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
@@ -193,6 +258,7 @@ export function CloneHereDialog({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{busy && token && <CloneProgress token={token} />}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
@@ -204,8 +270,8 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||
{t("fx.cancel")}
|
||||
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||
{busy ? t("clone.background") : t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
* another user's. An admin's Trash opens on one folder per user (with its
|
||||
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||
* without waiting the 7 days.
|
||||
* The sidebar nests each workspace under its owner's home (a chevron drops
|
||||
* them down, remembered per browser); one with no owner home stays in a flat
|
||||
* Workspaces list.
|
||||
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||
* (server/lib/file-explorer.js).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -92,6 +95,8 @@ import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { CloneHereDialog } from "./CloneHereDialog";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
/** One upload batch at most (the server caps one file the same). */
|
||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||
@@ -136,6 +141,11 @@ export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
||||
const MIME = "application/x-agenthub-fx";
|
||||
const VIEW_KEY = "agenthub.files.view";
|
||||
const HIDDEN_KEY = "agenthub.files.hidden";
|
||||
/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */
|
||||
const HOMES_OPEN_KEY = "agenthub.files.homesOpen";
|
||||
/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the
|
||||
* viewer may not open that home (an analyst's view of other users). */
|
||||
type UserRow = FsHome & { noHome?: boolean };
|
||||
|
||||
type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||
@@ -318,6 +328,16 @@ export function FileBrowser({
|
||||
bytes: number;
|
||||
} | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const { state: auth } = useAuth();
|
||||
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
const [openHomes, setOpenHomes] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]'));
|
||||
return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||||
} catch {
|
||||
return new Set(["me"]);
|
||||
}
|
||||
});
|
||||
const [clip, setClip] = useState<Clip | null>(clipboard);
|
||||
const [dropping, setDropping] = useState(false);
|
||||
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
|
||||
@@ -891,6 +911,52 @@ export function FileBrowser({
|
||||
onCancel={() => setRenaming(null)}
|
||||
/>
|
||||
) : null;
|
||||
// Each workspace drops down under its owner: the caller's own under My
|
||||
// home, another user's under theirs (`owner_name` = that home's id). An
|
||||
// owner whose home this viewer may not open (an analyst's view of other
|
||||
// users) still gets a row, one that only groups. A workspace with no owner
|
||||
// stays in the flat Workspaces list. Which rows are open is per browser.
|
||||
const { lanesOfHome, userRows } = useMemo(() => {
|
||||
const ids = new Set(homes.map((h) => h.id));
|
||||
const map = new Map<string, Lane[]>();
|
||||
const extra = new Map<string, UserRow>();
|
||||
for (const l of lanes) {
|
||||
const home =
|
||||
myId != null && l.owner_id === myId && ids.has("me")
|
||||
? "me"
|
||||
: l.owner_name && l.owner_name !== "me"
|
||||
? l.owner_name
|
||||
: null;
|
||||
if (!home) continue;
|
||||
map.set(home, [...(map.get(home) ?? []), l]);
|
||||
if (!ids.has(home) && !extra.has(home)) {
|
||||
extra.set(home, {
|
||||
id: home,
|
||||
label: l.owner_display_name || home,
|
||||
readOnly: true,
|
||||
noHome: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const rows: UserRow[] = [
|
||||
...homes.filter((h) => h.id !== "me"),
|
||||
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
return { lanesOfHome: map, userRows: rows };
|
||||
}, [homes, lanes, myId]);
|
||||
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
||||
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
|
||||
// A home holding the open workspace shows it, whatever was stored.
|
||||
const homeIsOpen = (id: string, own: Lane[]) =>
|
||||
openHomes.has(id) ||
|
||||
(view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target)));
|
||||
const toggleHome = (id: string, own: Lane[]) => {
|
||||
const next = new Set(openHomes);
|
||||
if (homeIsOpen(id, own)) next.delete(id);
|
||||
else next.add(id);
|
||||
setOpenHomes(next);
|
||||
writePref(HOMES_OPEN_KEY, JSON.stringify([...next]));
|
||||
};
|
||||
const placeClass = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
|
||||
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
|
||||
@@ -908,6 +974,7 @@ export function FileBrowser({
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onReopen={() => setCloning(true)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
@@ -919,64 +986,104 @@ export function FileBrowser({
|
||||
{/* ── Places ── */}
|
||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||
{homes.some((h) => h.id === "me") && (
|
||||
<div className="pt-3">
|
||||
{(() => {
|
||||
const laneRow = (l: Lane, nested: boolean) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => go("", { home: "me" })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === "me"
|
||||
)}
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={`${placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}${nested ? " pl-7" : ""}`}
|
||||
>
|
||||
<House className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{t("fx.myHome")}</span>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{homes.some((h) => h.id !== "me") && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.userHomes")}
|
||||
</p>
|
||||
{homes
|
||||
.filter((h) => h.id !== "me")
|
||||
.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
title={t("fx.readOnlyHome")}
|
||||
onClick={() => go("", { home: h.id })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
>
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{h.label}</span>
|
||||
<Eye className="ml-auto h-3 w-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.places")}
|
||||
</p>
|
||||
{lanes.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
// A home row with a chevron: its user's workspaces drop down under it.
|
||||
const homeRow = (h: UserRow) => {
|
||||
const own = lanesOfHome.get(h.id) ?? [];
|
||||
const open = own.length > 0 && homeIsOpen(h.id, own);
|
||||
const mine = h.id === "me";
|
||||
// No home to open: the name drops the list down too.
|
||||
const browse = !h.noHome;
|
||||
return (
|
||||
<div key={h.id} data-testid={`fx-home-${h.id}`}>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("fx.toggleWorkspaces", {
|
||||
name: mine ? t("fx.myHome") : h.label,
|
||||
})}
|
||||
aria-expanded={open}
|
||||
disabled={!own.length}
|
||||
onClick={() => toggleHome(h.id, own)}
|
||||
className="flex h-6 w-5 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-2 disabled:opacity-0"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={mine || !browse ? undefined : t("fx.readOnlyHome")}
|
||||
aria-expanded={browse ? undefined : open}
|
||||
onClick={() => (browse ? go("", { home: h.id }) : toggleHome(h.id, own))}
|
||||
onContextMenu={browse ? (e) => placeCtx.open(e, { home: h.id }) : undefined}
|
||||
className={placeClass(
|
||||
browse && view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
>
|
||||
{mine ? (
|
||||
<House className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<UserRound className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{mine ? t("fx.myHome") : h.label}</span>
|
||||
{own.length > 0 && (
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
{own.length}
|
||||
</span>
|
||||
)}
|
||||
{!mine && browse && (
|
||||
<Eye
|
||||
className={`${own.length ? "" : "ml-auto "}h-3 w-3 shrink-0 opacity-60`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{open && own.map((l) => laneRow(l, true))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const me = homes.find((h) => h.id === "me");
|
||||
const others = userRows;
|
||||
return (
|
||||
<>
|
||||
{me && <div className="pt-3">{homeRow(me)}</div>}
|
||||
{others.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{others.some((h) => !h.noHome) ? t("fx.userHomes") : t("fx.users")}
|
||||
</p>
|
||||
{others.map(homeRow)}
|
||||
</>
|
||||
)}
|
||||
{looseLanes.length > 0 && (
|
||||
<>
|
||||
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("fx.places")}
|
||||
</p>
|
||||
{looseLanes.map((l) => laneRow(l, false))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</nav>
|
||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||
@@ -1987,7 +2094,7 @@ function InlineName({
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") onDone();
|
||||
if (isSubmitEnter(e)) onDone();
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
onBlur={onDone}
|
||||
|
||||
@@ -1063,6 +1063,9 @@ export function LaneChangesPanel({
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
// A reply for the previous ref/file/page lands after the next request
|
||||
// started: drop it, or it overwrites the list the user is looking at.
|
||||
let live = true;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, {
|
||||
@@ -1070,16 +1073,23 @@ export function LaneChangesPanel({
|
||||
...(historyRef ? { ref: historyRef } : {}),
|
||||
...(historyFile ? { file: historyFile } : {}),
|
||||
})
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||||
.then(forLane(laneId, (r) => live && setCommits(r.commits)))
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => live && laneNow.current === laneId && setHistoryLoading(false));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, laneId, reread, historyLimit, historyRef, historyFile]);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
let live = true;
|
||||
void api.lanes
|
||||
.gitBranches(target)
|
||||
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
|
||||
.catch(() => setHistoryBranches([]));
|
||||
.then(forLane(laneId, (r) => live && setHistoryBranches(r.branches)))
|
||||
.catch(() => live && setHistoryBranches([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, laneId, reread]);
|
||||
|
||||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||||
@@ -1173,24 +1183,33 @@ export function LaneChangesPanel({
|
||||
// own fetch — and only while the viewer is actually showing it.
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
||||
// Opening file B before file A's read came back must not show (and let the
|
||||
// editor save) A's text under B's header.
|
||||
let live = true;
|
||||
void api.lanes
|
||||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||
.then(
|
||||
forLane(laneId, (r) =>
|
||||
setText(
|
||||
r.available
|
||||
? {
|
||||
path: r.path,
|
||||
lines: r.lines,
|
||||
truncated: r.truncated,
|
||||
binary: r.binary,
|
||||
sha: r.sha,
|
||||
}
|
||||
: null
|
||||
)
|
||||
forLane(
|
||||
laneId,
|
||||
(r) =>
|
||||
live &&
|
||||
setText(
|
||||
r.available
|
||||
? {
|
||||
path: r.path,
|
||||
lines: r.lines,
|
||||
truncated: r.truncated,
|
||||
binary: r.binary,
|
||||
sha: r.sha,
|
||||
}
|
||||
: null
|
||||
)
|
||||
)
|
||||
)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
||||
// The editor's original side, read beside the working file. A file that is
|
||||
// not there (new since) diffs against nothing: every line is added.
|
||||
@@ -1198,10 +1217,17 @@ export function LaneChangesPanel({
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
||||
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
||||
let live = true;
|
||||
void api.lanes.gitFile(target, openFile, rev).then(
|
||||
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
|
||||
forLane(laneId, () => setBaseText(""))
|
||||
forLane(
|
||||
laneId,
|
||||
(r) => live && setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")
|
||||
),
|
||||
forLane(laneId, () => live && setBaseText(""))
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||
* one saying why), opens each user's Trash from its folder (asking before a
|
||||
* purge there, sending the item's store) and
|
||||
* shows a Trash that could not be read as an error, never as empty.
|
||||
* shows a Trash that could not be read as an error, never as empty; nests a
|
||||
* user's workspaces under their home in the sidebar (under a fold-only row
|
||||
* when the viewer may not open that home).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -28,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneProgress: vi.fn(),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -470,6 +473,59 @@ describe("FileExplorer", () => {
|
||||
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
|
||||
expect(api.files.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops a user's workspaces down under their home; an unowned one stays in the list", async () => {
|
||||
localStorage.removeItem("agenthub.files.homesOpen");
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{ id: 2, title: "two", cwd: "/w/two", owner_id: 7, owner_name: "bob" },
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const toggle = await screen.findByRole("button", { name: "Show or hide Bob’s workspaces" });
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||
expect(within(places).queryByRole("button", { name: "two" })).toBeNull();
|
||||
expect(within(places).getByRole("button", { name: "one" })).toBeInTheDocument();
|
||||
await user.click(toggle);
|
||||
await user.click(within(places).getByRole("button", { name: "two" }));
|
||||
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||
expect(localStorage.getItem("agenthub.files.homesOpen")).toContain("bob");
|
||||
});
|
||||
|
||||
it("groups an owner whose home the viewer cannot open (an analyst) under a row that only folds", async () => {
|
||||
localStorage.removeItem("agenthub.files.homesOpen");
|
||||
vi.mocked(api.files.homes).mockResolvedValue({
|
||||
homes: [{ id: "me", label: "An", readOnly: false }],
|
||||
});
|
||||
vi.mocked(api.lanes.list).mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 1, title: "one", cwd: "/w/one" },
|
||||
{
|
||||
id: 3,
|
||||
title: "three",
|
||||
cwd: "/w/three",
|
||||
owner_id: 9,
|
||||
owner_name: "cara",
|
||||
owner_display_name: "Cara",
|
||||
},
|
||||
],
|
||||
counts: {},
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const places = screen.getByRole("navigation", { name: "Workspaces" });
|
||||
expect(within(places).getByText("Users")).toBeInTheDocument();
|
||||
expect(within(places).queryByRole("button", { name: "three" })).toBeNull();
|
||||
await user.click(within(places).getByRole("button", { name: /^Cara/ }));
|
||||
expect(api.files.list).not.toHaveBeenCalledWith({ home: "cara" }, "");
|
||||
expect(within(places).getByRole("button", { name: "three" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileExplorer — New → Clone repository", () => {
|
||||
@@ -498,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows progress and lets the page go on while the clone runs", async () => {
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 1,
|
||||
waiting: 0,
|
||||
limit: 2,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 45,
|
||||
detail: "450/1000",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
const chip = await screen.findByTestId("fx-clone-chip");
|
||||
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
|
||||
await user.click(chip);
|
||||
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
finish({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await user.keyboard("{Escape}");
|
||||
fail(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
const again = await openClone(user);
|
||||
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
|
||||
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
|
||||
"https://git.example/o/r.git"
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
ATTACH_MIME,
|
||||
addAttachments,
|
||||
claudeMention,
|
||||
copiedFilePaths,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
@@ -452,6 +453,14 @@ export function PromptEditor({
|
||||
});
|
||||
return true;
|
||||
};
|
||||
/** A file manager's copy pasted with no file beside it, only `file://`
|
||||
* text: attached by those paths, as the terminal pastes them. */
|
||||
const pastedLocalFiles = (dt: DataTransfer | null | undefined) => {
|
||||
const paths = isLocalBrowser() ? copiedFilePaths(dt) : null;
|
||||
if (!paths) return false;
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
return true;
|
||||
};
|
||||
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||
* dashboard's machine and the server finds the same files there (a file
|
||||
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
||||
@@ -675,6 +684,7 @@ export function PromptEditor({
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||
else if (pastedLocalFiles(e.clipboardData)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
@@ -863,7 +873,7 @@ export function CwdAutocomplete({
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
} else if (isSubmitEnter(e)) {
|
||||
if (open && flat[active]) {
|
||||
e.preventDefault();
|
||||
choose(flat[active]);
|
||||
|
||||
@@ -65,14 +65,10 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import {
|
||||
MENU_CURSOR_ROW,
|
||||
notePaneFocus,
|
||||
notePaneOutput,
|
||||
registerPane,
|
||||
} from "../../lib/panePaste";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste";
|
||||
import {
|
||||
claudePastes,
|
||||
copiedFilePaths,
|
||||
droppedFilePaths,
|
||||
isLocalBrowser,
|
||||
localCandidates,
|
||||
@@ -160,15 +156,6 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
|
||||
const hasText = (e: DragEvent) =>
|
||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||
|
||||
/** The `file://` paths a paste carries as text — a file manager's copy — or
|
||||
* null: its uri-list, else its plain text when every line is one. */
|
||||
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
|
||||
if (!/^file:\/\//.test(text.trim())) return null;
|
||||
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
|
||||
}
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
wsBaseUrl: string;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* account's own information (`GET /api/me/account` — username, role, member
|
||||
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
|
||||
* an https link the server downloads, `POST /api/me/avatar/url`; the sidebar
|
||||
* an https link the browser loads itself, `POST /api/me/avatar/url`; the sidebar
|
||||
* shows it at once), its display name
|
||||
* and email (`PATCH /api/me/account`), a password change that asks for the
|
||||
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
|
||||
@@ -31,6 +31,7 @@ import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { ROLE_BADGE } from "../users/UserEditDialog";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
@@ -47,6 +48,24 @@ const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unkno
|
||||
: err.message;
|
||||
};
|
||||
|
||||
/** Whether `url` loads as an image embedded in this page — the way every
|
||||
* avatar is shown: no referrer, the cookies a cross-site image gets. */
|
||||
export function loadsAsImage(url: string, timeoutMs = 10_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
const done = (ok: boolean) => {
|
||||
window.clearTimeout(timer);
|
||||
img.onload = img.onerror = null;
|
||||
resolve(ok);
|
||||
};
|
||||
const timer = window.setTimeout(() => done(false), timeoutMs);
|
||||
img.referrerPolicy = "no-referrer";
|
||||
img.onload = () => done(img.naturalWidth > 0);
|
||||
img.onerror = () => done(false);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function AccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [account, setAccount] = useState<ServerAccount | null>(null);
|
||||
@@ -68,19 +87,23 @@ export function AccountSettings() {
|
||||
);
|
||||
const load = useCallback(() => {
|
||||
setLoadError(null);
|
||||
api.me
|
||||
return api.me
|
||||
.account()
|
||||
.then(apply)
|
||||
.catch((e: Error) => setLoadError(e.message));
|
||||
}, [apply]);
|
||||
useEffect(load, [load]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
const [retrying, retry] = useSpin(load);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
|
||||
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
|
||||
<button type="button" className="btn-ghost text-xs" onClick={load}>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
|
||||
<button type="button" className="btn-ghost text-xs" onClick={retry} disabled={retrying}>
|
||||
<RefreshCw className={`h-3.5 w-3.5${retrying ? " animate-spin" : ""}`} />{" "}
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -133,6 +156,14 @@ function ProfileCard({
|
||||
const applyLink = async () => {
|
||||
const url = link?.trim();
|
||||
if (!url) return;
|
||||
// The avatar routes only redirect to the link, so it shows only if THIS
|
||||
// browser can load it as an embedded image: a link behind a login (a
|
||||
// Mattermost /api/v4/users/<id>/image) gets no cookie from another site
|
||||
// and fails — say so now instead of saving a picture nobody sees.
|
||||
setBusy(true);
|
||||
const ok = await loadsAsImage(url);
|
||||
setBusy(false);
|
||||
if (!ok) return void toast.error(t("account.avatar.linkUnreachable"));
|
||||
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
|
||||
};
|
||||
const pick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FileUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AgentInstructionsView } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
@@ -41,13 +42,16 @@ export function AgentInstructionsSettings() {
|
||||
|
||||
const load = () => {
|
||||
setLoadError(null);
|
||||
api.me
|
||||
return api.me
|
||||
.instructions()
|
||||
.then(apply)
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
const [retrying, retry] = useSpin(load);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
@@ -123,7 +127,12 @@ export function AgentInstructionsSettings() {
|
||||
<span className="truncate">
|
||||
{t("agentInstructions.loadFailed", { message: loadError })}
|
||||
</span>
|
||||
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
onClick={retry}
|
||||
disabled={retrying}
|
||||
>
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
@@ -374,9 +375,7 @@ export function AiToolsSettings({
|
||||
{ctx.menu.item.path && (
|
||||
<>
|
||||
{isAdmin && <MenuSeparator />}
|
||||
<MenuItem
|
||||
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
||||
>
|
||||
<MenuItem onSelect={pick((tool) => void copyText(tool.path ?? ""))}>
|
||||
{t("rowMenu.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||
* kind of host, the extra keys, the other hosts) — and, for
|
||||
* kind of host, then the other hosts; a named key is a card of its own under
|
||||
* its type's default one, with the same steps, and "Add another key" makes
|
||||
* the next one below) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
@@ -24,17 +26,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -96,17 +88,19 @@ function Code({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||
|
||||
/**
|
||||
* One kind of git host and the key it takes: no key → a Generate button;
|
||||
* a key → step 1 (copy its public half) followed by the host's own steps.
|
||||
* One SSH key and the steps of the host it is for: no key → a Generate
|
||||
* button; a key → step 1 (copy its public half) followed by the host's own
|
||||
* steps. The default key of a type and every named one share it.
|
||||
*/
|
||||
function KeyCard({
|
||||
title,
|
||||
sub,
|
||||
type,
|
||||
label,
|
||||
k,
|
||||
id,
|
||||
writable,
|
||||
busy,
|
||||
onGenerate,
|
||||
onRemove,
|
||||
@@ -114,12 +108,13 @@ function KeyCard({
|
||||
}: {
|
||||
title: string;
|
||||
sub: string;
|
||||
type: "ed25519" | "rsa";
|
||||
/** What names the public key field ("RSA", "ED25519" or the key's name). */
|
||||
label: string;
|
||||
k: Key | undefined;
|
||||
id: GitIdentity;
|
||||
writable: boolean;
|
||||
busy: boolean;
|
||||
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onGenerate: () => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -130,23 +125,19 @@ function KeyCard({
|
||||
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||
</div>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => void onGenerate(type)}
|
||||
>
|
||||
{writable && (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
|
||||
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
{k && id.writable && (
|
||||
{k && writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void onRemove(type)}
|
||||
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
|
||||
onClick={() => void onRemove()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
@@ -165,9 +156,9 @@ function KeyCard({
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
|
||||
value={k.publicKey}
|
||||
rows={type === "rsa" ? 3 : 2}
|
||||
rows={k.type === "rsa" ? 3 : 2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
@@ -182,6 +173,104 @@ function KeyCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
|
||||
* default key's login is the region's plain host; a named key's is saved
|
||||
* under an account name (its own name unless another is typed), tied to it.
|
||||
*/
|
||||
function AwsLoginForm({
|
||||
busy,
|
||||
named,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
/** The named key's name; undefined for the default key. */
|
||||
named?: string;
|
||||
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||
return (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={v.keyId}
|
||||
onChange={(e) => setV({ ...v, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={v.region}
|
||||
onChange={(e) => setV({ ...v, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={v.alias}
|
||||
onChange={(e) => setV({ ...v, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
|
||||
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
|
||||
* offers only this key — the default key would log in as the other account.
|
||||
*/
|
||||
function GitHostForm({
|
||||
busy,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onSave: (host: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [host, setHost] = useState("github.com");
|
||||
return (
|
||||
<form
|
||||
className="mt-1 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(host.trim());
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherHost")}
|
||||
placeholder="github.com"
|
||||
autoComplete="off"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -229,19 +318,42 @@ export function GitIdentitySettings() {
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||
const named = keys.filter((k) => k.name);
|
||||
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
|
||||
const keyLabel = (keyId: string | null | undefined) => {
|
||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||
const makeNamed = async () => {
|
||||
if (!newKey) return;
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||
// "Add another key" in a tab makes the next key of that tab's type at once,
|
||||
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
|
||||
// account name of the user's own goes in its step 3.
|
||||
const makeNamed = (type: "ed25519" | "rsa") => {
|
||||
const prefix = type === "rsa" ? "aws" : "git";
|
||||
let n = 2;
|
||||
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
|
||||
return run(
|
||||
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
};
|
||||
const addKey = (type: "ed25519" | "rsa") => (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.addKey")}
|
||||
</button>
|
||||
);
|
||||
const replaceNamed = async (k: Key) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.replaceConfirm"),
|
||||
confirmLabel: t("gitIdentity.replace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
@@ -304,29 +416,60 @@ export function GitIdentitySettings() {
|
||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||
});
|
||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||
const saveAws = async () => {
|
||||
let user = aws.keyId.trim();
|
||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||
// A login is shown under the key it names; one naming no key (or one gone)
|
||||
// under the default key.
|
||||
const namedIds = new Set(named.map((k) => k.id));
|
||||
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
|
||||
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
|
||||
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
|
||||
let user = v.keyId.trim();
|
||||
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||
if (m?.[1] && m[2]) {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
}
|
||||
const alias = aws.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
// A named key's account goes by the key's name unless another is typed.
|
||||
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||
return run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(aws.key ? { key: aws.key } : {}),
|
||||
...(k?.id ? { key: k.id } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
);
|
||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
||||
};
|
||||
const saveGitHost = (host: string, k: Key) => {
|
||||
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||
return run(
|
||||
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
|
||||
t("gitIdentity.otherSaved", { host: k.name })
|
||||
);
|
||||
};
|
||||
/** The logins of one key, each with its delete button. */
|
||||
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
|
||||
rows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{text(r)}
|
||||
</span>
|
||||
{id?.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||
const saveOther = async () => {
|
||||
let host = other.host.trim();
|
||||
@@ -346,7 +489,7 @@ export function GitIdentitySettings() {
|
||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||
};
|
||||
/** The key picker of a login form: every key, or one. */
|
||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
||||
const keySelect = (value: string, onChange: (v: string) => void) => (
|
||||
<select
|
||||
aria-label={t("gitIdentity.loginKey")}
|
||||
value={value}
|
||||
@@ -355,7 +498,7 @@ export function GitIdentitySettings() {
|
||||
>
|
||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||
{keys
|
||||
.filter((k) => k.id && (!only || k.type === only))
|
||||
.filter((k) => k.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
@@ -396,17 +539,6 @@ export function GitIdentitySettings() {
|
||||
const sshTabs = [
|
||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||
...(named.length > 0 || id.writable
|
||||
? [
|
||||
{
|
||||
id: "more",
|
||||
label: named.length
|
||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
||||
: t("gitIdentity.tabMore"),
|
||||
Icon: KeySquare,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||
: []),
|
||||
@@ -498,228 +630,116 @@ export function GitIdentitySettings() {
|
||||
onSelect={setSshTab}
|
||||
>
|
||||
{activeSshTab === "git" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
<div className="space-y-2">
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
label="ED25519"
|
||||
k={keyOf("ed25519")}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => generate("ed25519")}
|
||||
onRemove={() => removeKey("ed25519")}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
{namedOf("ed25519").map((k) => {
|
||||
const rows = rowsOf(otherRows, k);
|
||||
return (
|
||||
<KeyCard
|
||||
key={k.id}
|
||||
title={k.name!}
|
||||
sub={t("gitIdentity.namedGitSub")}
|
||||
label={k.name!}
|
||||
k={k}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => replaceNamed(k)}
|
||||
onRemove={() => removeNamed(k.id!)}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||
{id.writable && rows.length === 0 && (
|
||||
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
||||
</Code>
|
||||
))}
|
||||
</Step>
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
{id.writable &&
|
||||
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
|
||||
addKey("ed25519")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSshTab === "aws" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
|
||||
const isNamed = i > 0 && !!k;
|
||||
const rows = rowsOf(awsRows, isNamed ? k : undefined);
|
||||
return (
|
||||
<KeyCard
|
||||
key={k?.id ?? "id_rsa"}
|
||||
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
|
||||
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
|
||||
label={isNamed ? k!.name! : "RSA"}
|
||||
k={k}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
|
||||
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{loginRows(
|
||||
rows,
|
||||
(r) =>
|
||||
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||
)}
|
||||
{id.writable && (
|
||||
<AwsLoginForm
|
||||
busy={busy}
|
||||
named={isNamed ? k!.name! : undefined}
|
||||
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={aws.keyId}
|
||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={aws.region}
|
||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{awsRows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
{id.writable && keys.some((k) => k.type === "rsa") && (
|
||||
<>
|
||||
{addKey("rsa")}
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
{activeSshTab === "more" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSshTab === "other" && (
|
||||
|
||||
@@ -16,8 +16,7 @@ import { api } from "../../lib/api";
|
||||
import type { PushTokenStatus } from "../../lib/api";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
|
||||
function forwarder(origin: string) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import { CopyButton } from "../event-views/primitives";
|
||||
import { snapshotUrl } from "../SnapshotButton";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
export function SnapshotSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -116,9 +117,7 @@ export function SnapshotSettings() {
|
||||
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
|
||||
{t("snapshots.open")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
|
||||
>
|
||||
<MenuItem onSelect={pick((s) => void copyText(snapshotUrl(s.token)))}>
|
||||
{t("snapshots.copyLink")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
|
||||
@@ -78,8 +78,40 @@ describe("AccountSettings", () => {
|
||||
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
||||
});
|
||||
|
||||
/** jsdom never loads an image: play the browser's answer to `src`. */
|
||||
const stubImage = (loads: boolean) =>
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class {
|
||||
naturalWidth = loads ? 64 : 0;
|
||||
referrerPolicy = "";
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_v: string) {
|
||||
setTimeout(() => (loads ? this.onload?.() : this.onerror?.()), 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("refuses a link this browser cannot load as an image, and saves nothing", async () => {
|
||||
const ue = userEvent.setup();
|
||||
stubImage(false);
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
await ue.click(screen.getByTestId("account-avatar-url-toggle"));
|
||||
await ue.type(
|
||||
screen.getByTestId("account-avatar-url-input"),
|
||||
"https://chat.example/api/v4/users/x/image{Enter}"
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("account-avatar-url-input")).toBeTruthy());
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(me.setAvatarFromUrl).not.toHaveBeenCalled();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sets the avatar from a link and closes the field", async () => {
|
||||
const ue = userEvent.setup();
|
||||
stubImage(true);
|
||||
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-21T00:00:00Z" } };
|
||||
me.setAvatarFromUrl.mockResolvedValue(withAvatar);
|
||||
render(<AccountSettings />);
|
||||
@@ -92,6 +124,7 @@ describe("AccountSettings", () => {
|
||||
"/api/me/avatar?v=2026-10-21T00:00:00Z"
|
||||
);
|
||||
expect(screen.queryByTestId("account-avatar-url-input")).toBeNull();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the account's own information", async () => {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||
* the sections never mix; a named key
|
||||
* is made beside the default ones, and a second AWS account is saved under
|
||||
* its own name with the one key it uses.
|
||||
* the sections never mix; "Add another key" makes a named key as one more
|
||||
* card in its own tab, a second AWS account is saved from that card under
|
||||
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -216,9 +216,9 @@ describe("GitIdentitySettings", () => {
|
||||
fingerprint: "SHA256:r",
|
||||
};
|
||||
const PROD = {
|
||||
id: "id_rsa_aws-prod",
|
||||
id: "id_rsa_aws-2",
|
||||
type: "rsa" as const,
|
||||
name: "aws-prod",
|
||||
name: "aws-2",
|
||||
publicKey: "ssh-rsa B prod",
|
||||
fingerprint: "SHA256:p",
|
||||
};
|
||||
@@ -230,45 +230,78 @@ describe("GitIdentitySettings", () => {
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [RSA, PROD],
|
||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
// Only an RSA key: the AWS tab opens first.
|
||||
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
|
||||
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||
await tab("More keys");
|
||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
||||
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
|
||||
// One click makes the key — nothing to type before it.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
|
||||
const prod = await screen.findByRole("region", { name: "aws-2" });
|
||||
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
|
||||
// Another key can be added below it.
|
||||
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
// Its own step 3: the account name defaults to the key's name.
|
||||
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA2",
|
||||
alias: "aws-prod",
|
||||
key: "id_rsa_aws-prod",
|
||||
alias: "aws-2",
|
||||
key: "id_rsa_aws-2",
|
||||
});
|
||||
expect(
|
||||
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||
).toBeInTheDocument();
|
||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
||||
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
|
||||
// The default key's card does not list the named key's login.
|
||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||
|
||||
await tab("More keys (1)");
|
||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
|
||||
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
|
||||
});
|
||||
|
||||
it("adds a second GitHub key in its tab and ties it to its host", async () => {
|
||||
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
const WORK = {
|
||||
id: "id_ed25519_git-2",
|
||||
type: "ed25519" as const,
|
||||
name: "git-2",
|
||||
publicKey: "ssh-ed25519 W work",
|
||||
fingerprint: "SHA256:w",
|
||||
};
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||
view({ sshKeys: [ED, WORK], sshUsers: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [ED, WORK],
|
||||
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
|
||||
})
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
|
||||
const work = await screen.findByRole("region", { name: "git-2" });
|
||||
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
|
||||
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: "github.com",
|
||||
user: "git",
|
||||
alias: "git-2",
|
||||
key: "id_ed25519_git-2",
|
||||
});
|
||||
expect(
|
||||
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`): the
|
||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
||||
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
|
||||
* per user. An admin adds one — for Claude a login MOVED in from a saved
|
||||
* profile (Settings → AI tools → Profiles, `GET /api/accounts/profiles`), a
|
||||
* pasted `.credentials.json`, or a `claude setup-token` token; for Codex a
|
||||
* saved Codex profile (`?tool=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. 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. A profile's
|
||||
* @description The Users page's account pool card (`GET /api/accounts`), one
|
||||
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
||||
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
||||
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
||||
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
||||
* Oh My Pi has no pool but can share the host's login (the same switch as
|
||||
* Settings → AI tools, `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every
|
||||
* other installed tool says that its users sign in themselves. A tab shows
|
||||
* how many accounts its pool holds, and an empty pool says so instead of
|
||||
* vanishing. An admin adds an account to the open tab's tool — for Claude a
|
||||
* login MOVED in from a saved profile (Settings → AI tools → Profiles,
|
||||
* `GET /api/accounts/profiles`), a pasted `.credentials.json`, or a
|
||||
* `claude setup-token` token; for Codex a saved Codex profile
|
||||
* (`?tool=codex`), an OpenAI API key or a pasted `auth.json`; for Cursor a
|
||||
* Cursor API key — 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. 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. A profile's
|
||||
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
|
||||
* which opens Add on that profile; the header links back to the profiles.
|
||||
* which opens that tool's tab and Add on that profile; the header links back
|
||||
* to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,22 +29,28 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound, Users } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
type AccountPool,
|
||||
type OmpSharingState,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
type CcUsageWindow,
|
||||
} from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
||||
import { PoolGroups, groupsOf } from "./PoolGroups";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -53,19 +67,81 @@ type Row = {
|
||||
tokens7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** The 5-hour and weekly windows, as the account last reported them. */
|
||||
windows: QuotaWindow[];
|
||||
};
|
||||
|
||||
function rowsOf(pool: AccountPool): Row[] {
|
||||
return [
|
||||
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
|
||||
...pool.codex.map((a) => ({
|
||||
...a,
|
||||
tool: "codex" as const,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
})),
|
||||
];
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
|
||||
/**
|
||||
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||
*/
|
||||
function windowsOf(
|
||||
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
||||
): QuotaWindow[] {
|
||||
const q = a.quota as
|
||||
| {
|
||||
fiveHour?: CcUsageWindow | null;
|
||||
sevenDay?: CcUsageWindow | null;
|
||||
windows?: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const fromClaude = (w: CcUsageWindow | null | undefined) =>
|
||||
w ? { used: w.utilization, resetsAt: w.resetsAt } : null;
|
||||
const fromCodex = (label: string) => {
|
||||
const w = q?.windows?.find((x) => x.label === label);
|
||||
return w ? { used: w.usedPercent, resetsAt: w.resetsAt } : null;
|
||||
};
|
||||
return (["5h", "wk"] as const).map((key) => {
|
||||
const w = (key === "5h" ? fromClaude(q?.fiveHour) : fromClaude(q?.sevenDay)) ?? fromCodex(key);
|
||||
const passed = w?.resetsAt != null && Date.parse(w.resetsAt) <= Date.now();
|
||||
return {
|
||||
key,
|
||||
used: passed ? 0 : (w?.used ?? null),
|
||||
resetsAt: passed ? null : (w?.resetsAt ?? null),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** "2h 10m" / "3d 4h" until `iso`, or null once it has passed. */
|
||||
function untilText(iso: string | null): string | null {
|
||||
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||
if (!(ms > 0)) return null;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const d = Math.floor(m / 1440);
|
||||
const h = Math.floor((m % 1440) / 60);
|
||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
|
||||
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
|
||||
/** Fixed tabs, always shown; any other installed tool follows them. */
|
||||
const FIXED_TABS: { id: string; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
{ id: "omp", label: "Oh My Pi" },
|
||||
];
|
||||
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
|
||||
const METERED = new Set<PoolTool>(["claude", "codex"]);
|
||||
|
||||
const isPoolTool = (id: string): id is PoolTool => (POOL_TOOLS as string[]).includes(id);
|
||||
|
||||
function rowsOf(pool: AccountPool, tool: PoolTool): Row[] {
|
||||
if (tool === "claude") return pool.accounts.map((a) => ({ ...a, tool, windows: windowsOf(a) }));
|
||||
const list = tool === "codex" ? pool.codex : (pool.cursor ?? []);
|
||||
return list.map((a) => ({
|
||||
...a,
|
||||
tool,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
windows: tool === "codex" ? windowsOf(a as AccountPool["codex"][number]) : [],
|
||||
}));
|
||||
}
|
||||
|
||||
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||
@@ -79,18 +155,24 @@ function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
|
||||
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const aiTools = useAiTools();
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
const [omp, setOmp] = useState<OmpSharingState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
|
||||
const preset = (() => {
|
||||
const tool = params.get("poolAdd");
|
||||
const profile = params.get("poolProfile");
|
||||
return (tool === "claude" || tool === "codex") && profile ? { tool, profile } : null;
|
||||
})() as { tool: PoolTool; profile: string } | null;
|
||||
const [adding, setAdding] = useState(() => isAdmin && preset !== null);
|
||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||
})();
|
||||
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||
// What Add opens on: a tool, and a saved profile to share into its pool.
|
||||
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
|
||||
isAdmin && preset ? preset : null
|
||||
);
|
||||
const closeAdd = () => {
|
||||
setAdding(false);
|
||||
setAdding(null);
|
||||
if (!preset) return;
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("poolAdd");
|
||||
@@ -115,16 +197,32 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// Oh My Pi's tab: the host's login shared through omp's own gateway (S3).
|
||||
api.aiTools
|
||||
.ompSharing()
|
||||
.then(setOmp)
|
||||
.catch(() => setOmp(null));
|
||||
}, [load]);
|
||||
|
||||
const rows = pool ? rowsOf(pool) : [];
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
// Every other INSTALLED tool gets a tab too, so "no pool" is said, not implied.
|
||||
const tabs = [
|
||||
...FIXED_TABS,
|
||||
...aiTools.tools
|
||||
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.map((x) => ({ id: x.id, label: x.label })),
|
||||
];
|
||||
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
|
||||
const badge = (id: string): string | null => {
|
||||
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
||||
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-[14rem]">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||
</div>
|
||||
@@ -142,122 +240,74 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("pool.title")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||
>
|
||||
{tabs.map((x) => {
|
||||
const selected = x.id === current.id;
|
||||
const count = badge(x.id);
|
||||
return (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`pool-tab-${x.id}`}
|
||||
aria-selected={selected}
|
||||
aria-controls="pool-panel"
|
||||
onClick={() => setTab(x.id)}
|
||||
className={`-mb-px inline-flex shrink-0 items-center gap-1.5 border-b-2 px-3 pb-2.5 pt-1 text-sm transition-colors ${
|
||||
selected
|
||||
? "border-accent text-fg-primary font-medium"
|
||||
: "border-transparent text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-4 w-4" />
|
||||
{x.label}
|
||||
{count !== null && (
|
||||
<span
|
||||
className={`rounded-full px-1.5 text-[10px] tabular-nums ${
|
||||
selected ? "bg-accent/15 text-accent" : "bg-surface-3 text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && rows.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("pool.account")}</th>
|
||||
<th className={th}>{t("pool.tool")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.quota")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.spend")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<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}`)}
|
||||
{r.expired && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.livePanes}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">
|
||||
{fmtCost(r.spend5h)}
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setRemoving(r)}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
||||
{isPoolTool(current.id) ? (
|
||||
<PoolPanel
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
pool={pool}
|
||||
isAdmin={isAdmin}
|
||||
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
||||
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
||||
onEdit={setEditing}
|
||||
onRemove={setRemoving}
|
||||
/>
|
||||
) : current.id === "omp" ? (
|
||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||
) : (
|
||||
<NoPoolPanel id={current.id} label={current.label} />
|
||||
)}
|
||||
</div>
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
preset={preset}
|
||||
tool={adding.tool}
|
||||
label={tabs.find((x) => x.id === adding.tool)?.label ?? adding.tool}
|
||||
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.done.added", { name }));
|
||||
@@ -301,6 +351,398 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
||||
function PoolPanel({
|
||||
tool,
|
||||
label,
|
||||
pool,
|
||||
isAdmin,
|
||||
installed,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
pool: AccountPool | null;
|
||||
isAdmin: boolean;
|
||||
installed: boolean;
|
||||
/** Add an account; `profile` = share that saved profile into the pool. */
|
||||
onAdd: (profile?: string) => void;
|
||||
onEdit: (r: Row) => void;
|
||||
onRemove: (r: Row) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const rows = pool ? rowsOf(pool, tool) : [];
|
||||
const grouped = (pool?.groups ?? []).length > 0;
|
||||
const addButton = isAdmin && (
|
||||
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
||||
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
||||
{t(`pool.about.${tool}`)}
|
||||
{!installed && (
|
||||
<span className="ml-1 text-status-warning">
|
||||
{t("pool.notInstalled", { tool: label })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{rows.length > 0 && addButton}
|
||||
</div>
|
||||
{pool && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
||||
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
||||
{addButton}
|
||||
</div>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<AccountCard
|
||||
key={r.name}
|
||||
row={r}
|
||||
pool={pool!}
|
||||
grouped={grouped}
|
||||
isAdmin={isAdmin}
|
||||
onEdit={() => onEdit(r)}
|
||||
onRemove={() => onRemove(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && SOURCES[tool].includes("profile") && (
|
||||
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
||||
function AccountCard({
|
||||
row: r,
|
||||
pool,
|
||||
grouped,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Row;
|
||||
pool: AccountPool;
|
||||
grouped: boolean;
|
||||
isAdmin: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const metered = METERED.has(r.tool);
|
||||
const users = pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username);
|
||||
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
||||
}`}
|
||||
data-testid="pool-account"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
</div>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||
{isAdmin && (
|
||||
<div className="-mr-1 -mt-1 flex shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
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
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(r.expired || r.refreshError || r.full) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{r.expired && (
|
||||
<span className="badge py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("pool.liveCount", { count: r.livePanes })}
|
||||
</span>
|
||||
</div>
|
||||
{metered && (
|
||||
<div className="space-y-1.5" data-testid="pool-quota">
|
||||
{r.windows.map((w) => (
|
||||
<QuotaBar key={w.key} window={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{metered && (
|
||||
<div className="text-[11px] tabular-nums text-fg-muted">
|
||||
<div>
|
||||
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</div>
|
||||
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
||||
</div>
|
||||
)}
|
||||
{grouped && (
|
||||
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
||||
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
||||
{inGroups.length === 0 ? (
|
||||
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
||||
) : (
|
||||
inGroups.map((g) => (
|
||||
<span key={g.id} className="badge py-0 text-[10px]">
|
||||
{g.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
|
||||
<UserRound className="h-3 w-3" aria-hidden />
|
||||
{t("pool.pinned", { users: users.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||
* 0% = used up), with when it resets.
|
||||
*/
|
||||
function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
const { t } = useTranslation("users");
|
||||
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<div className="text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
|
||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${
|
||||
left == null
|
||||
? ""
|
||||
: left === 0
|
||||
? "bg-status-danger"
|
||||
: left <= 10
|
||||
? "bg-status-warning"
|
||||
: "bg-accent-solid"
|
||||
}`}
|
||||
style={{ width: `${left ?? 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-9 shrink-0 text-right tabular-nums text-fg-secondary">
|
||||
{left == null ? "—" : `${left}%`}
|
||||
</span>
|
||||
</div>
|
||||
{resets && (
|
||||
<div
|
||||
className="pl-[3.875rem] text-[10px] text-fg-muted"
|
||||
title={formatDateTime(w.resetsAt!)}
|
||||
>
|
||||
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
||||
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
||||
* leaves the switcher, since a login's refresh token may have one holder.
|
||||
*/
|
||||
function SavedProfiles({
|
||||
tool,
|
||||
pool,
|
||||
onShare,
|
||||
}: {
|
||||
tool: PoolTool;
|
||||
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
||||
pool: AccountPool | null;
|
||||
onShare: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool, pool]);
|
||||
return (
|
||||
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("pool.savedProfiles.title")}
|
||||
</h3>
|
||||
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
</div>
|
||||
{profiles !== null && profiles.length === 0 && (
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
||||
)}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
||||
<div className="truncate text-[11px] text-fg-muted">
|
||||
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
||||
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShare(p.name)}
|
||||
className="btn-ghost shrink-0 text-xs"
|
||||
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
|
||||
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
|
||||
* same admin switch as Settings → AI tools. Only with per-user accounts (S3).
|
||||
*/
|
||||
function OmpPanel({
|
||||
state,
|
||||
isAdmin,
|
||||
onChange,
|
||||
}: {
|
||||
state: OmpSharingState | null;
|
||||
isAdmin: boolean;
|
||||
onChange: (s: OmpSharingState) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const ts = useTranslation("settings").t;
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!state?.available) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-5 py-6">
|
||||
<Share2 className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
<p className="text-sm text-fg-muted leading-relaxed">{t("pool.omp.unavailable")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const error = state.broker.error || state.gateway.error;
|
||||
const status = !state.enabled
|
||||
? t("pool.omp.off")
|
||||
: !state.installed
|
||||
? ts("ompSharing.notInstalled")
|
||||
: state.broker.running && state.gateway.running
|
||||
? ts("ompSharing.running")
|
||||
: error
|
||||
? ts("ompSharing.failed", { error })
|
||||
: ts("ompSharing.starting");
|
||||
return (
|
||||
<div className="px-5 py-3 space-y-2">
|
||||
<p className="text-xs text-fg-secondary leading-relaxed">{t("pool.omp.about")}</p>
|
||||
{isAdmin ? (
|
||||
<SettingsSwitchRow
|
||||
label={ts("ompSharing.switch")}
|
||||
description={ts("ompSharing.hint")}
|
||||
checked={state.enabled}
|
||||
disabled={busy}
|
||||
onChange={async (on) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onChange(await api.aiTools.setOmpSharing(on));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<p data-testid="omp-pool-status" className="pb-2 text-xs text-fg-muted">
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Any other tool: no pool — say so, and why for the ones we know. */
|
||||
function NoPoolPanel({ id, label }: { id: string; label: string }) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-5 py-6">
|
||||
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-fg-secondary">{t("pool.none.title", { tool: label })}</p>
|
||||
<p className="text-xs text-fg-muted leading-relaxed">
|
||||
{t(`pool.none.reason.${id}`, {
|
||||
defaultValue: t("pool.none.reason.default", { tool: label }),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -439,23 +881,29 @@ type ClaudeSource = "profile" | "credentials" | "token";
|
||||
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
|
||||
claude: ["profile", "credentials", "token"],
|
||||
codex: ["profile", "token"],
|
||||
cursor: ["token"],
|
||||
};
|
||||
|
||||
function AddAccountDialog({
|
||||
tool,
|
||||
label,
|
||||
preset,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
/** The open tab's tool: the account is added to its pool. */
|
||||
tool: PoolTool;
|
||||
label: string;
|
||||
/** A saved profile to move in, from its "Move to pool" link. */
|
||||
preset?: { tool: PoolTool; profile: string } | null;
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>(preset?.tool ?? "claude");
|
||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
||||
const sources = SOURCES[tool];
|
||||
const [source, setSource] = useState<ClaudeSource>(sources[0] ?? "token");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
const [profile, setProfile] = useState(preset?.profile ?? "");
|
||||
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
@@ -463,12 +911,12 @@ function AddAccountDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProfiles(null);
|
||||
if (!sources.includes("profile")) return;
|
||||
api.accountPool
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, [tool]);
|
||||
}, [tool, sources]);
|
||||
|
||||
const fromProfile = source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
@@ -483,11 +931,9 @@ function AddAccountDialog({
|
||||
const body: PoolAccountInput =
|
||||
source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
: tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
: tool === "claude" && source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
try {
|
||||
const r = await api.accountPool.add(body, current);
|
||||
onDone(r.account.name);
|
||||
@@ -504,12 +950,13 @@ function AddAccountDialog({
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
const secretKind = tool === "codex" ? "codex" : source;
|
||||
// Codex's and Cursor's one non-profile source is their own kind of key.
|
||||
const secretKind = tool === "claude" ? source : tool;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.add")}
|
||||
title={t("pool.addFor", { tool: label })}
|
||||
confirmLabel={t("pool.add")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
@@ -519,40 +966,27 @@ function AddAccountDialog({
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
<Field label={t("pool.tool")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => {
|
||||
setTool(e.target.value as PoolTool);
|
||||
setProfile("");
|
||||
setSource("profile");
|
||||
}}
|
||||
aria-label={t("pool.tool")}
|
||||
{sources.length > 1 && (
|
||||
<Field
|
||||
label={t("pool.source")}
|
||||
hint={t(`pool.sourceHint.${tool !== "claude" && source === "token" ? tool : source}`)}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field
|
||||
label={t("pool.source")}
|
||||
hint={t(`pool.sourceHint.${tool === "codex" && source === "token" ? "codex" : source}`)}
|
||||
>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{SOURCES[tool].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{tool === "codex" && s === "token"
|
||||
? t("pool.secret.codex")
|
||||
: t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{sources.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{tool !== "claude" && s === "token"
|
||||
? t(`pool.secret.${tool}`)
|
||||
: t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{fromProfile && (
|
||||
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
||||
<select
|
||||
@@ -592,7 +1026,7 @@ function AddAccountDialog({
|
||||
</Field>
|
||||
{!fromProfile && (
|
||||
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
||||
{secretKind === "token" ? (
|
||||
{secretKind === "token" || secretKind === "cursor" ? (
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full font-mono text-sm"
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* @file PoolGroups.tsx
|
||||
* @description The account pool's GROUPS (`/api/accounts/groups`,
|
||||
* server/lib/pool-groups.js), shown as cards above the tool tabs: each team's
|
||||
* members run only on the accounts its group lists, of every tool, while a
|
||||
* user in no group runs on the shared pool (the accounts no group lists). A
|
||||
* card shows the group's members and its accounts per tool; an admin creates,
|
||||
* edits (name, members — one group per user, so ticking one moves them here —
|
||||
* and accounts) or deletes one, each time with their own password again.
|
||||
* `groupsOf` tells an account card which groups use it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type PoolGroup,
|
||||
type PoolTool,
|
||||
type ServerUserUsage,
|
||||
} from "../../lib/api";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
|
||||
/** Pool tools with their tab labels, in tab order. */
|
||||
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
|
||||
{ id: "claude", label: "Claude Code" },
|
||||
{ id: "codex", label: "Codex" },
|
||||
{ id: "cursor", label: "Cursor CLI" },
|
||||
];
|
||||
|
||||
/** Account names of `tool` in the pool. */
|
||||
function accountNames(pool: AccountPool, tool: PoolTool): string[] {
|
||||
const list = tool === "claude" ? pool.accounts : tool === "codex" ? pool.codex : pool.cursor;
|
||||
return (list ?? []).map((a) => a.name);
|
||||
}
|
||||
|
||||
/** The groups that list account `name` of `tool`. */
|
||||
export function groupsOf(pool: AccountPool, tool: PoolTool, name: string): PoolGroup[] {
|
||||
return (pool.groups ?? []).filter((g) => (g.accounts[tool] ?? []).includes(name));
|
||||
}
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`pool.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
export function PoolGroups({
|
||||
pool,
|
||||
isAdmin,
|
||||
onChanged,
|
||||
}: {
|
||||
pool: AccountPool;
|
||||
isAdmin: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const groups = pool.groups ?? [];
|
||||
const [editing, setEditing] = useState<PoolGroup | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 border-b border-border" data-testid="pool-groups">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Users className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<div className="flex-1 min-w-[14rem]">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("pool.groups.title")}</h3>
|
||||
<p className="text-xs text-fg-muted">{t("pool.groups.description")}</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setEditing("new")} className="btn-ghost text-xs">
|
||||
<Plus className="h-4 w-4" /> {t("pool.groups.new")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{groups.length === 0 ? (
|
||||
<p className="mt-3 text-xs text-fg-muted">{t("pool.groups.empty")}</p>
|
||||
) : (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
className="rounded-lg border border-border bg-surface-2 p-3"
|
||||
data-testid="pool-group"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">{g.name}</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.groups.members", { count: g.users.length })}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(g)}
|
||||
title={t("pool.groups.edit", { name: g.name })}
|
||||
aria-label={t("pool.groups.edit", { name: g.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleting(g)}
|
||||
title={t("pool.groups.delete", { name: g.name })}
|
||||
aria-label={t("pool.groups.delete", { name: g.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{g.users.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{g.users.map((u) => (
|
||||
<span key={u.userId} className="badge py-0 text-[11px]">
|
||||
{u.username}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 space-y-1">
|
||||
{POOL_TOOL_LABELS.map(({ id, label }) => {
|
||||
const names = g.accounts[id] ?? [];
|
||||
return (
|
||||
<div key={id} className="flex items-center gap-1.5 text-[11px]">
|
||||
<AiToolIcon tool={{ id, label }} className="h-3.5 w-3.5" />
|
||||
<span className="text-fg-secondary">{label}:</span>
|
||||
<span className={names.length ? "text-fg-primary" : "text-fg-muted"}>
|
||||
{names.length ? names.join(", ") : t("pool.groups.noAccounts")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{editing && (
|
||||
<GroupDialog
|
||||
pool={pool}
|
||||
group={editing === "new" ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.groups.saved", { name }));
|
||||
setEditing(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<DeleteGroupDialog
|
||||
group={deleting}
|
||||
onClose={() => setDeleting(null)}
|
||||
onDone={() => {
|
||||
toast.success(t("pool.groups.deleted", { name: deleting.name }));
|
||||
setDeleting(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a group, or edit one: its name, its members and its accounts. */
|
||||
function GroupDialog({
|
||||
pool,
|
||||
group,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
pool: AccountPool;
|
||||
group: PoolGroup | null;
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [name, setName] = useState(group?.name ?? "");
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [members, setMembers] = useState<Set<number>>(
|
||||
() => new Set(group?.users.map((u) => u.userId) ?? [])
|
||||
);
|
||||
const [accounts, setAccounts] = useState<Record<PoolTool, Set<string>>>(() => ({
|
||||
claude: new Set(group?.accounts.claude ?? []),
|
||||
codex: new Set(group?.accounts.codex ?? []),
|
||||
cursor: new Set(group?.accounts.cursor ?? []),
|
||||
}));
|
||||
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 groupOfUser = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.id !== group?.id && g.users.some((u) => u.userId === userId));
|
||||
const toggle = <T,>(set: Set<T>, v: T, on: boolean) => {
|
||||
const next = new Set(set);
|
||||
if (on) next.add(v);
|
||||
else next.delete(v);
|
||||
return next;
|
||||
};
|
||||
const ready = name.trim().length > 0 && current.length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const id = group
|
||||
? group.id
|
||||
: (await api.accountPool.createGroup(name.trim(), current)).group.id;
|
||||
await api.accountPool.updateGroup(
|
||||
id,
|
||||
{
|
||||
...(group ? { name: name.trim() } : {}),
|
||||
userIds: [...members],
|
||||
accounts: Object.fromEntries(
|
||||
POOL_TOOL_LABELS.map(({ id: tool }) => [tool, [...accounts[tool]]])
|
||||
),
|
||||
},
|
||||
current
|
||||
);
|
||||
onDone(name.trim());
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={group ? t("pool.groups.edit", { name: group.name }) : t("pool.groups.new")}
|
||||
message={t("pool.groups.hint")}
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("pool.groups.name")}</span>
|
||||
<input
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("pool.groups.name")}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("pool.groups.memberList")}
|
||||
</span>
|
||||
<div className="mt-1 max-h-40 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 other = groupOfUser(u.id);
|
||||
return (
|
||||
<div key={u.id} className="px-3 py-1.5">
|
||||
<Checkbox
|
||||
checked={members.has(u.id)}
|
||||
onChange={(on) => setMembers((s) => toggle(s, u.id, on))}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{other && (
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.groups.inGroup", { group: other.name })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{t("pool.groups.accountList")}
|
||||
</span>
|
||||
<div className="mt-1 max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||
{POOL_TOOL_LABELS.map(({ id: tool, label }) => {
|
||||
const names = accountNames(pool, tool);
|
||||
return (
|
||||
<div key={tool} className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-fg-secondary">
|
||||
<AiToolIcon tool={{ id: tool, label }} className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
{names.length === 0 ? (
|
||||
<p className="mt-1 text-[11px] text-fg-muted">{t("pool.groups.noneInPool")}</p>
|
||||
) : (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
||||
{names.map((n) => (
|
||||
<Checkbox
|
||||
key={n}
|
||||
checked={accounts[tool].has(n)}
|
||||
onChange={(on) =>
|
||||
setAccounts((a) => ({ ...a, [tool]: toggle(a[tool], n, on) }))
|
||||
}
|
||||
label={n}
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteGroupDialog({
|
||||
group,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
group: PoolGroup;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.accountPool.deleteGroup(group.id, current);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.groups.delete", { name: group.name })}
|
||||
message={t("pool.groups.deleteHint")}
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<label className="mt-3 block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input mt-1 w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file AccountPoolCard.test.tsx
|
||||
* @description The Users page's account pool card: lists the Claude and Codex
|
||||
* accounts with their live panes and quota, says what an empty pool means, an
|
||||
* @description The Users page's account pool card: one tab per AI tool — the
|
||||
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
|
||||
* Oh My Pi's login sharing, and a "no pool" note for any other installed
|
||||
* tool; says what an empty pool means per tool, an
|
||||
* 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
|
||||
@@ -22,11 +24,20 @@ const accountPool = vi.hoisted(() => ({
|
||||
remove: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
setUsers: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
updateGroup: vi.fn(),
|
||||
deleteGroup: vi.fn(),
|
||||
}));
|
||||
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const aiTools = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
ompSharing: vi.fn(),
|
||||
setOmpSharing: vi.fn(),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { accountPool, users } };
|
||||
return { ...actual, api: { accountPool, users, aiTools } };
|
||||
});
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
@@ -53,9 +64,44 @@ const POOL: AccountPool = {
|
||||
spend7d: 12,
|
||||
tokens5h: 250_000,
|
||||
tokens7d: 3_400_000,
|
||||
quota: {
|
||||
fiveHour: {
|
||||
utilization: 42,
|
||||
resetsAt: new Date(Date.now() + 2 * 3600e3 + 630e3).toISOString(),
|
||||
},
|
||||
sevenDay: {
|
||||
utilization: 15,
|
||||
resetsAt: new Date(Date.now() + 3 * 86400e3 + 3630e3).toISOString(),
|
||||
},
|
||||
checkedAt: "2026-10-01T00:00:00Z",
|
||||
error: null,
|
||||
},
|
||||
quotaPercent: 42,
|
||||
full: false,
|
||||
},
|
||||
{
|
||||
name: "team-2",
|
||||
kind: "token",
|
||||
email: null,
|
||||
refreshError: null,
|
||||
addedAt: "2026-10-01T00:00:00Z",
|
||||
expiresAt: null,
|
||||
expired: false,
|
||||
livePanes: 0,
|
||||
spend5h: 0,
|
||||
spend7d: 0,
|
||||
tokens5h: 0,
|
||||
tokens7d: 0,
|
||||
// A 5-hour window that has reset since the reading is full again.
|
||||
quota: {
|
||||
fiveHour: { utilization: 100, resetsAt: "2020-01-01T00:00:00Z" },
|
||||
sevenDay: null,
|
||||
checkedAt: "2026-10-01T00:00:00Z",
|
||||
error: null,
|
||||
},
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
},
|
||||
],
|
||||
codex: [
|
||||
{
|
||||
@@ -74,8 +120,30 @@ const POOL: AccountPool = {
|
||||
blockPercent: 90,
|
||||
};
|
||||
|
||||
const tool = (id: string, label: string, installed = true) => ({ id, label, installed });
|
||||
const OMP = {
|
||||
available: true,
|
||||
enabled: false,
|
||||
installed: true,
|
||||
broker: { running: false },
|
||||
gateway: { running: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
aiTools.list.mockResolvedValue({
|
||||
tools: [
|
||||
tool("claude", "Claude Code"),
|
||||
tool("codex", "Codex"),
|
||||
tool("cursor", "Cursor CLI"),
|
||||
tool("omp", "Oh My Pi"),
|
||||
tool("antigravity", "Antigravity"),
|
||||
tool("goose", "Goose", false),
|
||||
],
|
||||
default: "claude",
|
||||
});
|
||||
aiTools.ompSharing.mockResolvedValue(OMP);
|
||||
aiTools.setOmpSharing.mockResolvedValue({ ...OMP, enabled: true });
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
users.list.mockResolvedValue({
|
||||
users: [
|
||||
@@ -98,20 +166,145 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("lists every account, and explains an empty pool", async () => {
|
||||
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(card(false));
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
|
||||
expect(screen.queryByText("cx-1")).toBeNull();
|
||||
const [account, second] = screen.getAllByTestId("pool-account");
|
||||
// Every account of the pool has its card; quota is shown as what is LEFT.
|
||||
expect(second).toHaveTextContent("team-2");
|
||||
const quota = screen.getAllByTestId("pool-quota")[0]!;
|
||||
expect(quota).toHaveTextContent("5 hours");
|
||||
expect(quota).toHaveTextContent("58%");
|
||||
expect(quota).toHaveTextContent("resets in 2h 10m");
|
||||
expect(quota).toHaveTextContent("Week");
|
||||
expect(quota).toHaveTextContent("85%");
|
||||
expect(quota).toHaveTextContent("resets in 3d 1h");
|
||||
const reset = screen.getAllByTestId("pool-quota")[1]!;
|
||||
expect(reset).toHaveTextContent("100%");
|
||||
expect(reset).toHaveTextContent("—");
|
||||
expect(account).toHaveTextContent("250.0K tokens");
|
||||
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
||||
expect(account).toHaveTextContent("2 live");
|
||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
|
||||
// Without groups nothing is said about them on an account.
|
||||
expect(screen.queryByText("Shared pool")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
const codexTab = screen.getByRole("tab", { name: /Codex/ });
|
||||
expect(codexTab).toHaveTextContent("1");
|
||||
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
|
||||
await user.click(codexTab);
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("team-1")).toBeNull();
|
||||
// A pool with no account still has its tab, with what that means.
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
|
||||
// An installed tool without a pool says so; an uninstalled one gets no tab.
|
||||
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
||||
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
||||
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(card(true));
|
||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a Cursor API key from the Cursor tab", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
||||
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(screen.queryByLabelText("From")).toBeNull();
|
||||
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||
await user.type(screen.getByLabelText("Cursor API key"), "key_abc");
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "cursor", name: "cu-1", token: "key_abc" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||
});
|
||||
|
||||
it("shares a saved profile from its card", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the groups as cards and saves a new one with members and accounts", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
users: [{ userId: 8, username: "leo" }],
|
||||
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const groupCard = await screen.findByTestId("pool-group");
|
||||
expect(groupCard).toHaveTextContent("Team A");
|
||||
expect(groupCard).toHaveTextContent("leo");
|
||||
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
|
||||
await user.click(screen.getByRole("button", { name: "New group" }));
|
||||
await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Group name"), "Team B");
|
||||
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
|
||||
expect(screen.getByText("now in Team A")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
2,
|
||||
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||
});
|
||||
|
||||
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Oh My Pi/ }));
|
||||
expect(screen.getByTestId("omp-pool-status")).toHaveTextContent(/Sharing is off/);
|
||||
await user.click(screen.getByRole("switch"));
|
||||
await waitFor(() => expect(aiTools.setOmpSharing).toHaveBeenCalledWith(true));
|
||||
expect(screen.getByRole("tab", { name: /Oh My Pi/ })).toHaveTextContent("shared");
|
||||
});
|
||||
|
||||
it("moves a saved login in, the server's own included", async () => {
|
||||
@@ -119,7 +312,7 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const select = await screen.findByLabelText("Profile");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||
@@ -144,7 +337,7 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
@@ -168,6 +361,8 @@ describe("AccountPoolCard", () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Codex/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
@@ -230,7 +425,7 @@ describe("AccountPoolCard", () => {
|
||||
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||
expect(screen.getByLabelText("Tool")).toHaveValue("codex");
|
||||
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
|
||||
@@ -17,6 +17,7 @@ import { api } from "../../lib/api";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { laneChangeMatcher } from "../../lib/laneChange";
|
||||
import type { PipelineStats } from "../../lib/types";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
|
||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||
function pct(n: number, total: number): number {
|
||||
@@ -160,7 +161,7 @@ export function LanePipelinePanel({
|
||||
type="button"
|
||||
data-testid={`pipeline-stat-command-${n.id}`}
|
||||
title={t("lanes.copyCommand", { command: n.command })}
|
||||
onClick={() => void navigator.clipboard?.writeText(n.command || "")}
|
||||
onClick={() => void copyText(n.command || "")}
|
||||
className="block max-w-full truncate font-mono text-[10px] text-fg-muted hover:text-accent"
|
||||
>
|
||||
{n.command}
|
||||
|
||||
@@ -183,5 +183,19 @@
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}"
|
||||
"detail.added": "Added {{date}}",
|
||||
"tab.unowned": "Unowned",
|
||||
"tab.suggested": "Is this yours?",
|
||||
"auto": "Auto",
|
||||
"autoHint": "Added when its session stopped: a document the session created or changed",
|
||||
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
|
||||
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
|
||||
"unowned.mine": "It's mine",
|
||||
"unowned.takeIt": "Take it",
|
||||
"unowned.notMine": "Not mine",
|
||||
"unowned.assign": "Assign to…",
|
||||
"unowned.claimed": "Added to your library",
|
||||
"unowned.declined": "Removed from your list",
|
||||
"unowned.assigned": "Owner assigned",
|
||||
"unowned.empty": "Nothing without an owner."
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||
"clone.nameLabel": "Folder name",
|
||||
"clone.cloning": "Cloning…",
|
||||
"clone.background": "Run in background",
|
||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||
"clone.username": "Username",
|
||||
@@ -650,6 +651,8 @@
|
||||
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
||||
"fx.myHome": "Home",
|
||||
"fx.userHomes": "Users' homes",
|
||||
"fx.users": "Users",
|
||||
"fx.toggleWorkspaces": "Show or hide {{name}}’s workspaces",
|
||||
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
|
||||
"fx.readOnly": "Read-only",
|
||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||
|
||||
@@ -77,21 +77,13 @@
|
||||
"run_done_other": "{{count}} runs finished 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": [
|
||||
"good morning! ☀️",
|
||||
"morning! coffee first? ☕",
|
||||
"rise and shine 🌤️"
|
||||
],
|
||||
"morning": ["good morning! ☀️", "morning! coffee first? ☕", "rise and shine 🌤️"],
|
||||
"afternoon": [
|
||||
"good afternoon! 🐾",
|
||||
"hey, welcome back 😺",
|
||||
"afternoon! ready when you are ✨"
|
||||
],
|
||||
"evening": [
|
||||
"good evening! 🌆",
|
||||
"evening shift? I'm in 😼",
|
||||
"hey there, night owl-to-be 🌙"
|
||||
],
|
||||
"evening": ["good evening! 🌆", "evening shift? I'm in 😼", "hey there, night owl-to-be 🌙"],
|
||||
"night": [
|
||||
"burning the midnight oil? 🌙",
|
||||
"late one, huh? I'll keep watch 🦉",
|
||||
@@ -114,11 +106,7 @@
|
||||
"help": "I'm {{name}} - I watch your sessions. Try \"what's running\", \"any errors\", \"what's waiting\" or \"status\". Anything else, I'll hand to Claude.",
|
||||
"whoami": "I'm {{name}}, your session-watching buddy 🐾 Right-click me to change my look, or click my name in the panel to rename me.",
|
||||
"hello": "hi there! 😺 {{summary}}.",
|
||||
"thanks": [
|
||||
"anytime! 💜",
|
||||
"happy to help 😸",
|
||||
"you got it 🐾"
|
||||
],
|
||||
"thanks": ["anytime! 💜", "happy to help 😸", "you got it 🐾"],
|
||||
"errors_one": "{{count}} session errored{{list}} - open the panel to jump to it.",
|
||||
"errors_other": "{{count}} sessions errored{{list}} - open the panel to jump to them.",
|
||||
"errorsNone": "no errors - all clean 🐾",
|
||||
@@ -175,41 +163,17 @@
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤"
|
||||
],
|
||||
"worried": [
|
||||
"that didn't look right 😟",
|
||||
"keeping an eye out 👀",
|
||||
"hmm, something's off 🫣"
|
||||
],
|
||||
"worried": ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
"stuck": [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"great run! 😻",
|
||||
"love a tidy finish ✨",
|
||||
"purrfect 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"hmm, let me look… 🤔",
|
||||
"sniffing around… 🐾",
|
||||
"one sec, checking 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"on the prowl 👀",
|
||||
"watching your sessions 😼",
|
||||
"eyes peeled 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"wake me if something happens 😴",
|
||||
"curled up, all calm 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"all quiet 😺",
|
||||
"ready when you are 🐾",
|
||||
"just vibing ✨"
|
||||
]
|
||||
"happy": ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
"thinking": ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
"watching": ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
"sleeping": ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
"idle": ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Chat with {{name}}",
|
||||
@@ -944,16 +908,14 @@
|
||||
"alias": "Account name",
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "More SSH keys",
|
||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
||||
"newKey": "New key",
|
||||
"keyName": "Key name",
|
||||
"keyNamePlaceholder": "e.g. aws-prod",
|
||||
"keyType": "Key type",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "More keys",
|
||||
"tabOther": "Other hosts"
|
||||
"tabOther": "Other hosts",
|
||||
"addKey": "Add another key",
|
||||
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
||||
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||
"namedAwsSub": "RSA · for one more AWS account.",
|
||||
"removeKeyNamed": "Delete key {{name}}"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -1357,7 +1319,8 @@
|
||||
"tooBig": "The picture must be at most 1 MB",
|
||||
"fromUrl": "Use a picture from a link",
|
||||
"urlPlaceholder": "https://… image link",
|
||||
"urlApply": "Use"
|
||||
"urlApply": "Use",
|
||||
"linkUnreachable": "That link did not load as an image in your browser — it may need a login (a Mattermost or other chat picture does). Download the picture and upload it instead."
|
||||
},
|
||||
"loadFailed": "Could not load your account: {{message}}",
|
||||
"memberSince": "Member since",
|
||||
|
||||
@@ -35,5 +35,11 @@
|
||||
"autoFix": "Auto fix",
|
||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||
"autoFixWorkspace": "AgentHub update",
|
||||
"autoFixError": "Could not start the Auto fix session"
|
||||
"autoFixError": "Could not start the Auto fix session",
|
||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
|
||||
"chipTitle": "Updating to {{version}}…",
|
||||
"chipHint": "The update runs in the background — click to show it",
|
||||
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||
"pluginStep.plugin_update": "Step {{index}}/{{total}}: downloading the new version",
|
||||
"pluginStep.bootstrap": "Step {{index}}/{{total}}: installing it and restarting the dashboard"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"role": "Role",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"today": "Today",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"storage": "Storage",
|
||||
@@ -132,12 +133,10 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
"description": "Claude and Codex accounts every user's new sessions run on — the least-used one is picked, and a login is written into the user's home by the server, so nobody signs in per user.",
|
||||
"description": "Accounts each tool's new sessions run on, one tab per AI tool. The least-used account is picked (or the one a user is pinned to), so nobody signs in per user.",
|
||||
"refresh": "Re-read quotas",
|
||||
"add": "Add account",
|
||||
"empty": "No accounts yet. Until you add one, each user's session needs that user's own sign-in.",
|
||||
"account": "Account",
|
||||
"tool": "Tool",
|
||||
"livePanes": "Live",
|
||||
"quota": "Quota",
|
||||
"spend": "5 hours",
|
||||
@@ -146,12 +145,6 @@
|
||||
"added": "added {{at}}",
|
||||
"expired": "expired",
|
||||
"full": "full",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Name",
|
||||
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
||||
"remove": "Remove {{name}}",
|
||||
@@ -173,7 +166,8 @@
|
||||
"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.",
|
||||
"ENOUSER": "That user no longer exists."
|
||||
"ENOUSER": "That user no longer exists.",
|
||||
"ENOGROUP": "That group no longer exists."
|
||||
},
|
||||
"source": "From",
|
||||
"sources": {
|
||||
@@ -185,7 +179,8 @@
|
||||
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable.",
|
||||
"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.",
|
||||
"cursor": "A Cursor API key from the Cursor dashboard (Integrations → API keys)."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Saved in Settings → AI tools → Profiles. The one this server's own CLI is logged into can be moved too: the server keeps running on it.",
|
||||
@@ -197,12 +192,14 @@
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key or auth.json"
|
||||
"codex": "API key or auth.json",
|
||||
"cursor": "Cursor API key"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
||||
"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.",
|
||||
"cursor": "Create one in the Cursor dashboard under Integrations → API keys."
|
||||
},
|
||||
"refreshFailed": "refresh failed",
|
||||
"pinned": "Used by: {{users}}",
|
||||
@@ -218,7 +215,70 @@
|
||||
"noUsers": "No users yet.",
|
||||
"onAccount": "now on {{account}}",
|
||||
"editDone": "{{name}} saved",
|
||||
"profilesLink": "Profiles in Settings"
|
||||
"profilesLink": "Profiles in Settings",
|
||||
"addFor": "Add {{tool}} account",
|
||||
"type": "Type",
|
||||
"about": {
|
||||
"claude": "New Claude Code sessions run on these accounts. A setup-token goes in as an environment variable; a real login is written into each user's home by the server and refreshed by it.",
|
||||
"codex": "New Codex sessions (not on an AI Proxy) run on these accounts. An OpenAI API key goes in as an environment variable; a ChatGPT login is written into each user's own Codex home.",
|
||||
"cursor": "New Cursor CLI sessions run on these Cursor API keys, given to the session as CURSOR_API_KEY — it outranks the user's own `cursor-agent login` for that session only. Cursor reports no usage or quota here, so the account with the fewest live sessions is picked."
|
||||
},
|
||||
"notInstalled": "{{tool}} is not installed on this server yet.",
|
||||
"emptyFor": "No {{tool}} accounts yet. Until you add one, each user's {{tool}} session needs that user's own sign-in.",
|
||||
"kindShort": {
|
||||
"token": "setup-token",
|
||||
"login": "Login",
|
||||
"apikey": "API key",
|
||||
"chatgpt": "ChatGPT login"
|
||||
},
|
||||
"omp": {
|
||||
"badgeOn": "shared",
|
||||
"about": "Oh My Pi has no account pool: it shares this server's own omp login instead. Sign the host account in with `omp` (then /login), turn sharing on, and every user's omp session runs on it through omp's own gateway — with a key that works only while its pane runs.",
|
||||
"off": "Sharing is off: each user's omp session uses that user's own login.",
|
||||
"unavailable": "Oh My Pi has no account pool. Its login can be shared only on a server where every user has their own Linux account (server mode with per-user accounts); here each user's omp session uses that user's own login."
|
||||
},
|
||||
"none": {
|
||||
"title": "{{tool}} has no account pool",
|
||||
"reason": {
|
||||
"antigravity": "Antigravity signs in with a Google account and keeps that login in the system keyring; it takes no key AgentHub could hand a session. Each user signs in to agy themselves.",
|
||||
"default": "AgentHub cannot hand {{tool}} a shared login, so each user signs in to it themselves."
|
||||
}
|
||||
},
|
||||
"liveCount_one": "{{count}} live",
|
||||
"liveCount_other": "{{count}} live",
|
||||
"savedProfiles": {
|
||||
"title": "Saved profiles you can share",
|
||||
"empty": "No saved profiles for this tool. Save one in Settings → AI tools → Profiles, then share it here.",
|
||||
"activeHere": "in use on this server",
|
||||
"share": "Share to pool",
|
||||
"shareNamed": "Share {{name}} to the pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
"description": "Give each team its own accounts: a group's members run only on the group's accounts. Users in no group share the accounts no group lists.",
|
||||
"new": "New group",
|
||||
"empty": "No groups yet — every user shares every account.",
|
||||
"members_one": "{{count}} member",
|
||||
"members_other": "{{count}} members",
|
||||
"edit": "Edit group {{name}}",
|
||||
"delete": "Delete group {{name}}",
|
||||
"deleteHint": "Its members go back to the shared pool. No account is removed.",
|
||||
"noAccounts": "none — members sign in themselves",
|
||||
"saved": "Group {{name}} saved",
|
||||
"deleted": "Group {{name}} deleted",
|
||||
"hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.",
|
||||
"name": "Group name",
|
||||
"memberList": "Members",
|
||||
"inGroup": "now in {{group}}",
|
||||
"accountList": "Accounts",
|
||||
"noneInPool": "No accounts of this tool in the pool.",
|
||||
"shared": "Shared pool"
|
||||
},
|
||||
"window": {
|
||||
"5h": "5 hours",
|
||||
"wk": "Week"
|
||||
},
|
||||
"resetsIn": "resets in {{time}} · {{at}}"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
|
||||
@@ -183,5 +183,19 @@
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}"
|
||||
"detail.added": "Thêm lúc {{date}}",
|
||||
"tab.unowned": "Vô chủ",
|
||||
"tab.suggested": "Có phải của bạn?",
|
||||
"auto": "Tự động",
|
||||
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
|
||||
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
|
||||
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
|
||||
"unowned.mine": "Của tôi",
|
||||
"unowned.takeIt": "Nhận về tôi",
|
||||
"unowned.notMine": "Không phải của tôi",
|
||||
"unowned.assign": "Gán cho…",
|
||||
"unowned.claimed": "Đã nhận về thư viện của bạn",
|
||||
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
|
||||
"unowned.assigned": "Đã gán chủ",
|
||||
"unowned.empty": "Không có mục nào chưa có chủ."
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||
"clone.nameLabel": "Tên thư mục",
|
||||
"clone.cloning": "Đang clone…",
|
||||
"clone.background": "Chạy nền",
|
||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||
"clone.username": "Username",
|
||||
@@ -650,6 +651,8 @@
|
||||
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
|
||||
"fx.myHome": "Thư mục home",
|
||||
"fx.userHomes": "Home của các user",
|
||||
"fx.users": "Người dùng",
|
||||
"fx.toggleWorkspaces": "Hiện/ẩn workspace của {{name}}",
|
||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
|
||||
"fx.readOnly": "Chỉ đọc",
|
||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||
|
||||
@@ -77,26 +77,10 @@
|
||||
"run_done_other": "{{count}} lượt chạy vừa xong 🐾"
|
||||
},
|
||||
"greet": {
|
||||
"morning": [
|
||||
"chào buổi sáng! ☀️",
|
||||
"sáng rồi! cà phê chưa? ☕",
|
||||
"ngày mới tốt lành nha 🌤️"
|
||||
],
|
||||
"afternoon": [
|
||||
"chào buổi chiều! 🐾",
|
||||
"bạn quay lại rồi 😺",
|
||||
"chiều nay làm gì đây? ✨"
|
||||
],
|
||||
"evening": [
|
||||
"chào buổi tối! 🌆",
|
||||
"ca tối hả? có mình đây 😼",
|
||||
"tối rồi, cố lên nhé 🌙"
|
||||
],
|
||||
"night": [
|
||||
"khuya rồi mà vẫn làm à? 🌙",
|
||||
"để mình canh giúp cho 🦉",
|
||||
"nhớ nghỉ ngơi nha 💤"
|
||||
],
|
||||
"morning": ["chào buổi sáng! ☀️", "sáng rồi! cà phê chưa? ☕", "ngày mới tốt lành nha 🌤️"],
|
||||
"afternoon": ["chào buổi chiều! 🐾", "bạn quay lại rồi 😺", "chiều nay làm gì đây? ✨"],
|
||||
"evening": ["chào buổi tối! 🌆", "ca tối hả? có mình đây 😼", "tối rồi, cố lên nhé 🌙"],
|
||||
"night": ["khuya rồi mà vẫn làm à? 🌙", "để mình canh giúp cho 🦉", "nhớ nghỉ ngơi nha 💤"],
|
||||
"summary_one": "{{count}} phiên đang chạy",
|
||||
"summary_other": "{{count}} phiên đang chạy",
|
||||
"summaryWaiting": "{{count}} phiên chờ bạn",
|
||||
@@ -114,11 +98,7 @@
|
||||
"help": "Mình là {{name}} - mình canh các phiên của bạn. Thử hỏi \"đang chạy gì\", \"có lỗi không\", \"cái gì đang chờ\" hoặc \"tình hình\". Câu khác mình sẽ chuyển cho Claude.",
|
||||
"whoami": "Mình là {{name}}, bạn đồng hành canh phiên của bạn 🐾 Chuột phải vào mình để đổi ngoại hình, hoặc bấm vào tên trong bảng để đặt tên mới.",
|
||||
"hello": "chào bạn! 😺 {{summary}}.",
|
||||
"thanks": [
|
||||
"có gì đâu! 💜",
|
||||
"rất vui được giúp 😸",
|
||||
"chuyện nhỏ 🐾"
|
||||
],
|
||||
"thanks": ["có gì đâu! 💜", "rất vui được giúp 😸", "chuyện nhỏ 🐾"],
|
||||
"errors_one": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errors_other": "{{count}} phiên bị lỗi{{list}} - mở bảng để xem nhé.",
|
||||
"errorsNone": "không có lỗi nào - sạch sẽ 🐾",
|
||||
@@ -175,41 +155,13 @@
|
||||
"không gọi được máy chủ 📡",
|
||||
"mất sóng - đi ngủ chút 💤"
|
||||
],
|
||||
"worried": [
|
||||
"có vẻ không ổn 😟",
|
||||
"mình đang để mắt đây 👀",
|
||||
"hừm, có gì đó lạ lạ 🫣"
|
||||
],
|
||||
"stuck": [
|
||||
"một phiên im lặng lâu rồi… 🤔",
|
||||
"có gì bị kẹt không? ⏳",
|
||||
"vẫn đang nhai… 😾"
|
||||
],
|
||||
"happy": [
|
||||
"tuyệt vời! 😻",
|
||||
"kết thúc gọn ghê ✨",
|
||||
"hoàn hảo 💜"
|
||||
],
|
||||
"thinking": [
|
||||
"để mình xem… 🤔",
|
||||
"đang đánh hơi… 🐾",
|
||||
"chờ chút, đang kiểm tra 🔍"
|
||||
],
|
||||
"watching": [
|
||||
"đang tuần tra 👀",
|
||||
"đang canh các phiên của bạn 😼",
|
||||
"mắt mở to 🐾"
|
||||
],
|
||||
"sleeping": [
|
||||
"zzz… 💤",
|
||||
"có gì thì gọi mình dậy nhé 😴",
|
||||
"cuộn tròn, mọi thứ yên ổn 🐈"
|
||||
],
|
||||
"idle": [
|
||||
"yên ắng quá 😺",
|
||||
"sẵn sàng khi bạn cần 🐾",
|
||||
"chill thôi ✨"
|
||||
]
|
||||
"worried": ["có vẻ không ổn 😟", "mình đang để mắt đây 👀", "hừm, có gì đó lạ lạ 🫣"],
|
||||
"stuck": ["một phiên im lặng lâu rồi… 🤔", "có gì bị kẹt không? ⏳", "vẫn đang nhai… 😾"],
|
||||
"happy": ["tuyệt vời! 😻", "kết thúc gọn ghê ✨", "hoàn hảo 💜"],
|
||||
"thinking": ["để mình xem… 🤔", "đang đánh hơi… 🐾", "chờ chút, đang kiểm tra 🔍"],
|
||||
"watching": ["đang tuần tra 👀", "đang canh các phiên của bạn 😼", "mắt mở to 🐾"],
|
||||
"sleeping": ["zzz… 💤", "có gì thì gọi mình dậy nhé 😴", "cuộn tròn, mọi thứ yên ổn 🐈"],
|
||||
"idle": ["yên ắng quá 😺", "sẵn sàng khi bạn cần 🐾", "chill thôi ✨"]
|
||||
},
|
||||
"chat": {
|
||||
"label": "Trò chuyện với {{name}}",
|
||||
@@ -944,16 +896,14 @@
|
||||
"alias": "Tên tài khoản",
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "Thêm SSH key",
|
||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
||||
"newKey": "Key mới",
|
||||
"keyName": "Tên key",
|
||||
"keyNamePlaceholder": "vd aws-prod",
|
||||
"keyType": "Loại key",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "Key khác",
|
||||
"tabOther": "Host khác"
|
||||
"tabOther": "Host khác",
|
||||
"addKey": "Thêm key khác",
|
||||
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
||||
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||
"removeKeyNamed": "Xoá key {{name}}"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -1357,7 +1307,8 @@
|
||||
"tooBig": "Ảnh tối đa 1 MB",
|
||||
"fromUrl": "Dùng ảnh từ đường dẫn",
|
||||
"urlPlaceholder": "https://… đường dẫn ảnh",
|
||||
"urlApply": "Dùng"
|
||||
"urlApply": "Dùng",
|
||||
"linkUnreachable": "Trình duyệt không tải được ảnh từ link này — có thể link cần đăng nhập (ảnh Mattermost hay app chat khác là vậy). Hãy tải ảnh về rồi upload lên."
|
||||
},
|
||||
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||
"memberSince": "Tham gia từ",
|
||||
|
||||
@@ -35,5 +35,11 @@
|
||||
"autoFix": "Tự sửa",
|
||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||
"autoFixError": "Không mở được phiên Tự sửa"
|
||||
"autoFixError": "Không mở được phiên Tự sửa",
|
||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
|
||||
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
||||
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||
"pluginStep.plugin_update": "Bước {{index}}/{{total}}: tải phiên bản mới",
|
||||
"pluginStep.bootstrap": "Bước {{index}}/{{total}}: cài đặt và khởi động lại bảng điều khiển"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"role": "Vai trò",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Phiên",
|
||||
"today": "Hôm nay",
|
||||
"last7d": "7 ngày",
|
||||
"last30d": "30 ngày",
|
||||
"storage": "Dung lượng",
|
||||
@@ -132,12 +133,10 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
"description": "Các tài khoản Claude và Codex mà phiên mới của mọi user chạy trên đó — tự chọn tài khoản ít tải nhất, server ghi login vào home của user, không ai phải đăng nhập riêng.",
|
||||
"description": "Các tài khoản mà phiên mới của từng tool chạy trên đó, mỗi AI tool một tab. Tự chọn tài khoản ít tải nhất (hoặc tài khoản user được gán), không ai phải đăng nhập riêng.",
|
||||
"refresh": "Đọc lại quota",
|
||||
"add": "Thêm tài khoản",
|
||||
"empty": "Chưa có tài khoản nào. Khi chưa thêm, phiên của mỗi người dùng cần chính người đó đăng nhập.",
|
||||
"account": "Tài khoản",
|
||||
"tool": "Công cụ",
|
||||
"livePanes": "Đang chạy",
|
||||
"quota": "Quota",
|
||||
"spend": "5 giờ",
|
||||
@@ -146,12 +145,6 @@
|
||||
"added": "thêm lúc {{at}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "đầy",
|
||||
"kind": {
|
||||
"token": "Claude · setup-token",
|
||||
"login": "Claude · login",
|
||||
"apikey": "Codex · API key",
|
||||
"chatgpt": "Codex · ChatGPT"
|
||||
},
|
||||
"name": "Tên",
|
||||
"nameHint": "Nhãn tuỳ ý để bạn nhận ra: chữ, số, '.', '_' hoặc '-'",
|
||||
"remove": "Xoá {{name}}",
|
||||
@@ -173,7 +166,8 @@
|
||||
"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.",
|
||||
"ENOUSER": "User đó không còn tồn tại."
|
||||
"ENOUSER": "User đó không còn tồn tại.",
|
||||
"ENOGROUP": "Nhóm đó không còn tồn tại."
|
||||
},
|
||||
"source": "Nguồn",
|
||||
"sources": {
|
||||
@@ -185,7 +179,8 @@
|
||||
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
||||
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
||||
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường.",
|
||||
"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.",
|
||||
"cursor": "Cursor API key lấy từ Cursor dashboard (Integrations → API keys)."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập. Hồ sơ mà CLI của server đang dùng cũng chuyển được: server vẫn chạy trên nó.",
|
||||
@@ -197,12 +192,14 @@
|
||||
"secret": {
|
||||
"credentials": "Credentials JSON",
|
||||
"token": "Token",
|
||||
"codex": "API key hoặc auth.json"
|
||||
"codex": "API key hoặc auth.json",
|
||||
"cursor": "Cursor API key"
|
||||
},
|
||||
"secretHint": {
|
||||
"credentials": "Toàn bộ file: {\"claudeAiOauth\": {…}}",
|
||||
"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.",
|
||||
"cursor": "Tạo trong Cursor dashboard tại Integrations → API keys."
|
||||
},
|
||||
"refreshFailed": "làm mới lỗi",
|
||||
"pinned": "Đang dùng: {{users}}",
|
||||
@@ -218,7 +215,70 @@
|
||||
"noUsers": "Chưa có user nào.",
|
||||
"onAccount": "đang ở {{account}}",
|
||||
"editDone": "Đã lưu {{name}}",
|
||||
"profilesLink": "Hồ sơ trong Cài đặt"
|
||||
"profilesLink": "Hồ sơ trong Cài đặt",
|
||||
"addFor": "Thêm tài khoản {{tool}}",
|
||||
"type": "Loại",
|
||||
"about": {
|
||||
"claude": "Phiên Claude Code mới chạy trên các tài khoản này. Setup-token được đưa vào dưới dạng biến môi trường; login thật được server ghi vào home của từng user và tự làm mới.",
|
||||
"codex": "Phiên Codex mới (không qua AI Proxy) chạy trên các tài khoản này. OpenAI API key được đưa vào dưới dạng biến môi trường; login ChatGPT được ghi vào Codex home riêng của từng user.",
|
||||
"cursor": "Phiên Cursor CLI mới chạy trên các Cursor API key này, đưa vào phiên qua CURSOR_API_KEY — chỉ trong phiên đó nó được ưu tiên hơn `cursor-agent login` của chính user. Cursor không báo mức dùng hay quota ở đây, nên tài khoản có ít phiên đang chạy nhất sẽ được chọn."
|
||||
},
|
||||
"notInstalled": "{{tool}} chưa được cài trên server này.",
|
||||
"emptyFor": "Chưa có tài khoản {{tool}} nào. Khi chưa thêm, phiên {{tool}} của mỗi user cần chính user đó đăng nhập.",
|
||||
"kindShort": {
|
||||
"token": "setup-token",
|
||||
"login": "Login",
|
||||
"apikey": "API key",
|
||||
"chatgpt": "Login ChatGPT"
|
||||
},
|
||||
"omp": {
|
||||
"badgeOn": "chia sẻ",
|
||||
"about": "Oh My Pi không có account pool: thay vào đó nó chia sẻ login omp của chính server. Đăng nhập tài khoản host bằng `omp` (rồi /login), bật chia sẻ, và phiên omp của mọi user chạy trên login đó qua gateway của omp — với một key chỉ dùng được khi pane còn chạy.",
|
||||
"off": "Chia sẻ đang tắt: phiên omp của mỗi user dùng login của chính user đó.",
|
||||
"unavailable": "Oh My Pi không có account pool. Login của nó chỉ chia sẻ được trên server mà mỗi user có tài khoản Linux riêng (server mode với tài khoản theo user); ở đây phiên omp của mỗi user dùng login của chính user đó."
|
||||
},
|
||||
"none": {
|
||||
"title": "{{tool}} không có account pool",
|
||||
"reason": {
|
||||
"antigravity": "Antigravity đăng nhập bằng tài khoản Google và giữ login trong keyring của hệ thống; nó không nhận key nào để AgentHub đưa cho phiên. Mỗi user tự đăng nhập agy.",
|
||||
"default": "AgentHub không thể đưa cho {{tool}} một login dùng chung, nên mỗi user tự đăng nhập."
|
||||
}
|
||||
},
|
||||
"liveCount_one": "{{count}} đang chạy",
|
||||
"liveCount_other": "{{count}} đang chạy",
|
||||
"savedProfiles": {
|
||||
"title": "Profile đã lưu có thể chia sẻ",
|
||||
"empty": "Chưa có profile nào của tool này. Lưu một profile trong Settings → AI tools → Profiles rồi chia sẻ ở đây.",
|
||||
"activeHere": "đang dùng trên server này",
|
||||
"share": "Chia sẻ vào pool",
|
||||
"shareNamed": "Chia sẻ {{name}} vào pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Nhóm",
|
||||
"description": "Mỗi nhóm có tài khoản riêng: thành viên của nhóm chỉ chạy trên tài khoản của nhóm. User không thuộc nhóm nào dùng chung các tài khoản không nằm trong nhóm nào.",
|
||||
"new": "Tạo nhóm",
|
||||
"empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.",
|
||||
"members_one": "{{count}} thành viên",
|
||||
"members_other": "{{count}} thành viên",
|
||||
"edit": "Sửa nhóm {{name}}",
|
||||
"delete": "Xoá nhóm {{name}}",
|
||||
"deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.",
|
||||
"noAccounts": "chưa có — thành viên tự đăng nhập",
|
||||
"saved": "Đã lưu nhóm {{name}}",
|
||||
"deleted": "Đã xoá nhóm {{name}}",
|
||||
"hint": "Tick các thành viên và tài khoản mà họ dùng. Mỗi user thuộc một nhóm, tick một user sẽ chuyển họ sang đây. Một tài khoản có thể dùng cho nhiều nhóm.",
|
||||
"name": "Tên nhóm",
|
||||
"memberList": "Thành viên",
|
||||
"inGroup": "đang ở {{group}}",
|
||||
"accountList": "Tài khoản",
|
||||
"noneInPool": "Pool chưa có tài khoản nào của tool này.",
|
||||
"shared": "Pool chung"
|
||||
},
|
||||
"window": {
|
||||
"5h": "5 giờ",
|
||||
"wk": "Tuần"
|
||||
},
|
||||
"resetsIn": "reset sau {{time}} · {{at}}"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudePastes } from "../promptAttach";
|
||||
import { claudePastes, copiedFilePaths, localCandidates } from "../promptAttach";
|
||||
|
||||
describe("claudePastes", () => {
|
||||
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
|
||||
@@ -18,3 +18,46 @@ describe("claudePastes", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A clipboard as the browser hands it to a paste handler. */
|
||||
const clip = (data: Record<string, string>) =>
|
||||
({ getData: (t: string) => data[t] ?? "" }) as unknown as DataTransfer;
|
||||
const file = (name: string) => new File(["x"], name);
|
||||
|
||||
describe("localCandidates (a file manager's copy, pasted)", () => {
|
||||
it("takes the plain-text paths when their names match the pasted files", () => {
|
||||
expect(window.location.hostname).toBe("localhost");
|
||||
const dt = clip({ "text/plain": "/home/u/Videos/big.mp4\n/home/u/b c.pdf\n" });
|
||||
expect(localCandidates(dt, [file("b c.pdf"), file("big.mp4")])).toEqual([
|
||||
"/home/u/Videos/big.mp4",
|
||||
"/home/u/b c.pdf",
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes file:// lines too, decoded", () => {
|
||||
const dt = clip({ "text/plain": "file:///home/u/a%20b.zip" });
|
||||
expect(localCandidates(dt, [file("a b.zip")])).toEqual(["/home/u/a b.zip"]);
|
||||
});
|
||||
|
||||
it("refuses text that does not name exactly the pasted files", () => {
|
||||
expect(localCandidates(clip({ "text/plain": "/etc/passwd" }), [file("big.mp4")])).toBeNull();
|
||||
expect(localCandidates(clip({ "text/plain": "hello" }), [file("hello")])).toBeNull();
|
||||
expect(
|
||||
localCandidates(clip({ "text/plain": "/a/x.txt" }), [file("x.txt"), file("y.txt")])
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers a uri-list (a drag) as before", () => {
|
||||
const dt = clip({ "text/uri-list": "file:///tmp/z.bin", "text/plain": "/other/z.bin" });
|
||||
expect(localCandidates(dt, [file("z.bin")])).toEqual(["/tmp/z.bin"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copiedFilePaths (no file beside the text)", () => {
|
||||
it("takes file:// text, never bare absolute paths, which stay text to paste", () => {
|
||||
expect(copiedFilePaths(clip({ "text/plain": "file:///home/u/a.txt" }))).toEqual([
|
||||
"/home/u/a.txt",
|
||||
]);
|
||||
expect(copiedFilePaths(clip({ "text/plain": "/home/u/a.txt" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+81
-5
@@ -538,6 +538,8 @@ export type ServerUserUsage = AuthUser & {
|
||||
disabled: boolean;
|
||||
workspaces: number;
|
||||
sessions: number;
|
||||
/** Since the viewer's local midnight (absent from an older server). */
|
||||
today?: { cost: number; tokens: number };
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** Active login sessions (cookies not expired). */
|
||||
@@ -660,6 +662,13 @@ export type PoolAccount = {
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
/** The last usage reading of the account's own token, or null before one. */
|
||||
quota?: {
|
||||
fiveHour: CcUsageWindow | null;
|
||||
sevenDay: CcUsageWindow | null;
|
||||
checkedAt: string;
|
||||
error: string | null;
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -673,6 +682,11 @@ export type CodexPoolAccount = {
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
/** The newest `rate_limits` its sessions' rollouts carry (`5h`, `wk`, …), or null. */
|
||||
quota?: {
|
||||
windows: { label: string; usedPercent: number; resetsAt: string | null }[];
|
||||
asOf: string;
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -683,14 +697,37 @@ export type AccountPin = {
|
||||
account: string;
|
||||
pinnedAt?: string;
|
||||
};
|
||||
/** One Cursor pool account — a Cursor API key, never the key itself; no quota. */
|
||||
export type CursorPoolAccount = Omit<CodexPoolAccount, "kind"> & { kind: "apikey" };
|
||||
export type AccountPool = {
|
||||
accounts: PoolAccount[];
|
||||
codex: CodexPoolAccount[];
|
||||
/** Absent from a server older than the Cursor pool. */
|
||||
cursor?: CursorPoolAccount[];
|
||||
/** Per tool; absent from an older server, empty in local mode. */
|
||||
pins?: { claude: AccountPin[]; codex: AccountPin[] };
|
||||
pins?: Partial<Record<PoolTool, AccountPin[]>>;
|
||||
/** Server mode's pool groups; absent from an older server, empty in local mode. */
|
||||
groups?: PoolGroup[];
|
||||
blockPercent: number;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex";
|
||||
/**
|
||||
* A pool group: its members (one group per user) run only on its accounts;
|
||||
* a user in no group runs on the accounts no group lists (the shared pool).
|
||||
*/
|
||||
export type PoolGroup = {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt?: string;
|
||||
users: { userId: number; username: string }[];
|
||||
accounts: Partial<Record<PoolTool, string[]>>;
|
||||
};
|
||||
/** What `PATCH /api/accounts/groups/:id` takes — every part optional. */
|
||||
export type PoolGroupPatch = {
|
||||
name?: string;
|
||||
userIds?: number[];
|
||||
accounts?: Partial<Record<PoolTool, string[]>>;
|
||||
};
|
||||
export type PoolTool = "claude" | "codex" | "cursor";
|
||||
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||
export type PoolProfile = {
|
||||
name: string;
|
||||
@@ -1048,7 +1085,8 @@ export const api = {
|
||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
||||
list: () =>
|
||||
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||
* account changes, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
@@ -1182,6 +1220,24 @@ export const api = {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ tool, currentPassword }),
|
||||
}),
|
||||
/** POST /api/accounts/groups — admin: a new, empty pool group. */
|
||||
createGroup: (name: string, currentPassword: string) =>
|
||||
request<{ group: { id: number; name: string } }>("/accounts/groups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, currentPassword }),
|
||||
}),
|
||||
/** PATCH /api/accounts/groups/:id — admin: each part given is set exactly. */
|
||||
updateGroup: (id: number, body: PoolGroupPatch, currentPassword: string) =>
|
||||
request<{ group: PoolGroup }>(`/accounts/groups/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** DELETE /api/accounts/groups/:id — its members go back to the shared pool. */
|
||||
deleteGroup: (id: number, currentPassword: string) =>
|
||||
request<{ ok: true }>(`/accounts/groups/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
}),
|
||||
},
|
||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||
/** Self-update status: whether this install is a git clone and, if so,
|
||||
@@ -2963,7 +3019,9 @@ export const api = {
|
||||
},
|
||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||
artifacts: {
|
||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
||||
list: (
|
||||
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
if (params.kind) qs.set("kind", params.kind);
|
||||
@@ -3039,6 +3097,18 @@ export const api = {
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||
},
|
||||
/** An unowned row: its suggested user (or an admin) takes it. */
|
||||
claim: (id: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
|
||||
/** An unowned row offered to the caller is not theirs. */
|
||||
decline: (id: number) =>
|
||||
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
|
||||
/** Admin: give an unowned row to a user. */
|
||||
assign: (id: number, userId: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId }),
|
||||
}),
|
||||
update: (
|
||||
id: number,
|
||||
body: {
|
||||
@@ -4329,7 +4399,7 @@ export interface Artifact {
|
||||
market: string | null;
|
||||
/** page: the claude.ai link. */
|
||||
url: string | null;
|
||||
files: { path: string; size: number }[];
|
||||
files: { path: string; size: number; mtime?: number }[];
|
||||
size: number | null;
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
@@ -4338,6 +4408,12 @@ export interface Artifact {
|
||||
inLibrary: boolean;
|
||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||
installed?: boolean;
|
||||
/** file: captured when its session stopped, not pinned by hand. */
|
||||
auto?: boolean;
|
||||
/** Server mode: nobody owns it yet. */
|
||||
unowned?: boolean;
|
||||
/** Unowned and offered to the caller to claim or decline. */
|
||||
suggestedForMe?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
|
||||
* `@` form a file is handed to `claude` in, and what a browser on the
|
||||
* dashboard's own machine attaches by path instead of uploading
|
||||
* (`localCandidates`, checked by the server in `sameFilesHere` —
|
||||
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
|
||||
* file-manager copy carries —, checked by the server in `sameFilesHere` —
|
||||
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
|
||||
* sent with the prompt; that check is the one call here to the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -85,7 +86,47 @@ export function localCandidates(
|
||||
): string[] | null {
|
||||
if (!isLocalBrowser()) return null;
|
||||
const paths = droppedFilePaths(dt);
|
||||
return paths && paths.length === files.length ? paths : null;
|
||||
if (paths) return paths.length === files.length ? paths : null;
|
||||
// A PASTE of a file manager's copy has no uri-list: Chrome hands over the
|
||||
// files and the paths as plain text only. Kept only when the names match
|
||||
// the files one for one — `sameFilesHere` then checks the sizes.
|
||||
const copied = textFilePaths(dt);
|
||||
if (!copied || copied.length !== files.length) return null;
|
||||
const base = (p: string) => p.split(/[\\/]/).pop() ?? "";
|
||||
const want = files.map((f) => f.name).sort();
|
||||
const have = copied.map(base).sort();
|
||||
return have.every((n, i) => n === want[i]) ? copied : null;
|
||||
}
|
||||
|
||||
/** A paste's plain text as absolute paths — every non-empty line a `file://`
|
||||
* URI or an absolute path (a file manager's copy) — else null. */
|
||||
function textFilePaths(dt: DataTransfer | null | undefined): string[] | null {
|
||||
let lines: string[];
|
||||
try {
|
||||
lines = (dt?.getData("text/plain") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
if (lines.every((l) => l.startsWith("file://"))) {
|
||||
return droppedFilePaths({ getData: () => lines.join("\n") } as unknown as DataTransfer);
|
||||
}
|
||||
return lines.every((l) => /^(\/|[A-Za-z]:[\\/])/.test(l)) ? lines : null;
|
||||
}
|
||||
|
||||
/** The `file://` paths a paste carries as text with no file beside it — a
|
||||
* file manager's copy, as some browsers paste it — or null: its uri-list,
|
||||
* else its plain text when every line is one. Plain absolute paths are NOT
|
||||
* taken here: with no file to compare against, they are text to paste. */
|
||||
export function copiedFilePaths(dt: DataTransfer | null | undefined): string[] | null {
|
||||
const uris = droppedFilePaths(dt);
|
||||
if (uris) return uris;
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
if (!/^file:\/\//.test(text.trim())) return null;
|
||||
return textFilePaths(dt);
|
||||
}
|
||||
|
||||
/** Whether `paths` are the dropped `files` as the dashboard's machine sees
|
||||
|
||||
@@ -1458,6 +1458,14 @@ export interface UpdateStatusPayload {
|
||||
plugin_root?: string;
|
||||
finished_at?: string;
|
||||
} | null;
|
||||
/** The step a running plugin-update chain is on (null between runs or on
|
||||
* a server that predates it). Polled while an update runs. */
|
||||
plugin_update_progress?: {
|
||||
step: string;
|
||||
index: number;
|
||||
total: number;
|
||||
started_at: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ───── Terminal run status ─────
|
||||
|
||||
@@ -243,14 +243,19 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Switching tabs quickly must not show the previous tool's numbers.
|
||||
let cancelled = false;
|
||||
api.aiTools
|
||||
.usage(toolId)
|
||||
.then(setUsage)
|
||||
.catch(() => setUsage(null));
|
||||
.then((u) => !cancelled && setUsage(u))
|
||||
.catch(() => !cancelled && setUsage(null));
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes.filter((l) => l.tool === toolId)))
|
||||
.catch(() => setLanes([]));
|
||||
.then((r) => !cancelled && setLanes(r.lanes.filter((l) => l.tool === toolId)))
|
||||
.catch(() => !cancelled && setLanes([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [toolId]);
|
||||
|
||||
if (!tool) return null;
|
||||
|
||||
+120
-13
@@ -49,7 +49,9 @@ import {
|
||||
type ArtifactInstalls,
|
||||
type ArtifactKind,
|
||||
type ArtifactSettings,
|
||||
type ServerUserUsage,
|
||||
} from "../lib/api";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
@@ -114,7 +116,11 @@ const footAction =
|
||||
export function Artifacts() {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||
const [tab, setTab] = useState<"all" | "mine" | "hub">("all");
|
||||
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
|
||||
const { isAdmin } = usePermissions();
|
||||
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
|
||||
const [unownedCount, setUnownedCount] = useState(0);
|
||||
const [users, setUsers] = useState<ServerUserUsage[]>([]);
|
||||
const [kind, setKind] = useState<ArtifactKind | "">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||
@@ -147,14 +153,45 @@ export function Artifacts() {
|
||||
return () => window.clearTimeout(id);
|
||||
}, [load, q]);
|
||||
|
||||
const serverMode = !!settings?.serverMode;
|
||||
const countUnowned = useCallback(() => {
|
||||
if (!serverMode) return;
|
||||
api.artifacts
|
||||
.list({ scope: "unowned" })
|
||||
.then((r) => setUnownedCount(r.items.length))
|
||||
.catch(() => setUnownedCount(0));
|
||||
}, [serverMode]);
|
||||
useEffect(countUnowned, [countUnowned]);
|
||||
useEffect(() => {
|
||||
if (tab !== "unowned" || !isAdmin || users.length) return;
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users.filter((u) => !u.disabled)))
|
||||
.catch(() => setUsers([]));
|
||||
}, [tab, isAdmin, users.length]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg) => {
|
||||
if (msg.type === "artifact_changed") load();
|
||||
if (msg.type === "artifact_changed") {
|
||||
load();
|
||||
countUnowned();
|
||||
}
|
||||
}),
|
||||
[load]
|
||||
[load, countUnowned]
|
||||
);
|
||||
|
||||
const settle = async (run: () => Promise<unknown>, done: string) => {
|
||||
try {
|
||||
await run();
|
||||
toast.success(t(done));
|
||||
load();
|
||||
countUnowned();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// The Add menu closes on Escape or a click anywhere outside it.
|
||||
useEffect(() => {
|
||||
if (!addMenu) return;
|
||||
@@ -297,15 +334,63 @@ export function Artifacts() {
|
||||
},
|
||||
});
|
||||
|
||||
const card = (a: Artifact) => (
|
||||
<ArtifactCard
|
||||
key={a.id}
|
||||
item={a}
|
||||
server={server}
|
||||
canInstall={!!settings?.installsEnabled}
|
||||
{...actionsFor(a)}
|
||||
/>
|
||||
);
|
||||
const card = (a: Artifact) =>
|
||||
tab === "unowned" ? (
|
||||
<div key={a.id} className="flex flex-col gap-2" data-testid={`unowned-${a.id}`}>
|
||||
<ArtifactCard
|
||||
item={a}
|
||||
server={server}
|
||||
canInstall={!!settings?.installsEnabled}
|
||||
{...actionsFor(a)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{(a.suggestedForMe || isAdmin) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary px-2 py-1 text-xs"
|
||||
onClick={() => settle(() => api.artifacts.claim(a.id), "unowned.claimed")}
|
||||
>
|
||||
{t(a.suggestedForMe ? "unowned.mine" : "unowned.takeIt")}
|
||||
</button>
|
||||
)}
|
||||
{a.suggestedForMe && (
|
||||
<button
|
||||
type="button"
|
||||
className={footAction}
|
||||
onClick={() => settle(() => api.artifacts.decline(a.id), "unowned.declined")}
|
||||
>
|
||||
{t("unowned.notMine")}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && users.length > 0 && (
|
||||
<select
|
||||
className="input h-7 w-auto py-0 text-xs"
|
||||
aria-label={t("unowned.assign")}
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const userId = Number(e.target.value);
|
||||
if (userId) settle(() => api.artifacts.assign(a.id, userId), "unowned.assigned");
|
||||
}}
|
||||
>
|
||||
<option value="">{t("unowned.assign")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.displayName || u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ArtifactCard
|
||||
key={a.id}
|
||||
item={a}
|
||||
server={server}
|
||||
canInstall={!!settings?.installsEnabled}
|
||||
{...actionsFor(a)}
|
||||
/>
|
||||
);
|
||||
|
||||
// Local mode (everything is yours): the unfiltered page is shelved by
|
||||
// kind; a category or a search is one flat grid of results.
|
||||
@@ -320,7 +405,7 @@ export function Artifacts() {
|
||||
// what you shared, My library into what you installed from others (the
|
||||
// server adds those to `mine`) and your own.
|
||||
const halves =
|
||||
server && items
|
||||
server && items && tab !== "unowned"
|
||||
? (["others", "own"] as const).map((who) => ({
|
||||
key: `${tab}.${who}`,
|
||||
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
|
||||
@@ -424,12 +509,29 @@ export function Artifacts() {
|
||||
{t(`tab.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
{(isAdmin || unownedCount > 0 || tab === "unowned") && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={tab === "unowned"}
|
||||
onClick={() => setTab("unowned")}
|
||||
className={segment(tab === "unowned")}
|
||||
data-testid="artifacts-tab-unowned"
|
||||
title={t(isAdmin ? "unowned.hintAdmin" : "unowned.hint")}
|
||||
>
|
||||
{t(isAdmin ? "tab.unowned" : "tab.suggested")}
|
||||
{unownedCount > 0 && <span className="ml-1 text-fg-muted">{unownedCount}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items === null ? (
|
||||
<SkeletonGrid label={t("loading")} />
|
||||
) : items.length === 0 && tab === "unowned" ? (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
|
||||
{t("unowned.empty")}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
hub={tab === "hub"}
|
||||
@@ -755,6 +857,11 @@ function ArtifactCard({
|
||||
{t(hubOnly ? "hubOnly" : "shared")}
|
||||
</span>
|
||||
)}
|
||||
{a.auto && (
|
||||
<span className="badge self-start" title={t("autoHint")}>
|
||||
{t("auto")}
|
||||
</span>
|
||||
)}
|
||||
{a.description && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
|
||||
)}
|
||||
|
||||
@@ -159,6 +159,7 @@ import type {
|
||||
CcSettingsSource,
|
||||
CcStatusline,
|
||||
} from "../lib/api";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
|
||||
/** How an inline editor reports unsaved edits to the page. */
|
||||
type OnDirty = (id: string, dirty: boolean) => void;
|
||||
@@ -1795,7 +1796,7 @@ function PluginsPanel({
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
if (isSubmitEnter(e)) void submit();
|
||||
}}
|
||||
placeholder={t("manage.installPlaceholder")}
|
||||
aria-label={t("manage.installTitle")}
|
||||
@@ -2328,7 +2329,7 @@ function McpPanel({
|
||||
value={commandLine}
|
||||
onChange={(e) => setCommandLine(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
if (isSubmitEnter(e)) void submit();
|
||||
}}
|
||||
placeholder={t("manage.addMcpCommandPlaceholder")}
|
||||
aria-label={t("mcp.command")}
|
||||
@@ -2339,7 +2340,7 @@ function McpPanel({
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit();
|
||||
if (isSubmitEnter(e)) void submit();
|
||||
}}
|
||||
placeholder={t("manage.addMcpUrlPlaceholder")}
|
||||
aria-label={t("mcp.url")}
|
||||
@@ -4214,7 +4215,7 @@ function MarketplacesPanel({
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void add();
|
||||
if (isSubmitEnter(e)) void add();
|
||||
}}
|
||||
placeholder={t("manage.addMarketplacePlaceholder")}
|
||||
aria-label={t("manage.addMarketplaceTitle")}
|
||||
|
||||
@@ -95,6 +95,7 @@ import type {
|
||||
CodexScope,
|
||||
CodexSkill,
|
||||
} from "../lib/api";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
|
||||
type TabKey = "overview" | "skills" | "agents" | "rules" | "memory" | "config" | "mcp" | "plugins";
|
||||
|
||||
@@ -1630,7 +1631,7 @@ function PluginsPanel({
|
||||
value={spec}
|
||||
onChange={(e) => setSpec(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submitPlugin();
|
||||
if (isSubmitEnter(e)) void submitPlugin();
|
||||
}}
|
||||
placeholder={t("plugins.installPlaceholder")}
|
||||
aria-label={t("plugins.installTitle")}
|
||||
@@ -1710,7 +1711,7 @@ function PluginsPanel({
|
||||
value={mktUrl}
|
||||
onChange={(e) => setMktUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submitMarketplace();
|
||||
if (isSubmitEnter(e)) void submitMarketplace();
|
||||
}}
|
||||
placeholder={t("plugins.addMarketplacePlaceholder")}
|
||||
aria-label={t("plugins.addMarketplaceTitle")}
|
||||
|
||||
@@ -1092,7 +1092,7 @@ function AnalyticsTab() {
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{loadError}</span>
|
||||
<button onClick={load} className="btn-ghost text-xs">
|
||||
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -3133,7 +3133,7 @@ export function Dashboard() {
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("failedLoad")}: {error}
|
||||
</span>
|
||||
<button onClick={load} className="btn-ghost text-xs">
|
||||
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -561,7 +561,7 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("failedList")}: {error}
|
||||
</span>
|
||||
<button onClick={load} className="btn-ghost text-xs">
|
||||
<button onClick={refresh} disabled={spinning} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -120,6 +120,7 @@ import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
|
||||
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
|
||||
* it COUNTs whole tables, and hooks fire many times a second while sessions run. */
|
||||
@@ -1335,7 +1336,7 @@ function ClearDataDialog({
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && ok && !busy) onConfirm();
|
||||
if (isSubmitEnter(e) && ok && !busy) onConfirm();
|
||||
}}
|
||||
autoComplete="off"
|
||||
className="input mt-1 w-full font-mono text-sm"
|
||||
|
||||
@@ -85,6 +85,7 @@ import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAuth, usePermissions } from "../components/AuthGate";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
|
||||
const GROUPS = ["general", "agents", "data"] as const;
|
||||
type Group = (typeof GROUPS)[number];
|
||||
@@ -515,7 +516,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]), nav);
|
||||
if (isSubmitEnter(e) && results[0]) navigate(hrefOf(results[0]), nav);
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* @description The Users tab of the Administration page (`/admin`, which
|
||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* role, state, workspaces, sessions, the tokens/$ it used today (the
|
||||
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
@@ -214,13 +214,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
)}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[860px]" data-testid="users-table">
|
||||
<table className="w-full min-w-[960px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
<th className={th}>{t("role")}</th>
|
||||
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("today")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||
@@ -299,6 +300,7 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions}
|
||||
</td>
|
||||
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
<StorageCell
|
||||
|
||||
@@ -1105,10 +1105,18 @@ export function Workspace() {
|
||||
setFeatures([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.lanes.features
|
||||
.list(currentLane.id)
|
||||
.then((data) => setFeatures(data.features))
|
||||
.catch(() => setFeatures([]));
|
||||
.then((data) => {
|
||||
if (!cancelled) setFeatures(data.features);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFeatures([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentLane?.id]);
|
||||
|
||||
// Fetch the archived snapshot when the picker selects one — read-only, never
|
||||
@@ -1138,10 +1146,18 @@ export function Workspace() {
|
||||
setProofFeatures([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.lanes.proof
|
||||
.list(currentLane.id)
|
||||
.then((data) => setProofFeatures(data.features))
|
||||
.catch(() => setProofFeatures([]));
|
||||
.then((data) => {
|
||||
if (!cancelled) setProofFeatures(data.features);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProofFeatures([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentLane?.id, viewedFeatureSlug]);
|
||||
|
||||
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
|
||||
|
||||
@@ -84,7 +84,15 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
...api,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: { list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })) },
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: null })),
|
||||
ompSharing: vi.fn(async () => ({ available: false })),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -54,6 +54,12 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
accountPool: {
|
||||
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||
profiles: vi.fn(async () => ({ profiles: [] })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: null })),
|
||||
ompSharing: vi.fn(async () => ({ available: false })),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -798,6 +798,7 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
>
|
||||
<a
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||
data-discover="true"
|
||||
href="/settings/agents#tool-profiles"
|
||||
>
|
||||
<svg
|
||||
@@ -2806,6 +2807,7 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</div>
|
||||
<a
|
||||
class="btn-ghost shrink-0 text-xs"
|
||||
data-discover="true"
|
||||
href="/settings/pricing#pricing-claude"
|
||||
>
|
||||
Open Pricing
|
||||
@@ -3222,6 +3224,7 @@ exports[`screen snapshots > Dashboard 1`] = `
|
||||
>
|
||||
<a
|
||||
class="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
data-discover="true"
|
||||
href="/sessions"
|
||||
>
|
||||
<div
|
||||
@@ -3475,6 +3478,7 @@ exports[`screen snapshots > Dashboard 1`] = `
|
||||
</div>
|
||||
<a
|
||||
class="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
data-discover="true"
|
||||
href="/sessions?view=events"
|
||||
>
|
||||
<div
|
||||
@@ -5408,6 +5412,7 @@ exports[`screen snapshots > Dashboard 1`] = `
|
||||
|
||||
<a
|
||||
class="text-accent hover:underline"
|
||||
data-discover="true"
|
||||
href="/settings/notifications"
|
||||
>
|
||||
Add one in Settings → Notifications
|
||||
@@ -7239,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.17 (rebuild client to match)
|
||||
UI build v2.8.21 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -11059,6 +11064,7 @@ exports[`screen snapshots > Workflows 1`] = `
|
||||
</div>
|
||||
<a
|
||||
class="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
|
||||
data-discover="true"
|
||||
href="/sessions/sess-1"
|
||||
title="Open parent session"
|
||||
>
|
||||
|
||||
+251
-157
@@ -22,6 +22,10 @@ Complete REST API and WebSocket documentation for Agent Dashboard.
|
||||
- [Remote Data Sources](#remote-data-sources)
|
||||
- [Query Explorer](#query-explorer)
|
||||
- [Scheduled reports](#scheduled-reports)
|
||||
- [Webhooks](#webhooks)
|
||||
- [Web Push](#web-push)
|
||||
- [Updates](#updates)
|
||||
- [Import history](#import-history)
|
||||
- [WebSocket API](#websocket-api)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Rate Limiting](#rate-limiting)
|
||||
@@ -85,13 +89,13 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `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; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `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). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). 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}], 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).
|
||||
`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, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `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`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). 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.
|
||||
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`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). 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 and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
|
||||
|
||||
**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`):
|
||||
|
||||
@@ -108,7 +112,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
|
||||
| `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=<avatarAt>`), `404 ENOAVATAR` when it has none |
|
||||
| `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `<data dir>/avatars/u<id>.<ext>`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited |
|
||||
| `POST /api/me/avatar/url` (`{url}`) | the same picture, downloaded by the SERVER from an `https://` link (no userinfo; every resolved address — an IP literal too — must be public, never loopback/private/link-local/CGNAT/reserved, and the connection is pinned to the vetted address; ≤ 3 redirects, each re-checked; 10 s timeout; ≤ 1 MB read; no cookie sent), then checked like an upload (`server/lib/avatar-url.js`). `400 EBADURL`/`EPRIVATEURL`/`EBADIMAGE`, `413 ETOOBIG`, `502 EFETCH`, `403 ELOCALMODE`. Login cookie only, audited |
|
||||
| `POST /api/me/avatar/url` (`{url}`) | the picture as a LINK: stored as typed (`users.avatar_link`, an `https://` URL with no userinfo, ≤ 2048 chars, not `localhost` or a loopback/private/link-local/reserved IP literal) and NEVER downloaded by the server — `GET /api/me/avatar` and `GET /api/users/:id/avatar` answer `302` to it (`Referrer-Policy: no-referrer`), so the viewer's browser loads it with its own logins and network (a Mattermost `/api/v4/users/<id>/image` link works). Replaces an uploaded picture, and an upload replaces it (`server/lib/avatar-url.js`). `400 EBADURL`/`EPRIVATEURL`, `403 ELOCALMODE`. Login cookie only, audited |
|
||||
| `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too |
|
||||
|
||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||
@@ -286,7 +290,7 @@ POST /api/lanes/:id/fs/upload-batch?dir=&conflict= length + JSON list + bytes
|
||||
GET /api/lanes/:id/fs/zip?p=<path>&p=… application/zip, streamed
|
||||
```
|
||||
|
||||
The workspace **File Explorer** (server mode only — a local dashboard has no **Files** entry, `/files` redirects home, and its buttons are hidden; the routes still answer) — the **Files** page in the sidebar (`/files?lane=<id>&path=<folder>`), laid out like GNOME Files (workspaces as places, Back/Forward, a path bar, icon grid or list, hidden files behind Ctrl+H), and the same manager in a dialog from a workspace's Files tab (the folder button; "Open in File Explorer" on a folder). `?session=` / `?source=` pick the same folder the
|
||||
The workspace **File Explorer** (server mode only — a local dashboard has no **Files** entry, `/files` redirects home, and its buttons are hidden; the routes still answer) — the **Files** page in the sidebar (`/files?lane=<id>&path=<folder>`), laid out like GNOME Files (workspaces as places — each nested under its owner's home, dropped down by that home's chevron (open homes kept per browser in `agenthub.files.homesOpen`), an owner whose home the viewer may not open (an analyst's view) under a row that only folds, one with no owner in a flat Workspaces list — Back/Forward, a path bar, icon grid or list, hidden files behind Ctrl+H), and the same manager in a dialog from a workspace's Files tab (the folder button; "Open in File Explorer" on a folder). `?session=` / `?source=` pick the same folder the
|
||||
git reads do. Paths go through the read path's guards (`lanePath`, the parent
|
||||
`resolveInLane`-confined, the last segment kept as a name so a symlink is acted
|
||||
on as a link); nothing in `.git`, no secret file, never the workspace folder
|
||||
@@ -327,6 +331,25 @@ by one, 0.5 s as one batch.
|
||||
GET /api/homes { homes: [{ id: "me" | <username>, label, readOnly, dir? }] }
|
||||
GET /api/homes/:who/fs/list|trash|zip|download as the workspace routes above
|
||||
POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||
GET /api/homes/trash { items, errors, keepDays } — every Trash the caller may open (below)
|
||||
```
|
||||
|
||||
One line per route, for searching (bodies and answers as the workspace routes above; `copy` takes `fromLane` instead of `fromHome`, `trash/restore` and `trash/purge` also take `store`):
|
||||
|
||||
```
|
||||
GET /api/homes/:who/fs/download?path=
|
||||
GET /api/homes/:who/fs/zip?p=<path>&p=…
|
||||
GET /api/homes/:who/fs/trash
|
||||
POST /api/homes/:who/fs/newfile
|
||||
POST /api/homes/:who/fs/rename
|
||||
POST /api/homes/:who/fs/move
|
||||
POST /api/homes/:who/fs/copy
|
||||
POST /api/homes/:who/fs/delete
|
||||
POST /api/homes/:who/fs/trash/restore
|
||||
POST /api/homes/:who/fs/trash/purge
|
||||
POST /api/homes/:who/fs/upload?path=&conflict=
|
||||
POST /api/homes/:who/fs/upload-check
|
||||
POST /api/homes/:who/fs/upload-batch?dir=&conflict=
|
||||
```
|
||||
|
||||
The same File Explorer on a user's HOME (`server/lib/homes.js`): under S3 their
|
||||
@@ -372,7 +395,10 @@ Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
|
||||
into the folder it has open — of the user's own home or of a workspace's own
|
||||
folder, never a session worktree, an extra folder or another user's home —
|
||||
through `POST /api/lanes/clone` below with that folder as `parent`, so every
|
||||
check of that route applies, and opens a workspace on the clone.
|
||||
check of that route applies, and opens a workspace on the clone. It sends a
|
||||
`progressToken` and shows the queue place / git progress while the clone runs;
|
||||
closing the dialog then only hides it behind a progress chip (click to reopen)
|
||||
— the request keeps going and its outcome arrives as a toast.
|
||||
|
||||
#### Clone a repository into a new workspace
|
||||
|
||||
@@ -549,6 +575,14 @@ Error `409 ENOCWD` is returned when the workspace's folder no longer exists (or
|
||||
|
||||
Error `409 ERUNLIVE` is returned if the workspace already has a live run; stop it first with the `stop` action. The same code and status come back when the workspace's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
|
||||
|
||||
#### Other workspace actions
|
||||
|
||||
```http
|
||||
POST /api/lanes/:id/:action
|
||||
```
|
||||
|
||||
The one control route behind `start` (above), `stop`, `message`, `clear`, `reset`, `remove` and `purge` (same-origin). Any other `:action` is `400 EBADACTION`, an unknown workspace `404 ENOLANE`. `reset`, `remove` and `purge` are destructive and need `{ "confirm": true }` (`400 ECONFIRM`) — see below.
|
||||
|
||||
#### Preflight a destructive workspace action
|
||||
|
||||
```http
|
||||
@@ -1274,6 +1308,33 @@ anything is spawned, and `args` travels as an array of strings straight into
|
||||
argv — neither is ever joined into a command string. An unknown name returns
|
||||
`400 ENOHOOK` with the allowed list; a workspace with no slot returns `409 ENOSLOT`.
|
||||
|
||||
#### Scaffold a profile, sync MCP servers, read an integration
|
||||
|
||||
```http
|
||||
POST /api/lanes/:id/profile/init
|
||||
POST /api/lanes/:id/mcp/sync
|
||||
GET /api/lanes/:id/integrations/:name
|
||||
```
|
||||
|
||||
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub lanes profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
|
||||
|
||||
#### CI status and base sync
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/ci/status?ref=<branch|sha>
|
||||
POST /api/lanes/:id/sync-base
|
||||
```
|
||||
|
||||
`ci/status` is ONE snapshot of GitHub Actions for `ref` through `gh` in the workspace's folder — not a poll loop: `{ state: "pending"|"success"|"failure", ref, checks: [{ name, conclusion, url }] }`; `400 EBADREF` without `ref`. `sync-base` (same-origin, under the workspace lock, needs the workspace profile) merges the profile's base branch INTO the workspace's branch behind a migration-number collision preflight: body `{ mode?: "check"|"merge"|"continue" (default merge), branch? }`. Every documented outcome — clean, a collision, a conflict left in place — is a 200 with `code` 0/4/5; only a malformed request, a missing profile or an out-of-order `continue` answers an `error`. It never writes the stage.
|
||||
|
||||
#### Housekeeping
|
||||
|
||||
```http
|
||||
POST /api/lanes/gc
|
||||
```
|
||||
|
||||
Same-origin, admin only (`403 EADMIN` for a developer or analyst — it is machine-wide). Reaps orphaned Playwright MCP processes under `LANES_ROOT` and caps oversized logs under `LANES_ROOT/.state/`. `{ dryRun?: true }` reports without acting. Answers `{ reaped, capped }`.
|
||||
|
||||
#### Tail a workspace's log
|
||||
|
||||
```http
|
||||
@@ -1564,6 +1625,17 @@ Both `GET /api/workflows` and `/skill-usage` also take `from=YYYY-MM-DD&to=YYYY-
|
||||
query failure.
|
||||
|
||||
|
||||
#### Workflow-tool runs
|
||||
|
||||
```http
|
||||
GET /api/workflows/runs?status=&session_id=&limit=&offset=
|
||||
GET /api/workflows/runs/:runId
|
||||
GET /api/workflows/session/:id
|
||||
```
|
||||
|
||||
`runs` lists the fleets the Claude Code **Workflow** tool launched, ingested from their on-disk journals (`server/lib/workflow-ingest.js`): `{ runs, total, counts, limit, offset }`, newest first, `phases`/`progress` parsed into arrays, `counts` per status; `status` (omit or `all` for every one) or `session_id` filters, `limit` 1–1000 (default 50). A developer sees only runs of their own sessions. `runs/:runId` → `{ workflow, agents, events }` — the run, its inner agents and their events (oldest first, at most 5000); `404 WORKFLOW_NOT_FOUND`. `session/:id` is the session drill-in of the Workflow analytics page: `{ session, tree, toolTimeline, swimLanes, events }` (the agent tree, every tool call in order, one swim lane per agent, the first 500 events); `404` for an unknown session.
|
||||
|
||||
|
||||
### Locks
|
||||
|
||||
Cross-workspace named locks (`server/lib/named-lock.js`) — the OTHER axis from a
|
||||
@@ -1772,6 +1844,16 @@ classDiagram
|
||||
|
||||
---
|
||||
|
||||
#### Create Session
|
||||
|
||||
```http
|
||||
POST /api/sessions
|
||||
```
|
||||
|
||||
Body `{ id, name?, cwd?, model?, metadata? }` (`id` required, else `400 INVALID_INPUT`). An existing id answers `200 { session, created: false }` unchanged; a new one is inserted `active` and broadcast as `session_created` → `201 { session, created: true }`. Sessions normally come from hooks; in server mode a developer or analyst is refused (`403 EADMIN`).
|
||||
|
||||
---
|
||||
|
||||
#### Update Session
|
||||
|
||||
```http
|
||||
@@ -1961,53 +2043,24 @@ curl http://localhost:4820/api/sessions/sess_abc123/stats
|
||||
|
||||
---
|
||||
|
||||
#### Get Session Agents
|
||||
#### Session transcripts
|
||||
|
||||
```http
|
||||
GET /api/sessions/:id/agents
|
||||
GET /api/sessions/:id/transcripts
|
||||
GET /api/sessions/:id/transcript?agent_id=&run_id=&limit=&after=&before=&offset=
|
||||
```
|
||||
|
||||
Returns all agents for a session.
|
||||
`transcripts` lists the session's conversation files — the main one first, then each subagent's and compaction's — as `{ transcripts: [{ id, name, type: "main"|"subagent"|"compaction", subagent_type?, db_agent_id, … }] }`; `db_agent_id` links an entry to its `agents` row (`null` when none matches). A Codex, Antigravity, Cursor, omp or OpenCode session lists its own tool's journal instead.
|
||||
|
||||
**Path Parameters:**
|
||||
`transcript` reads one of them as structured messages: no `agent_id` = the main conversation, `agent_id` (+ `run_id` for a Workflow-tool agent) = a subagent's — each one plain name, never a path. `limit` defaults to 50 (max 200); `after=<line>` returns only messages past that JSONL line (the chat's tail), `before=<line>` the page before it (history), `offset` is the legacy pager. Answers `{ messages, total, has_more, last_line, first_line }` (empty when the file is gone). The live file under the Claude projects folder is preferred, else the snapshot taken at import. `404 NOT_FOUND` for an unknown session.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | Session ID |
|
||||
---
|
||||
|
||||
**Example Request:**
|
||||
#### Get Session Agents
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/sessions/sess_abc123/agents
|
||||
```
|
||||
There is no `/api/sessions/:id/agents` route: a session's agents come with `GET /api/sessions/:id` (its `agents` array) or from `GET /api/agents?session_id=<id>` (see [Agents](#agents)).
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": [
|
||||
{
|
||||
"id": "sess_abc123-main",
|
||||
"session_id": "sess_abc123",
|
||||
"name": "Main Agent - my-project",
|
||||
"type": "main",
|
||||
"subagent_type": null,
|
||||
"status": "idle",
|
||||
"current_tool": null,
|
||||
"task": null,
|
||||
"started_at": "2024-03-18T12:00:00Z",
|
||||
"ended_at": null,
|
||||
"updated_at": "2024-03-18T12:05:00Z",
|
||||
"parent_agent_id": null,
|
||||
"awaiting_input_since": "2024-03-18T12:05:00Z",
|
||||
"awaiting_reason": "stop",
|
||||
"cost": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> **Note on `cost`** — `/api/agents` and `/api/sessions/:id/agents` attach a `cost` (USD) to each agent: the agent's **own** cost, computed server-side from the per-agent token buckets stored in `agents.metadata.tokens` and priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see [Pricing](#pricing)). It is `0` for main agents (whose cost is the session total, reported by `/api/pricing/cost/:sessionId`), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total.
|
||||
> **Note on `cost`** — `/api/agents` and `GET /api/sessions/:id` (its `agents`) attach a `cost` (USD) to each agent: the agent's **own** cost, computed server-side from the per-agent token buckets stored in `agents.metadata.tokens` and priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see [Pricing](#pricing)). It is `0` for main agents (whose cost is the session total, reported by `/api/pricing/cost/:sessionId`), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total.
|
||||
|
||||
> **Note on `status` vs Waiting** — agents are persisted with one of `idle | connected | working | completed | error`. The yellow **Waiting** badge surfaced in the dashboard is a UI overlay derived from `awaiting_input_since` being non-null on a non-terminal agent (typically `idle` after a `Stop`, or `connected` right after `SessionStart`). Filter `?status=idle` on `/api/agents` and inspect `awaiting_input_since` to enumerate currently-waiting main agents.
|
||||
|
||||
@@ -2071,54 +2124,18 @@ curl http://localhost:4820/api/agents/agent_xyz789
|
||||
|
||||
---
|
||||
|
||||
#### Get Agent Tools
|
||||
#### Create or update an agent
|
||||
|
||||
```http
|
||||
GET /api/agents/:id/tools
|
||||
POST /api/agents
|
||||
PATCH /api/agents/:id
|
||||
```
|
||||
|
||||
Returns tool executions for an agent.
|
||||
`POST` body `{ id, session_id, name, type?, subagent_type?, status?, task?, parent_agent_id?, metadata? }` (`id`, `session_id`, `name` required, else `400 INVALID_INPUT`): an existing id answers `200 { agent, created: false }` (`404 ENOTFOUND` when it is another user's in server mode); a new one is inserted (`type` default `main`, `status` default `waiting`), broadcast as `agent_created` → `201 { agent, created: true }`. `PATCH` takes any of `name`, `status`, `task`, `current_tool`, `ended_at`, `metadata` and answers `{ agent }`; `404 NOT_FOUND` for an unknown id. Agents normally come from hooks.
|
||||
|
||||
**Path Parameters:**
|
||||
#### Tool Execution Flow
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | Agent ID |
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/agents/agent_xyz789/tools
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"id": 1,
|
||||
"agent_id": "agent_xyz789",
|
||||
"tool_name": "bash",
|
||||
"duration_ms": 1234,
|
||||
"success": 1,
|
||||
"error_message": null,
|
||||
"created_at": "2024-03-18T12:01:00Z"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"agent_id": "agent_xyz789",
|
||||
"tool_name": "view",
|
||||
"duration_ms": 45,
|
||||
"success": 1,
|
||||
"error_message": null,
|
||||
"created_at": "2024-03-18T12:02:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Tool Execution Flow:**
|
||||
Tool calls are recorded as `PreToolUse`/`PostToolUse` events, not as a separate table — read them with `GET /api/events?agent_id=<id>` or `?tool_name=<name>`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -2129,13 +2146,13 @@ sequenceDiagram
|
||||
participant DB as Database
|
||||
|
||||
Agent->>PreHook: Tool about to execute
|
||||
PreHook->>DB: Set current_tool
|
||||
PreHook->>DB: Store PreToolUse event, set current_tool
|
||||
|
||||
Agent->>Tool: Execute (bash, view, etc.)
|
||||
Tool-->>Agent: Result
|
||||
|
||||
Agent->>PostHook: Tool completed
|
||||
PostHook->>DB: Create tool_execution record
|
||||
PostHook->>DB: Store PostToolUse event
|
||||
PostHook->>DB: Clear current_tool
|
||||
PostHook->>DB: Update token counts + cost
|
||||
```
|
||||
@@ -2154,6 +2171,16 @@ Returns `{ events, limit, offset, total }`, newest first, filtered by `event_typ
|
||||
|
||||
---
|
||||
|
||||
#### Event filter values
|
||||
|
||||
```http
|
||||
GET /api/events/facets
|
||||
```
|
||||
|
||||
`{ event_types, tool_names }` — the distinct values in the events table, sorted, for the Events page's filters (a developer's: only their own sessions' events).
|
||||
|
||||
---
|
||||
|
||||
### Data cleanup
|
||||
|
||||
#### Clean up old data
|
||||
@@ -2198,50 +2225,57 @@ POST /api/settings/uninstall
|
||||
|
||||
Settings → System's **Uninstall AgentHub** (same-origin only). Body `{ "confirm": true }` — anything else is `400 ECONFIRM`; in server mode `403 ESERVERMODE` (a shared dashboard is removed with `agenthub uninstall` on the host). Spawns `scripts/uninstall.js --yes` detached and answers `202 { ok: true, log }` at once; the worker then stops this server and removes everything `agenthub uninstall --yes` does (see [CLI.md](CLI.md)), the database included. `log` is `$TMPDIR/agenthub-uninstall-<uid>.log`, the only record left afterwards.
|
||||
|
||||
#### Clear, re-import, export and restore data
|
||||
|
||||
```http
|
||||
POST /api/settings/clear-data
|
||||
POST /api/settings/reimport
|
||||
GET /api/settings/export
|
||||
POST /api/settings/import
|
||||
POST /api/settings/reset-pricing
|
||||
```
|
||||
|
||||
`clear-data` deletes every session, agent, event, token row, workflow run, fired alert and webhook delivery in one transaction (alert rules, webhook targets, pricing, snapshots and reports survive) → `{ ok: true, cleared: <table counts before> }`. `reimport` re-reads the Claude projects folder (`importAllSessions`) → `{ ok: true, imported, skipped, errors, … }`; `500 IMPORT_FAILED`. `export` streams one JSON attachment (`agent-monitor-export-<date>.json`: `format`, `version`, `exported_at`, `sessions`, `agents`, `events`, `token_usage`, `workflows`, `dashboard_runs`, `alert_rules`, `model_pricing`), events written in pages so a large database never sits in memory. `import` (same-origin) restores such a file sent as multipart `file` (≤ 512 MB, else `413 FILE_TOO_LARGE`): non-destructive — a session already here is skipped whole — → `{ ok: true, source, format, …counts }`; `400 NO_FILE` / `BAD_UPLOAD` / `INVALID_FORMAT`. `reset-pricing` reseeds the default model prices (and intro rates), then waits up to 3 s for the automatic price sync → `{ ok: true, pricing }`; `{ scope: "claude" }` resets only the `claude-…` rows. All five are admin-only in server mode, and `export` is staff-only.
|
||||
|
||||
#### Hooks for Claude Code, Codex and Antigravity
|
||||
|
||||
```http
|
||||
POST /api/settings/reinstall-hooks
|
||||
GET /api/settings/codex-hooks
|
||||
POST /api/settings/codex-hooks
|
||||
DELETE /api/settings/codex-hooks
|
||||
GET /api/settings/antigravity-hooks
|
||||
POST /api/settings/antigravity-hooks
|
||||
DELETE /api/settings/antigravity-hooks
|
||||
```
|
||||
|
||||
`reinstall-hooks` rewrites AgentHub's hooks in Claude Code's `settings.json` → `{ ok, hooks }` (`500 HOOK_INSTALL_FAILED`). `codex-hooks` reads, installs (or reinstalls) and removes AgentHub's entries in `$CODEX_HOME/hooks.json` (`server/lib/codex-hooks.js`); every answer is the status `{ supported, path, script, readable, installed, trusted, events: { <Event>: { installed, trusted } }, error? }` — `trusted` is read from Codex's own `config.toml`, which AgentHub never writes. `antigravity-hooks` does the same for the named hook `agenthub` in `~/.gemini/config/hooks.json` (status `{ supported, path, script, readable, installed, enabled, events }`). Only on a click; the writes are same-origin; `400 EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (a forwarder script that is not AgentHub's), else `500 ESAVE`.
|
||||
|
||||
#### Privacy
|
||||
|
||||
```http
|
||||
GET /api/settings/privacy
|
||||
PUT /api/settings/privacy
|
||||
```
|
||||
|
||||
`{ redact_secrets, ignore_cwd, env_owned: { redact_secrets, ignore_cwd } }` — opt-in redaction of secrets in stored payloads, and the folders whose hooks are dropped (`server/lib/privacy.js`). `PUT` (same-origin) takes either field (`redact_secrets` boolean, `ignore_cwd` a list of absolute folders) and answers `{ ok: true, …state }`; `400 INVALID_INPUT` / `EBADPATH` (not absolute, a filesystem root, over 100 entries), `409 EENVOWNED` when the environment sets it, `500 ESAVE`.
|
||||
|
||||
#### Push ingest token
|
||||
|
||||
```http
|
||||
GET /api/settings/push-token
|
||||
POST /api/settings/push-token
|
||||
DELETE /api/settings/push-token
|
||||
```
|
||||
|
||||
The token for [`POST /api/hooks/ingest-batch`](#push-ingest-post-apihooksingest-batch). `GET` → `{ enabled, env_owned }` — never the token. `POST` (same-origin) makes a new one (the old one stops working) and returns it THIS ONCE: `{ ok: true, token, enabled, env_owned }`; `DELETE` turns push ingest off → `{ ok: true, enabled: false, env_owned }`. `409 EENVOWNED` when `AGENTHUB_PUSH_TOKEN_SHA256` comes from the environment.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Tools
|
||||
|
||||
#### List All Tools
|
||||
|
||||
```http
|
||||
GET /api/tools
|
||||
```
|
||||
|
||||
Returns all tool executions across all sessions.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `limit` | integer | 100 | Max tools to return |
|
||||
| `tool_name` | string | - | Filter by tool name |
|
||||
| `success` | boolean | - | Filter by success status |
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/tools?limit=50&tool_name=bash
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"id": 1,
|
||||
"agent_id": "agent_xyz789",
|
||||
"tool_name": "bash",
|
||||
"duration_ms": 1234,
|
||||
"success": 1,
|
||||
"error_message": null,
|
||||
"created_at": "2024-03-18T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"total": 156
|
||||
}
|
||||
```
|
||||
There is no `/api/tools` route. Per-tool counts come from `GET /api/sessions/:id/stats` (`tools_used`, `tool_call_count`) and `GET /api/events/facets`; individual calls from `GET /api/events?tool_name=<name>`.
|
||||
|
||||
---
|
||||
|
||||
@@ -2290,6 +2324,27 @@ A ready-made Prometheus config, recording rules and three Grafana boards built o
|
||||
|
||||
---
|
||||
|
||||
#### Overview counts
|
||||
|
||||
```http
|
||||
GET /api/stats?tz_offset=&sources=&tool=
|
||||
```
|
||||
|
||||
The Dashboard's header numbers: the overview counts (sessions, agents, events…), `events_today` (in the local day of `tz_offset`, the browser's `getTimezoneOffset()`), `ws_connections`, `agents_by_status` and `sessions_by_status` (`{ <status>: count }`). `sources`/`tool` narrow it like `/api/sessions`; a malformed `tool` is `400 INVALID_INPUT`.
|
||||
|
||||
#### Health and API docs
|
||||
|
||||
```http
|
||||
GET /api/health
|
||||
GET /api/openapi.json
|
||||
GET /api/redoc
|
||||
GET /api/redoc/redoc.standalone.js
|
||||
```
|
||||
|
||||
`health` → `{ status: "ok", version, timestamp }`, exempt from every login. `openapi.json` is this API's OpenAPI 3.0 document (also browsable at `/api/docs`, Swagger UI); `redoc` renders it with ReDoc, whose script `redoc.standalone.js` is served locally (no CDN).
|
||||
|
||||
---
|
||||
|
||||
### Pricing
|
||||
|
||||
#### List Pricing Rules
|
||||
@@ -2535,41 +2590,28 @@ POST /api/alerts/rules/preview
|
||||
|
||||
### Notifications
|
||||
|
||||
#### Get Session Notifications
|
||||
Notifications are not stored per session and have no REST read. Hook `Notification` events land in the events table (`GET /api/events?session_id=<id>&event_type=Notification`), and the dashboard's alerts feed is [`GET /api/alerts`](#alerts-feed).
|
||||
#### Alert rules
|
||||
|
||||
```http
|
||||
GET /api/sessions/:id/notifications
|
||||
GET /api/alerts/rules
|
||||
POST /api/alerts/rules
|
||||
PATCH /api/alerts/rules/:id
|
||||
DELETE /api/alerts/rules/:id
|
||||
```
|
||||
|
||||
Returns notifications for a session.
|
||||
`GET` → `{ rules: [{ id, name, rule_type, config, enabled, cooldown_seconds, … }] }` (`config` parsed). `POST` body `{ name, rule_type, config, enabled?, cooldown_seconds? }`: `rule_type` is one of `event_pattern`, `inactivity`, `status_duration`, `token_threshold`, `cost_threshold`, `budget`, and `config` is validated for it (`validateRuleConfig`); `cooldown_seconds` defaults to 300 → `201 { rule }`. `PATCH` takes `name`, `config` (checked against the stored type — `rule_type` is immutable), `enabled`, `cooldown_seconds` → `{ rule }`. `DELETE` → `{ ok: true }`, its fired alerts cascading away. `400 INVALID_INPUT`, `404 NOT_FOUND`. In server mode rules are staff-only and their writes admin-only. See [Spend Budgets](#spend-budgets) for `budget` and the read-only preview.
|
||||
|
||||
**Path Parameters:**
|
||||
#### Alerts feed
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | Session ID |
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/sessions/sess_abc123/notifications
|
||||
```http
|
||||
GET /api/alerts?unacked=true&limit=&offset=
|
||||
POST /api/alerts/:id/ack
|
||||
POST /api/alerts/ack-all
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
`GET` → `{ alerts, total, unacked, limit, offset }`, newest first (`limit` 1–200, default 50; `unacked=true` only the unacknowledged). A developer's feed is their own sessions' alerts (one with no session — a budget — is staff-only); an analyst's rows carry `can_ack`. `:id/ack` acknowledges one (`:id` numeric) → `{ alert }`, broadcast as `alert_updated`; `404 NOT_FOUND`. `ack-all` → `{ ok: true, acknowledged }` (a developer's or analyst's: their own sessions' only).
|
||||
|
||||
```json
|
||||
{
|
||||
"notifications": [
|
||||
{
|
||||
"id": 1,
|
||||
"session_id": "sess_abc123",
|
||||
"notification_type": "backgroundTaskComplete",
|
||||
"message": "Explore agent completed",
|
||||
"created_at": "2024-03-18T12:05:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Remote Data Sources
|
||||
|
||||
@@ -2895,6 +2937,7 @@ POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back
|
||||
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
|
||||
GET /api/run/:id Run handle (returns live run state)
|
||||
DELETE /api/run/:id[?archive=1] Kill the pane → {ok}. `archive=1` (the UI's Stop) also archives the session it held → {ok, archived: <session id> | null}; without it (Sleep, the CLI) the session stays listed to resume
|
||||
DELETE /api/run/history/:id Forget one CLOSED Terminal tab (`tool = 'shell'`) from the history now → {removed}; 409 ERUNLIVE while open, 404 ENOTFOUND for any other row
|
||||
```
|
||||
|
||||
`:id` must be a workspace pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
|
||||
@@ -3047,7 +3090,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
|
||||
### Artifacts hub
|
||||
|
||||
```http
|
||||
GET /api/artifacts?scope=all|mine|hub&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
||||
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` (default scope `all`)
|
||||
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
|
||||
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
|
||||
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
|
||||
@@ -3067,17 +3110,21 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n
|
||||
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
|
||||
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
|
||||
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
|
||||
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
|
||||
POST /api/artifacts/:id/decline → {id, declined} — the suggested user
|
||||
POST /api/artifacts/:id/assign {userId} → {item} — admin
|
||||
```
|
||||
|
||||
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
|
||||
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned.
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
|
||||
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
|
||||
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
|
||||
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
|
||||
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
|
||||
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
|
||||
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. `scope=mine` is the caller's own library rows plus the shared skills/plugins of OTHER users that the caller has installed (`installed: true`, `mine: false`); in server mode the page splits EVERY tab by `mine`, others' section first — **All** into *Shared by others* / *Mine*, **Hub** into *Shared by others* / *Shared by me*, **My library** into *Installed from others* / *Mine* — and each section pages 12 cards at a time (client-side). Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
|
||||
- **Unowned** — server mode: a row with no owner (`unowned: true`). `scope=unowned` lists every one for an admin and, for anyone else, those suggested to them (`suggestedForMe: true`; such a row is also visible to them, `canSeeId`). **claim** makes the suggested user — or an admin — its owner; **decline** drops the suggestion (the row stays for an admin); **assign** `{userId}` is an admin giving it to an enabled user (`400 EBADFIELD` otherwise). All three answer `409 EOWNED` once it has an owner, `400 ELOCALMODE` in local mode, and are written to `audit_log`. The page shows the tab **Unowned** to an admin and **Is this yours?** to a user with suggestions.
|
||||
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
|
||||
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
|
||||
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
|
||||
@@ -3145,6 +3192,53 @@ POST /api/settings/compact { confirm: true } → 202 — started (or alr
|
||||
Settings → Data → **Session cleanup** (`server/lib/db-retention.js`). Automatic retention is OFF unless `days` (1–3650, `AGENTHUB_RETENTION_DAYS` in the settings file) is set; nothing is deleted by default. When set, the hourly maintenance (`DASHBOARD_DB_MAINTAIN_MS`) purges finished (completed/error/abandoned) sessions that started more than `days` ago — the same rule and the same deletion (`purgeSessionIds`: events, agents, token usage, the session) as `POST /api/settings/cleanup` with `purge_days` — 25 sessions per transaction with a yield between, at most 5000 per run. A new database is created with `auto_vacuum = INCREMENTAL`, an existing one up to 32 MB is converted by a VACUUM at startup; the same hourly run then returns free pages to the OS (`incremental_vacuum`, in 1024-page slices, at most 65536 pages per run) once more than 1024 are free. A larger, older database is converted only by **Compact**: a full VACUUM in a worker thread followed by a TRUNCATE checkpoint — it holds the write lock while it runs (tens of seconds on a GB-size file), so hooks wait meanwhile; it runs only on a click. In server mode both writes are an admin's (`/settings` is in `ADMIN_WRITE_PREFIXES`).
|
||||
---
|
||||
|
||||
### Webhooks
|
||||
|
||||
```http
|
||||
GET /api/webhooks/providers
|
||||
GET /api/webhooks
|
||||
POST /api/webhooks
|
||||
PATCH /api/webhooks/:id
|
||||
DELETE /api/webhooks/:id
|
||||
POST /api/webhooks/:id/test
|
||||
GET /api/webhooks/:id/deliveries?limit=&offset=
|
||||
```
|
||||
|
||||
Where fired alerts are delivered (`server/lib/webhook-providers.js`). `providers` → `{ providers }`, the catalog with each provider's fields (secrets flagged). A target is answered REDACTED: `{ id, name, type, enabled, url_preview (masked), has_secret, headers (values redacted, generic family only), config (secret fields as ••••), rule_ids, created_at, updated_at, last_delivery }`. `POST` body `{ name, type, url?, enabled?, secret?, headers?, rule_ids?, config? }` — `type` one of the catalog ids (`slack`, `discord`, `teams`, `google_chat`, `mattermost`, `rocketchat`, `telegram`, `pagerduty`, `opsgenie`, `splunk_oncall`, `zapier`, `make`, `pipedream`, `generic`); `url` is required for providers that need one; `secret`/`headers` apply to the generic family only; `rule_ids` limits it to those alert rules (omit = all) → `201 { target }`. `PATCH` changes only the keys present (`config` is merged over the stored one, so a secret need not be re-sent) → `{ target }`. `DELETE` → `{ ok: true }` (its delivery log cascades). `test` sends a synthetic alert and always answers 200 `{ ok, status, attempts, error }`. `deliveries` → `{ deliveries, limit, offset }` (`limit` 1–200, default 20). `400 INVALID_INPUT`, `404 NOT_FOUND`. Staff-only in server mode, writes admin-only.
|
||||
|
||||
### Web Push
|
||||
|
||||
```http
|
||||
GET /api/push/vapid-public-key
|
||||
POST /api/push/subscribe
|
||||
DELETE /api/push/subscribe
|
||||
POST /api/push/send
|
||||
```
|
||||
|
||||
`vapid-public-key` → `{ publicKey }` for `PushManager.subscribe`. `subscribe` stores a browser subscription `{ endpoint, keys: { p256dh, auth } }` (upsert by `endpoint`; in server mode tagged with the user) → `{ ok: true }`; `DELETE` takes `{ endpoint }` in the BODY (a user removes only their own) → `{ ok: true }`. `send` `{ title, body, url? }` pushes to every subscription (in server mode: the caller's own) and fires a native notification in the desktop app → `{ ok: true, native, pushed, failed }`. `400` with `{ error: { message } }` on a missing field.
|
||||
|
||||
### Updates
|
||||
|
||||
```http
|
||||
GET /api/updates/status
|
||||
POST /api/updates/check
|
||||
POST /api/updates/apply
|
||||
```
|
||||
|
||||
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
|
||||
|
||||
### Import history
|
||||
|
||||
```http
|
||||
GET /api/import/guide
|
||||
POST /api/import/rescan
|
||||
POST /api/import/scan-path
|
||||
POST /api/import/upload
|
||||
```
|
||||
|
||||
Bring Claude Code history in (Settings → Import). `guide` → `{ platform, default_projects_dir, default_projects_dir_display, default_projects_dir_exists, default_projects_dir_stats: { projects, jsonl_files }, archive_command, supported_extensions, max_upload_bytes, max_upload_files, steps }`. `rescan` re-reads the default projects folder → `{ ok: true, source: "default", … }`. `scan-path` `{ path }` (absolute, `~` expanded, a directory) imports from it → `{ ok: true, source: "path", path, imported, skipped, backfilled, errors, sessions_seen, files_scanned }`; `400 INVALID_INPUT` / `PATH_NOT_FOUND` / `NOT_A_DIRECTORY`. `upload` takes multipart `files` (`.jsonl`, `.meta.json`, `.zip`, `.tar`, `.tar.gz`, `.tgz`, `.gz`; up to `AGENTHUB_IMPORT_MAX_FILES` = 2000 files and `AGENTHUB_IMPORT_MAX_BYTES` = 1 GB), extracts archives into a private temp dir and imports → `{ ok: true, source: "upload", files_received, rejected_files, entries_extracted, entries_skipped, …counts }`; `400 NO_FILES` / `NO_JSONL`, `413` when an archive passes the extraction limits. Imports are idempotent, and progress is broadcast as `import.progress`. Staff-only in server mode, admin-only to run; `500 IMPORT_FAILED`.
|
||||
|
||||
|
||||
## WebSocket API
|
||||
|
||||
### Connection
|
||||
|
||||
+3
-3
@@ -108,8 +108,8 @@ 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`). `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 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`). With `--tool cursor` the **Cursor account pool**: `add <name> --tool cursor` (a Cursor API key, typed hidden or on stdin). `pin <account> <user>` / `unpin <user>` (add `--tool codex` or `--tool cursor`) 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\|developer]\|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 developers' 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
|
||||
|
||||
@@ -273,7 +273,7 @@ Omit `<id>` and the command addresses the workspace owning the current directory
|
||||
|
||||
Resolution order, shared by every cwd-resolving command: `--workspace` (old name `--lane`)/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
|
||||
|
||||
Because a workspace's services are fully detached, restarting the dashboard (or `agenthub update`) never stops a running stack.
|
||||
Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack.
|
||||
|
||||
### Integrations
|
||||
|
||||
|
||||
+1
-1
@@ -619,7 +619,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`).
|
||||
|
||||
| Table | Columns |
|
||||
| --- | --- |
|
||||
| `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`\|`developer` — `member` before, migrated on start), `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`) |
|
||||
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ Internal build — all rights reserved.
|
||||
Prometheus rules + Grafana boards for `/api/metrics`
|
||||
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](MONITORING.md)).
|
||||
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
|
||||
every page and API call, with admin / member / analyst roles. It is off by
|
||||
every page and API call, with admin / developer / analyst roles. It is off by
|
||||
default, and a local install does not change. It is login only, without
|
||||
per-user isolation yet ([docs/SERVER-MODE.md](SERVER-MODE.md)).
|
||||
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ graph TB
|
||||
|----------|-------------|----------|
|
||||
| [client/README.md](../client/README.md) | React frontend architecture, components, state management | Frontend developers |
|
||||
| [server/README.md](../server/README.md) | Express backend, database, WebSocket, API | Backend developers |
|
||||
| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (including `run_stream` / `run_status` / `run_input_ack` for the Run page) | Integration developers |
|
||||
| [API.md](./API.md) | REST API endpoints (sessions, agents, events, stats, analytics, hooks, pricing, workflows, settings, import history, **cc-config**, **run**), WebSocket protocol (JSON broadcast messages, plus the binary `/ws-pty/:runId` terminal stream of a lane pane) | Integration developers |
|
||||
| [SERVER-MODE.md](./SERVER-MODE.md) | Server mode: logins and roles for a dashboard shared over the web (`agenthub server setup`), reverse proxy setup | Operators |
|
||||
| [DATABASE.md](./DATABASE.md) | SQLite schema, queries, performance | Database administrators |
|
||||
| [HOOKS.md](./HOOKS.md) | Claude Code hook system integration | Hook developers |
|
||||
|
||||
+58
-5
@@ -160,7 +160,7 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
An account's last login is kept in `users.last_login_at`, so it still shows
|
||||
after the login itself has ended.
|
||||
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
|
||||
sessions, and the tokens and $ it used in the last 7 and 30 days — the
|
||||
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days — the
|
||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
||||
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||
@@ -345,7 +345,10 @@ remembered, so the default is always the admin's own account.
|
||||
|
||||
Every signed-in user has **Settings → Account**: their username, role, member
|
||||
since, workspace and session counts and active logins; their own picture
|
||||
(PNG, JPEG, GIF or WebP up to 1 MB, kept in `<data dir>/avatars/`; shown with
|
||||
(PNG, JPEG, GIF or WebP up to 1 MB, kept in `<data dir>/avatars/`, or an
|
||||
https link to one, which the browser loads itself — never downloaded, so a
|
||||
link behind a login (a Mattermost `/api/v4/users/<id>/image`) cannot show and
|
||||
is refused when saved; shown with
|
||||
their name and role at the foot of the sidebar, which opens this page); their
|
||||
own display name and email; **Change password** (the current password is required, the login
|
||||
it is changed from stays and every other one ends); and **Sign out other
|
||||
@@ -532,12 +535,62 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
|
||||
> **Ceiling:** a pane can read its own account's key (`$AGENTHUB_POOL_KEY`) or
|
||||
> `auth.json`, like the Claude pool's token.
|
||||
|
||||
## Cursor account pool
|
||||
|
||||
Cursor CLI sessions get a pool too (`server/lib/cursor-pool.js`): Cursor API
|
||||
keys (Cursor dashboard → Integrations → API keys), added on the Users page's
|
||||
**Account pool → Cursor CLI** tab or from the shell:
|
||||
|
||||
```bash
|
||||
agenthub accounts add cursor-team --tool cursor # key typed hidden, or piped on stdin
|
||||
agenthub accounts list --tool cursor
|
||||
agenthub accounts remove cursor-team --tool cursor
|
||||
agenthub accounts pin cursor-team mia --tool cursor
|
||||
```
|
||||
|
||||
- The key reaches the pane only as tmux `-e CURSOR_API_KEY=…` (a member's
|
||||
launch file under S3), which `cursor-agent` prefers over the account's own
|
||||
`cursor-agent login` for that process only. Nothing is written into a home.
|
||||
- Cursor reports no usage or quota to AgentHub, so the account with the fewest
|
||||
live Cursor panes is picked.
|
||||
|
||||
> **Ceiling:** a pane can read its own key (`$CURSOR_API_KEY`).
|
||||
|
||||
## Account pool groups
|
||||
|
||||
By default every user shares every pool account. **Groups** split the pool
|
||||
by team (Users page → **Account pool → Groups**, server mode only,
|
||||
`server/lib/pool-groups.js`):
|
||||
|
||||
- A group has a name, members and accounts of every pool tool (Claude, Codex,
|
||||
Cursor). Its members' new sessions run only on its accounts of that tool; a
|
||||
group with no account of a tool leaves its members on their own sign-in.
|
||||
- A user belongs to at most one group (ticking them in another moves them).
|
||||
An account may serve several groups.
|
||||
- A user in no group runs on the **shared pool**: the accounts no group lists.
|
||||
With no group at all, everything is shared, as before.
|
||||
- A pin (Edit on an account) is an admin's explicit choice and wins over the
|
||||
group.
|
||||
- A login already live in a member's home stays until their panes end (one
|
||||
`.credentials.json` / `auth.json` per home), even after a group change.
|
||||
- Each change needs the admin's own password again and is audited. Removing
|
||||
an account takes it out of every group; deleting a group sends its members
|
||||
back to the shared pool and removes no account.
|
||||
|
||||
The tab bar also shows Oh My Pi (its login sharing, below) and every other
|
||||
installed tool, which says it has no pool (Antigravity keeps its Google login
|
||||
in the system keyring; there is no key AgentHub could hand a session).
|
||||
|
||||
The Claude and Codex tabs list the saved profiles of Settings → AI tools →
|
||||
Profiles as cards; **Share to pool** moves one in (the profile leaves the
|
||||
switcher, as with **Move to pool** in Settings).
|
||||
|
||||
## Oh My Pi login sharing
|
||||
|
||||
With one Linux user per account, a member's omp has its own `~/.omp` and no
|
||||
login. An admin can let every member's omp run on the HOST account's omp
|
||||
logins instead: **Settings → AI tools → Oh My Pi login sharing** (off by
|
||||
default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
||||
logins instead: **Settings → AI tools → Oh My Pi login sharing**, or the
|
||||
**Oh My Pi** tab of the Users page's Account pool (off by default; `PUT /api/ai-tools/omp-sharing`, audited). It uses omp's own auth
|
||||
broker and gateway (`server/lib/omp-share.js`):
|
||||
|
||||
```bash
|
||||
@@ -685,7 +738,7 @@ What runs as the member, and how:
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **More keys** (named keys for extra accounts) and **Other hosts** (advanced); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
|
||||
@@ -8,8 +8,8 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||
|
||||
| Kind | What it is | Where it comes from |
|
||||
|---|---|---|
|
||||
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
|
||||
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; or uploaded from the computer |
|
||||
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's (or a Claude Docs doc-creating `batch`'s) PostToolUse hook, or a link pasted by hand |
|
||||
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; a pptx/docx/xlsx/pdf/html the session created or changed, captured at its Stop (`auto`); or uploaded from the computer |
|
||||
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
|
||||
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
||||
|
||||
|
||||
+5325
-628
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.17",
|
||||
"version": "2.8.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.17",
|
||||
"version": "2.8.21",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.17",
|
||||
"version": "2.8.21",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
|
||||
* only "Command failed: <argv>".
|
||||
*
|
||||
* While a step runs, `plugin-update-progress.json` beside it names that step
|
||||
* (`progress`), so the dashboard can say how far the chain got rather than
|
||||
* only that it is busy; a final verdict removes it.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -43,6 +47,10 @@ const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
|
||||
// main() — which a require never runs — writes these.
|
||||
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
|
||||
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
|
||||
const PROGRESS_FILE = path.join(runtimeDirArg || "", "plugin-update-progress.json");
|
||||
|
||||
/** The chain's steps, in order, as `progress` reports them. */
|
||||
const STEPS = ["marketplace_update", "plugin_update", "bootstrap"];
|
||||
|
||||
// Names come from the local cache PATH, never from an HTTP body, but they still
|
||||
// reach an argv — so they are re-validated here rather than trusted across the
|
||||
@@ -59,13 +67,34 @@ function log(line) {
|
||||
}
|
||||
}
|
||||
|
||||
function record(result) {
|
||||
/** Every verdict but the pre-written handover one (`final: false`) ends the
|
||||
* chain, so its progress record goes with it. */
|
||||
function record(result, { final = true } = {}) {
|
||||
try {
|
||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
RESULT_FILE,
|
||||
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
|
||||
);
|
||||
if (final) fs.rmSync(PROGRESS_FILE, { force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Say which step is running now — a courtesy for the UI, never a verdict. */
|
||||
function progress(step) {
|
||||
try {
|
||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
PROGRESS_FILE,
|
||||
JSON.stringify({
|
||||
step,
|
||||
index: STEPS.indexOf(step) + 1,
|
||||
total: STEPS.length,
|
||||
started_at: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
@@ -182,6 +211,7 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
progress("marketplace_update");
|
||||
try {
|
||||
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
||||
} catch (err) {
|
||||
@@ -189,6 +219,7 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
progress("plugin_update");
|
||||
try {
|
||||
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
||||
} catch (err) {
|
||||
@@ -219,12 +250,16 @@ function main() {
|
||||
// `last_plugin_update: null` for an update that had in fact succeeded. A
|
||||
// pre-written record is overwritten by the real outcome when this survives,
|
||||
// and is the outcome when it does not.
|
||||
record({
|
||||
ok: true,
|
||||
step: "bootstrap",
|
||||
plugin_root: newRoot,
|
||||
note: "the new plugin version is installed; the server is being handed over",
|
||||
});
|
||||
record(
|
||||
{
|
||||
ok: true,
|
||||
step: "bootstrap",
|
||||
plugin_root: newRoot,
|
||||
note: "the new plugin version is installed; the server is being handed over",
|
||||
},
|
||||
{ final: false }
|
||||
);
|
||||
progress("bootstrap");
|
||||
try {
|
||||
// Foreground and blocking: this stops the old server (us), waits for the
|
||||
// ports, then starts the new one. It must finish before we record success.
|
||||
|
||||
@@ -94,7 +94,6 @@ const EXPECTED_API_PATHS = [
|
||||
"/api/run/files",
|
||||
"/api/run/binary",
|
||||
"/api/run/{id}",
|
||||
"/api/run/{id}/message",
|
||||
"/api/webhooks",
|
||||
"/api/webhooks/providers",
|
||||
"/api/webhooks/{id}",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
||||
* junk skipped, nothing stored when one fails) or written in the dialog, a
|
||||
* file uploaded, sharing refused in local mode, visibility for a server-mode
|
||||
* developer, the admin's installs switch, and same-origin writes.
|
||||
* developer, the admin's installs switch, same-origin writes, Claude Docs
|
||||
* docs captured at their birth, documents captured at a session's stop, and
|
||||
* unowned rows claimed, declined and assigned.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -120,6 +122,42 @@ describe("pages", () => {
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||
});
|
||||
|
||||
it("records a Claude Docs doc at its birth, never on a later batch", () => {
|
||||
const tool = "mcp__claude_ai_Claude_Docs__batch";
|
||||
assert.equal(artifacts.isPageTool(tool), true);
|
||||
assert.equal(artifacts.isPageTool("mcp__claude_ai_Claude_Docs__update"), false);
|
||||
assert.equal(artifacts.isPageTool("Artifact"), true);
|
||||
const row = artifacts.captureFromHook("s1", {
|
||||
tool_name: tool,
|
||||
tool_input: { container: { kind: "project", create: { name: "Q3 plan" } }, batch: [] },
|
||||
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocAbc123" } }),
|
||||
});
|
||||
assert.equal(row.url, "https://claude.ai/artifact/DocAbc123");
|
||||
assert.equal(row.title, "Q3 plan");
|
||||
const edit = artifacts.captureFromHook("s1", {
|
||||
tool_name: tool,
|
||||
tool_input: { container: { kind: "project", id: "x" }, batch: [] },
|
||||
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocOther9" } }),
|
||||
});
|
||||
assert.equal(edit, null);
|
||||
// The doc's own link wins over one its content cites earlier in the ack.
|
||||
const cited = artifacts.captureFromHook("s1", {
|
||||
tool_name: tool,
|
||||
tool_input: { container: { kind: "project", create: { name: "Notes" } }, batch: [] },
|
||||
tool_response: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
JSON.stringify({
|
||||
acks: [{ xml: "see https://claude.ai/artifact/CitedOne1" }],
|
||||
frame: { artifactUrl: "https://claude.ai/artifact/OwnDoc22" },
|
||||
}) + "\n\nThe doc's viewer is at https://claude.ai/artifact/OwnDoc22 — open it.",
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(cited.url, "https://claude.ai/artifact/OwnDoc22");
|
||||
});
|
||||
|
||||
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
||||
const uid = db
|
||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
||||
@@ -711,3 +749,101 @@ describe("search, frontmatter and marketplace limits", () => {
|
||||
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outputs captured at a session's stop", () => {
|
||||
it("snapshots new documents once, refreshes a changed one, ignores the rest", async () => {
|
||||
const past = new Date(Date.now() - 3600 * 1000);
|
||||
fs.utimesSync(path.join(ws, "out", "report.html"), past, past); // older than the session
|
||||
const deck = path.join(ws, "out", "deck.pptx");
|
||||
fs.writeFileSync(deck, "PK-deck-v1");
|
||||
fs.writeFileSync(path.join(ws, "notes.md"), "not an output");
|
||||
const soon = new Date(Date.now() + 1000); // even a future mtime is captured once
|
||||
fs.utimesSync(deck, soon, soon);
|
||||
|
||||
const first = await artifacts.captureOutputs("s1");
|
||||
assert.deepEqual(
|
||||
first.map((r) => r.source),
|
||||
["out/deck.pptx"]
|
||||
);
|
||||
assert.equal(first[0].auto, 1);
|
||||
assert.equal(first[0].lane_id, lane.id);
|
||||
assert.equal((await artifacts.captureOutputs("s1")).length, 0, "unchanged: not again");
|
||||
|
||||
fs.writeFileSync(deck, "PK-deck-v2");
|
||||
const later = new Date(Date.now() + 60 * 1000);
|
||||
fs.utimesSync(deck, later, later);
|
||||
const again = await artifacts.captureOutputs("s1");
|
||||
assert.equal(again.length, 1);
|
||||
assert.equal(again[0].id, first[0].id, "same row, new snapshot");
|
||||
assert.notEqual(again[0].snapshot, first[0].snapshot);
|
||||
assert.equal(
|
||||
artifacts.list(null, { kind: "file" }).find((i) => i.id === first[0].id).auto,
|
||||
true
|
||||
);
|
||||
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unowned rows", () => {
|
||||
it("are suggested, claimed, declined and assigned only by the right people", () => {
|
||||
const addUser = (name, role) =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)")
|
||||
.run(name, role).lastInsertRowid
|
||||
);
|
||||
const bobId = addUser("bob-u", "developer");
|
||||
const amyId = addUser("amy-u", "developer");
|
||||
const adminId = addUser("root-u", "admin");
|
||||
const bob = { method: "POST", user: { id: bobId, role: "developer", username: "bob-u" } };
|
||||
const amy = { method: "POST", user: { id: amyId, role: "developer", username: "amy-u" } };
|
||||
const admin = { method: "POST", user: { id: adminId, role: "admin", username: "root-u" } };
|
||||
db.prepare("UPDATE lanes SET owner_id = ? WHERE id = ?").run(bobId, lane.id);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
try {
|
||||
// A host session with no workspace, run inside bob's workspace folder.
|
||||
const row = artifacts.captureFromHook(
|
||||
"loose-host",
|
||||
{
|
||||
tool_input: { action: "publish" },
|
||||
tool_response: "https://claude.ai/artifact/un1",
|
||||
cwd: ws,
|
||||
},
|
||||
{ kind: "host" }
|
||||
);
|
||||
assert.equal(row.owner_id, null);
|
||||
assert.equal(row.suggested_owner_id, bobId);
|
||||
assert.ok(
|
||||
artifacts.list(bob, { scope: "unowned" }).some((i) => i.id === row.id && i.suggestedForMe)
|
||||
);
|
||||
assert.ok(
|
||||
artifacts.list(admin, { scope: "unowned" }).some((i) => i.id === row.id && i.unowned)
|
||||
);
|
||||
assert.equal(artifacts.list(amy, { scope: "unowned" }).length, 0);
|
||||
assert.equal(artifacts.canSeeId(amy, row.id), false);
|
||||
assert.throws(() => artifacts.claim(amy, row.id), { code: "ENOTFOUND" });
|
||||
|
||||
const claimed = artifacts.claim(bob, row.id);
|
||||
assert.equal(claimed.ownerId, bobId);
|
||||
assert.throws(() => artifacts.claim(bob, row.id), { code: "EOWNED" });
|
||||
|
||||
const other = artifacts.captureFromHook(
|
||||
"loose-host-2",
|
||||
{
|
||||
tool_input: { action: "publish" },
|
||||
tool_response: "https://claude.ai/artifact/un2",
|
||||
cwd: ws,
|
||||
},
|
||||
{ kind: "host" }
|
||||
);
|
||||
assert.deepEqual(artifacts.decline(bob, other.id), { id: other.id, declined: true });
|
||||
assert.equal(artifacts.canSeeId(bob, other.id), false, "declined: no longer offered");
|
||||
assert.throws(() => artifacts.assign(bob, other.id, bobId), { code: "ENOTFOUND" });
|
||||
assert.throws(() => artifacts.assign(admin, other.id, 999999), { code: "EBADFIELD" });
|
||||
assert.equal(artifacts.assign(admin, other.id, amyId).ownerId, amyId);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
}
|
||||
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1215,4 +1215,15 @@ describe("/api/cc-config", () => {
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADARG");
|
||||
});
|
||||
|
||||
it("refuses a write from a foreign origin before it reaches the CLI", async () => {
|
||||
for (const p of ["/api/cc-config/mcp/add", "/api/cc-config/plugins/install"]) {
|
||||
const { status } = await fetchJson(p, {
|
||||
method: "POST",
|
||||
body: { name: "x", command: "true", plugin: "x@y" },
|
||||
headers: { Origin: "https://evil.example" },
|
||||
});
|
||||
assert.equal(status, 403, p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @file cursor-pool.test.js
|
||||
* @description The Cursor account pool (server/lib/cursor-pool.js), fake keys
|
||||
* only: key validation and file modes, the balancer (fewest live Cursor panes,
|
||||
* a pin wins), the key reaching a `cursor-agent` pane only through tmux `-e
|
||||
* CURSOR_API_KEY` and never its argv, the account recorded on the run, pins
|
||||
* for `cursor` and the account_pins rebuild that drops the old tool CHECK, and
|
||||
* an empty pool leaving every spawn as it was.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "cursor-pool-test-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "claude");
|
||||
|
||||
const { db } = require("../db");
|
||||
const pool = require("../lib/cursor-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
const tmux = require("../lib/tmux");
|
||||
const pty = require("../lib/pty-run");
|
||||
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
let tmuxArgv;
|
||||
|
||||
const envOf = (argv, name) =>
|
||||
argv
|
||||
.slice(0, argv.indexOf("--"))
|
||||
.find((x) => String(x).startsWith(`${name}=`))
|
||||
?.slice(name.length + 1);
|
||||
|
||||
function run(id, account, status = "running") {
|
||||
db.prepare(
|
||||
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, tool)
|
||||
VALUES (?, 'interactive', '/tmp', ?, ?, ?, 'cursor')`
|
||||
).run(id, status, new Date().toISOString(), account);
|
||||
}
|
||||
|
||||
before(() => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") tmuxArgv = args;
|
||||
return "";
|
||||
});
|
||||
});
|
||||
|
||||
after(() => {
|
||||
tmux.__setExecImpl(null);
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
|
||||
db.prepare("DELETE FROM dashboard_runs").run();
|
||||
tmuxArgv = null;
|
||||
});
|
||||
|
||||
describe("cursor pool: accounts", () => {
|
||||
it("stores a key 0600 in a 0700 dir and never lists it", () => {
|
||||
const added = pool.addAccount("team", { apiKey: KEY("a") });
|
||||
assert.deepEqual(Object.keys(added).sort(), ["addedAt", "kind", "name"]);
|
||||
assert.equal(fs.statSync(pool.poolDir()).mode & 0o777, 0o700);
|
||||
assert.equal(fs.statSync(path.join(pool.poolDir(), "team.json")).mode & 0o777, 0o600);
|
||||
const listed = pool.poolStatus();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.ok(!JSON.stringify(listed).includes(KEY("a")));
|
||||
assert.equal(listed[0].quotaPercent, null);
|
||||
});
|
||||
|
||||
it("refuses a bad name, a bad key and a taken name", () => {
|
||||
assert.throws(() => pool.addAccount("../x", { apiKey: KEY("a") }), { code: "EBADNAME" });
|
||||
assert.throws(() => pool.addAccount("x", { apiKey: "short" }), { code: "EBADTOKEN" });
|
||||
assert.throws(() => pool.addAccount("x", { apiKey: `${KEY("a")}\nX=1` }), {
|
||||
code: "EBADTOKEN",
|
||||
});
|
||||
pool.addAccount("x", { apiKey: KEY("a") });
|
||||
assert.throws(() => pool.addAccount("x", { apiKey: KEY("b") }), { code: "EEXISTS" });
|
||||
pool.removeAccount("x");
|
||||
assert.throws(() => pool.removeAccount("x"), { code: "ENOACCOUNT" });
|
||||
});
|
||||
|
||||
it("picks the account with the fewest live panes", () => {
|
||||
pool.addAccount("a", { apiKey: KEY("a") });
|
||||
pool.addAccount("b", { apiKey: KEY("b") });
|
||||
run("r1", "a");
|
||||
run("r2", "b", "completed");
|
||||
assert.equal(pool.pickAccount().name, "b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor pool: spawn", () => {
|
||||
it("an empty pool leaves a Cursor spawn exactly as before", () => {
|
||||
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "cursor" });
|
||||
assert.equal(envOf(tmuxArgv, "CURSOR_API_KEY"), undefined);
|
||||
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||
assert.equal(row.account, null);
|
||||
});
|
||||
|
||||
it("the key reaches the pane through tmux -e only, and the run names its account", () => {
|
||||
pool.addAccount("a", { apiKey: KEY("a") });
|
||||
pty.spawnRun({ laneId: 7, cwd: "/tmp", tool: "cursor", initialPrompt: "hi" });
|
||||
assert.equal(envOf(tmuxArgv, "CURSOR_API_KEY"), KEY("a"));
|
||||
const after = tmuxArgv.slice(tmuxArgv.indexOf("--"));
|
||||
assert.ok(!after.some((x) => String(x).includes(KEY("a"))), "never in the pane's argv");
|
||||
const row = db.prepare("SELECT account FROM dashboard_runs WHERE id = 'agenthub-lane-7'").get();
|
||||
assert.equal(row.account, "a");
|
||||
|
||||
// Other tools never get it.
|
||||
tmuxArgv = null;
|
||||
pty.spawnRun({ laneId: 8, cwd: "/tmp", tool: "claude" });
|
||||
assert.ok(!tmuxArgv.some((x) => String(x).includes("CURSOR_API_KEY")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor pool: pins", () => {
|
||||
it("account_pins takes a cursor pin, and removing the account drops it", () => {
|
||||
const sql = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='account_pins'")
|
||||
.get().sql;
|
||||
assert.ok(!sql.includes("CHECK(tool IN"), "no tool CHECK left");
|
||||
assert.ok(pins.TOOLS.includes("cursor"));
|
||||
const uid = db
|
||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('pinned', 'x')")
|
||||
.run().lastInsertRowid;
|
||||
pool.addAccount("a", { apiKey: KEY("a") });
|
||||
db.prepare("INSERT INTO account_pins (user_id, tool, account) VALUES (?, 'cursor', 'a')").run(
|
||||
uid
|
||||
);
|
||||
assert.equal(pins.listPins("cursor").length, 1);
|
||||
pool.removeAccount("a");
|
||||
assert.equal(pins.listPins("cursor").length, 0);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,9 @@
|
||||
* another account never sees it through `/api/me`; staff read it at
|
||||
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
||||
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
||||
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) takes https only and
|
||||
* never reaches a loopback/private address.
|
||||
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never
|
||||
* downloaded, and the avatar routes redirect to it; https only, never a
|
||||
* loopback/private IP literal.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -110,6 +111,46 @@ it("refuses an SVG, an empty body, a too-large image and no login", async () =>
|
||||
assert.equal((await put("", PNG)).status, 401);
|
||||
});
|
||||
|
||||
it("a picture from a link is stored and redirected to, never downloaded", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const ann = await login("ann", "ann-pass-1");
|
||||
const link =
|
||||
"https://chat.runsystem.vn/api/v4/users/7bya4wy58bdk3qmb5ks1rgoqkh/image?_=1776312011651";
|
||||
assert.equal((await put(ava, PNG)).status, 200);
|
||||
const set = await fetch(BASE + "/api/me/avatar/url", {
|
||||
method: "POST",
|
||||
headers: { cookie: ava, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: ` ${link} ` }),
|
||||
});
|
||||
assert.equal(set.status, 200);
|
||||
assert.ok((await set.json()).user.avatarAt);
|
||||
// The uploaded file is gone: a link replaces it.
|
||||
assert.equal(fs.readdirSync(path.join(TMP, "avatars")).length, 0);
|
||||
|
||||
const got = await fetch(BASE + "/api/me/avatar", {
|
||||
headers: { cookie: ava },
|
||||
redirect: "manual",
|
||||
});
|
||||
assert.equal(got.status, 302);
|
||||
assert.equal(got.headers.get("location"), link);
|
||||
assert.equal(got.headers.get("referrer-policy"), "no-referrer");
|
||||
const id = auth.findUser("ava").id;
|
||||
const staff = await fetch(`${BASE}/api/users/${id}/avatar`, {
|
||||
headers: { cookie: ann },
|
||||
redirect: "manual",
|
||||
});
|
||||
assert.equal(staff.status, 302);
|
||||
assert.equal(staff.headers.get("location"), link);
|
||||
|
||||
// An upload replaces the link.
|
||||
assert.equal((await put(ava, PNG)).status, 200);
|
||||
assert.equal(
|
||||
(await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava }, redirect: "manual" })).status,
|
||||
200
|
||||
);
|
||||
await fetch(BASE + "/api/me/avatar", { method: "DELETE", headers: { cookie: ava } });
|
||||
});
|
||||
|
||||
it("a picture from a link: https only, never a private or local address", async () => {
|
||||
const ava = await login("ava", "ava-pass-1");
|
||||
const post = (url) =>
|
||||
|
||||
@@ -19,7 +19,12 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update");
|
||||
const {
|
||||
readLastPluginUpdate,
|
||||
readPluginUpdateProgress,
|
||||
schedulePluginUpdate,
|
||||
runtimeDir,
|
||||
} = require("../lib/plugin-update");
|
||||
|
||||
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
|
||||
|
||||
@@ -84,6 +89,43 @@ describe("readLastPluginUpdate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("readPluginUpdateProgress", () => {
|
||||
const write = (name, body) => {
|
||||
const rt = path.join(tmpDir, name);
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
if (body !== undefined) fs.writeFileSync(path.join(rt, "plugin-update-progress.json"), body);
|
||||
return rt;
|
||||
};
|
||||
|
||||
it("is null with no file and for malformed JSON", () => {
|
||||
assert.equal(readPluginUpdateProgress(write("prog-none")), null);
|
||||
assert.equal(readPluginUpdateProgress(write("prog-bad", "{nope")), null);
|
||||
});
|
||||
|
||||
it("reads the running step", () => {
|
||||
const started_at = new Date().toISOString();
|
||||
const rt = write(
|
||||
"prog-ok",
|
||||
JSON.stringify({ step: "plugin_update", index: 2, total: 3, started_at })
|
||||
);
|
||||
assert.deepEqual(readPluginUpdateProgress(rt), {
|
||||
step: "plugin_update",
|
||||
index: 2,
|
||||
total: 3,
|
||||
started_at,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a file left by a helper that died long ago", () => {
|
||||
const old = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const rt = write(
|
||||
"prog-stale",
|
||||
JSON.stringify({ step: "bootstrap", index: 3, total: 3, started_at: old })
|
||||
);
|
||||
assert.equal(readPluginUpdateProgress(rt), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedulePluginUpdate", () => {
|
||||
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
|
||||
// Redirect the runtime dir into the temp tree — this must not touch the
|
||||
@@ -152,6 +194,20 @@ describe("plugin-update-helper name guard", () => {
|
||||
assert.equal(v.step, "validate");
|
||||
});
|
||||
|
||||
it("removes a progress record with its final verdict", () => {
|
||||
const rt = path.join(tmpDir, "guard-progress");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rt, "plugin-update-progress.json"),
|
||||
JSON.stringify({ step: "plugin_update", started_at: new Date().toISOString() })
|
||||
);
|
||||
execFileSync(process.execPath, [HELPER, "../evil", "agenthub", "/nonexistent/root", rt], {
|
||||
stdio: "ignore",
|
||||
timeout: 30_000,
|
||||
});
|
||||
assert.equal(fs.existsSync(path.join(rt, "plugin-update-progress.json")), false);
|
||||
});
|
||||
|
||||
it("stamps every verdict with a finish time", () => {
|
||||
// The failure mode this file exists to prevent is a helper that dies
|
||||
// silently: the tab would poll a server that never returns and say nothing.
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @file pool-groups.test.js
|
||||
* @description Account pool groups (server/lib/pool-groups.js and the
|
||||
* `/api/accounts/groups` routes), fake accounts only: with no group every
|
||||
* account is shared as before; a group's member gets only the group's
|
||||
* accounts of each tool (none → no pool account), a user in no group only
|
||||
* the accounts no group lists, a pin wins over the group; one group per user;
|
||||
* a removed account or deleted group leaves no rows behind; and the routes
|
||||
* need an admin's own password again, are audited and refuse in local mode.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "pool-groups-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const groups = require("../lib/pool-groups");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
const claudePool = require("../lib/claude-pool");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
const KEY = (c) => `key_${c.repeat(40)}`;
|
||||
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
|
||||
let server;
|
||||
let BASE;
|
||||
let MIA;
|
||||
let LEO;
|
||||
let ZOE;
|
||||
|
||||
async function call(method, p, { cookie, body } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.headers.get("set-cookie").split(";")[0];
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
await auth.createUser("boss", BOSS_PASS, "admin");
|
||||
MIA = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||
LEO = (await auth.createUser("leo", "member-pass-2", "developer")).id;
|
||||
ZOE = (await auth.createUser("zoe", "member-pass-3", "developer")).id;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.prepare("DELETE FROM pool_groups").run();
|
||||
db.prepare("DELETE FROM account_pins").run();
|
||||
for (const dir of [cursorPool.poolDir(), claudePool.poolDir()]) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const c of ["a", "b", "c"]) cursorPool.addAccount(c, { apiKey: KEY(c) });
|
||||
});
|
||||
|
||||
describe("pool groups: who may take which account", () => {
|
||||
it("shares every account while no group exists", () => {
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "a");
|
||||
assert.ok(["a", "b", "c"].every(groups.allowed(MIA, "cursor")));
|
||||
});
|
||||
|
||||
it("gives a member only the group's accounts, the rest only the shared ones", () => {
|
||||
const g = groups.createGroup("Team A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "b");
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "a", "b is no longer shared");
|
||||
// An account may serve several groups; a group with no account of a tool
|
||||
// leaves its members on their own sign-in.
|
||||
const h = groups.createGroup("Team B");
|
||||
groups.setGroupUsers(h.id, [ZOE]);
|
||||
assert.equal(cursorPool.pickAccount(ZOE), null);
|
||||
groups.setGroupAccounts(h.id, "cursor", ["b", "c"]);
|
||||
assert.ok(["b", "c"].includes(cursorPool.pickAccount(ZOE).name));
|
||||
assert.equal(cursorPool.pickAccount(LEO).name, "a");
|
||||
// Claude follows the same groups.
|
||||
claudePool.addAccount("shared", TOKEN("s"));
|
||||
claudePool.addAccount("team", TOKEN("t"));
|
||||
groups.setGroupAccounts(g.id, "claude", ["team"]);
|
||||
assert.equal(claudePool.pickAccount(null, MIA).name, "team");
|
||||
assert.equal(claudePool.pickAccount(null, LEO).name, "shared");
|
||||
});
|
||||
|
||||
it("puts a user in one group only, and honours a pin outside it", () => {
|
||||
const g = groups.createGroup("A");
|
||||
const h = groups.createGroup("B");
|
||||
groups.setGroupUsers(g.id, [MIA, LEO]);
|
||||
groups.setGroupUsers(h.id, [MIA]);
|
||||
const list = groups.listGroups();
|
||||
assert.deepEqual(
|
||||
list.find((x) => x.name === "A").users.map((u) => u.username),
|
||||
["leo"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
list.find((x) => x.name === "B").users.map((u) => u.username),
|
||||
["mia"]
|
||||
);
|
||||
groups.setGroupAccounts(h.id, "cursor", ["b"]);
|
||||
pins.setPin(MIA, "cursor", "c");
|
||||
assert.equal(cursorPool.pickAccount(MIA).name, "c");
|
||||
});
|
||||
|
||||
it("drops a removed account from its groups, and a group's rows with it", () => {
|
||||
const g = groups.createGroup("A");
|
||||
groups.setGroupUsers(g.id, [MIA]);
|
||||
groups.setGroupAccounts(g.id, "cursor", ["b"]);
|
||||
cursorPool.removeAccount("b");
|
||||
assert.deepEqual(groups.listGroups()[0].accounts.cursor, []);
|
||||
groups.deleteGroup(g.id);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM pool_group_users").get().n, 0);
|
||||
assert.throws(() => groups.setGroupAccounts(g.id, "cursor", ["a"]), { code: "ENOGROUP" });
|
||||
assert.throws(() => groups.createGroup(""), { code: "EBADNAME" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pool groups: routes", () => {
|
||||
it("creates, edits and deletes a group with the admin's own password, audited", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const bad = await call("POST", "/api/accounts/groups", {
|
||||
cookie: boss,
|
||||
body: { name: "Team A", currentPassword: "nope" },
|
||||
});
|
||||
assert.equal(bad.body.error.code, "EREAUTH");
|
||||
const made = await call("POST", "/api/accounts/groups", {
|
||||
cookie: boss,
|
||||
body: { name: "Team A", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(made.status, 201);
|
||||
const id = made.body.group.id;
|
||||
const dup = await call("POST", "/api/accounts/groups", {
|
||||
cookie: boss,
|
||||
body: { name: "team a", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(dup.body.error.code, "EEXISTS");
|
||||
const edit = await call("PATCH", `/api/accounts/groups/${id}`, {
|
||||
cookie: boss,
|
||||
body: {
|
||||
name: "Team Alpha",
|
||||
userIds: [MIA],
|
||||
accounts: { cursor: ["a"] },
|
||||
currentPassword: BOSS_PASS,
|
||||
},
|
||||
});
|
||||
assert.equal(edit.status, 200);
|
||||
assert.equal(edit.body.group.name, "Team Alpha");
|
||||
assert.deepEqual(edit.body.group.accounts.cursor, ["a"]);
|
||||
const missing = await call("PATCH", `/api/accounts/groups/${id}`, {
|
||||
cookie: boss,
|
||||
body: { accounts: { cursor: ["nope"] }, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(missing.body.error.code, "ENOACCOUNT");
|
||||
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||
assert.equal(list.body.groups[0].users[0].username, "mia");
|
||||
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
|
||||
assert.match(audit.path, /group Team Alpha cursor accounts/);
|
||||
const dev = await login("leo", "member-pass-2");
|
||||
const refused = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||
cookie: dev,
|
||||
body: { currentPassword: "member-pass-2" },
|
||||
});
|
||||
assert.equal(refused.status, 403);
|
||||
const del = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(del.status, 200);
|
||||
const gone = await call("DELETE", `/api/accounts/groups/${id}`, {
|
||||
cookie: boss,
|
||||
body: { currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(gone.status, 404);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file server-mode-account-pool.test.js
|
||||
* @description The Claude/Codex account pool from the Users page: only an
|
||||
* @description The Claude/Codex/Cursor account pool from the Users page: only an
|
||||
* 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
|
||||
@@ -25,6 +25,7 @@ const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const claudePool = require("../lib/claude-pool");
|
||||
const codexPool = require("../lib/codex-pool");
|
||||
const cursorPool = require("../lib/cursor-pool");
|
||||
|
||||
// The quota read would go to the network; the pool's list must not.
|
||||
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||
@@ -113,6 +114,30 @@ describe("the account pool from the Users page", () => {
|
||||
assert.equal(gone.status, 404);
|
||||
});
|
||||
|
||||
it("adds a Cursor API key, lists it under `cursor` and removes it", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const CKEY = `key_${"c".repeat(40)}`;
|
||||
const add = await call("POST", "/api/accounts", {
|
||||
cookie: boss,
|
||||
body: { tool: "cursor", name: "cu-1", token: CKEY, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(add.status, 201);
|
||||
assert.equal(cursorPool.__read("cu-1").key, CKEY);
|
||||
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||
assert.deepEqual(
|
||||
list.body.cursor.map((a) => a.name),
|
||||
["cu-1"]
|
||||
);
|
||||
assert.deepEqual(list.body.pins.cursor, []);
|
||||
assert.ok(!JSON.stringify(list.body).includes(CKEY));
|
||||
const del = await call("DELETE", "/api/accounts/cu-1", {
|
||||
cookie: boss,
|
||||
body: { tool: "cursor", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(del.status, 200);
|
||||
assert.equal(cursorPool.__read("cu-1"), null);
|
||||
});
|
||||
|
||||
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const add = (body) =>
|
||||
@@ -123,7 +148,7 @@ describe("the account pool from the Users page", () => {
|
||||
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
|
||||
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
|
||||
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
|
||||
assert.equal((await add({ tool: "cursor" })).body.error.code, "EBADTOOL");
|
||||
assert.equal((await add({ tool: "antigravity" })).body.error.code, "EBADTOOL");
|
||||
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ describe("staff views", () => {
|
||||
assert.equal(mia.workspaces, 1);
|
||||
assert.equal(mia.sessions, 1);
|
||||
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
|
||||
// This hour's spend is today's in every timezone.
|
||||
assert.equal(mia.today.cost, mia.last7d.cost);
|
||||
const east = await call("/api/users?tz_offset=-840", admin);
|
||||
assert.equal(east.body.users.find((u) => u.username === "mia").today.tokens, mia.today.tokens);
|
||||
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
||||
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
|
||||
const member = await call("/api/users", await login("mia", "member-pass-1"));
|
||||
|
||||
+67
-5
@@ -960,6 +960,20 @@ try {
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE artifacts ADD COLUMN in_library INTEGER NOT NULL DEFAULT 1").run();
|
||||
}
|
||||
// `auto = 1`: a document file a session wrote, captured when it stopped
|
||||
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
|
||||
// no owner, the user whose folder the session ran in — they may claim it or
|
||||
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
|
||||
for (const [col, ddl] of [
|
||||
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
|
||||
["suggested_owner_id", "suggested_owner_id INTEGER"],
|
||||
]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
|
||||
} catch {
|
||||
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
|
||||
}
|
||||
}
|
||||
|
||||
// One row per install of an artifact (server/lib/artifacts.js `install`):
|
||||
// who, when, and the folders it was written to — `artifacts.installs` only
|
||||
@@ -1933,9 +1947,10 @@ try {
|
||||
}
|
||||
// An account's own display name and email (the Users page's Edit user and
|
||||
// Settings → Account), and when its avatar was last set (server/lib/avatars.js
|
||||
// — the image is a file, the column only versions its URL). Additive; after
|
||||
// the rebuild above, which predates them.
|
||||
for (const col of ["display_name", "email", "avatar_at"]) {
|
||||
// — the image is a file, the column only versions its URL — or `avatar_link`,
|
||||
// a link the avatar routes redirect to). Additive; after the rebuild above,
|
||||
// which predates them.
|
||||
for (const col of ["display_name", "email", "avatar_at", "avatar_link"]) {
|
||||
try {
|
||||
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||
} catch {
|
||||
@@ -1957,16 +1972,63 @@ for (const [col, type] of [
|
||||
}
|
||||
// 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.
|
||||
// The account is named, not referenced — the pool lives in files. The tool
|
||||
// list is account-pins.js's `TOOLS`, not a CHECK (a new pool needs no rebuild).
|
||||
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')),
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
`);
|
||||
// Account pool groups (server/lib/pool-groups.js): a team's members run on the
|
||||
// team's own pool accounts. One group per user; an account, named like a pin,
|
||||
// may serve several groups. Accounts no group lists are the shared pool.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS pool_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS pool_group_users (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER NOT NULL REFERENCES pool_groups(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS pool_group_accounts (
|
||||
group_id INTEGER NOT NULL REFERENCES pool_groups(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, tool, account)
|
||||
);
|
||||
`);
|
||||
// A database from before the Cursor pool: its `tool` CHECK allowed only
|
||||
// claude/codex. Rebuilt with every pin kept.
|
||||
{
|
||||
const meta = db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='account_pins'")
|
||||
.get();
|
||||
if (meta?.sql?.includes("CHECK(tool IN")) {
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
CREATE TABLE account_pins_new (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tool TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
pinned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (user_id, tool)
|
||||
);
|
||||
INSERT INTO account_pins_new (user_id, tool, account, pinned_at)
|
||||
SELECT user_id, tool, account, pinned_at FROM account_pins;
|
||||
DROP TABLE account_pins;
|
||||
ALTER TABLE account_pins_new RENAME TO account_pins;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @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
|
||||
* balancer would pick (claude-pool.js / codex-pool.js / cursor-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
|
||||
@@ -19,14 +19,15 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const TOOLS = ["claude", "codex"];
|
||||
const TOOLS = ["claude", "codex", "cursor"];
|
||||
const POOLS = { claude: "./claude-pool", codex: "./codex-pool", cursor: "./cursor-pool" };
|
||||
|
||||
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");
|
||||
const poolOf = (tool) => require(POOLS[tool]);
|
||||
|
||||
function validTool(tool) {
|
||||
if (!TOOLS.includes(tool)) throw err("EBADTOOL", "tool: claude or codex");
|
||||
if (!TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${TOOLS.join(", ")}`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
@@ -132,13 +133,23 @@ async function applyPin(userId, tool) {
|
||||
return poolOf(tool).switchHome(member, pin);
|
||||
}
|
||||
|
||||
/** A removed account takes its pins with it. */
|
||||
/** A removed account takes its pins — and its place in every group — with it. */
|
||||
function dropAccount(tool, account) {
|
||||
try {
|
||||
db().prepare("DELETE FROM account_pins WHERE tool = ? AND account = ?").run(tool, account);
|
||||
} catch {
|
||||
/* no database (a bare CLI test): nothing pinned */
|
||||
}
|
||||
require("./pool-groups").dropAccount(tool, account);
|
||||
}
|
||||
|
||||
module.exports = { TOOLS, pinFor, listPins, setPin, setAccountUsers, applyPin, dropAccount };
|
||||
module.exports = {
|
||||
TOOLS,
|
||||
poolOf,
|
||||
pinFor,
|
||||
listPins,
|
||||
setPin,
|
||||
setAccountUsers,
|
||||
applyPin,
|
||||
dropAccount,
|
||||
};
|
||||
|
||||
+281
-18
@@ -6,8 +6,12 @@
|
||||
* Four kinds:
|
||||
* - `page` a claude.ai Artifact a session published: only a link, accepted
|
||||
* only as `https://claude.ai/…`. Captured from the `Artifact`
|
||||
* tool's PostToolUse hook (`captureFromHook`) or pasted by hand.
|
||||
* - `file` a file an agent wrote in a workspace, pinned by the user.
|
||||
* tool's PostToolUse hook (`captureFromHook`; a Claude Docs doc's
|
||||
* birth too) or pasted by hand. In server mode a row nobody owns
|
||||
* waits for its suggested user or an admin (`claim`/`decline`/`assign`).
|
||||
* - `file` a file an agent wrote in a workspace, pinned by the user — or a
|
||||
* document (pptx/docx/xlsx/pdf/html) a session created, captured
|
||||
* when it stops (`captureOutputs`, `auto`).
|
||||
* - `skill` a Claude Code skill folder (the user's own, or a workspace's
|
||||
* `.claude/skills/<name>`).
|
||||
* - `plugin` a plugin by `name@marketplace` and its marketplace source; no
|
||||
@@ -85,14 +89,21 @@ const installsOffFlag = () => path.join(root(), "installs-off");
|
||||
function visibleWhere(req) {
|
||||
const u = req?.user;
|
||||
if (!u || u.role === "admin" || u.role === "analyst") return { where: "1", params: [] };
|
||||
return { where: "(a.owner_id = ? OR a.shared = 1)", params: [u.id] };
|
||||
return {
|
||||
where: "(a.owner_id = ? OR a.shared = 1 OR (a.owner_id IS NULL AND a.suggested_owner_id = ?))",
|
||||
params: [u.id, u.id],
|
||||
};
|
||||
}
|
||||
|
||||
const canSee = (req, row) => {
|
||||
const u = req?.user;
|
||||
if (!row) return false;
|
||||
if (!u || u.role === "admin" || u.role === "analyst") return true;
|
||||
return row.owner_id === u.id || row.shared === 1;
|
||||
return (
|
||||
row.owner_id === u.id ||
|
||||
row.shared === 1 ||
|
||||
(row.owner_id == null && row.suggested_owner_id === u.id)
|
||||
);
|
||||
};
|
||||
|
||||
/** Only the owner changes a row — or an admin, or anyone in local mode. */
|
||||
@@ -101,7 +112,10 @@ const canChange = (req, row) =>
|
||||
|
||||
/** For scope.js's PATH_RULES: may this request name artifact `id`? */
|
||||
function canSeeId(req, id) {
|
||||
return canSee(req, sql("SELECT owner_id, shared FROM artifacts WHERE id = ?").get(Number(id)));
|
||||
return canSee(
|
||||
req,
|
||||
sql("SELECT owner_id, shared, suggested_owner_id FROM artifacts WHERE id = ?").get(Number(id))
|
||||
);
|
||||
}
|
||||
|
||||
function load(req, id) {
|
||||
@@ -144,6 +158,10 @@ function view(row, req, owners = null) {
|
||||
sharedAt: row.shared_at,
|
||||
inLibrary: row.in_library !== 0,
|
||||
installs: row.installs,
|
||||
auto: row.auto === 1,
|
||||
// Server mode only: a row nobody owns waits for an admin or its suggested user.
|
||||
unowned: isServer() && row.owner_id == null,
|
||||
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -152,7 +170,9 @@ function view(row, req, owners = null) {
|
||||
/**
|
||||
* The rows this request may see. `scope`: `mine` (own library rows plus the
|
||||
* shared skills/plugins of others the caller has installed; every row in
|
||||
* local mode), `hub` (shared rows, server mode), anything else = all visible.
|
||||
* local mode), `hub` (shared rows, server mode), `unowned` (server mode: rows
|
||||
* nobody owns — every one for an admin, those suggested to the caller for
|
||||
* anyone else), anything else = all visible.
|
||||
* Each row also carries `installed` (`installedCheck`).
|
||||
*/
|
||||
function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } = {}) {
|
||||
@@ -168,6 +188,13 @@ function list(req, { scope = "all", kind, q, laneId, sessionId, limit = 200 } =
|
||||
params.push(req.user.id, req.user.id);
|
||||
} else if (scope === "hub") {
|
||||
where.push("a.shared = 1");
|
||||
} else if (scope === "unowned") {
|
||||
if (!isServer() || !req?.user) return [];
|
||||
where.push("a.owner_id IS NULL");
|
||||
if (req.user.role !== "admin") {
|
||||
where.push("a.suggested_owner_id = ?");
|
||||
params.push(req.user.id);
|
||||
}
|
||||
}
|
||||
if (kind) {
|
||||
if (!KINDS.includes(kind)) throw fail("EBADFIELD", `kind: one of ${KINDS.join(", ")}`);
|
||||
@@ -297,23 +324,90 @@ function addPage(req, { url, title, description, laneId, sessionId }) {
|
||||
* connection came from (`hook-auth.js` `hookPeer`, by uid — never the body).
|
||||
* The host's account is shared by every admin, so it names nobody.
|
||||
*/
|
||||
/**
|
||||
* Whose a row with no owner probably is, from the folder its session ran in:
|
||||
* the owner of the workspace holding that folder, else (S3) the member whose
|
||||
* home it is. Only a suggestion — that user confirms (`claim`). Server mode only.
|
||||
*/
|
||||
function suggestOwner(cwd) {
|
||||
if (!isServer() || typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
|
||||
const lane = require("./lanes").resolveLaneByCwd(cwd);
|
||||
if (lane?.owner_id != null) return lane.owner_id;
|
||||
try {
|
||||
const acct = require("./run-as").ownerForRead(cwd);
|
||||
if (!acct?.name) return null;
|
||||
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(acct.name);
|
||||
return row ? row.id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function peerOwner(peer) {
|
||||
if (peer?.kind !== "member" || !peer.name) return null;
|
||||
const row = sql("SELECT id FROM users WHERE username = ? AND disabled = 0").get(peer.name);
|
||||
return row ? row.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A claude.ai connector's `batch` tool (Claude Docs today, `mcp__claude_ai_Claude_Docs__batch`):
|
||||
* the one that births a doc. Slides, dashboards and designs are made through the
|
||||
* `Artifact` tool (`type_url`) and are captured as its publish.
|
||||
*/
|
||||
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
|
||||
|
||||
/** Whether a PostToolUse of this tool may have published a claude.ai page. */
|
||||
function isPageTool(toolName) {
|
||||
return toolName === "Artifact" || DOCS_BATCH_RE.test(String(toolName || ""));
|
||||
}
|
||||
|
||||
/** The `container.create` of a Claude Docs `batch` — set only when it births a doc. */
|
||||
function docsBirth(input) {
|
||||
let container = input.container;
|
||||
if (typeof container === "string") {
|
||||
try {
|
||||
container = JSON.parse(container);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const create = container?.create;
|
||||
return create && typeof create === "object" ? create : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `frame.artifactUrl` (or `frame.url`) of a Claude Docs ack. The ack is JSON with a prose
|
||||
* trailer appended in the same text block, so it is matched, not parsed.
|
||||
*/
|
||||
const FRAME_URL_RE = /"frame":\{[^{}]*?"(?:artifactUrl|url)":"(https:\/\/claude\.ai\/[^"]+)"/;
|
||||
function docsFrameUrl(response) {
|
||||
const blocks = Array.isArray(response) ? response : [response];
|
||||
for (const b of blocks) {
|
||||
const raw = typeof b === "string" ? b : b?.text;
|
||||
const m = typeof raw === "string" ? FRAME_URL_RE.exec(raw) : null;
|
||||
if (m && PAGE_URL_RE.test(m[1])) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Artifact` tool's PostToolUse hook: a publish answers with the page's
|
||||
* claude.ai URL somewhere in its response. Recorded once per (owner, url),
|
||||
* owned by the session's workspace owner, else by the member who sent the
|
||||
* hook (`peerOwner`). Returns the row or null.
|
||||
* claude.ai URL somewhere in its response. A Claude Docs `batch` that births a
|
||||
* doc answers with the doc's link the same way (later batches edit it, and are
|
||||
* skipped). Recorded once per (owner, url), owned by the session's workspace
|
||||
* owner, else by the member who sent the hook (`peerOwner`). Returns the row
|
||||
* or null.
|
||||
*/
|
||||
function captureFromHook(sessionId, data, peer = null) {
|
||||
const input = data?.tool_input || {};
|
||||
const docs = DOCS_BATCH_RE.test(String(data?.tool_name || ""));
|
||||
const birth = docs ? docsBirth(input) : null;
|
||||
if (docs && !birth) return null;
|
||||
if (input.action && input.action !== "publish") return null;
|
||||
// A doc's own link is its ack's `frame`; the first link in the text may be one the doc's content cites.
|
||||
const framed = birth ? docsFrameUrl(data?.tool_response) : null;
|
||||
const text = JSON.stringify(data?.tool_response ?? "");
|
||||
const m = PAGE_IN_TEXT_RE.exec(text);
|
||||
const m = framed ? [framed] : PAGE_IN_TEXT_RE.exec(text);
|
||||
if (!m) return null;
|
||||
const url = m[0].replace(/[\\"]+$/, "");
|
||||
if (!PAGE_URL_RE.test(url)) return null;
|
||||
@@ -327,13 +421,14 @@ function captureFromHook(sessionId, data, peer = null) {
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
return insert({
|
||||
kind: "page",
|
||||
title: cleanTitle(input.title, file ? path.basename(file) : "Artifact"),
|
||||
title: cleanTitle(birth ? birth.name : input.title, file ? path.basename(file) : "Artifact"),
|
||||
description: cleanDescription(input.description),
|
||||
owner_id: ownerId,
|
||||
lane_id: laneId,
|
||||
session_id: String(sessionId),
|
||||
source: file ? file.slice(0, 1000) : null,
|
||||
url,
|
||||
suggested_owner_id: ownerId == null ? suggestOwner(data?.cwd) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,9 +484,8 @@ function fileCandidates(laneId) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
|
||||
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
|
||||
const lane = laneOf(laneId);
|
||||
/** A new snapshot of one workspace file, read under the read path's guards. */
|
||||
async function snapshotLaneFile(lane, rel) {
|
||||
const { openDownload } = require("./lane-file-transfer");
|
||||
const file = await openDownload(lane.cwd, typeof rel === "string" ? rel : "");
|
||||
if (file.size > FILE_MAX_BYTES) {
|
||||
@@ -399,10 +493,9 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
throw fail("ETOOBIG", "a file artifact is at most 25 MB");
|
||||
}
|
||||
const snap = newSnapshotDir();
|
||||
const name = file.name;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const out = fs.createWriteStream(path.join(snap.dir, name), { mode: 0o600 });
|
||||
const out = fs.createWriteStream(path.join(snap.dir, file.name), { mode: 0o600 });
|
||||
file.stream.on("error", reject);
|
||||
out.on("error", reject);
|
||||
out.on("finish", resolve);
|
||||
@@ -412,6 +505,13 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
dropSnapshot(snap.id);
|
||||
throw err;
|
||||
}
|
||||
return { snap, name: file.name, size: file.size };
|
||||
}
|
||||
|
||||
/** Pin one file of a workspace: a snapshot of it, under the read path's guards. */
|
||||
async function pinFile(req, { laneId, path: rel, title, description, sessionId }) {
|
||||
const lane = laneOf(laneId);
|
||||
const { snap, name, size } = await snapshotLaneFile(lane, rel);
|
||||
const relPosix = require("./git-diff").lanePath(rel);
|
||||
return view(
|
||||
insert({
|
||||
@@ -423,13 +523,121 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
|
||||
session_id: sessionId ? String(sessionId) : null,
|
||||
source: relPosix,
|
||||
snapshot: snap.id,
|
||||
files: JSON.stringify([{ path: name, size: file.size }]),
|
||||
size: file.size,
|
||||
files: JSON.stringify([{ path: name, size }]),
|
||||
size,
|
||||
}),
|
||||
req
|
||||
);
|
||||
}
|
||||
|
||||
// ── Outputs captured when a session stops ───────────────────────────────────
|
||||
|
||||
/** Documents a session makes for people (slides, Word, Excel, PDF, HTML reports). */
|
||||
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
|
||||
/** Most files one stop captures, so a script that writes hundreds stays bounded. */
|
||||
const OUTPUT_MAX = 20;
|
||||
const capturing = new Map();
|
||||
|
||||
/**
|
||||
* The workspace's documents a session created or changed since it started:
|
||||
* untracked or modified files in a repo (an HTML file only when untracked, so a
|
||||
* site's own pages are not taken), every file of a plain folder. Paths are
|
||||
* workspace-relative.
|
||||
*/
|
||||
async function outputCandidates(lane, since) {
|
||||
const gd = require("./git-diff");
|
||||
let paths;
|
||||
let tracked = new Set();
|
||||
if (await gd.inRepo(lane.cwd)) {
|
||||
const untracked = (await gd.lsFiles(lane.cwd, ["--others", "--exclude-standard"])).all;
|
||||
const modified = (await gd.lsFiles(lane.cwd, ["--modified"])).all;
|
||||
tracked = new Set(modified);
|
||||
paths = [...untracked, ...modified];
|
||||
} else {
|
||||
paths = (await gd.walkFolder(lane.cwd)).files;
|
||||
}
|
||||
const out = [];
|
||||
for (const rel of new Set(paths)) {
|
||||
if (!OUTPUT_EXT.test(rel)) continue;
|
||||
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue;
|
||||
let st;
|
||||
try {
|
||||
st = await fs.promises.lstat(path.join(lane.cwd, rel));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!st.isFile() || st.mtimeMs < since || st.size > FILE_MAX_BYTES) continue;
|
||||
out.push({ rel: gd.lanePath(rel), mtimeMs: st.mtimeMs });
|
||||
}
|
||||
return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, OUTPUT_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* At a session's Stop: snapshot each document it created or changed into its
|
||||
* workspace owner's Artifacts, marked `auto`. A file seen again with the mtime
|
||||
* its snapshot recorded is skipped; a changed one replaces the row's snapshot. A file the user
|
||||
* pinned by hand is left to them. Returns the rows added or refreshed.
|
||||
*/
|
||||
function captureOutputs(sessionId) {
|
||||
const laneId = sessionLane(sessionId);
|
||||
if (laneId == null) return Promise.resolve([]);
|
||||
// One capture per workspace at a time: two Stops in a row must not both insert.
|
||||
const prev = capturing.get(laneId) || Promise.resolve();
|
||||
const run = prev.then(() => captureOutputsNow(laneId, sessionId)).catch(() => []);
|
||||
capturing.set(laneId, run);
|
||||
run.finally(() => {
|
||||
if (capturing.get(laneId) === run) capturing.delete(laneId);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function captureOutputsNow(laneId, sessionId) {
|
||||
const lane = require("./lanes").getLane(Number(laneId));
|
||||
if (!lane) return [];
|
||||
const started = sql("SELECT started_at FROM sessions WHERE id = ?").get(String(sessionId));
|
||||
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
|
||||
const done = [];
|
||||
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
|
||||
const rows = sql(
|
||||
"SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?"
|
||||
).all(lane.id, rel);
|
||||
if (rows.some((r) => r.auto !== 1)) continue;
|
||||
const row = rows[0];
|
||||
const kept = row?.files ? JSON.parse(row.files)[0] : null;
|
||||
if (kept && kept.mtime === mtimeMs) continue;
|
||||
let shot;
|
||||
try {
|
||||
shot = await snapshotLaneFile(lane, rel);
|
||||
} catch {
|
||||
continue; // a secret name, a vanished file, one grown past the cap
|
||||
}
|
||||
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
|
||||
if (row) {
|
||||
sql(
|
||||
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
|
||||
dropSnapshot(row.snapshot);
|
||||
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
|
||||
} else {
|
||||
done.push(
|
||||
insert({
|
||||
kind: "file",
|
||||
title: cleanTitle(null, shot.name),
|
||||
owner_id: lane.owner_id ?? null,
|
||||
lane_id: lane.id,
|
||||
session_id: String(sessionId),
|
||||
source: rel,
|
||||
snapshot: shot.snap.id,
|
||||
files,
|
||||
size: shot.size,
|
||||
auto: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return done;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file uploaded from the user's computer: kept as a snapshot like a pinned
|
||||
* one, under a name made from the upload's own (no folder, no path).
|
||||
@@ -1171,6 +1379,56 @@ function addPlugin(req, { key, market, title, description }) {
|
||||
|
||||
// ── change / delete ───────────────────────────────────────────────────────
|
||||
|
||||
// ── Unowned rows ──────────────────────────────────────────────────────────
|
||||
|
||||
function unownedRow(req, id) {
|
||||
const row = load(req, id);
|
||||
if (!isServer()) throw fail("ELOCALMODE", "only server mode has owners");
|
||||
if (row.owner_id != null) throw fail("EOWNED", "this artifact already has an owner");
|
||||
return row;
|
||||
}
|
||||
|
||||
/** "It is mine": the suggested user (or an admin, for themselves) takes an unowned row. */
|
||||
function claim(req, id) {
|
||||
const row = unownedRow(req, id);
|
||||
if (req.user.role !== "admin" && row.suggested_owner_id !== req.user.id) {
|
||||
throw fail("EFORBIDDEN", "only the suggested user or an admin can claim it");
|
||||
}
|
||||
sql(
|
||||
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(req.user.id, row.id);
|
||||
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
audit(req, next, "claim");
|
||||
return view(next, req);
|
||||
}
|
||||
|
||||
/** "Not mine": the suggestion goes; the row stays unowned for an admin to place. */
|
||||
function decline(req, id) {
|
||||
const row = unownedRow(req, id);
|
||||
if (row.suggested_owner_id !== req.user.id) {
|
||||
throw fail("EFORBIDDEN", "only the suggested user can decline it");
|
||||
}
|
||||
sql("UPDATE artifacts SET suggested_owner_id = NULL WHERE id = ?").run(row.id);
|
||||
audit(req, row, "decline");
|
||||
return { id: row.id, declined: true };
|
||||
}
|
||||
|
||||
/** An admin gives an unowned row to an enabled user. */
|
||||
function assign(req, id, userId) {
|
||||
const row = unownedRow(req, id);
|
||||
if (req.user.role !== "admin") throw fail("EFORBIDDEN", "only an admin assigns an owner");
|
||||
const user = /^\d+$/.test(String(userId))
|
||||
? sql("SELECT id FROM users WHERE id = ? AND disabled = 0").get(Number(userId))
|
||||
: null;
|
||||
if (!user) throw fail("EBADFIELD", "userId: an enabled user is required");
|
||||
sql(
|
||||
"UPDATE artifacts SET owner_id = ?, suggested_owner_id = NULL, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
|
||||
).run(user.id, row.id);
|
||||
const next = sql("SELECT * FROM artifacts WHERE id = ?").get(row.id);
|
||||
audit(req, next, "assign");
|
||||
return view(next, req);
|
||||
}
|
||||
|
||||
function audit(req, row, action) {
|
||||
if (!req?.user) return;
|
||||
try {
|
||||
@@ -1669,6 +1927,11 @@ module.exports = {
|
||||
canSeeId,
|
||||
addPage,
|
||||
captureFromHook,
|
||||
captureOutputs,
|
||||
isPageTool,
|
||||
claim,
|
||||
decline,
|
||||
assign,
|
||||
fileCandidates,
|
||||
pinFile,
|
||||
availableSkills,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file avatar-redirect.js
|
||||
* @description Answers an avatar GET (`/api/me/avatar`, `/api/users/:id/avatar`)
|
||||
* with a 302 to the account's stored avatar LINK (server/lib/avatars.js), so
|
||||
* the viewer's browser loads the picture itself — with its own logins and
|
||||
* network — and the server never fetches it. No referrer is sent with it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** True when the account has a link and the redirect was sent. */
|
||||
function sendAvatarLink(res, userId) {
|
||||
const link = require("./avatars").avatarLink(userId);
|
||||
if (!link) return false;
|
||||
res.set("Referrer-Policy", "no-referrer");
|
||||
// The client versions the URL with `avatarAt`, so a new link is a new URL.
|
||||
res.set("Cache-Control", "private, max-age=86400");
|
||||
res.redirect(302, link);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { sendAvatarLink };
|
||||
+22
-100
@@ -1,26 +1,19 @@
|
||||
/**
|
||||
* @file avatar-url.js
|
||||
* @description Fetches an avatar picture from a URL the account typed
|
||||
* (Settings → Account, `POST /api/me/avatar/url`), so the server — not the
|
||||
* browser, which most image hosts' CORS would block — downloads it. A URL the
|
||||
* user names makes the SERVER connect somewhere, so it is held to: `https://`
|
||||
* with no userinfo, a host whose EVERY resolved address is public (no
|
||||
* loopback, private, link-local, CGNAT, multicast or reserved range — the
|
||||
* dashboard's own network is never reachable this way), the connection pinned
|
||||
* to the vetted address (a second DNS answer cannot rebind it), at most
|
||||
* `MAX_REDIRECTS` redirects each re-checked the same way, a timeout, and at
|
||||
* most `MAX_AVATAR_BYTES` read. No cookie or credential is ever sent. The
|
||||
* bytes then go through `setAvatar`'s own magic-byte check like an upload.
|
||||
* @description Checks an avatar LINK the account typed (Settings → Account,
|
||||
* `POST /api/me/avatar/url`). The link is stored as it is and the avatar
|
||||
* routes redirect the viewer's browser to it — the server never downloads it,
|
||||
* so a picture behind the viewer's own login or on a company-internal host
|
||||
* (e.g. a Mattermost `/api/v4/users/<id>/image`) still shows. Held to:
|
||||
* `https://`, no userinfo, at most `MAX_LINK_LENGTH` characters, and no
|
||||
* loopback/private/link-local/reserved IP literal as the host (a link may not
|
||||
* aim every viewer's browser at their own machine or LAN).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const dns = require("node:dns");
|
||||
const https = require("node:https");
|
||||
const net = require("node:net");
|
||||
const { MAX_AVATAR_BYTES } = require("./avatars");
|
||||
|
||||
const MAX_REDIRECTS = 3;
|
||||
const TIMEOUT_MS = 10_000;
|
||||
const MAX_LINK_LENGTH = 2048;
|
||||
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
@@ -75,100 +68,29 @@ function isPublicAddress(address) {
|
||||
return !blocked.check(address, "ipv6");
|
||||
}
|
||||
|
||||
/** The URL as a `URL`, or EBADURL: https only, no userinfo, a real host. */
|
||||
/** The link, normalised, or EBADURL / EPRIVATEURL: https only, no userinfo, a real host. */
|
||||
function parseAvatarUrl(text) {
|
||||
const raw = String(text ?? "").trim();
|
||||
if (raw.length > MAX_LINK_LENGTH) throw fail("EBADURL", "the link is too long");
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(text ?? "").trim());
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw fail("EBADURL", "not a valid URL");
|
||||
}
|
||||
if (url.protocol !== "https:") throw fail("EBADURL", "only an https:// link");
|
||||
if (url.username || url.password) throw fail("EBADURL", "a link with a login is refused");
|
||||
if (!url.hostname) throw fail("EBADURL", "not a valid URL");
|
||||
// Node never calls `lookup` for an IP-literal host, so it is checked here.
|
||||
const literal = url.hostname.replace(/^\[|\]$/g, "");
|
||||
if (net.isIP(literal) && !isPublicAddress(literal)) {
|
||||
const host = url.hostname.toLowerCase();
|
||||
const literal = host.replace(/^\[|\]$/g, "");
|
||||
if (
|
||||
host === "localhost" ||
|
||||
host.endsWith(".localhost") ||
|
||||
(net.isIP(literal) && !isPublicAddress(literal))
|
||||
) {
|
||||
throw fail("EPRIVATEURL", "that address is not on the public internet");
|
||||
}
|
||||
return url;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* `lookup` for `https.request`: resolves every address and refuses the host
|
||||
* when ANY of them is not public, then hands over only vetted ones.
|
||||
*/
|
||||
function safeLookup(hostname, options, cb) {
|
||||
if (typeof options === "function") [cb, options] = [options, {}];
|
||||
const literal = hostname.replace(/^\[|\]$/g, "");
|
||||
const done = (addrs) => {
|
||||
if (!addrs.length || !addrs.every((a) => isPublicAddress(a.address))) {
|
||||
return cb(fail("EPRIVATEURL", "that address is not on the public internet"));
|
||||
}
|
||||
if (options?.all) return cb(null, addrs);
|
||||
return cb(null, addrs[0].address, addrs[0].family);
|
||||
};
|
||||
if (net.isIP(literal)) return done([{ address: literal, family: net.isIP(literal) }]);
|
||||
dns.lookup(hostname, { all: true, verbatim: true }, (err, addrs) => {
|
||||
if (err) return cb(fail("EFETCH", `could not resolve ${hostname}`));
|
||||
done(addrs);
|
||||
});
|
||||
}
|
||||
|
||||
function getOnce(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
lookup: safeLookup,
|
||||
agent: false,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { Accept: "image/png,image/jpeg,image/gif,image/webp", "User-Agent": "AgentHub" },
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode || 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
return resolve({ redirect: new URL(res.headers.location, url).href });
|
||||
}
|
||||
if (status !== 200) {
|
||||
res.resume();
|
||||
return reject(fail("EFETCH", `the server answered ${status}`));
|
||||
}
|
||||
if (Number(res.headers["content-length"]) > MAX_AVATAR_BYTES) {
|
||||
res.destroy();
|
||||
return reject(fail("ETOOBIG", "the image is too large"));
|
||||
}
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
res.on("data", (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_AVATAR_BYTES) {
|
||||
res.destroy();
|
||||
return reject(fail("ETOOBIG", "the image is too large"));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => resolve({ body: Buffer.concat(chunks) }));
|
||||
res.on("error", (e) => reject(e.code ? e : fail("EFETCH", e.message)));
|
||||
}
|
||||
);
|
||||
req.on("timeout", () => req.destroy(fail("EFETCH", "the server took too long")));
|
||||
req.on("error", (e) =>
|
||||
reject(e.code === "EPRIVATEURL" || e.code === "EFETCH" ? e : fail("EFETCH", e.message))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Downloads the picture at `text`; resolves to its bytes (unchecked — `setAvatar` sniffs them). */
|
||||
async function fetchAvatarImage(text) {
|
||||
let url = parseAvatarUrl(text);
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const out = await getOnce(url);
|
||||
if (out.body) return out.body;
|
||||
url = parseAvatarUrl(out.redirect);
|
||||
}
|
||||
throw fail("EFETCH", "too many redirects");
|
||||
}
|
||||
|
||||
module.exports = { fetchAvatarImage, isPublicAddress, parseAvatarUrl };
|
||||
module.exports = { MAX_LINK_LENGTH, isPublicAddress, parseAvatarUrl };
|
||||
|
||||
+43
-6
@@ -5,7 +5,9 @@
|
||||
* `<data dir>/avatars/u<id>.<png|jpg|gif|webp>` — a name made from the
|
||||
* account id and the type its magic bytes say, never from the request — and
|
||||
* `users.avatar_at` records when it was set, so the client can version the
|
||||
* URL. Raster only: an SVG served from the dashboard's origin runs script.
|
||||
* URL. Or, instead of a file, a LINK (`users.avatar_link`, checked by
|
||||
* avatar-url.js) the serving routes redirect the browser to — never
|
||||
* downloaded. Setting one clears the other. Raster only: an SVG served from the dashboard's origin runs script.
|
||||
* At most `MAX_AVATAR_BYTES`; the serving route adds `nosniff` and a
|
||||
* `sandbox` CSP like every other user-supplied file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -32,10 +34,35 @@ function fileName(userId, ext) {
|
||||
|
||||
const db = () => require("../db").db;
|
||||
|
||||
/** Deletes every stored avatar file of the account and clears `avatar_at`. */
|
||||
function removeAvatar(userId) {
|
||||
const removeFiles = (userId) => {
|
||||
for (const ext of EXTS) fs.rmSync(fileName(userId, ext), { force: true });
|
||||
db().prepare("UPDATE users SET avatar_at = NULL WHERE id = ?").run(Number(userId));
|
||||
};
|
||||
|
||||
/** Deletes every stored avatar file of the account and clears `avatar_at` and the link. */
|
||||
function removeAvatar(userId) {
|
||||
removeFiles(userId);
|
||||
db()
|
||||
.prepare("UPDATE users SET avatar_at = NULL, avatar_link = NULL WHERE id = ?")
|
||||
.run(Number(userId));
|
||||
}
|
||||
|
||||
/** Stores `text` (EBADURL / EPRIVATEURL) as the account's avatar link; returns `avatar_at`. */
|
||||
function setAvatarLink(userId, text) {
|
||||
const link = require("./avatar-url").parseAvatarUrl(text);
|
||||
removeFiles(userId);
|
||||
const at = new Date().toISOString();
|
||||
db()
|
||||
.prepare("UPDATE users SET avatar_at = ?, avatar_link = ? WHERE id = ?")
|
||||
.run(at, link, Number(userId));
|
||||
return at;
|
||||
}
|
||||
|
||||
/** The account's avatar link, or null when it has none. */
|
||||
function avatarLink(userId) {
|
||||
return (
|
||||
db().prepare("SELECT avatar_link FROM users WHERE id = ?").get(Number(userId))?.avatar_link ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/** Stores `buf` as the account's avatar (EBADIMAGE / ETOOBIG); returns `avatar_at`. */
|
||||
@@ -54,7 +81,9 @@ function setAvatar(userId, buf) {
|
||||
if (other !== ext) fs.rmSync(fileName(userId, other), { force: true });
|
||||
}
|
||||
const at = new Date().toISOString();
|
||||
db().prepare("UPDATE users SET avatar_at = ? WHERE id = ?").run(at, Number(userId));
|
||||
db()
|
||||
.prepare("UPDATE users SET avatar_at = ?, avatar_link = NULL WHERE id = ?")
|
||||
.run(at, Number(userId));
|
||||
return at;
|
||||
}
|
||||
|
||||
@@ -68,4 +97,12 @@ function avatarFile(userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { MAX_AVATAR_BYTES, setAvatar, removeAvatar, avatarFile, avatarsDir };
|
||||
module.exports = {
|
||||
MAX_AVATAR_BYTES,
|
||||
setAvatar,
|
||||
setAvatarLink,
|
||||
removeAvatar,
|
||||
avatarFile,
|
||||
avatarLink,
|
||||
avatarsDir,
|
||||
};
|
||||
|
||||
@@ -503,6 +503,10 @@ function pick(member, ownerId = null) {
|
||||
live = liveLogin ? placed || liveLogin : null;
|
||||
}
|
||||
}
|
||||
// The owner's group's accounts only (pool-groups.js) — a login already live
|
||||
// in their home stays: one .credentials.json per home.
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "claude");
|
||||
usable = usable.filter((a) => allowed(a.name));
|
||||
// A pinned token goes in by env and wins outright; a pinned login waits
|
||||
// while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "claude");
|
||||
|
||||
@@ -358,6 +358,10 @@ function pick(member, ownerId = null) {
|
||||
live = usable.find((a) => a.kind === "chatgpt" && liveInHome(a.name, member)) || null;
|
||||
}
|
||||
}
|
||||
// The owner's group's accounts only (pool-groups.js) — a login already live
|
||||
// in their home stays: one auth.json per home.
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "codex");
|
||||
usable = usable.filter((a) => allowed(a.name));
|
||||
// A pinned API key goes in by env and wins outright; a pinned ChatGPT login
|
||||
// waits while another login holds this home.
|
||||
const pin = require("./account-pins").pinFor(ownerId, "codex");
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @file cursor-pool.js
|
||||
* @description The Cursor CLI account POOL, modelled on codex-pool.js's API-key
|
||||
* half: Cursor API keys an admin added (`agenthub accounts add <name> --tool
|
||||
* cursor`, or the Users page), and the balancer that gives every new
|
||||
* `cursor-agent` pane one of them. The key reaches the pane only through tmux
|
||||
* `-e CURSOR_API_KEY` (a member's launch file under S3) — `cursor-agent` reads
|
||||
* it from there and it outranks the account's own `cursor-agent login` for
|
||||
* that process only; nothing is written into anyone's home. An empty pool
|
||||
* changes nothing: panes run on their home's own login as before.
|
||||
*
|
||||
* Records live in `<CLAUDE_HOME>/agenthub-cursor-accounts/<name>.json` (dir
|
||||
* 0700, file 0600); no route returns a key.
|
||||
*
|
||||
* `pick` ranks by fewest live Cursor panes, then name — Cursor sessions carry
|
||||
* no token counts here (cursor-usage.js) and the CLI exposes no quota, so
|
||||
* there is nothing else to rank by. A user an admin pinned to one account
|
||||
* (account-pins.js) skips the ranking.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||
// A Cursor API key is one opaque token; never whitespace, never a newline
|
||||
// (it travels as an env value through tmux and a member's launch file).
|
||||
const KEY_RE = /^[A-Za-z0-9_.-]{20,500}$/;
|
||||
const KEY_ENV = "CURSOR_API_KEY";
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
const poolDir = () => path.join(getClaudeHome(), "agenthub-cursor-accounts");
|
||||
const fileOf = (name) => path.join(poolDir(), `${name}.json`);
|
||||
|
||||
function validName(name) {
|
||||
const n = String(name || "").trim();
|
||||
if (!NAME_RE.test(n))
|
||||
throw err("EBADNAME", "account name: 1–64 letters, digits, '.', '_' or '-'");
|
||||
return n;
|
||||
}
|
||||
|
||||
function readAccount(name) {
|
||||
try {
|
||||
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
|
||||
if (a.kind === "apikey" && typeof a.key === "string") return a;
|
||||
} catch {
|
||||
/* absent or not ours */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function allAccounts() {
|
||||
let names = [];
|
||||
try {
|
||||
names = fs.readdirSync(poolDir()).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names
|
||||
.map((f) => f.slice(0, -5))
|
||||
.filter((n) => NAME_RE.test(n))
|
||||
.map(readAccount)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Add a Cursor API key (Cursor dashboard → Integrations → API keys). */
|
||||
function addAccount(name, { apiKey } = {}) {
|
||||
const n = validName(name);
|
||||
const key = String(apiKey || "").trim();
|
||||
if (!KEY_RE.test(key)) {
|
||||
throw err("EBADTOKEN", "not a Cursor API key — create one in the Cursor dashboard");
|
||||
}
|
||||
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
|
||||
const record = { name: n, kind: "apikey", key, addedAt: new Date().toISOString() };
|
||||
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(poolDir(), 0o700);
|
||||
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
|
||||
return { name: n, kind: record.kind, addedAt: record.addedAt };
|
||||
}
|
||||
|
||||
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("cursor", n);
|
||||
}
|
||||
|
||||
function livePanes(account) {
|
||||
const { db } = require("../db");
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM dashboard_runs
|
||||
WHERE tool = 'cursor' AND account = ? AND status IN ('running', 'spawning')`
|
||||
)
|
||||
.get(account).n;
|
||||
}
|
||||
|
||||
/** Every account with what the balancer sees — never a key. */
|
||||
function poolStatus() {
|
||||
return allAccounts().map((a) => ({
|
||||
name: a.name,
|
||||
kind: a.kind,
|
||||
addedAt: a.addedAt,
|
||||
livePanes: livePanes(a.name),
|
||||
...require("./pool-usage").accountUsage("cursor", a.name, { wholeSession: true }),
|
||||
quota: null,
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The record a new Cursor pane should run on, or null (empty pool). */
|
||||
function pick(ownerId = null) {
|
||||
// The owner's group's accounts only (pool-groups.js).
|
||||
const allowed = require("./pool-groups").allowed(ownerId, "cursor");
|
||||
const usable = allAccounts().filter((a) => allowed(a.name));
|
||||
if (usable.length === 0) return null;
|
||||
const pin = require("./account-pins").pinFor(ownerId, "cursor");
|
||||
const pinned = pin ? usable.find((a) => a.name === pin) : null;
|
||||
if (pinned) return pinned;
|
||||
return usable
|
||||
.map((a) => ({ a, n: livePanes(a.name) }))
|
||||
.sort((x, y) => x.n - y.n || x.a.name.localeCompare(y.a.name))[0].a;
|
||||
}
|
||||
|
||||
/** `{name, kind}` of the account a new Cursor pane would get, or null. */
|
||||
function pickAccount(ownerId = null) {
|
||||
const a = pick(ownerId);
|
||||
return a ? { name: a.name, kind: a.kind } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a new Cursor pane needs from the pool, or null: `{name, env}`. `env`
|
||||
* goes through tmux `-e` (a member's launch file), never argv.
|
||||
*/
|
||||
function forSpawn(ownerId = null) {
|
||||
const a = pick(ownerId);
|
||||
return a ? { name: a.name, env: { [KEY_ENV]: a.key } } : null;
|
||||
}
|
||||
|
||||
/** Is `name` an account in the pool? */
|
||||
const hasAccount = (name) => NAME_RE.test(String(name)) && Boolean(readAccount(String(name)));
|
||||
|
||||
module.exports = {
|
||||
hasAccount,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
poolStatus,
|
||||
pickAccount,
|
||||
forSpawn,
|
||||
poolDir,
|
||||
__read: readAccount,
|
||||
};
|
||||
@@ -1350,6 +1350,7 @@ module.exports = {
|
||||
laneStatus,
|
||||
listCommits,
|
||||
listPaths,
|
||||
lsFiles,
|
||||
listTree,
|
||||
listTreeAll,
|
||||
parseNumstat,
|
||||
@@ -1358,6 +1359,7 @@ module.exports = {
|
||||
readFileAt,
|
||||
readImageAt,
|
||||
resolveBaseRef,
|
||||
walkFolder,
|
||||
defaultBranch,
|
||||
DEFAULT_CONTEXT,
|
||||
MAX_CONTEXT,
|
||||
|
||||
@@ -28,6 +28,9 @@ const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { getDataDir } = require("./claude-home");
|
||||
|
||||
/** Longer than any one step's timeout in the helper (bootstrap: 15 min). */
|
||||
const PROGRESS_STALE_MS = 20 * 60 * 1000;
|
||||
|
||||
/** Runtime directory the helper writes its verdict into. Outside the plugin
|
||||
* cache on purpose — that directory is replaced by the update itself. */
|
||||
function runtimeDir() {
|
||||
@@ -53,6 +56,34 @@ function readLastPluginUpdate(rt = runtimeDir()) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The step a running chain is on (`plugin-update-progress.json`, written by the
|
||||
* helper as each step starts and removed with its final verdict), or null.
|
||||
* Surfaced as `plugin_update_progress` so the dashboard can show how far an
|
||||
* update got. A file older than the chain's longest run is left over from a
|
||||
* helper that died, and reads as nothing.
|
||||
*
|
||||
* @returns {{step: string, index: number, total: number, started_at: string} | null}
|
||||
*/
|
||||
function readPluginUpdateProgress(rt = runtimeDir()) {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(rt, "plugin-update-progress.json"), "utf8")
|
||||
);
|
||||
if (!parsed || typeof parsed.step !== "string") return null;
|
||||
const started = Date.parse(parsed.started_at);
|
||||
if (!Number.isFinite(started) || Date.now() - started > PROGRESS_STALE_MS) return null;
|
||||
return {
|
||||
step: parsed.step,
|
||||
index: Number(parsed.index) || 0,
|
||||
total: Number(parsed.total) || 0,
|
||||
started_at: parsed.started_at,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the detached update chain. Returns immediately; the caller must have
|
||||
* already written its HTTP response, because this process is about to be
|
||||
@@ -69,6 +100,7 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
|
||||
try {
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
|
||||
fs.rmSync(path.join(rt, "plugin-update-progress.json"), { force: true });
|
||||
} catch {
|
||||
/* best effort — a stale verdict is better than failing the update */
|
||||
}
|
||||
@@ -86,4 +118,9 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
|
||||
return { spawned: true, runtime_dir: rt };
|
||||
}
|
||||
|
||||
module.exports = { schedulePluginUpdate, readLastPluginUpdate, runtimeDir };
|
||||
module.exports = {
|
||||
schedulePluginUpdate,
|
||||
readLastPluginUpdate,
|
||||
readPluginUpdateProgress,
|
||||
runtimeDir,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @file pool-groups.js
|
||||
* @description Account pool GROUPS (server mode): an admin splits the pool so
|
||||
* each team's sessions run on that team's own accounts. A group is a name, its
|
||||
* members (one group per user, `pool_group_users`) and the pool accounts it
|
||||
* may use, of every tool (`pool_group_accounts`; one account may serve many
|
||||
* groups). The balancers (claude-pool.js, codex-pool.js, cursor-pool.js) ask
|
||||
* `allowed(ownerId, tool)` which accounts a workspace OWNER's new pane may
|
||||
* take: a member of a group gets only that group's accounts of the tool —
|
||||
* none means the user's own sign-in, as with an empty pool — and a user in no
|
||||
* group gets the SHARED pool, the accounts no group lists. With no group at
|
||||
* all every account is shared, exactly as before groups existed. A pin
|
||||
* (account-pins.js) is an admin's explicit choice and is honoured whatever the
|
||||
* group. Local mode has no users: everything is shared there.
|
||||
*
|
||||
* Accounts are named, not referenced — the pool lives in files — so removing
|
||||
* an account drops its group rows (`dropAccount`); deleting a user or a group
|
||||
* cascades.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const db = () => require("../db").db;
|
||||
const pins = () => require("./account-pins");
|
||||
|
||||
const NAME_MAX = 64;
|
||||
|
||||
function validName(name) {
|
||||
const n = String(name ?? "").trim();
|
||||
if (!n || n.length > NAME_MAX || /[\u0000-\u001f]/.test(n)) {
|
||||
throw err("EBADNAME", `group name: 1–${NAME_MAX} characters`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function groupRow(id) {
|
||||
const g = Number.isInteger(id)
|
||||
? db().prepare("SELECT id, name FROM pool_groups WHERE id = ?").get(id)
|
||||
: null;
|
||||
if (!g) throw err("ENOGROUP", `no group ${id}`);
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Every group with its members and accounts per tool. */
|
||||
function listGroups() {
|
||||
const groups = db()
|
||||
.prepare("SELECT id, name, created_at AS createdAt FROM pool_groups ORDER BY name")
|
||||
.all();
|
||||
const users = db()
|
||||
.prepare(
|
||||
`SELECT g.group_id AS groupId, u.id AS userId, u.username
|
||||
FROM pool_group_users g JOIN users u ON u.id = g.user_id ORDER BY u.username`
|
||||
)
|
||||
.all();
|
||||
const accounts = db()
|
||||
.prepare("SELECT group_id AS groupId, tool, account FROM pool_group_accounts ORDER BY account")
|
||||
.all();
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
users: users
|
||||
.filter((u) => u.groupId === g.id)
|
||||
.map(({ userId, username }) => ({ userId, username })),
|
||||
accounts: Object.fromEntries(
|
||||
pins().TOOLS.map((t) => [
|
||||
t,
|
||||
accounts.filter((a) => a.groupId === g.id && a.tool === t).map((a) => a.account),
|
||||
])
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function createGroup(name) {
|
||||
const n = validName(name);
|
||||
try {
|
||||
const id = db().prepare("INSERT INTO pool_groups (name) VALUES (?)").run(n).lastInsertRowid;
|
||||
return { id: Number(id), name: n };
|
||||
} catch (e) {
|
||||
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function renameGroup(id, name) {
|
||||
const g = groupRow(id);
|
||||
const n = validName(name);
|
||||
try {
|
||||
db().prepare("UPDATE pool_groups SET name = ? WHERE id = ?").run(n, g.id);
|
||||
} catch (e) {
|
||||
if (String(e.code).startsWith("SQLITE_CONSTRAINT")) throw err("EEXISTS", `group ${n} exists`);
|
||||
throw e;
|
||||
}
|
||||
return { id: g.id, name: n };
|
||||
}
|
||||
|
||||
/** Its members go back to the shared pool; its accounts become shared if no other group lists them. */
|
||||
function deleteGroup(id) {
|
||||
const g = groupRow(id);
|
||||
db().prepare("DELETE FROM pool_groups WHERE id = ?").run(g.id);
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly `userIds` are the group's members afterwards — a user in another
|
||||
* group moves here (one group per user). One transaction.
|
||||
*/
|
||||
function setGroupUsers(id, userIds) {
|
||||
const g = groupRow(id);
|
||||
if (!Array.isArray(userIds) || !userIds.every(Number.isInteger)) {
|
||||
throw err("EBADUSER", "userIds: an array of user ids");
|
||||
}
|
||||
const want = [...new Set(userIds)];
|
||||
for (const uid of want) {
|
||||
if (!db().prepare("SELECT 1 FROM users WHERE id = ?").get(uid))
|
||||
throw err("ENOUSER", `no user ${uid}`);
|
||||
}
|
||||
db().transaction(() => {
|
||||
db().prepare("DELETE FROM pool_group_users WHERE group_id = ?").run(g.id);
|
||||
const put = db().prepare(
|
||||
`INSERT INTO pool_group_users (user_id, group_id) VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET group_id = excluded.group_id`
|
||||
);
|
||||
for (const uid of want) put.run(uid, g.id);
|
||||
})();
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Exactly `names` are the group's accounts of `tool` afterwards. */
|
||||
function setGroupAccounts(id, tool, names) {
|
||||
const g = groupRow(id);
|
||||
if (!pins().TOOLS.includes(tool)) throw err("EBADTOOL", `tool: ${pins().TOOLS.join(", ")}`);
|
||||
if (!Array.isArray(names) || !names.every((n) => typeof n === "string")) {
|
||||
throw err("EBADNAME", "accounts: an array of account names");
|
||||
}
|
||||
const want = [...new Set(names)];
|
||||
const pool = pins().poolOf(tool);
|
||||
for (const n of want) {
|
||||
if (!pool.hasAccount(n)) throw err("ENOACCOUNT", `no ${tool} account ${n} in the pool`);
|
||||
}
|
||||
db().transaction(() => {
|
||||
db().prepare("DELETE FROM pool_group_accounts WHERE group_id = ? AND tool = ?").run(g.id, tool);
|
||||
const put = db().prepare(
|
||||
"INSERT INTO pool_group_accounts (group_id, tool, account) VALUES (?, ?, ?)"
|
||||
);
|
||||
for (const n of want) put.run(g.id, tool, n);
|
||||
})();
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which accounts of `tool` a pane of workspace owner `ownerId` may take, as a
|
||||
* predicate on the account name: their group's, else the shared ones; their
|
||||
* pin always. Everything when groups do not apply (local mode, no group yet,
|
||||
* or no database to ask).
|
||||
*/
|
||||
function allowed(ownerId, tool) {
|
||||
if (!require("./auth").isServerMode()) return () => true;
|
||||
try {
|
||||
if (!db().prepare("SELECT 1 FROM pool_groups LIMIT 1").get()) return () => true;
|
||||
const pin = pins().pinFor(ownerId, tool);
|
||||
const group = Number.isInteger(ownerId)
|
||||
? db().prepare("SELECT group_id FROM pool_group_users WHERE user_id = ?").get(ownerId)
|
||||
: null;
|
||||
const names = new Set(
|
||||
(group
|
||||
? db()
|
||||
.prepare("SELECT account FROM pool_group_accounts WHERE group_id = ? AND tool = ?")
|
||||
.all(group.group_id, tool)
|
||||
: []
|
||||
).map((r) => r.account)
|
||||
);
|
||||
const grouped = group
|
||||
? null
|
||||
: new Set(
|
||||
db()
|
||||
.prepare("SELECT DISTINCT account FROM pool_group_accounts WHERE tool = ?")
|
||||
.all(tool)
|
||||
.map((r) => r.account)
|
||||
);
|
||||
return (name) => name === pin || (group ? names.has(name) : !grouped.has(name));
|
||||
} catch {
|
||||
return () => true; // a spawn never fails over a group lookup
|
||||
}
|
||||
}
|
||||
|
||||
/** A removed account leaves every group. */
|
||||
function dropAccount(tool, account) {
|
||||
try {
|
||||
db()
|
||||
.prepare("DELETE FROM pool_group_accounts WHERE tool = ? AND account = ?")
|
||||
.run(tool, account);
|
||||
} catch {
|
||||
/* no database (a bare CLI test): nothing grouped */
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listGroups,
|
||||
createGroup,
|
||||
renameGroup,
|
||||
deleteGroup,
|
||||
setGroupUsers,
|
||||
setGroupAccounts,
|
||||
allowed,
|
||||
dropAccount,
|
||||
};
|
||||
@@ -488,6 +488,8 @@ function spawnRun(args) {
|
||||
entry.launcher === "codex" && !proxyProfile
|
||||
? require("./codex-pool").forSpawn(require("./run-as").accountForLane(laneId), laneOwner)
|
||||
: null;
|
||||
// Cursor's pool is API keys only (cursor-pool.js): CURSOR_API_KEY through `-e`.
|
||||
const cursorPool = tool === "cursor" ? require("./cursor-pool").forSpawn(laneOwner) : null;
|
||||
const paneCred = require("./auth").newPaneToken();
|
||||
// A member's omp on the host's shared omp login (omp-share.js): its
|
||||
// models.yml points at the loopback gateway, with a key already listed there.
|
||||
@@ -611,7 +613,7 @@ function spawnRun(args) {
|
||||
resumeSessionId: resumeSessionId || null,
|
||||
worktree: worktree === true || inWorktree === true,
|
||||
proxy: proxyProfile ? proxyProfile.name : null,
|
||||
account: (poolAccount || codexPool)?.name ?? null,
|
||||
account: (poolAccount || codexPool || cursorPool)?.name ?? null,
|
||||
paneTokenHash: paneCred ? paneCred.hash : null,
|
||||
prompt: initialPrompt || "",
|
||||
status: "running",
|
||||
@@ -668,6 +670,7 @@ function spawnRun(args) {
|
||||
// this process only. Through tmux `-e`, never typed into the pane.
|
||||
...(poolAccount ? poolAccount.env : {}),
|
||||
...(codexPool ? codexPool.env : {}),
|
||||
...(cursorPool ? cursorPool.env : {}),
|
||||
...(ompShare ? ompShare.env : {}),
|
||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execFile } = require("child_process");
|
||||
const { readLastPluginUpdate } = require("./plugin-update");
|
||||
const { readLastPluginUpdate, readPluginUpdateProgress } = require("./plugin-update");
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
||||
@@ -115,10 +115,16 @@ function execNpm(cwd, args, opts = {}) {
|
||||
if (!err) return resolve(String(stdout).trim());
|
||||
if (err.killed) {
|
||||
return reject(
|
||||
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
|
||||
new Error(
|
||||
`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`
|
||||
)
|
||||
);
|
||||
}
|
||||
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
|
||||
const tail = String(stderr || err.message || err)
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.slice(-15)
|
||||
.join("\n");
|
||||
reject(new Error(`${step} failed:\n${tail}`));
|
||||
}
|
||||
);
|
||||
@@ -491,6 +497,7 @@ async function pluginStatus(root, pluginInstall, options = {}) {
|
||||
plugin_marketplace: pluginInstall.marketplace,
|
||||
plugin_name: pluginInstall.plugin,
|
||||
last_plugin_update: readLastPluginUpdate(),
|
||||
plugin_update_progress: readPluginUpdateProgress(),
|
||||
|
||||
local_version: localVersion,
|
||||
// Not a clone, so there is nothing to be shallow about and no distances to
|
||||
@@ -785,6 +792,8 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
|
||||
// every consumer (route, WS broadcast, CLI) gets it from one place — and so
|
||||
// a chain that died is visible instead of looking like a slow restart.
|
||||
last_plugin_update: pluginInstall ? readLastPluginUpdate() : null,
|
||||
// The step a running chain is on, while it runs.
|
||||
plugin_update_progress: pluginInstall ? readPluginUpdateProgress() : null,
|
||||
local_version: localVersion,
|
||||
// True when commit distances are unusable and version strings are the only
|
||||
// signal — the UI must not render an "N commits" badge in that case.
|
||||
|
||||
@@ -20,6 +20,11 @@ const misc = require("./openapi-extra/misc");
|
||||
const auth = require("./openapi-extra/auth");
|
||||
const files = require("./openapi-extra/files");
|
||||
const artifacts = require("./openapi-extra/artifacts");
|
||||
// Routes the spec had missed (audit 2026-10-22): new path keys only — a key
|
||||
// that already existed got its missing method in place instead.
|
||||
const lanesMore = require("./openapi-extra/lanes-more");
|
||||
const toolsMore = require("./openapi-extra/tools-more");
|
||||
const opsMore = require("./openapi-extra/ops-more");
|
||||
// Enriched overrides of already-documented endpoints — same operationId, tags,
|
||||
// and request/response `$ref` schemas as the base, with added examples and
|
||||
// richer descriptions. Listed last so they win the merge.
|
||||
@@ -58,6 +63,9 @@ module.exports = combine(
|
||||
auth,
|
||||
files,
|
||||
artifacts,
|
||||
lanesMore,
|
||||
toolsMore,
|
||||
opsMore,
|
||||
overrideSessionsAgents,
|
||||
overrideCore,
|
||||
overridePricingAlerts,
|
||||
|
||||
@@ -36,9 +36,17 @@ const schemas = {
|
||||
laneId: { type: "integer", nullable: true },
|
||||
sessionId: { type: "string", nullable: true },
|
||||
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
|
||||
market: { type: "string", nullable: true, description: "Plugin: owner/repo, git URL or marketplace.json URL." },
|
||||
market: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "Plugin: owner/repo, git URL or marketplace.json URL.",
|
||||
},
|
||||
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
|
||||
files: { type: "array", items: { type: "object" }, description: "The files the snapshot recorded." },
|
||||
files: {
|
||||
type: "array",
|
||||
items: { type: "object" },
|
||||
description: "The files the snapshot recorded.",
|
||||
},
|
||||
size: { type: "integer", nullable: true },
|
||||
shared: { type: "boolean" },
|
||||
sharedAt: { type: "string", nullable: true },
|
||||
@@ -82,13 +90,23 @@ const paths = {
|
||||
description:
|
||||
"A developer sees their own rows plus shared ones; staff see every row in `hub` scope.",
|
||||
parameters: [
|
||||
{ name: "scope", in: "query", schema: { type: "string", enum: ["mine", "hub"] } },
|
||||
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
|
||||
{
|
||||
name: "scope",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["all", "mine", "hub", "unowned"] },
|
||||
},
|
||||
{
|
||||
name: "kind",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["page", "file", "skill", "plugin"] },
|
||||
},
|
||||
{ name: "q", in: "query", schema: { type: "string" } },
|
||||
laneQuery,
|
||||
{ name: "sessionId", in: "query", schema: { type: "string" } },
|
||||
],
|
||||
responses: { 200: { description: "`{items}`.", content: { "application/json": { schema: items } } } },
|
||||
responses: {
|
||||
200: { description: "`{items}`.", content: { "application/json": { schema: items } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/artifacts/settings": {
|
||||
@@ -186,11 +204,14 @@ const paths = {
|
||||
tags: ["Artifacts"],
|
||||
summary: "Keep one uploaded file (≤ 25 MB)",
|
||||
operationId: "uploadArtifactFile",
|
||||
requestBody: multipart({
|
||||
files: { type: "string", format: "binary" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
}, ["files"]),
|
||||
requestBody: multipart(
|
||||
{
|
||||
files: { type: "string", format: "binary" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
["files"]
|
||||
),
|
||||
responses: created(item),
|
||||
},
|
||||
},
|
||||
@@ -218,13 +239,16 @@ const paths = {
|
||||
operationId: "uploadArtifactSkills",
|
||||
description:
|
||||
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
|
||||
requestBody: multipart({
|
||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||
paths: { type: "string" },
|
||||
name: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
}, ["files"]),
|
||||
requestBody: multipart(
|
||||
{
|
||||
files: { type: "array", items: { type: "string", format: "binary" } },
|
||||
paths: { type: "string" },
|
||||
name: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
["files"]
|
||||
),
|
||||
responses: created(items),
|
||||
},
|
||||
},
|
||||
@@ -242,7 +266,10 @@ const paths = {
|
||||
},
|
||||
["name", "description", "content"]
|
||||
),
|
||||
responses: created({ type: "object", properties: { item: item.properties.item, items: items.properties.items } }),
|
||||
responses: created({
|
||||
type: "object",
|
||||
properties: { item: item.properties.item, items: items.properties.items },
|
||||
}),
|
||||
},
|
||||
},
|
||||
"/api/artifacts/plugin/inspect": {
|
||||
@@ -250,10 +277,14 @@ const paths = {
|
||||
tags: ["Artifacts"],
|
||||
summary: "Read a marketplace's plugin list",
|
||||
operationId: "inspectArtifactMarket",
|
||||
description: "Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
|
||||
description:
|
||||
"Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
|
||||
requestBody: json({ market: { type: "string" } }, ["market"]),
|
||||
responses: {
|
||||
200: { description: "`{market, name, description, plugins: [{name, key, description, version, category}]}`." },
|
||||
200: {
|
||||
description:
|
||||
"`{market, name, description, plugins: [{name, key, description, version, category}]}`.",
|
||||
},
|
||||
502: errs,
|
||||
},
|
||||
},
|
||||
@@ -281,7 +312,10 @@ const paths = {
|
||||
summary: "One artifact",
|
||||
operationId: "getArtifact",
|
||||
parameters: [idParam],
|
||||
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 404: errs },
|
||||
responses: {
|
||||
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
|
||||
404: errs,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Artifacts"],
|
||||
@@ -295,7 +329,11 @@ const paths = {
|
||||
source: { type: "string", description: "Plugin only." },
|
||||
market: { type: "string", description: "Plugin only." },
|
||||
}),
|
||||
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 403: errs, 404: errs },
|
||||
responses: {
|
||||
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
|
||||
403: errs,
|
||||
404: errs,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Artifacts"],
|
||||
|
||||
+164
-14
@@ -3,10 +3,13 @@
|
||||
* - GET /api/auth/me (Auth)
|
||||
* - POST /api/auth/login (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, Codex and Cursor account pools and
|
||||
* the pool groups, 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
|
||||
* - PUT /api/accounts/{name}/users (Auth) — an account's Edit: exactly who runs on it
|
||||
* - POST /api/accounts/groups, PATCH/DELETE /api/accounts/groups/{id} (Auth) —
|
||||
* pool groups: a team's members run only on the group's accounts
|
||||
* - 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
|
||||
@@ -14,7 +17,7 @@
|
||||
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
|
||||
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||
* - GET/PUT/DELETE /api/me/avatar (Auth) — the account's own picture
|
||||
* - POST /api/me/avatar/url (Auth) — the same picture downloaded from a link
|
||||
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
|
||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
||||
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||
@@ -43,6 +46,27 @@ const schemas = {
|
||||
pinnedAt: { type: "string" },
|
||||
},
|
||||
},
|
||||
PoolGroup: {
|
||||
type: "object",
|
||||
required: ["id", "name", "users", "accounts"],
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
name: { type: "string" },
|
||||
createdAt: { type: "string" },
|
||||
users: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { userId: { type: "integer" }, username: { type: "string" } },
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
type: "object",
|
||||
description: "Per tool (claude, codex, cursor), the pool account names it may use",
|
||||
additionalProperties: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
UserPrefs: {
|
||||
type: "object",
|
||||
required: ["prefs", "onboardedAt"],
|
||||
@@ -309,9 +333,10 @@ const paths = {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
operationId: "getMyAvatar",
|
||||
summary: "The account's own picture (nosniff, sandbox CSP)",
|
||||
summary: "The account's own picture (nosniff, sandbox CSP), or a 302 to its avatar link",
|
||||
responses: {
|
||||
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
|
||||
302: { description: "Redirect to the account's avatar link" },
|
||||
401: err("EAUTH"),
|
||||
404: err("ENOAVATAR"),
|
||||
},
|
||||
@@ -368,6 +393,11 @@ const paths = {
|
||||
properties: {
|
||||
workspaces: { type: "integer" },
|
||||
sessions: { type: "integer" },
|
||||
today: {
|
||||
type: "object",
|
||||
description: "Spent since the viewer's local midnight (`tz_offset`).",
|
||||
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||
},
|
||||
last7d: {
|
||||
type: "object",
|
||||
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
|
||||
@@ -500,7 +530,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "setMyAvatarFromUrl",
|
||||
summary:
|
||||
"Set the account's picture from an https link the server downloads (public addresses only, at most 3 redirects, PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited",
|
||||
"Set the account's picture to an https link, stored and never downloaded — the avatar GETs answer 302 to it (no userinfo, no localhost/private IP literal, at most 2048 chars). Login cookie only, audited",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -515,11 +545,9 @@ const paths = {
|
||||
},
|
||||
responses: {
|
||||
200: { description: "Set", content: accountBody },
|
||||
400: err("EBADURL / EPRIVATEURL / EBADIMAGE"),
|
||||
400: err("EBADURL / EPRIVATEURL"),
|
||||
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||
403: err("ELOCALMODE"),
|
||||
413: err("ETOOBIG"),
|
||||
502: err("EFETCH — the link could not be downloaded"),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -531,6 +559,7 @@ const paths = {
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
|
||||
302: { description: "Redirect to that account's avatar link" },
|
||||
403: err("EADMIN — a developer"),
|
||||
404: err("ENOAVATAR"),
|
||||
},
|
||||
@@ -636,7 +665,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "listPoolAccounts",
|
||||
summary:
|
||||
"The Claude and Codex account pools as the balancer sees them (never a key or token)",
|
||||
"The Claude, Codex and Cursor account pools as the balancer sees them, and the pool groups (never a key or token)",
|
||||
parameters: [
|
||||
{
|
||||
name: "force",
|
||||
@@ -681,6 +710,27 @@ const paths = {
|
||||
properties: {
|
||||
claude: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||
codex: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||
cursor: { type: "array", items: { $ref: "#/components/schemas/AccountPin" } },
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
type: "array",
|
||||
description:
|
||||
"Pool groups (server/lib/pool-groups.js; empty in local mode): a member runs only on its group's accounts, a user in no group on the accounts no group lists",
|
||||
items: { $ref: "#/components/schemas/PoolGroup" },
|
||||
},
|
||||
cursor: {
|
||||
type: "array",
|
||||
description:
|
||||
"The Cursor account pool (server/lib/cursor-pool.js): API keys only, no quota or spend",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
kind: { type: "string", enum: ["apikey"] },
|
||||
addedAt: { type: "string" },
|
||||
livePanes: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
@@ -715,7 +765,7 @@ const paths = {
|
||||
tags: ["Auth"],
|
||||
operationId: "addPoolAccount",
|
||||
summary:
|
||||
"Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back",
|
||||
"Admin: add a Claude (saved login, pasted credentials or setup-token), Codex (saved profile, API key / auth.json text) or Cursor (API key) account to the pool, with their own password again. The secret is never answered back",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -724,7 +774,7 @@ const paths = {
|
||||
type: "object",
|
||||
required: ["tool", "currentPassword"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex"] },
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
name: { type: "string", description: "Defaults to the profile's name" },
|
||||
profile: {
|
||||
type: "string",
|
||||
@@ -736,7 +786,8 @@ const paths = {
|
||||
},
|
||||
token: {
|
||||
type: "string",
|
||||
description: "Claude: a setup-token; Codex: an API key or auth.json text",
|
||||
description:
|
||||
"Claude: a setup-token; Codex: an API key or auth.json text; Cursor: a Cursor API key",
|
||||
},
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
@@ -770,7 +821,7 @@ const paths = {
|
||||
type: "object",
|
||||
required: ["tool", "userId", "currentPassword"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex"] },
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
userId: { type: "integer" },
|
||||
account: { type: "string", nullable: true },
|
||||
currentPassword: { type: "string" },
|
||||
@@ -816,7 +867,7 @@ const paths = {
|
||||
type: "object",
|
||||
required: ["tool", "userIds", "currentPassword"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex"] },
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
userIds: { type: "array", items: { type: "integer" } },
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
@@ -836,6 +887,105 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/groups": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
operationId: "createPoolGroup",
|
||||
summary: "Admin: a new, empty pool group, with their own password again",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name", "currentPassword"],
|
||||
properties: { name: { type: "string" }, currentPassword: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: { description: "`{group: {id, name}}`" },
|
||||
400: err("EBADNAME"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
409: err("EEXISTS — a group has that name"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/groups/{id}": {
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
operationId: "updatePoolGroup",
|
||||
summary:
|
||||
"Admin: rename a pool group, set exactly its members (one group per user — a member of another group moves here) and/or exactly its accounts of each tool named, with their own password again",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
userIds: { type: "array", items: { type: "integer" } },
|
||||
accounts: {
|
||||
type: "object",
|
||||
description: "`{tool: [account names]}` for claude, codex, cursor",
|
||||
additionalProperties: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "`{group}`",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { group: { $ref: "#/components/schemas/PoolGroup" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: err("EBADNAME, EBADUSER, EBADTOOL"),
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOGROUP, ENOUSER, ENOACCOUNT"),
|
||||
409: err("EEXISTS — a group has that name"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
operationId: "deletePoolGroup",
|
||||
summary:
|
||||
"Admin: delete a pool group — its members go back to the shared pool, no account is removed — with their own password again",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["currentPassword"],
|
||||
properties: { currentPassword: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{ok: true}`" },
|
||||
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||
404: err("ENOGROUP"),
|
||||
429: err("ETOOMANY — too many wrong passwords"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/accounts/{name}": {
|
||||
delete: {
|
||||
tags: ["Auth"],
|
||||
@@ -850,7 +1000,7 @@ const paths = {
|
||||
type: "object",
|
||||
required: ["tool", "currentPassword"],
|
||||
properties: {
|
||||
tool: { type: "string", enum: ["claude", "codex"] },
|
||||
tool: { type: "string", enum: ["claude", "codex", "cursor"] },
|
||||
currentPassword: { type: "string" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1903,6 +1903,65 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Merge keys into one settings layer",
|
||||
description:
|
||||
'Sets and/or unsets dotted keys (`set: {"hooks.PreToolUse": [...]}`, `unset: ["model"]`) in one settings.json layer. The file is re-read unredacted and only the named keys change; a value containing the `<redacted>` placeholder is refused (`EREDACTED`). The project layers resolve against the SERVER\'s cwd, never `?cwd=`. An existing file is backed up first. Broadcasts `cc_config_changed`. Admin-only in server mode.',
|
||||
operationId: "ccConfigPatchSettings",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["scope"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project", "project-local"] },
|
||||
set: { type: "object", additionalProperties: true },
|
||||
unset: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Written (or nothing changed).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
backupPath: { type: "string", nullable: true },
|
||||
created: { type: "boolean" },
|
||||
changed: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "`EBADSCOPE`, `EBADCONTENT` (nothing to change) or `EREDACTED`.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "`EBADJSON` — the file on disk is not a JSON object; fix it by hand.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
413: {
|
||||
description: "`ETOOLARGE`.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/memory": {
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for the workspace (lane) routes the base spec and
|
||||
* server/openapi-extra/lanes.js did not cover: the lane list and create, the
|
||||
* folder browser, pipeline/feature/stage reads and declarations, proof
|
||||
* screenshots, the MCP/profile/integration/CI/sync-base runtime primitives,
|
||||
* removing a session's worktree, housekeeping (`/gc`), a workspace's own
|
||||
* context (server/routes/lane-sources.js: instructions, extra folders,
|
||||
* context files, memory), plus the cross-lane named locks (/api/locks) and
|
||||
* the project tree (/api/projects). Exports `{ tags, schemas, paths }` for
|
||||
* server/openapi-extra.js. Every /api/lanes path is also served under
|
||||
* /api/workspaces by the same handlers.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "Locks",
|
||||
description:
|
||||
"Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.",
|
||||
},
|
||||
{
|
||||
name: "Projects",
|
||||
description:
|
||||
"Workspaces grouped by the git repository they belong to, with that repository's worktrees (server/lib/projects.js).",
|
||||
},
|
||||
];
|
||||
|
||||
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
|
||||
const json = (schema) => ({ content: { "application/json": { schema } } });
|
||||
const obj = { type: "object", additionalProperties: true };
|
||||
const notFound = { description: "ENOLANE — workspace not found." };
|
||||
const crossOrigin = { description: "The browser request was not same-origin." };
|
||||
const s3Refused = {
|
||||
description:
|
||||
"Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member's folder — the runtime layer runs as the dashboard's own account).",
|
||||
};
|
||||
|
||||
const SOURCES_VIEW = {
|
||||
type: "object",
|
||||
description: "A workspace's own context, as the Context panel shows it.",
|
||||
properties: {
|
||||
instructions: {
|
||||
type: "string",
|
||||
description: "The workspace's instructions (`''` when unset).",
|
||||
},
|
||||
supported: {
|
||||
type: "boolean",
|
||||
description: "Whether the workspace's tool takes instructions and sources (Claude, Codex).",
|
||||
},
|
||||
maxFileBytes: { type: "integer", example: 10485760 },
|
||||
maxInstructions: { type: "integer", example: 16000 },
|
||||
sources: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
kind: { type: "string", enum: ["folder", "file"] },
|
||||
path: { type: "string" },
|
||||
name: { type: "string" },
|
||||
size: { type: "integer", nullable: true },
|
||||
added_at: { type: "string", format: "date-time" },
|
||||
missing: { type: "boolean", description: "The path no longer exists on disk." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sourcesErrors = {
|
||||
400: { description: "EBADFIELD, EBADPATH, ETOOMANY, EUNSUPPORTED or another validation code." },
|
||||
403: {
|
||||
description: "EBADORIGIN (not same-origin) or EFOLDER (outside the caller's own folder).",
|
||||
},
|
||||
404: { description: "ENOLANE, or ENOSOURCE for an unknown source id." },
|
||||
};
|
||||
|
||||
const paths = {
|
||||
"/api/lanes": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "List workspaces with live counts",
|
||||
description:
|
||||
"Every workspace the caller may see (server mode: a developer only their own), each as the full lane payload (stage, pipeline nodes, liveness, run state). One `tmux list-sessions` answers every lane's run. Working-copy facts are NOT here — see `GET /api/lanes/{id}/git`.",
|
||||
operationId: "listLanes",
|
||||
responses: {
|
||||
200: {
|
||||
description: "The lanes and their counts.",
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
lanes: { type: "array", items: obj },
|
||||
counts: {
|
||||
type: "object",
|
||||
properties: {
|
||||
total: { type: "integer" },
|
||||
running: { type: "integer" },
|
||||
needs_you: { type: "integer" },
|
||||
dead: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Create a workspace for an existing folder",
|
||||
description:
|
||||
"Inserts a lane row (owner = the caller, never from the body) and broadcasts `lane_update`. A non-admin may only name a folder inside their own (EFOLDER), never one another user's workspace owns (EOWNED). Uniqueness is per (cwd, tool). Same-origin only.",
|
||||
operationId: "createLane",
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["cwd"],
|
||||
properties: {
|
||||
cwd: { type: "string", description: "Absolute folder path." },
|
||||
title: { type: "string" },
|
||||
branch: { type: "string", nullable: true },
|
||||
pipeline: { type: "string", default: "default" },
|
||||
kind: { type: "string", enum: ["adopted", "managed"], default: "adopted" },
|
||||
tool: { type: "string", default: "claude", description: "An AI Tools catalog id." },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
201: { description: "`{lane}` — the new lane payload.", ...json(obj) },
|
||||
400: {
|
||||
description: "EBADCWD, EBADPIPELINE, EBADKIND, EBADFIELD, EBADTOOL or EDISABLED.",
|
||||
},
|
||||
403: { description: "EFOLDER, or not same-origin." },
|
||||
409: {
|
||||
description:
|
||||
"EDUPCWD (a workspace already owns that cwd for this tool), EOWNED, EOWNERFOLDER or EMEMBERGONE.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/browse": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "List a server folder for the folder browser",
|
||||
description:
|
||||
"Read-only directory listing for the Add workspace dialogs (a browser cannot expose absolute paths from its own picker). Folders first; each entry carries `mtime` (ms) and, for a folder, `isGitRepo`. A developer browses only inside their own folder (by real path; EFOLDER), and never sees the dot entries at its top. An admin with their own Linux account (S3) starts in that home; `host=1` opens the host account's home instead.",
|
||||
operationId: "browseLaneFolders",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description: "Default: the caller's start home.",
|
||||
},
|
||||
{
|
||||
name: "files",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description: "List files too; `path` may then name a file (its folder opens).",
|
||||
},
|
||||
{
|
||||
name: "hidden",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description: "List dot entries too.",
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description: "S3 admin: open the host account's home.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{path, parent, entries:[{name, path, isGitRepo, file?, mtime}], home, hostHome?}` — `parent` is null at the top.",
|
||||
...json(obj),
|
||||
},
|
||||
400: { description: "ENOTFOUND (path does not exist) or ENOTADIR." },
|
||||
403: { description: "EFOLDER — outside the caller's own folder." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/pipelines": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "List loaded pipeline templates",
|
||||
description:
|
||||
"Templates loaded from DASHBOARD_PIPELINES_DIR. AgentHub ships none, so this is empty unless a template was dropped in (pipelines are dormant).",
|
||||
operationId: "listLanePipelines",
|
||||
responses: { 200: { description: "`{pipelines:[…template]}`.", ...json(obj) } },
|
||||
},
|
||||
},
|
||||
"/api/lanes/pipeline-stats": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Per-node pass statistics across lanes and archived features",
|
||||
description:
|
||||
"Folds `lane_stage_events` into one verdict per (run, node): `entered`, `passed`, `failed`, `withEvidence` count declarations; `detected` counts nodes only inferred from hooks. Pipelines of in-scope lanes with no events appear at zero. The `lanes` list (picker) is never narrowed by `lane`. Server mode: a developer's aggregate covers their own workspaces.",
|
||||
operationId: "lanePipelineStats",
|
||||
parameters: [
|
||||
{
|
||||
name: "lane",
|
||||
in: "query",
|
||||
schema: { type: "string" },
|
||||
description: "A lane id, or `all`/empty for every lane.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"`{pipelines:[{id, name, runs, nodes:[{id, label, icon, gate, command, entered, passed, withEvidence, failed, detected}]}], lanes:[{id, title, cwd}]}`, busiest pipeline first.",
|
||||
...json(obj),
|
||||
},
|
||||
400: { description: "EBADLANE — `lane` is not an id." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/gc": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Machine-wide lane housekeeping",
|
||||
description:
|
||||
"Kills orphaned (parent pid 1) Playwright-MCP process trees under LANES_ROOT and caps every hook log over 10 MB under `LANES_ROOT/.state/lane<N>/logs/` to its last 2 MB, in place. `dryRun: true` only reports. Admin only in server mode (EADMIN); same-origin.",
|
||||
operationId: "laneHousekeeping",
|
||||
requestBody: json({ type: "object", properties: { dryRun: { type: "boolean" } } }),
|
||||
responses: {
|
||||
200: {
|
||||
description: "`{reaped:[pid], capped:[{path, sizeBefore}]}`.",
|
||||
...json(obj),
|
||||
},
|
||||
403: { description: "EADMIN, or not same-origin." },
|
||||
500: { description: "The sweep failed." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/stage": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Declare a lane's stage",
|
||||
description:
|
||||
"The driving session's own claim of progress (`agenthub stage`). Clears detection columns on a real transition; a declaration on a node BEFORE the current one drops the stage records of every node ahead. The dashboard UI never calls this. With no pipeline template installed the lane has no stages (ENOPIPELINE). Broadcasts `lane_update`. Same-origin.",
|
||||
operationId: "declareLaneStage",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
properties: {
|
||||
stage: { type: "string", description: "A node id or alias of the lane's pipeline." },
|
||||
status: { type: "string" },
|
||||
evidence: { type: "string", nullable: true },
|
||||
note: { type: "string" },
|
||||
result: { type: "string", description: "`fail` marks the node failed." },
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{lane}`.", ...json(obj) },
|
||||
400: {
|
||||
description:
|
||||
"ENOPIPELINE (no template installed) or EBADSTAGE (not a node of this pipeline; `error.stages` lists the valid ids).",
|
||||
},
|
||||
403: crossOrigin,
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/features": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "List a lane's features",
|
||||
description:
|
||||
"Every feature the lane has activated, live or archived, each with its computed pipeline view (`pipeline_name`, `pipeline_nodes`, `progress`).",
|
||||
operationId: "listLaneFeatures",
|
||||
parameters: [idParam],
|
||||
responses: { 200: { description: "`{features:[…]}`.", ...json(obj) }, 404: notFound },
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/features/{slug}": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One feature of a lane",
|
||||
description: "Including an archived feature's saved pipeline state.",
|
||||
operationId: "getLaneFeature",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "`{feature}`.", ...json(obj) },
|
||||
404: { description: "ENOLANE or ENOFEATURE." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/features/activate": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Switch the lane to a feature",
|
||||
description:
|
||||
"`agenthub feature activate`: archives the outgoing feature's pipeline and clears detection (a detection belongs to the feature that produced it), then makes `slug` the live feature. Broadcasts `lane_update`. Same-origin.",
|
||||
operationId: "activateLaneFeature",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["slug"],
|
||||
properties: { slug: { type: "string" }, title: { type: "string" } },
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{lane, feature}`.", ...json(obj) },
|
||||
400: { description: "EBADSLUG." },
|
||||
403: crossOrigin,
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/proof": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "List a lane's proof screenshots",
|
||||
description:
|
||||
"Feature slugs under the lane's proof root (`.playwright-mcp/proof`), plus activated features with no proof yet; most recently touched first.",
|
||||
operationId: "listLaneProof",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: "`{features:[{slug, groups:{<group>:[image]}, ticket_report, mtime}]}`.",
|
||||
...json(obj),
|
||||
},
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/proof/{slug}": {
|
||||
delete: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Delete proof screenshots",
|
||||
description:
|
||||
"Body `{group, images}` unlinks those images (and an emptied group dir); `{group}` removes the whole group; no body removes every group except `ticket/`. Containment-checked to the proof root. Same-origin; S3 guard.",
|
||||
operationId: "deleteLaneProof",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
requestBody: json({
|
||||
type: "object",
|
||||
properties: {
|
||||
group: { type: "string" },
|
||||
images: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
}),
|
||||
responses: {
|
||||
200: { description: "`{deleted}` — number of files removed.", ...json(obj) },
|
||||
400: { description: "EBADPATH." },
|
||||
403: s3Refused,
|
||||
404: { description: "ENOLANE or ENOFEATURE." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/proof/{slug}/{group}/{file}": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Serve one proof file",
|
||||
description:
|
||||
"A proof image or the ticket's REPORT.html, containment-checked (no traversal or symlink escape, image/HTML extensions only). Plain-text `not found` otherwise.",
|
||||
operationId: "getLaneProofFile",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
|
||||
{ name: "group", in: "path", required: true, schema: { type: "string" } },
|
||||
{ name: "file", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "The file (`text/html`, `image/png` or `image/jpeg`)." },
|
||||
404: { description: "Lane or file not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/proof-link": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Converge the lane's proof/ onto .playwright-mcp/proof",
|
||||
description:
|
||||
"Merges a real `proof/` dir into the canonical root and replaces it with a relative symlink; idempotent, never clobbers a stray file named `proof`. Never automatic (`agenthub lanes proof-link`). Same-origin; S3 guard.",
|
||||
operationId: "linkLaneProof",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: { description: "`{linked}` — false when nothing changed.", ...json(obj) },
|
||||
403: s3Refused,
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/mcp/sync": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Give the lane its source repo's MCP servers",
|
||||
description:
|
||||
"Relocates the source repo's configured `mcpServers` (from ~/.claude.json) into `<lane>/.mcp.json` (git-excluded), pins Playwright's output dir, seeds Chromium profiles. Never touches permissions or settings.local.json. Same-origin; S3 guard.",
|
||||
operationId: "syncLaneMcp",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: { description: "`{servers:[name], profilesSeeded:[name]}`.", ...json(obj) },
|
||||
400: { description: "ENOMCPCONFIG — the source repo has no MCP config." },
|
||||
403: s3Refused,
|
||||
404: notFound,
|
||||
500: { description: "ERUNTIME." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/profile/init": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Scaffold .agenthub/profile/ for a detected Node.js project",
|
||||
description:
|
||||
"Always targets the lane's own folder. No detectable Node.js project is a normal 200 (`scaffolded:false`). Same-origin; S3 guard.",
|
||||
operationId: "initLaneProfile",
|
||||
parameters: [idParam],
|
||||
requestBody: json({
|
||||
type: "object",
|
||||
properties: { force: { type: "boolean", description: "Overwrite an existing profile." } },
|
||||
}),
|
||||
responses: {
|
||||
200: {
|
||||
description: "`{scaffolded:true, written, todos}` or `{scaffolded:false, reason}`.",
|
||||
...json(obj),
|
||||
},
|
||||
400: { description: "EPROFILEEXISTS (without `force`)." },
|
||||
403: s3Refused,
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/integrations/{name}": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Whether a profile integration is enabled",
|
||||
description:
|
||||
"Reads `<NAME>_ENABLED` from `.agenthub/profile/integrations.env` in the lane folder or its source repo. Read-only.",
|
||||
operationId: "laneIntegrationEnabled",
|
||||
parameters: [
|
||||
idParam,
|
||||
{
|
||||
name: "name",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
example: "tracker",
|
||||
},
|
||||
],
|
||||
responses: { 200: { description: "`{enabled}`.", ...json(obj) }, 404: notFound },
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/ci/status": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "GitHub Actions status snapshot for a ref",
|
||||
description:
|
||||
"One `gh` read for a branch or sha in the lane's repo — not a poll loop; the caller owns any wait/retry. Read-only.",
|
||||
operationId: "laneCiStatus",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "ref", in: "query", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "`{state, ref, checks:[…]}` (`pending` with no checks yet).",
|
||||
...json(obj),
|
||||
},
|
||||
400: { description: "EBADREF — ref is required." },
|
||||
404: notFound,
|
||||
500: { description: "ERUNTIME (gh failed)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/sync-base": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Merge the base branch into the feature branch (gated)",
|
||||
description:
|
||||
"The one sanctioned merge of the ship-feature pipeline: fetch, a migration-number collision preflight, then merge — under the lane lock, synchronously. Every documented outcome is 200 with `code`: 0 (ok; `check` adds `devDelta`/`overlap`), 4 (conflict left in place, `conflictedFiles`), 5 (migration collision, `collisions`). Requires a lane profile. Never writes stage/status/notes. Same-origin; S3 guard.",
|
||||
operationId: "syncLaneBase",
|
||||
parameters: [idParam],
|
||||
requestBody: json({
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { type: "string", enum: ["check", "merge", "continue"], default: "merge" },
|
||||
branch: { type: "string" },
|
||||
},
|
||||
}),
|
||||
responses: {
|
||||
200: { description: "`{code, …}` as above.", ...json(obj) },
|
||||
400: {
|
||||
description:
|
||||
"ENOPROFILE, EBADBRANCH, EUNRESOLVED, EMERGEUNCOMMITTED or another runtime input code.",
|
||||
},
|
||||
403: s3Refused,
|
||||
404: notFound,
|
||||
500: { description: "ERUNTIME." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/session-worktree/remove": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Remove a session's `claude --worktree` tree",
|
||||
description:
|
||||
"The one dashboard path that deletes a worktree: resolved from the session row (`sessionWorktree`), never a path. `git worktree remove` without `--force`, then `git branch -d` (never `-D`) when git agrees the branch is merged — else the branch is kept and reported. Refused while a live pane holds the session. Same-origin.",
|
||||
operationId: "removeSessionWorktree",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["session", "confirm"],
|
||||
properties: { session: { type: "string" }, confirm: { type: "boolean", enum: [true] } },
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{removed:true, branch, branchKept}`.", ...json(obj) },
|
||||
400: { description: "ECONFIRM or ENOWORKTREE." },
|
||||
403: crossOrigin,
|
||||
404: notFound,
|
||||
409: {
|
||||
description:
|
||||
"ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree).",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/sources": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "A workspace's instructions, extra folders and context files",
|
||||
operationId: "getLaneSources",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: { description: "The context view.", ...json(SOURCES_VIEW) },
|
||||
404: notFound,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/instructions": {
|
||||
put: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Set a workspace's instructions",
|
||||
description:
|
||||
"Stored on the lane row and given to every new Claude/Codex pane at spawn (never written into the repo). `null` clears. Capped at `maxInstructions`. Broadcasts `lane_update`. Same-origin.",
|
||||
operationId: "setLaneInstructions",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["instructions"],
|
||||
properties: { instructions: { type: "string", nullable: true } },
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "The context view.", ...json(SOURCES_VIEW) },
|
||||
...sourcesErrors,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/sources/folder": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Add an extra folder",
|
||||
description:
|
||||
"An absolute, existing directory that is not (and does not contain) the OS home and is not the workspace's own folder. A developer may name only folders in their own (checked before the path is probed, and again on the real path). The folder stays the user's — AgentHub never deletes it. Same-origin.",
|
||||
operationId: "addLaneSourceFolder",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({ type: "object", required: ["path"], properties: { path: { type: "string" } } }),
|
||||
},
|
||||
responses: {
|
||||
201: { description: "The context view.", ...json(SOURCES_VIEW) },
|
||||
...sourcesErrors,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/sources/file": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Upload a context file",
|
||||
description:
|
||||
"Raw bytes (≤ 10 MB) with the name in the `X-File-Name` header (URI-encoded). Stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives. Same-origin.",
|
||||
operationId: "addLaneSourceFile",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "X-File-Name", in: "header", required: true, schema: { type: "string" } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||
},
|
||||
responses: {
|
||||
201: { description: "The context view.", ...json(SOURCES_VIEW) },
|
||||
...sourcesErrors,
|
||||
413: { description: "ETOOBIG — over 10 MB." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/sources/clone": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Clone a repo as an extra folder",
|
||||
description:
|
||||
"Clones `url` (the `publish` URL allowlist; run as the folder's account with that account's own git identity) BESIDE the workspace folder — no path from the request — then adds it as an extra folder. The clone is the user's; removing the source never deletes it. Same-origin.",
|
||||
operationId: "cloneLaneSource",
|
||||
parameters: [idParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["url"],
|
||||
properties: {
|
||||
url: { type: "string" },
|
||||
name: { type: "string", description: "Folder name; default derived from the URL." },
|
||||
credentials: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description: "A typed HTTPS login for this one clone.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
201: { description: "The context view plus `path` (the clone).", ...json(obj) },
|
||||
400: {
|
||||
description:
|
||||
"EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).",
|
||||
},
|
||||
403: { description: "EFOLDER, or not same-origin." },
|
||||
404: notFound,
|
||||
409: {
|
||||
description:
|
||||
"EEXISTS (name taken), ECREDENTIALS / ESSHAUTH (the remote wants a login), ENOREPO, EOWNERFOLDER or EMEMBERGONE.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/sources/{sourceId}": {
|
||||
delete: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Remove an extra folder or context file",
|
||||
description:
|
||||
"Drops the row; for an uploaded file AgentHub also deletes its own stored copy. A folder on disk is never deleted. Same-origin.",
|
||||
operationId: "removeLaneSource",
|
||||
parameters: [
|
||||
idParam,
|
||||
{ name: "sourceId", in: "path", required: true, schema: { type: "integer" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "The context view.", ...json(SOURCES_VIEW) },
|
||||
...sourcesErrors,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/memory": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The memory files the workspace's agent already reads",
|
||||
description:
|
||||
"Read-only: the folder's CLAUDE.md / .claude/CLAUDE.md / CLAUDE.local.md / AGENTS.md and, for Claude, up to 50 `.md` files of its auto-memory dir `<CLAUDE_HOME>/projects/<encoded cwd>/memory/`. Every path derives from the lane row.",
|
||||
operationId: "getLaneMemory",
|
||||
parameters: [idParam],
|
||||
responses: { 200: { description: "`{files:[…]}`.", ...json(obj) }, 404: notFound },
|
||||
},
|
||||
},
|
||||
"/api/locks": {
|
||||
get: {
|
||||
tags: ["Locks"],
|
||||
summary: "List held named locks",
|
||||
description:
|
||||
"Server mode: a developer sees only their own namespace, with the prefix stripped.",
|
||||
operationId: "listLocks",
|
||||
responses: {
|
||||
200: { description: "`{locks:[{name, holder, since, ageSec}]}`.", ...json(obj) },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/locks/{name}/acquire": {
|
||||
post: {
|
||||
tags: ["Locks"],
|
||||
summary: "Try to take a named lock (never waits)",
|
||||
description:
|
||||
"Atomic mkdir under the locks root. A lock held longer than the max hold time is stale and broken. `name` is one plain name (no path). Same-origin.",
|
||||
operationId: "acquireLock",
|
||||
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["holder"],
|
||||
properties: { holder: { type: "string" } },
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{acquired:true}`.", ...json(obj) },
|
||||
400: { description: "EBADNAME or EBADHOLDER." },
|
||||
403: crossOrigin,
|
||||
409: {
|
||||
description: "`{acquired:false, holder, since, ageSec}` — held by someone else.",
|
||||
...json(obj),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/locks/{name}/release": {
|
||||
post: {
|
||||
tags: ["Locks"],
|
||||
summary: "Release a named lock",
|
||||
description: "Only the recorded holder may release it. Same-origin.",
|
||||
operationId: "releaseLock",
|
||||
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
...json({
|
||||
type: "object",
|
||||
required: ["holder"],
|
||||
properties: { holder: { type: "string" } },
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{ok:true}`.", ...json(obj) },
|
||||
400: { description: "EBADNAME or EBADHOLDER." },
|
||||
403: crossOrigin,
|
||||
404: { description: "ENOLOCK — not held." },
|
||||
409: { description: "ENOTHOLDER — `error.currentHolder` names the owner." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/projects": {
|
||||
get: {
|
||||
tags: ["Projects"],
|
||||
summary: "Workspaces grouped by git repository",
|
||||
description:
|
||||
"Each project: `{root, name, workspaces:[id], worktrees:[{path, branch, main, workspace_id, source?}]}` (`source` is `claude` for a `.claude/worktrees` tree, `agenthub` under LANES_ROOT); non-repo folders last as `Folders` (`root:null`). Roots and `git worktree list` are cached in memory (worktrees 30 s). Read on demand, never polled. Server mode: a developer's own workspaces, without worktrees another user's workspace sits on.",
|
||||
operationId: "listProjects",
|
||||
responses: { 200: { description: "`{projects:[…]}`.", ...json(obj) } },
|
||||
},
|
||||
},
|
||||
"/api/projects/refresh": {
|
||||
post: {
|
||||
tags: ["Projects"],
|
||||
summary: "Drop the project caches and list again",
|
||||
operationId: "refreshProjects",
|
||||
responses: {
|
||||
200: { description: "`{projects:[…]}`.", ...json(obj) },
|
||||
403: crossOrigin,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas: {}, paths };
|
||||
@@ -6,7 +6,8 @@
|
||||
* `LaneStageDetectionFields` schema for the stage-detection fields every lane
|
||||
* response carries, and the idempotent `POST /api/lanes/ensure` lookup the
|
||||
* Workspace page opens a directory with, and the Files tab's reads (tree,
|
||||
* quick-open, download — also in a folder that is not a git repo) and upload.
|
||||
* quick-open, download — also in a folder that is not a git repo) and upload,
|
||||
* one lane's GET/DELETE, and the editor's whole-file save.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -299,6 +300,30 @@ const paths = {
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "One workspace",
|
||||
description: "The same lane payload `GET /api/lanes` lists.",
|
||||
operationId: "getLane",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "`{lane}`." },
|
||||
404: { description: "ENOLANE — workspace not found." },
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Delete a workspace row",
|
||||
description:
|
||||
"Deletes the lane row only (its `lane_sources` rows cascade, and AgentHub's own copies of uploaded context files go with them). Stops nothing and never touches the folder, worktree or branch — `POST /api/lanes/{id}/remove` is the UI's path that also stops runs and stacks. Broadcasts `lane_update` with `{removed: id}`. Same-origin.",
|
||||
operationId: "deleteLane",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "`{ok: true}`." },
|
||||
403: { description: "The browser request was not same-origin." },
|
||||
404: { description: "ENOLANE — workspace not found." },
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Partially update a lane",
|
||||
@@ -635,6 +660,37 @@ const paths = {
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Save one text file from the viewer's editor",
|
||||
description:
|
||||
"lane-file-write.js: the read path's guards (lane confinement, secret refusal) plus existing-text-file-only (never creates a file or touches a binary) and a `baseSha` stale check. Answers the file re-read at rev `work`, fresh `sha` included. Same-origin.",
|
||||
operationId: "writeLaneFile",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["path", "content", "baseSha"],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
baseSha: { type: "string", description: "The `sha` the file was opened at." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "`{available: true, …}` — the same shape as the GET." },
|
||||
400: { description: "EBADPATH, ENOFILE, EBADCONTENT, ETOOBIG or EBINARY." },
|
||||
403: { description: "ESECRET or EFOREIGN, or not same-origin." },
|
||||
404: { description: "Lane not found." },
|
||||
409: { description: "ESTALE — the file changed since `baseSha`." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/blame": {
|
||||
get: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user