Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44426faf16 | |||
| 26952ae0d9 | |||
| 649f0599f8 | |||
| 8d2765313c | |||
| 57f02f0d89 | |||
| 50ac0f0daf | |||
| fa74cce3fc | |||
| 3113479cd2 |
+9
-13
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.16 (2026-10-22) — Oh My Pi login sharing hands each pane a key already listed with the gateway (omp hung on a key minted at start)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.19 (2026-10-22) — an avatar can be a link the browser loads (refused when it does not load); Files nests workspaces under their owners' homes._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
|
||||
+6
-6
@@ -2845,12 +2845,12 @@ 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)",
|
||||
],
|
||||
[
|
||||
@@ -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();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
@@ -919,64 +985,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 +2093,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";
|
||||
@@ -470,6 +472,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", () => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
/**
|
||||
* @file ToolProfilesSettings — Settings → AI tools → Profiles: every AI tool's
|
||||
* login profiles in ONE place, a tab per tool. Claude Code's are cc-profiles
|
||||
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
|
||||
* warning — this panel used to be a modal on the Claude Code page); Codex and
|
||||
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
|
||||
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
|
||||
* each Claude/Codex profile to the account pool in Administration → Users,
|
||||
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
|
||||
* pool's Add dialog on it), and shows how many accounts the pool holds.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRightLeft,
|
||||
Check,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type CcAccountIdentity,
|
||||
type CcProfile,
|
||||
type CcProfileUsage,
|
||||
type CcUsageWindow,
|
||||
type ToolProfile,
|
||||
type ToolProfileIdentity,
|
||||
type ToolProfilesTool,
|
||||
} from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { SkeletonRows } from "../config/configPrimitives";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in. */
|
||||
const TOOLS: { id: string; label: string; login: string }[] = [
|
||||
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
||||
{ id: "codex", label: "Codex", login: "codex login" },
|
||||
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
||||
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
||||
];
|
||||
/** Tools the account pool takes (server/routes/accounts.js). */
|
||||
const POOL_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
|
||||
const btnCls =
|
||||
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
|
||||
|
||||
export function ToolProfilesSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const { local, seesAll } = usePermissions();
|
||||
const shown = TOOLS.filter((x) => {
|
||||
const tool = aiTools.byId(x.id);
|
||||
if (x.id === "claude") return tool?.enabled !== false;
|
||||
return !!tool && tool.installed && tool.enabled !== false;
|
||||
});
|
||||
const [tab, setTab] = useState<string>("claude");
|
||||
const current = shown.find((x) => x.id === tab) ?? shown[0];
|
||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
|
||||
const loadOthers = useCallback(
|
||||
() =>
|
||||
api.toolProfiles
|
||||
.list()
|
||||
.then((r) => setOthers(r.tools))
|
||||
.catch(() => setOthers([])),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadOthers();
|
||||
}, [loadOthers]);
|
||||
// The pool is a server-mode, staff-only read.
|
||||
useEffect(() => {
|
||||
if (local || !seesAll) return;
|
||||
api.accountPool
|
||||
.list()
|
||||
.then(setPool)
|
||||
.catch(() => setPool(null));
|
||||
}, [local, seesAll]);
|
||||
|
||||
if (!current) return null;
|
||||
const poolCount = (tool: string) =>
|
||||
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="tool-profiles"
|
||||
title={t("toolProfiles.title")}
|
||||
description={t("toolProfiles.description")}
|
||||
>
|
||||
<div role="tablist" aria-label={t("toolProfiles.title")} className="flex flex-wrap gap-1.5">
|
||||
{shown.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={x.id === current.id}
|
||||
onClick={() => setTab(x.id)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs ${
|
||||
x.id === current.id
|
||||
? "border-accent/40 bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={{ id: x.id, label: x.label }} className="h-3.5 w-3.5" />
|
||||
{x.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="mt-3 space-y-3">
|
||||
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
<PoolLine count={poolCount(current.id)!} />
|
||||
)}
|
||||
{current.id === "claude" ? (
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
|
||||
) : (
|
||||
<OtherProfiles
|
||||
key={current.id}
|
||||
tool={current.id}
|
||||
label={current.label}
|
||||
login={current.login}
|
||||
state={others?.find((o) => o.tool === current.id) ?? null}
|
||||
loading={others === null}
|
||||
pooled={!local && POOL_TOOLS.has(current.id)}
|
||||
onChanged={loadOthers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** "The pool holds N accounts" + the way to it (Administration → Users). */
|
||||
function PoolLine({ count }: { count: number }) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-fg-secondary">
|
||||
<KeyRound className="h-3.5 w-3.5 text-fg-muted" />
|
||||
<span className="flex-1">{t("toolProfiles.poolCount", { count })}</span>
|
||||
<Link to="/admin#account-pool" className="text-accent hover:underline">
|
||||
{t("toolProfiles.openPool")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The link that opens the pool's Add dialog on one saved profile (admins only). */
|
||||
function MoveToPool({ tool, name }: { tool: string; name: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { isAdmin } = usePermissions();
|
||||
if (!isAdmin) return null;
|
||||
return (
|
||||
<Link
|
||||
to={`/admin?${new URLSearchParams({ poolAdd: tool, poolProfile: name })}#account-pool`}
|
||||
title={t("toolProfiles.moveToPoolHint")}
|
||||
className="h-7 text-[11px] px-2 rounded-md text-fg-muted hover:text-accent hover:bg-surface-3 inline-flex items-center gap-1"
|
||||
>
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
{t("toolProfiles.moveToPool")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginHint({ text, command }: { text: string; command: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] text-fg-muted mb-1">{text}</div>
|
||||
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
|
||||
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
|
||||
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await copyText(command, true)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
|
||||
title={t("snippet.copy")}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A profile's label, renamed in place: click it (or the menu's Rename), type, Enter. */
|
||||
function LabelEdit({
|
||||
label,
|
||||
busy,
|
||||
editing,
|
||||
setEditing,
|
||||
onRename,
|
||||
}: {
|
||||
label: string;
|
||||
busy: boolean;
|
||||
editing: boolean;
|
||||
setEditing: (on: boolean) => void;
|
||||
onRename: (next: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [draft, setDraft] = useState(label);
|
||||
useEffect(() => {
|
||||
if (editing) setDraft(label);
|
||||
}, [editing, label]);
|
||||
if (editing) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => setEditing(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
if (e.key !== "Enter") return;
|
||||
const next = draft.trim();
|
||||
setEditing(false);
|
||||
if (next && next !== label) onRename(next);
|
||||
}}
|
||||
maxLength={60}
|
||||
aria-label={t("profiles.renameLabel")}
|
||||
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => setEditing(true)}
|
||||
title={t("profiles.renameHint")}
|
||||
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||
>
|
||||
{label}
|
||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One mutation with a busy key, a toast either way, then a reload. */
|
||||
function useRunner(load: () => Promise<unknown>) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(success);
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[load]
|
||||
);
|
||||
return { busy, run };
|
||||
}
|
||||
|
||||
// ── Codex, Oh My Pi, … ─────────────────────────────────────────────────
|
||||
|
||||
function OtherProfiles({
|
||||
tool,
|
||||
label,
|
||||
login,
|
||||
state,
|
||||
loading,
|
||||
pooled,
|
||||
onChanged,
|
||||
}: {
|
||||
tool: string;
|
||||
label: string;
|
||||
login: string;
|
||||
state: ToolProfilesTool | null;
|
||||
loading: boolean;
|
||||
pooled: boolean;
|
||||
onChanged: () => Promise<unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t: tc } = useTranslation("ccConfig");
|
||||
const { busy, run } = useRunner(onChanged);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
|
||||
if (loading) return <SkeletonRows n={2} />;
|
||||
if (!state) return <p className="text-xs text-fg-muted">{t("toolProfiles.noProfiles")}</p>;
|
||||
if (!state.supported) {
|
||||
return (
|
||||
<p className="text-xs text-fg-muted inline-flex items-start gap-1.5">
|
||||
<ShieldAlert className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t(`toolProfiles.unsupported.${tool}`, { defaultValue: state.reason ?? "" })}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const items = state.items;
|
||||
const activeSaved = items.some((p) => p.active);
|
||||
const remove = async (p: ToolProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
message: t("toolProfiles.deleteConfirmBody", { tool: label }),
|
||||
confirmLabel: tc("profiles.deleteButton"),
|
||||
});
|
||||
if (ok) {
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.toolProfiles.remove(tool, p.name),
|
||||
tc("profiles.deleteSuccess", { name: p.label })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">
|
||||
{t(`toolProfiles.subtitle.${tool}`, { defaultValue: "" })}
|
||||
</p>
|
||||
{state.error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{state.error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className={`${rowCls} space-y-1`}>
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{tc("profiles.activeLabel")}
|
||||
</div>
|
||||
{state.active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">{who(state.active)}</div>
|
||||
<OtherIdentityLine identity={state.active} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("toolProfiles.noActive", { tool: label })}</div>
|
||||
)}
|
||||
{state.active && !activeSaved && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{tc("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run("save", () => api.toolProfiles.save(tool), tc("profiles.saveSuccess"))
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{busy === "save" ? tc("profiles.saving") : t("toolProfiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||
{tc("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items.map((p) => (
|
||||
<div key={p.name} data-testid={`profile-row-${tool}-${p.name}`} className={rowCls}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.toolProfiles.rename(tool, p.name, next),
|
||||
tc("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.active && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">{who(p)}</span>
|
||||
{!p.active && (
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.toolProfiles.activate(tool, p.name),
|
||||
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
|
||||
)
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
{busy === `switch:${p.name}`
|
||||
? tc("profiles.switching")
|
||||
: tc("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool={tool} name={p.name} />}
|
||||
{!p.active && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={tc("profiles.deleteButton")}
|
||||
aria-label={tc("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<OtherIdentityLine identity={p} />
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{tc("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const who = (id: ToolProfileIdentity) => id.email || id.detail || id.kind;
|
||||
|
||||
function OtherIdentityLine({ identity }: { identity: ToolProfileIdentity }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const parts = [
|
||||
t(`toolProfiles.kind.${identity.kind}`, { defaultValue: identity.kind }),
|
||||
identity.email ? identity.detail : null,
|
||||
].filter(Boolean);
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
function ActiveBadge() {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
return (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||
{t("profiles.activeBadge")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Claude Code ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
|
||||
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
|
||||
// subagents, memory, plugins, marketplaces and settings all stay exactly where
|
||||
// they are. The dashboard cannot log a new account in (only `claude auth login`
|
||||
// can mint tokens), so adding a profile is always "log in, then snapshot what
|
||||
// the CLI wrote" — a save button on the live account, never a name field: the
|
||||
// server derives the identifier, and the user edits the label in place. The
|
||||
// save button only appears while the live account is unsaved, because one
|
||||
// account is one profile.
|
||||
|
||||
function ClaudeProfiles({ pooled }: { pooled: boolean }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
|
||||
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Quota per profile, keyed by `name`, fetched for every profile once the
|
||||
* list arrives: the live account is read from the CLI and never refreshed,
|
||||
* any other profile only when its token has actually expired. */
|
||||
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||
/** Per profile, so one slow account does not freeze the other rows. */
|
||||
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
const ctx = useContextMenu<CcProfile>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.ccConfig.profiles();
|
||||
setItems(r.items);
|
||||
setActive(r.active);
|
||||
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "unknown");
|
||||
}
|
||||
}, []);
|
||||
const { busy, run } = useRunner(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const checkUsage = useCallback(async (name: string) => {
|
||||
setUsageBusy((prev) => ({ ...prev, [name]: true }));
|
||||
setUsageError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.ccConfig.profileUsage(name);
|
||||
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||
} catch (err: unknown) {
|
||||
setUsageError((prev) => ({
|
||||
...prev,
|
||||
[name]: err instanceof Error ? err.message : "unknown error",
|
||||
}));
|
||||
} finally {
|
||||
setUsageBusy((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Every profile's quota, once per profile per mount: a retry is the row's
|
||||
// own button, so a failed account is not re-asked on every render.
|
||||
useEffect(() => {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
const copies = (p: CcProfile) =>
|
||||
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
||||
/** Deletable unless it is the ONLY saved copy of the live account. */
|
||||
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const switchTo = (p: CcProfile) =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.ccConfig.activateProfile(p.name),
|
||||
t("profiles.switchSuccess", { name: p.label })
|
||||
);
|
||||
const remove = async (p: CcProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
message: t("profiles.deleteConfirmBody"),
|
||||
confirmLabel: t("profiles.deleteButton"),
|
||||
});
|
||||
if (!ok) return;
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.ccConfig.deleteProfile(p.name),
|
||||
t("profiles.deleteSuccess", { name: p.label })
|
||||
);
|
||||
};
|
||||
const pick = (fn: (p: CcProfile) => void) => () => {
|
||||
const p = ctx.menu!.item;
|
||||
ctx.close();
|
||||
fn(p);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
|
||||
{mismatchEmail && (
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${rowCls} space-y-1`}>
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("profiles.activeLabel")}
|
||||
</div>
|
||||
{active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{active.email || active.displayName || active.accountUuid}
|
||||
</div>
|
||||
<IdentityLine identity={active} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||
)}
|
||||
{active && !activeSaved && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
|
||||
}
|
||||
className={btnCls}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items?.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
className={rowCls}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.active && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!p.active && (
|
||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
|
||||
{!ctx.menu.item.active && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||
{t("profiles.switchButton")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
|
||||
onSelect={pick((p) => void checkUsage(p.name))}
|
||||
>
|
||||
{t("profiles.usageCheck")}
|
||||
</MenuItem>
|
||||
{deletable(ctx.menu.item) && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy !== null}
|
||||
testId="profile-menu-delete"
|
||||
onSelect={pick((p) => void remove(p))}
|
||||
>
|
||||
{t("profiles.deleteButton")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
<LoginHint text={t("profiles.loginHint")} command="claude auth login" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||
* reports — the 5-hour session allowance and the 7-day one. Reading a saved
|
||||
* profile's usage may refresh its OAuth token (rotating it on disk), so it
|
||||
* happens once per mount or click, never on a poll.
|
||||
*/
|
||||
function ProfileUsageLine({
|
||||
usage,
|
||||
error,
|
||||
busy,
|
||||
disabled,
|
||||
onCheck,
|
||||
}: {
|
||||
usage?: CcProfileUsage;
|
||||
error?: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.resetsAt
|
||||
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||
: undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="text-status-danger">{error}</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{t("profiles.usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!usage) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
title={t("profiles.usageHint")}
|
||||
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||
<span title={resetTitle(usage.fiveHour)}>
|
||||
{t("profiles.usageFiveHour")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||
</span>
|
||||
<span title={resetTitle(usage.sevenDay)}>
|
||||
{t("profiles.usageSevenDay")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @file Tests for ToolProfilesSettings — Settings → AI tools → Profiles: one
|
||||
* tab per AI tool. Claude's tab asks for EVERY saved account's quota once (a
|
||||
* failing account costs the others nothing), and its right-click delete asks
|
||||
* first and never offers to delete the only copy of the live account; Codex's
|
||||
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
|
||||
* switched; and in server mode a profile links into the account pool.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../../i18n";
|
||||
import { settleConfirm } from "../../../lib/confirm";
|
||||
import { ToolProfilesSettings } from "../ToolProfilesSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: true, local: true, seesAll: true }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
vi.mock("../../../hooks/useAiTools", () => ({
|
||||
useAiTools: () => ({
|
||||
byId: (id: string) => ({ id, installed: true, enabled: true }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const m = vi.hoisted(() => {
|
||||
const window = (utilization: number) => ({ utilization, resetsAt: "2026-09-18T08:00:00Z" });
|
||||
return {
|
||||
window,
|
||||
cc: {
|
||||
profiles: vi.fn(),
|
||||
profileUsage: vi.fn(),
|
||||
deleteProfile: vi.fn(),
|
||||
activateProfile: vi.fn(),
|
||||
saveProfile: vi.fn(),
|
||||
renameProfile: vi.fn(),
|
||||
},
|
||||
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
||||
pool: { list: vi.fn() },
|
||||
};
|
||||
});
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
ccConfig: m.cc,
|
||||
toolProfiles: m.tp,
|
||||
accountPool: m.pool,
|
||||
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
||||
},
|
||||
}));
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function show() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ToolProfilesSettings />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.local = true;
|
||||
m.cc.profiles.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||
],
|
||||
active: { accountUuid: "u1", email: "a@b.c" },
|
||||
});
|
||||
m.cc.profileUsage.mockImplementation(async (name: string) => {
|
||||
if (name === "spare") throw new Error("this profile's saved login has been superseded");
|
||||
return { name, refreshed: false, fiveHour: m.window(8), sevenDay: m.window(70) };
|
||||
});
|
||||
m.cc.deleteProfile.mockResolvedValue({ ok: true, name: "spare" });
|
||||
m.tp.list.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "codex",
|
||||
supported: true,
|
||||
items: [
|
||||
{
|
||||
name: "a",
|
||||
label: "Alice",
|
||||
active: true,
|
||||
kind: "chatgpt",
|
||||
email: "a@x",
|
||||
detail: "plus",
|
||||
},
|
||||
{ name: "b", label: "Bob", active: false, kind: "apikey", email: null, detail: "…1234" },
|
||||
],
|
||||
active: { kind: "chatgpt", email: "a@x", detail: "plus" },
|
||||
},
|
||||
{ tool: "omp", supported: true, items: [], active: null },
|
||||
{ tool: "antigravity", supported: false, reason: "keyring", items: [], active: null },
|
||||
],
|
||||
});
|
||||
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
||||
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — Claude", () => {
|
||||
it("asks for every saved account's quota once, a failure costing the others nothing", async () => {
|
||||
await show();
|
||||
await settle();
|
||||
expect(m.cc.profileUsage).toHaveBeenCalledWith("work");
|
||||
expect(m.cc.profileUsage).toHaveBeenCalledWith("spare");
|
||||
expect(m.cc.profileUsage.mock.calls.filter((c) => c[0] === "work")).toHaveLength(1);
|
||||
expect(await screen.findByText("8%")).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("deletes from the right-click menu only after confirming", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||
await settle();
|
||||
expect(m.cc.deleteProfile).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(m.cc.deleteProfile).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("offers no delete for the only saved copy of the live account", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — other tools", () => {
|
||||
it("switches a Codex login through /api/tool-profiles", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.click(screen.getByRole("tab", { name: /codex/i }));
|
||||
const bob = screen.getByTestId("profile-row-codex-b");
|
||||
expect(bob).toHaveTextContent("…1234");
|
||||
await user.click(screen.getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.tp.activate).toHaveBeenCalledWith("codex", "b");
|
||||
expect(m.tp.list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("says why Antigravity cannot be switched", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
await user.click(screen.getByRole("tab", { name: /antigravity/i }));
|
||||
expect(screen.getByText(/system keyring/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — account pool", () => {
|
||||
it("is not shown in local mode", async () => {
|
||||
await show();
|
||||
expect(m.pool.list).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText(/Move to pool/)).toBeNull();
|
||||
});
|
||||
|
||||
it("in server mode shows the pool's size and links a profile into it", async () => {
|
||||
perms.local = false;
|
||||
await show();
|
||||
expect(screen.getByText(/holds 1 account/)).toBeInTheDocument();
|
||||
const [link] = screen.getAllByRole("link", { name: /Move to pool/ });
|
||||
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
||||
});
|
||||
});
|
||||
@@ -4,19 +4,23 @@
|
||||
* 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
|
||||
* switch-user profile (`GET /api/accounts/profiles`), a pasted
|
||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
||||
* 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 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
@@ -77,7 +81,22 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
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);
|
||||
const closeAdd = () => {
|
||||
setAdding(false);
|
||||
if (!preset) return;
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("poolAdd");
|
||||
next.delete("poolProfile");
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
const [editing, setEditing] = useState<Row | null>(null);
|
||||
|
||||
@@ -102,13 +121,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="account-pool">
|
||||
<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">
|
||||
<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>
|
||||
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
|
||||
{t("pool.profilesLink")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
@@ -235,10 +257,11 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
)}
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
onClose={() => setAdding(false)}
|
||||
preset={preset}
|
||||
onClose={closeAdd}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.done.added", { name }));
|
||||
setAdding(false);
|
||||
closeAdd();
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
@@ -412,19 +435,27 @@ function EditAccountDialog({
|
||||
}
|
||||
|
||||
type ClaudeSource = "profile" | "credentials" | "token";
|
||||
/** What each tool's pool account can come from (server/routes/accounts.js). */
|
||||
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
|
||||
claude: ["profile", "credentials", "token"],
|
||||
codex: ["profile", "token"],
|
||||
};
|
||||
|
||||
function AddAccountDialog({
|
||||
preset,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
/** 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>("claude");
|
||||
const [tool, setTool] = useState<PoolTool>(preset?.tool ?? "claude");
|
||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
const [profile, setProfile] = useState("");
|
||||
const [profile, setProfile] = useState(preset?.profile ?? "");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
@@ -432,13 +463,14 @@ function AddAccountDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setProfiles(null);
|
||||
api.accountPool
|
||||
.profiles()
|
||||
.profiles(tool)
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
}, [tool]);
|
||||
|
||||
const fromProfile = tool === "claude" && source === "profile";
|
||||
const fromProfile = source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
@@ -449,10 +481,10 @@ function AddAccountDialog({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const body: PoolAccountInput =
|
||||
tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
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() };
|
||||
@@ -491,29 +523,36 @@ function AddAccountDialog({
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => setTool(e.target.value as PoolTool)}
|
||||
onChange={(e) => {
|
||||
setTool(e.target.value as PoolTool);
|
||||
setProfile("");
|
||||
setSource("profile");
|
||||
}}
|
||||
aria-label={t("pool.tool")}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
{tool === "claude" && (
|
||||
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{(["profile", "credentials", "token"] as const).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`pool.sources.${s}`)}
|
||||
</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>
|
||||
{fromProfile && (
|
||||
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
||||
<select
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { AccountPool } from "../../../lib/api";
|
||||
|
||||
const accountPool = vi.hoisted(() => ({
|
||||
@@ -30,6 +31,13 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
|
||||
/** The card reads `?poolAdd=` and links to Settings: it needs a router. */
|
||||
const card = (isAdmin: boolean, url = "/admin") => (
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const POOL: AccountPool = {
|
||||
accounts: [
|
||||
{
|
||||
@@ -91,7 +99,7 @@ beforeEach(() => {
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("lists every account, and explains an empty pool", async () => {
|
||||
const { unmount } = render(<AccountPoolCard isAdmin={false} />);
|
||||
const { unmount } = render(card(false));
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
@@ -102,14 +110,14 @@ describe("AccountPoolCard", () => {
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves a saved login in, the server's own included", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
@@ -134,7 +142,7 @@ describe("AccountPoolCard", () => {
|
||||
it("adds a setup-token only with the admin's own password", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
@@ -159,7 +167,7 @@ describe("AccountPoolCard", () => {
|
||||
it("removes an account with the admin's own password", async () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
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" }));
|
||||
@@ -184,7 +192,7 @@ describe("AccountPoolCard", () => {
|
||||
removed: [{ userId: 8, username: "leo" }],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
render(card(true));
|
||||
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
||||
@@ -206,4 +214,40 @@ describe("AccountPoolCard", () => {
|
||||
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||
);
|
||||
});
|
||||
|
||||
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "cx" } });
|
||||
accountPool.profiles.mockImplementation(async (tool: string) =>
|
||||
tool === "codex"
|
||||
? {
|
||||
profiles: [
|
||||
{ name: "cx", label: "Codex", email: "c@x", subscriptionType: "plus", active: false },
|
||||
],
|
||||
}
|
||||
: { profiles: [] }
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
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");
|
||||
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(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "codex", name: "cx", profile: "cx" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("links back to the profiles in Settings", async () => {
|
||||
render(card(false));
|
||||
const link = await screen.findByRole("link", { name: "Profiles in Settings" });
|
||||
expect(link.getAttribute("href")).toBe("/settings/agents#tool-profiles");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -650,6 +650,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.",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||
"quotaActive": "in use",
|
||||
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Settings → AI tools → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
"quotaSignInExpired": "Sign-in expired",
|
||||
"quotaWindow": {
|
||||
|
||||
@@ -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}}",
|
||||
@@ -1357,7 +1321,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",
|
||||
@@ -1385,5 +1350,32 @@
|
||||
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
|
||||
"signedOut_one": "Signed out {{count}} other device",
|
||||
"signedOut_other": "Signed out {{count}} other devices"
|
||||
},
|
||||
"toolProfiles": {
|
||||
"title": "Profiles",
|
||||
"description": "Every AI tool's saved logins in one place. Switching swaps only the login; config, skills, sessions and history stay as they are.",
|
||||
"poolCount_one": "The account pool holds {{count}} account for this tool.",
|
||||
"poolCount_other": "The account pool holds {{count}} accounts for this tool.",
|
||||
"openPool": "Open the account pool",
|
||||
"moveToPool": "Move to pool",
|
||||
"moveToPoolHint": "Administration → Users → Account pool, with this profile chosen. The pool becomes the login's only holder and the profile is removed.",
|
||||
"saveButton": "Save this login as a profile",
|
||||
"noActive": "{{tool}} holds no login on this machine.",
|
||||
"noProfiles": "Could not load the profiles.",
|
||||
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
|
||||
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
|
||||
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
|
||||
"subtitle": {
|
||||
"codex": "A profile is one Codex login (a ChatGPT account or an API key): the whole auth.json. config.toml, sessions, skills and history stay where they are.",
|
||||
"omp": "A profile is the full set of logins Oh My Pi stores (every provider), read with `omp auth export` and put back with `omp auth import`. Settings, models and sessions stay where they are. Running omp sessions pick up the switch on their own."
|
||||
},
|
||||
"unsupported": {
|
||||
"antigravity": "Antigravity keeps its login in the system keyring, which AgentHub does not read or write, so it cannot be switched from here. Sign in or out inside agy itself."
|
||||
},
|
||||
"kind": {
|
||||
"chatgpt": "ChatGPT login",
|
||||
"apikey": "API key",
|
||||
"credentials": "Logins"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,19 +177,20 @@
|
||||
},
|
||||
"source": "From",
|
||||
"sources": {
|
||||
"profile": "A saved login (Switch account profiles)",
|
||||
"profile": "A saved profile (Settings → AI tools → Profiles)",
|
||||
"credentials": "Paste .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Saved in Settings → Claude config → Switch account. The one this server's own Claude Code is logged into can be moved too: the server keeps running on it and refreshes it for everyone.",
|
||||
"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.",
|
||||
"chooseProfile": "Choose a saved login…",
|
||||
"noProfiles": "No saved logins — save one in Settings → Claude config → Switch account",
|
||||
"noProfiles": "No saved profiles — save one in Settings → AI tools → Profiles",
|
||||
"loadingProfiles": "Loading…",
|
||||
"activeHere": "in use on this server — keeps working",
|
||||
"nameFromProfile": "Leave blank to use the profile's name",
|
||||
@@ -216,7 +217,8 @@
|
||||
"users": "Users on this account",
|
||||
"noUsers": "No users yet.",
|
||||
"onAccount": "now on {{account}}",
|
||||
"editDone": "{{name}} saved"
|
||||
"editDone": "{{name}} saved",
|
||||
"profilesLink": "Profiles in Settings"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
|
||||
@@ -650,6 +650,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.",
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Cài đặt → AI tools → Hồ sơ đăng nhập để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||
"quotaWindow": {
|
||||
|
||||
@@ -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}}",
|
||||
@@ -1357,7 +1309,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ừ",
|
||||
@@ -1385,5 +1338,32 @@
|
||||
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
|
||||
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
|
||||
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
|
||||
},
|
||||
"toolProfiles": {
|
||||
"title": "Hồ sơ đăng nhập",
|
||||
"description": "Mọi đăng nhập đã lưu của các AI tool ở một nơi. Khi đổi chỉ phần đăng nhập được hoán đổi; config, skills, phiên làm việc và lịch sử vẫn giữ nguyên.",
|
||||
"poolCount_one": "Account pool đang có {{count}} tài khoản cho tool này.",
|
||||
"poolCount_other": "Account pool đang có {{count}} tài khoản cho tool này.",
|
||||
"openPool": "Mở account pool",
|
||||
"moveToPool": "Đưa vào pool",
|
||||
"moveToPoolHint": "Quản trị → Người dùng → Account pool, chọn sẵn hồ sơ này. Pool sẽ là nơi duy nhất giữ đăng nhập này và hồ sơ được xoá.",
|
||||
"saveButton": "Lưu đăng nhập này thành hồ sơ",
|
||||
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
|
||||
"noProfiles": "Không tải được danh sách hồ sơ.",
|
||||
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
|
||||
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
|
||||
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
|
||||
"subtitle": {
|
||||
"codex": "Mỗi hồ sơ là một đăng nhập Codex (tài khoản ChatGPT hoặc API key): toàn bộ auth.json. config.toml, phiên, skills và lịch sử vẫn giữ nguyên.",
|
||||
"omp": "Mỗi hồ sơ là toàn bộ các đăng nhập Oh My Pi đang lưu (mọi provider), đọc bằng `omp auth export` và ghi lại bằng `omp auth import`. Settings, model và phiên vẫn giữ nguyên. Các phiên omp đang chạy tự nhận đăng nhập mới."
|
||||
},
|
||||
"unsupported": {
|
||||
"antigravity": "Antigravity lưu đăng nhập trong keyring của hệ điều hành, AgentHub không đọc hay ghi vào đó nên không đổi được từ đây. Hãy đăng nhập/đăng xuất ngay trong agy."
|
||||
},
|
||||
"kind": {
|
||||
"chatgpt": "Đăng nhập ChatGPT",
|
||||
"apikey": "API key",
|
||||
"credentials": "Đăng nhập"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,19 +177,20 @@
|
||||
},
|
||||
"source": "Nguồn",
|
||||
"sources": {
|
||||
"profile": "Login đã lưu (profile Switch account)",
|
||||
"profile": "Hồ sơ đã lưu (Cài đặt → AI tools → Hồ sơ đăng nhập)",
|
||||
"credentials": "Dán .credentials.json / Keychain",
|
||||
"token": "claude setup-token"
|
||||
},
|
||||
"sourceHint": {
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"profile": "Profile",
|
||||
"profileHint": "Lưu ở Settings → Claude config → Switch account. Profile mà Claude Code của server đang dùng cũng chuyển được: server vẫn chạy trên nó và tự làm mới cho mọi người.",
|
||||
"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ó.",
|
||||
"chooseProfile": "Chọn login đã lưu…",
|
||||
"noProfiles": "Chưa có login nào — lưu ở Settings → Claude config → Switch account",
|
||||
"noProfiles": "Chưa có hồ sơ nào — lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập",
|
||||
"loadingProfiles": "Đang tải…",
|
||||
"activeHere": "server đang dùng — vẫn chạy tiếp",
|
||||
"nameFromProfile": "Để trống để dùng tên profile",
|
||||
@@ -216,7 +217,8 @@
|
||||
"users": "User dùng tài khoản này",
|
||||
"noUsers": "Chưa có user nào.",
|
||||
"onAccount": "đang ở {{account}}",
|
||||
"editDone": "Đã lưu {{name}}"
|
||||
"editDone": "Đã lưu {{name}}",
|
||||
"profilesLink": "Hồ sơ trong Cài đặt"
|
||||
},
|
||||
"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();
|
||||
});
|
||||
});
|
||||
|
||||
+54
-1
@@ -699,6 +699,29 @@ export type PoolProfile = {
|
||||
subscriptionType: string | null;
|
||||
active: boolean;
|
||||
};
|
||||
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
|
||||
export type ToolProfileIdentity = {
|
||||
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
|
||||
kind: string;
|
||||
email: string | null;
|
||||
/** codex: the ChatGPT plan or `…1234` of a key; omp: its providers. */
|
||||
detail: string | null;
|
||||
};
|
||||
export type ToolProfile = ToolProfileIdentity & {
|
||||
name: string;
|
||||
label: string;
|
||||
savedAt: number | null;
|
||||
active: boolean;
|
||||
};
|
||||
export type ToolProfilesTool = {
|
||||
tool: string;
|
||||
supported: boolean;
|
||||
/** Why a tool cannot be switched (`supported: false`). */
|
||||
reason?: string;
|
||||
items: ToolProfile[];
|
||||
active: ToolProfileIdentity | null;
|
||||
error?: { code?: string; message: string } | null;
|
||||
};
|
||||
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
|
||||
export type PoolAccountInput = {
|
||||
tool: PoolTool;
|
||||
@@ -1096,6 +1119,33 @@ export const api = {
|
||||
body: JSON.stringify({ laneId, currentPassword }),
|
||||
}),
|
||||
},
|
||||
/** Login profiles of Codex, Oh My Pi… (Claude's: `ccConfig.profiles`). */
|
||||
toolProfiles: {
|
||||
/** GET /api/tool-profiles — every covered tool, switchable or not. */
|
||||
list: () => request<{ tools: ToolProfilesTool[] }>("/tool-profiles"),
|
||||
/** POST /api/tool-profiles/:tool — save the login the tool holds now. */
|
||||
save: (tool: string) =>
|
||||
request<{ ok: true; name: string; label: string }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}`,
|
||||
{ method: "POST", body: JSON.stringify({}) }
|
||||
),
|
||||
rename: (tool: string, name: string, label: string) =>
|
||||
request<{ ok: true; name: string; label: string }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ name, label }) }
|
||||
),
|
||||
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
|
||||
activate: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string; savedOutgoing: string | null }>(
|
||||
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
|
||||
{ method: "POST", body: JSON.stringify({ name }) }
|
||||
),
|
||||
remove: (tool: string, name: string) =>
|
||||
request<{ ok: true; name: string }>(`/tool-profiles/${encodeURIComponent(tool)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
},
|
||||
/** The Claude/Codex account pool every member's new panes run on
|
||||
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
|
||||
accountPool: {
|
||||
@@ -1103,7 +1153,10 @@ export const api = {
|
||||
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
|
||||
/** POST /api/accounts — admin, with their OWN password again. */
|
||||
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
|
||||
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
|
||||
profiles: (tool: PoolTool = "claude") =>
|
||||
request<{ profiles: PoolProfile[] }>(
|
||||
tool === "codex" ? "/accounts/profiles?tool=codex" : "/accounts/profiles"
|
||||
),
|
||||
add: (body: PoolAccountInput, currentPassword: string) =>
|
||||
request<{ account: { name: string } }>("/accounts", {
|
||||
method: "POST",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -313,9 +313,21 @@ export const SETTINGS_INDEX: {
|
||||
to: "?tab=settings",
|
||||
},
|
||||
{
|
||||
section: "agents/claude-code",
|
||||
titleKey: "ccConfig:profiles.title",
|
||||
keywords: ["account", "login", "quota", "switch"],
|
||||
section: "agents",
|
||||
titleKey: "toolProfiles.title",
|
||||
keywords: [
|
||||
"profile",
|
||||
"account",
|
||||
"login",
|
||||
"quota",
|
||||
"switch",
|
||||
"codex",
|
||||
"omp",
|
||||
"pool",
|
||||
"hồ sơ",
|
||||
"tài khoản",
|
||||
],
|
||||
to: "#tool-profiles",
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
|
||||
@@ -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;
|
||||
|
||||
+16
-497
@@ -10,8 +10,10 @@
|
||||
* concurrent-write races with the live CLI. Its Overview also holds the
|
||||
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
|
||||
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
|
||||
* The toolbar's Switch profile button links to Settings → AI tools → Profiles
|
||||
* (`ToolProfilesSettings`), where every AI tool's login profiles live.
|
||||
* Every card with action buttons (plugins, marketplaces, MCP servers, memory
|
||||
* files, auth profiles, and the `MdItemList` artifacts) also opens a
|
||||
* files, and the `MdItemList` artifacts) also opens a
|
||||
* right-click menu that repeats those buttons — same handlers, same
|
||||
* confirm path — plus Copy name/path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -61,7 +63,8 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useSettingsHref } from "../lib/settingsLink";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import {
|
||||
Boxes,
|
||||
@@ -89,7 +92,6 @@ import {
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
ShieldAlert,
|
||||
Lock,
|
||||
History,
|
||||
Terminal,
|
||||
@@ -153,14 +155,11 @@ import type {
|
||||
CcPluginItem,
|
||||
CcPluginsResponse,
|
||||
CcUpdateAllBatch,
|
||||
CcProfile,
|
||||
CcAccountIdentity,
|
||||
CcProfileUsage,
|
||||
CcUsageWindow,
|
||||
CcScope,
|
||||
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;
|
||||
@@ -450,7 +449,6 @@ export function CcConfig() {
|
||||
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
|
||||
const setToast = showToast;
|
||||
const [backupsOpen, setBackupsOpen] = useState(false);
|
||||
const [profilesOpen, setProfilesOpen] = useState(false);
|
||||
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
|
||||
|
||||
const scopeRef = useRef(scope);
|
||||
@@ -682,7 +680,6 @@ export function CcConfig() {
|
||||
setAgenthubKey((k) => k + 1);
|
||||
}}
|
||||
onOpenBackups={() => setBackupsOpen(true)}
|
||||
onOpenProfiles={() => setProfilesOpen(true)}
|
||||
wsConnected={wsConnected}
|
||||
/>
|
||||
|
||||
@@ -811,7 +808,6 @@ export function CcConfig() {
|
||||
/>
|
||||
)}
|
||||
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
|
||||
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -823,20 +819,13 @@ interface HeaderProps {
|
||||
lastUpdated: Date | null;
|
||||
onRefresh: () => void;
|
||||
onOpenBackups: () => void;
|
||||
onOpenProfiles: () => void;
|
||||
wsConnected: boolean;
|
||||
}
|
||||
|
||||
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
|
||||
function Header({
|
||||
loading,
|
||||
lastUpdated,
|
||||
onRefresh,
|
||||
onOpenBackups,
|
||||
onOpenProfiles,
|
||||
wsConnected,
|
||||
}: HeaderProps) {
|
||||
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const settingsHref = useSettingsHref();
|
||||
const { t: tCommon } = useTranslation("common");
|
||||
const formatted = lastUpdated
|
||||
? lastUpdated.toLocaleTimeString(undefined, {
|
||||
@@ -863,14 +852,14 @@ function Header({
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 justify-end flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProfiles}
|
||||
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
|
||||
<Link
|
||||
to={settingsHref("agents#tool-profiles")}
|
||||
className="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"
|
||||
>
|
||||
<UserCog className="w-3.5 h-3.5" />
|
||||
{t("profiles.openButton")}
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBackups}
|
||||
@@ -1807,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")}
|
||||
@@ -2340,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")}
|
||||
@@ -2351,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")}
|
||||
@@ -4226,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")}
|
||||
@@ -4737,476 +4726,6 @@ function CommandSnippet({ command, label }: { command: string; label?: string })
|
||||
);
|
||||
}
|
||||
|
||||
// ── Auth profiles modal ────────────────────────────────────────────────
|
||||
//
|
||||
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
|
||||
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
|
||||
// subagents, memory, plugins, marketplaces and settings all stay exactly where
|
||||
// they are — that is the whole point of the feature.
|
||||
//
|
||||
// The dashboard cannot log a new account in (only `claude auth login` can mint
|
||||
// tokens), so adding a profile is always "log in, then snapshot what the CLI
|
||||
// wrote" — hence a save button on the live account rather than an add-account
|
||||
// flow. There is no name field: the server derives the identifier, and the user
|
||||
// edits the display label in place by clicking it. That keeps the one string
|
||||
// the user types free of filename rules, so spaces and diacritics are fine.
|
||||
// The save button only appears while the live account is unsaved, because one
|
||||
// account is one profile — a second copy would just report itself active too.
|
||||
|
||||
function ProfilesModal({
|
||||
onClose,
|
||||
onToast,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onToast: (t: NonNullable<Toast>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
|
||||
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
/** Quota per profile, keyed by `name`, fetched for every profile once the
|
||||
* list arrives — the whole point of the panel is comparing accounts, and one
|
||||
* button per row made the user do that by hand. The cost is bounded: the
|
||||
* live account is read from the CLI and never refreshed, and any other
|
||||
* profile is refreshed only when its token has actually expired, which
|
||||
* leaves it valid for hours of further opens. */
|
||||
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
|
||||
const [usageError, setUsageError] = useState<Record<string, string>>({});
|
||||
/** Per profile, so one slow account does not freeze the other rows. */
|
||||
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||
const autoFetched = useRef<Set<string>>(new Set());
|
||||
const ctx = useContextMenu<CcProfile>();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.ccConfig.profiles();
|
||||
setItems(r.items);
|
||||
setActive(r.active);
|
||||
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "unknown");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const menuOpen = useRef(false);
|
||||
menuOpen.current = !!ctx.menu;
|
||||
// An open right-click menu takes this Escape for itself.
|
||||
useDialog(true, panelRef, () => {
|
||||
if (!menuOpen.current) onClose();
|
||||
});
|
||||
|
||||
/** Run one profile mutation, then refresh the list. */
|
||||
const run = useCallback(
|
||||
async (key: string, fn: () => Promise<unknown>, success: string) => {
|
||||
setBusy(key);
|
||||
try {
|
||||
await fn();
|
||||
onToast({ kind: "success", message: success });
|
||||
await load();
|
||||
} catch (err: unknown) {
|
||||
onToast({
|
||||
kind: "error",
|
||||
message: err instanceof Error ? err.message : "unknown error",
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[load, onToast]
|
||||
);
|
||||
|
||||
const checkUsage = useCallback(async (name: string) => {
|
||||
setUsageBusy((prev) => ({ ...prev, [name]: true }));
|
||||
setUsageError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await api.ccConfig.profileUsage(name);
|
||||
setUsage((prev) => ({ ...prev, [name]: result }));
|
||||
} catch (err: unknown) {
|
||||
setUsageError((prev) => ({
|
||||
...prev,
|
||||
[name]: err instanceof Error ? err.message : "unknown error",
|
||||
}));
|
||||
} finally {
|
||||
setUsageBusy((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Every profile's quota, as soon as there is a list to ask about. Once per
|
||||
// profile per opening: a retry is the row's own button, so a failed account
|
||||
// is not re-asked on every render.
|
||||
useEffect(() => {
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
const copies = (p: CcProfile) =>
|
||||
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
||||
/** Deletable unless it is the ONLY saved copy of the live account — a
|
||||
* redundant copy has to be removable, or an accidental duplicate could
|
||||
* never be cleaned up. */
|
||||
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
|
||||
const startRename = (p: CcProfile) => {
|
||||
setDraft(p.label);
|
||||
setRenaming(p.name);
|
||||
};
|
||||
const switchTo = (p: CcProfile) =>
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.ccConfig.activateProfile(p.name),
|
||||
t("profiles.switchSuccess", { name: p.label })
|
||||
);
|
||||
// A saved profile holds that account's OAuth credentials — deleting it
|
||||
// cannot be undone from here, so it asks first.
|
||||
const remove = async (p: CcProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
message: t("profiles.deleteConfirmBody"),
|
||||
confirmLabel: t("profiles.deleteButton"),
|
||||
});
|
||||
if (!ok) return;
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.ccConfig.deleteProfile(p.name),
|
||||
t("profiles.deleteSuccess", { name: p.label })
|
||||
);
|
||||
};
|
||||
const pick = (fn: (p: CcProfile) => void) => () => {
|
||||
const p = ctx.menu!.item;
|
||||
ctx.close();
|
||||
fn(p);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cc-profiles-title"
|
||||
className="relative w-full max-w-2xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<UserCog className="w-4 h-4 text-fg-muted" />
|
||||
<span id="cc-profiles-title" className="text-sm font-medium text-fg-primary flex-1">
|
||||
{t("profiles.title")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-b border-border space-y-2">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
|
||||
{mismatchEmail && (
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
{error && (
|
||||
<div className="text-sm text-status-danger inline-flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 space-y-1">
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("profiles.activeLabel")}
|
||||
</div>
|
||||
{active ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{active.email || active.displayName || active.accountUuid}
|
||||
</div>
|
||||
<IdentityLine identity={active} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||
)}
|
||||
{active && !activeSaved && (
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
|
||||
}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items?.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
className="rounded-lg border border-border bg-surface-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{renaming === p.name ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (e.key !== "Enter") return;
|
||||
const next = draft.trim();
|
||||
setRenaming(null);
|
||||
if (!next || next === p.label) return;
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
);
|
||||
}}
|
||||
maxLength={60}
|
||||
aria-label={t("profiles.renameLabel")}
|
||||
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startRename(p)}
|
||||
title={t("profiles.renameHint")}
|
||||
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||
>
|
||||
{p.label}
|
||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
|
||||
</button>
|
||||
)}
|
||||
{p.active && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||
{t("profiles.activeBadge")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!p.active && (
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() => switchTo(p)}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy === `switch:${p.name}`
|
||||
? t("profiles.switching")
|
||||
: t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||
<MenuItem onSelect={pick(startRename)}>{t("profiles.rename")}</MenuItem>
|
||||
{!ctx.menu.item.active && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||
{t("profiles.switchButton")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
|
||||
onSelect={pick((p) => void checkUsage(p.name))}
|
||||
>
|
||||
{t("profiles.usageCheck")}
|
||||
</MenuItem>
|
||||
{deletable(ctx.menu.item) && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy !== null}
|
||||
testId="profile-menu-delete"
|
||||
onSelect={pick((p) => void remove(p))}
|
||||
>
|
||||
{t("profiles.deleteButton")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
|
||||
<CommandSnippet command="claude auth login" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One profile's quota: a button until asked, then the two windows Anthropic
|
||||
* reports — the 5-hour session allowance and the 7-day one.
|
||||
*
|
||||
* Deliberately not fetched with the profile list. Reading a saved profile's
|
||||
* usage means refreshing its OAuth token, and that rotates the credentials on
|
||||
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
|
||||
* on a render or a poll.
|
||||
*/
|
||||
function ProfileUsageLine({
|
||||
usage,
|
||||
error,
|
||||
busy,
|
||||
disabled,
|
||||
onCheck,
|
||||
}: {
|
||||
usage?: CcProfileUsage;
|
||||
error?: string;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
onCheck: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const pct = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—";
|
||||
const resetTitle = (w: CcUsageWindow | null | undefined) =>
|
||||
w && w.resetsAt
|
||||
? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() })
|
||||
: undefined;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="text-status-danger">{error}</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{t("profiles.usageRetry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!usage) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
title={t("profiles.usageHint")}
|
||||
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
|
||||
<span title={resetTitle(usage.fiveHour)}>
|
||||
{t("profiles.usageFiveHour")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
|
||||
</span>
|
||||
<span title={resetTitle(usage.sevenDay)}>
|
||||
{t("profiles.usageSevenDay")}{" "}
|
||||
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCheck}
|
||||
disabled={disabled}
|
||||
className="hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
// ── Backups modal ──────────────────────────────────────────────────────
|
||||
|
||||
function BackupsModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -69,6 +69,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -84,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];
|
||||
@@ -296,6 +298,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<AgentInstructionsSettings />
|
||||
<SessionDefaultsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* Every AI tool's login profiles, linked to the account pool. */}
|
||||
<ToolProfilesSettings />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
@@ -512,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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file Covers the right-click menus on the Claude Code config page: a menu
|
||||
* item runs the SAME action as the card's button — a plugin's uninstall still
|
||||
* takes two clicks before it reaches the API, and a profile's delete calls the
|
||||
* same endpoint the trash button does.
|
||||
* takes two clicks before it reaches the API. (The profiles' menu moved with
|
||||
* them to Settings → AI tools: ToolProfilesSettings.test.tsx.)
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { settleConfirm } from "../../lib/confirm";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
@@ -124,41 +123,4 @@ describe("CcConfig — right-click menus", () => {
|
||||
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "uninstall");
|
||||
expect(screen.queryByTestId("plugin-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||
await settle();
|
||||
// A saved login is never deleted on one click: it asks first.
|
||||
expect(ccConfigMock.deleteProfile).not.toHaveBeenCalled();
|
||||
await act(async () => settleConfirm(true));
|
||||
await settle();
|
||||
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("offers no delete for the only saved copy of the live account", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* @file Covers the quota lines in the Claude Code auth-profile modal: opening
|
||||
* it asks for EVERY saved account's quota, not one row at a time.
|
||||
*
|
||||
* The one-at-a-time version made the user click through their own accounts to
|
||||
* answer the only question the panel exists for — which account still has room.
|
||||
* The other half is that each row fails on its own: an account whose saved
|
||||
* login has been superseded must not stop its neighbours from reporting.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
const window = (utilization: number) => ({
|
||||
utilization,
|
||||
resetsAt: "2026-09-18T08:00:00Z",
|
||||
});
|
||||
return {
|
||||
window,
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/.claude",
|
||||
projectClaudeDir: "/repo/.claude",
|
||||
projectRoot: "/repo",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
plugins: 0,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
settingsFiles: 0,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
skills: r({ items: [] }),
|
||||
agents: r({ items: [] }),
|
||||
commands: r({ items: [] }),
|
||||
outputStyles: r({ items: [] }),
|
||||
memory: r({ items: [] }),
|
||||
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
|
||||
marketplaces: r({ marketplaces: [] }),
|
||||
mcp: r({ mcp: [] }),
|
||||
hooks: r({ hooks: [] }),
|
||||
settings: r({ sources: [] }),
|
||||
keybindings: r({ groups: [] }),
|
||||
statusline: r({}),
|
||||
hookScripts: r({ items: [] }),
|
||||
backups: r({ items: [] }),
|
||||
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||
pluginsAvailable: r({ installed: [], available: [] }),
|
||||
updateAllStatus: r({ batch: null }),
|
||||
// The modal's own list: two saved accounts, one of them live.
|
||||
profiles: r({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||
],
|
||||
active: { accountUuid: "u1", email: "a@b.c" },
|
||||
}),
|
||||
profileUsage: vi.fn().mockImplementation(async (name: string) => {
|
||||
if (name === "spare") {
|
||||
throw new Error("this profile's saved login has been superseded");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
refreshed: false,
|
||||
fiveHour: window(8),
|
||||
sevenDay: window(70),
|
||||
checkedAt: "2026-09-17T16:00:00Z",
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CcConfig } from "../CcConfig";
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function openProfiles() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
await settle();
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("CcConfig — auth profile quotas", () => {
|
||||
it("asks for every saved account's quota when the modal opens", async () => {
|
||||
await openProfiles();
|
||||
|
||||
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("work");
|
||||
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("spare");
|
||||
expect(await screen.findByText("8%")).toBeInTheDocument();
|
||||
expect(await screen.findByText("70%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports the account that failed without costing the others their numbers", async () => {
|
||||
await openProfiles();
|
||||
|
||||
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("8%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks once per profile, however many times the panel re-renders", async () => {
|
||||
await openProfiles();
|
||||
await settle();
|
||||
await settle();
|
||||
|
||||
const asked = ccConfigMock.profileUsage.mock.calls.filter((c) => c[0] === "work");
|
||||
expect(asked).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
// AI tools → Profiles.
|
||||
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
||||
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -796,9 +796,10 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
<div
|
||||
class="flex items-center gap-2 justify-end flex-wrap"
|
||||
>
|
||||
<button
|
||||
<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"
|
||||
type="button"
|
||||
data-discover="true"
|
||||
href="/settings/agents#tool-profiles"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-user-cog w-3.5 h-3.5"
|
||||
@@ -851,7 +852,7 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
Switch profile
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
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"
|
||||
type="button"
|
||||
@@ -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.16 (rebuild client to match)
|
||||
UI build v2.8.19 (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"
|
||||
>
|
||||
|
||||
+250
-151
@@ -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)
|
||||
@@ -91,7 +95,7 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
|
||||
|
||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||
|
||||
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `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; deleting a user drops theirs.
|
||||
|
||||
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
||||
|
||||
@@ -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
|
||||
@@ -549,6 +572,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 +1305,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 +1622,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 +1841,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 +2040,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 +2121,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 +2143,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 +2168,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 +2222,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 +2321,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 +2587,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 +2934,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.
|
||||
@@ -2938,6 +2978,18 @@ Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source:
|
||||
|
||||
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
|
||||
|
||||
### AI tool login profiles
|
||||
|
||||
```
|
||||
GET /api/tool-profiles
|
||||
POST /api/tool-profiles/:tool Body: { name? }
|
||||
PATCH /api/tool-profiles/:tool Body: { name, label }
|
||||
POST /api/tool-profiles/:tool/activate Body: { name }
|
||||
DELETE /api/tool-profiles/:tool Body: { name }
|
||||
```
|
||||
|
||||
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
|
||||
|
||||
### AI Proxy profiles
|
||||
|
||||
```
|
||||
@@ -3133,6 +3185,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, repo_root, … }` (`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
|
||||
|
||||
+2
-2
@@ -109,7 +109,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
|
||||
| `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 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 |
|
||||
|
||||
+10
-4
@@ -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
|
||||
@@ -388,7 +391,7 @@ In server mode an admin can do the same on the **Users** page, in the
|
||||
**Account pool** card: **Add account** and the bin icon to remove one. A Claude
|
||||
account comes from one of three sources:
|
||||
|
||||
- **A saved login**: a profile from Settings → Claude config → Switch account
|
||||
- **A saved login**: a profile from Settings → AI tools → Profiles
|
||||
(log in with `claude auth login`, then save it). It is MOVED into the pool, so
|
||||
the profile leaves the switcher. The account this server's own Claude Code is
|
||||
logged into can be moved in too. It moves with the login the CLI holds right
|
||||
@@ -401,8 +404,11 @@ account comes from one of three sources:
|
||||
using that login where you copied it from.
|
||||
- **A `claude setup-token` token** (`sk-ant-…`), as with `agenthub accounts add`.
|
||||
|
||||
A Codex account is an OpenAI API key, or the whole `~/.codex/auth.json` after
|
||||
`codex login`.
|
||||
A Codex account is an OpenAI API key, the whole `~/.codex/auth.json` after
|
||||
`codex login`, or a saved Codex profile from Settings → AI tools → Profiles
|
||||
(moved in like a Claude one). Each Claude/Codex profile there has a **Move to
|
||||
pool** link that opens this dialog on it, and the card links back with
|
||||
**Profiles in Settings**.
|
||||
|
||||
A **login** (the first two sources) works like a Codex ChatGPT login. Claude
|
||||
Code reads it from no env var, so before a member's pane starts, AgentHub writes
|
||||
|
||||
+5097
-625
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.16",
|
||||
"version": "2.8.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.16",
|
||||
"version": "2.8.19",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.16",
|
||||
"version": "2.8.19",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,6 +338,42 @@ describe("moving a saved profile in from the Users page", () => {
|
||||
pool.removeAccount("work");
|
||||
});
|
||||
|
||||
it("moves a saved Codex profile in, and lists Codex profiles without a token", async () => {
|
||||
const dir = path.join(TMP, ".claude", "agenthub-profiles", "codex");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const idToken = `x.${Buffer.from(JSON.stringify({ email: "cx@example.com" })).toString("base64url")}.s`;
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "cx.json"),
|
||||
JSON.stringify({
|
||||
name: "cx",
|
||||
label: "Codex work",
|
||||
secret: {
|
||||
tokens: {
|
||||
id_token: idToken,
|
||||
access_token: "at-cx",
|
||||
refresh_token: "rt-cx",
|
||||
account_id: "a-cx",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const list = await call("GET", "/api/accounts/profiles?tool=codex");
|
||||
assert.deepEqual(
|
||||
list.body.profiles.map((p) => [p.name, p.label, p.email]),
|
||||
[["cx", "Codex work", "cx@example.com"]]
|
||||
);
|
||||
assert.ok(!JSON.stringify(list.body).includes("rt-cx"));
|
||||
const moved = await call("POST", "/api/accounts", {
|
||||
tool: "codex",
|
||||
profile: "cx",
|
||||
currentPassword: "admin-pass-1",
|
||||
});
|
||||
assert.equal(moved.status, 201);
|
||||
assert.equal(moved.body.account.kind, "chatgpt");
|
||||
assert.equal(fs.existsSync(path.join(dir, "cx.json")), false, "the pool is its one holder");
|
||||
require("../lib/codex-pool").removeAccount("cx");
|
||||
});
|
||||
|
||||
it("takes a pasted .credentials.json once Anthropic names its account", async () => {
|
||||
const pasted = oauth("p");
|
||||
owners[pasted.accessToken] = { uuid: "uuid-pasted", email: "p@example.com" };
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @file Tests for the non-Claude login profiles (tool-profiles.js): saving the
|
||||
* live Codex login, switching between logins, the guards that keep a switch from
|
||||
* losing the only copy of one, and Oh My Pi's credential identity.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tool-profiles-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.CODEX_HOME = path.join(ROOT, ".codex");
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const profiles = require("../lib/tool-profiles");
|
||||
|
||||
const AUTH = path.join(process.env.CODEX_HOME, "auth.json");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
const jwt = (claims) => `x.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
|
||||
|
||||
/** Leave a ChatGPT login on disk the way `codex login` would. */
|
||||
function login(account, email, refresh = "rt-1") {
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
AUTH,
|
||||
JSON.stringify({
|
||||
OPENAI_API_KEY: null,
|
||||
tokens: {
|
||||
id_token: jwt({
|
||||
email,
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: account, chatgpt_plan_type: "plus" },
|
||||
}),
|
||||
access_token: "at",
|
||||
refresh_token: refresh,
|
||||
account_id: account,
|
||||
},
|
||||
last_refresh: "2026-10-01T00:00:00Z",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
|
||||
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("tool-profiles: codex", () => {
|
||||
it("reports the live login and saves it under a name derived from it", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
assert.deepEqual(profiles.listProfiles("codex").active, {
|
||||
kind: "chatgpt",
|
||||
email: "alice@example.com",
|
||||
detail: "plus",
|
||||
});
|
||||
const r = profiles.saveProfile("codex");
|
||||
assert.equal(r.name, "alice");
|
||||
const { items } = profiles.listProfiles("codex");
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[0].secret, undefined, "never a token in a listing");
|
||||
const mode = fs.statSync(path.join(profiles.profilesDir("codex"), "alice.json")).mode & 0o777;
|
||||
assert.equal(mode, 0o600);
|
||||
});
|
||||
|
||||
it("re-saving refreshes the login's own profile; a second name is EDUPLICATE", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-a", "alice@example.com", "rt-2");
|
||||
assert.equal(profiles.saveProfile("codex").name, "alice");
|
||||
assert.equal(profiles.readProfile("codex", "alice").secret.tokens.refresh_token, "rt-2");
|
||||
assert.throws(() => profiles.saveProfile("codex", "other"), { code: "EDUPLICATE" });
|
||||
});
|
||||
|
||||
it("switches, saving the outgoing login back first", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
// Alice's tokens rotated while she was live? Bob is live now; switch to Alice.
|
||||
const r = profiles.activateProfile("codex", "alice");
|
||||
assert.equal(r.savedOutgoing, "bob");
|
||||
const live = JSON.parse(fs.readFileSync(AUTH, "utf8"));
|
||||
assert.equal(live.tokens.account_id, "acct-a");
|
||||
const { items } = profiles.listProfiles("codex");
|
||||
assert.deepEqual(
|
||||
items.map((p) => [p.name, p.active]),
|
||||
[
|
||||
["alice", true],
|
||||
["bob", false],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to switch away from an unsaved login", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-c", "carol@example.com");
|
||||
assert.throws(() => profiles.activateProfile("codex", "alice"), { code: "EUNSAVED" });
|
||||
});
|
||||
|
||||
it("will not delete the live login's profile, deletes another", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
assert.throws(() => profiles.deleteProfile("codex", "bob"), { code: "EACTIVE" });
|
||||
profiles.deleteProfile("codex", "alice");
|
||||
assert.deepEqual(
|
||||
profiles.listProfiles("codex").items.map((p) => p.name),
|
||||
["bob"]
|
||||
);
|
||||
});
|
||||
|
||||
it("renames the label only, and refuses a taken one", () => {
|
||||
login("acct-a", "alice@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
login("acct-b", "bob@example.com");
|
||||
profiles.saveProfile("codex");
|
||||
profiles.renameProfile("codex", "alice", "Công việc");
|
||||
assert.equal(
|
||||
profiles.listProfiles("codex").items.find((p) => p.name === "alice").label,
|
||||
"Công việc"
|
||||
);
|
||||
assert.throws(() => profiles.renameProfile("codex", "bob", "công việc"), {
|
||||
code: "ELABELTAKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("an API-key login is identified by its key, shown by its last four", () => {
|
||||
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
|
||||
fs.writeFileSync(AUTH, JSON.stringify({ OPENAI_API_KEY: "sk-test-abcdefghij1234" }));
|
||||
const r = profiles.saveProfile("codex");
|
||||
assert.equal(r.kind, "apikey");
|
||||
assert.equal(r.detail, "…1234");
|
||||
});
|
||||
|
||||
it("saving with no login is ENOAUTH", () => {
|
||||
assert.throws(() => profiles.saveProfile("codex"), { code: "ENOAUTH" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-profiles: tools", () => {
|
||||
it("lists antigravity as unsupported and refuses it", () => {
|
||||
const agy = profiles.tools().find((t) => t.tool === "antigravity");
|
||||
assert.equal(agy.supported, false);
|
||||
assert.throws(() => profiles.listProfiles("antigravity"), { code: "EUNSUPPORTED" });
|
||||
assert.throws(() => profiles.listProfiles("nope"), { code: "EBADTOOL" });
|
||||
});
|
||||
|
||||
it("an omp credential is identified by who it is, never by its rotating token", () => {
|
||||
const a = { provider: "anthropic", credential: { type: "oauth", access: "1", email: "a@x" } };
|
||||
const b = { provider: "anthropic", credential: { type: "oauth", access: "2", email: "a@x" } };
|
||||
assert.equal(profiles.ompCredentialId(a), profiles.ompCredentialId(b));
|
||||
assert.equal(profiles.ompCredentialId(a), "anthropic:a@x");
|
||||
});
|
||||
|
||||
it("a switch hands the adapter the outgoing login so it can restore it", () => {
|
||||
let disk = { who: "a" };
|
||||
const writes = [];
|
||||
profiles.__setAdapter("fake", {
|
||||
label: "Fake",
|
||||
read: () => disk,
|
||||
write: (secret, outgoing) => {
|
||||
writes.push([secret.who, outgoing && outgoing.who]);
|
||||
disk = secret;
|
||||
return [];
|
||||
},
|
||||
identify: (s) => ({ key: s.who, kind: "x", email: null, detail: null }),
|
||||
});
|
||||
profiles.saveProfile("fake", "a");
|
||||
disk = { who: "b" };
|
||||
profiles.saveProfile("fake", "b");
|
||||
profiles.activateProfile("fake", "a");
|
||||
assert.deepEqual(writes, [["a", "b"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool-profiles: omp", () => {
|
||||
it("switches through a cleared store + `omp auth import -`, never by argv", () => {
|
||||
const omp = path.join(ROOT, ".omp");
|
||||
const db = path.join(omp, "agent", "agent.db");
|
||||
const state = path.join(ROOT, "omp-state.json");
|
||||
const argv = path.join(ROOT, "omp-argv.log");
|
||||
fs.mkdirSync(path.dirname(db), { recursive: true });
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const d = new DatabaseSync(db);
|
||||
d.exec(`CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, data TEXT);
|
||||
CREATE TABLE auth_credential_blocks (credential_id INTEGER);
|
||||
CREATE TABLE auth_credential_refresh_leases (credential_id INTEGER);
|
||||
INSERT INTO auth_credentials (provider, data) VALUES ('anthropic', '{}');`);
|
||||
d.close();
|
||||
// A fake omp: export prints the state file, import REPLACES it from stdin
|
||||
// only when the store was cleared first (as the real one merges).
|
||||
const bin = path.join(ROOT, "omp");
|
||||
fs.writeFileSync(
|
||||
bin,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
fs.appendFileSync(${JSON.stringify(argv)}, process.argv.slice(2).join(" ") + "\\n");
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(${JSON.stringify(db)});
|
||||
const rows = db.prepare("SELECT COUNT(*) AS n FROM auth_credentials").get().n;
|
||||
if (process.argv[3] === "export") {
|
||||
process.stdout.write(fs.readFileSync(${JSON.stringify(state)}, "utf8"));
|
||||
} else {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
if (rows) { process.stderr.write("merged into a non-empty store"); process.exit(1); }
|
||||
fs.writeFileSync(${JSON.stringify(state)}, JSON.stringify(input));
|
||||
}
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
const set = (email, token) =>
|
||||
fs.writeFileSync(
|
||||
state,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
credentials: [
|
||||
{ provider: "anthropic", credential: { type: "oauth", access: token, email } },
|
||||
],
|
||||
})
|
||||
);
|
||||
process.env.AGENTHUB_OMP_DIR = omp;
|
||||
process.env.AGENTHUB_OMP_BIN = bin;
|
||||
try {
|
||||
set("a@x", "secret-a");
|
||||
assert.equal(profiles.saveProfile("omp").name, "a");
|
||||
set("b@x", "secret-b");
|
||||
profiles.saveProfile("omp");
|
||||
const r = profiles.activateProfile("omp", "a");
|
||||
assert.equal(r.savedOutgoing, "b");
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(state, "utf8")).credentials[0].credential.email,
|
||||
"a@x"
|
||||
);
|
||||
assert.ok(!fs.readFileSync(argv, "utf8").includes("secret"), "secrets only on stdin");
|
||||
const { items, active } = profiles.listProfiles("omp");
|
||||
assert.equal(active.email, "a@x");
|
||||
assert.deepEqual(
|
||||
items.map((p) => [p.name, p.active, p.detail]),
|
||||
[
|
||||
["a", true, "anthropic"],
|
||||
["b", false, "anthropic"],
|
||||
]
|
||||
);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_OMP_DIR;
|
||||
delete process.env.AGENTHUB_OMP_BIN;
|
||||
}
|
||||
});
|
||||
});
|
||||
+4
-3
@@ -1933,9 +1933,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 {
|
||||
|
||||
@@ -142,6 +142,7 @@ const snapshotsRouter = require("./routes/snapshots");
|
||||
const authRouter = require("./routes/auth");
|
||||
const meRouter = require("./routes/me");
|
||||
const accountsRouter = require("./routes/accounts");
|
||||
const toolProfilesRouter = require("./routes/tool-profiles");
|
||||
const usersRouter = require("./routes/users");
|
||||
const hostRouter = require("./routes/host");
|
||||
const { auditGuard } = require("./lib/audit");
|
||||
@@ -209,6 +210,7 @@ function createApp() {
|
||||
app.use("/api/import", importRouter);
|
||||
app.use("/api/updates", updatesRouter);
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/tool-profiles", toolProfilesRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
// `/api/ai-routers` is the pre-rename path, kept for a client older than it.
|
||||
app.use(["/api/ai-proxies", "/api/ai-routers"], aiProxiesRouter);
|
||||
|
||||
@@ -63,6 +63,8 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
"/updates",
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
// The AI tools' login profiles: switching one changes the host's own login.
|
||||
"/tool-profiles",
|
||||
"/git-credentials",
|
||||
"/ai-tools",
|
||||
"/import",
|
||||
@@ -88,6 +90,7 @@ const ADMIN_WRITE_PREFIXES = [
|
||||
const STAFF_ONLY_PREFIXES = [
|
||||
"/cc-config",
|
||||
"/codex-config",
|
||||
"/tool-profiles",
|
||||
"/git-credentials",
|
||||
"/remote-sources",
|
||||
"/webhooks",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* @file tool-profiles.js
|
||||
* @description Named login profiles for the AI tools other than Claude Code —
|
||||
* the same idea as cc-profiles.js (which stays Claude's, with its Keychain and
|
||||
* `.claude.json` rules): snapshot the login a tool's CLI holds right now, and
|
||||
* swap it back in later, touching nothing but that login.
|
||||
*
|
||||
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
|
||||
* key). Config, sessions, skills and history stay where they are.
|
||||
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
|
||||
* and put back with `omp auth import -` (secrets on stdin, never argv).
|
||||
* Import MERGES, so a switch first empties omp's own credential tables in
|
||||
* `~/.omp/agent/agent.db`; a failed import puts the outgoing set back.
|
||||
* - `antigravity`: not switchable — agy keeps its login in the OS keyring
|
||||
* (Secret Service), which AgentHub does not read or write.
|
||||
*
|
||||
* Profiles live one JSON file each under
|
||||
* `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (dir 0700, files 0600). They hold
|
||||
* live tokens, so `listProfiles` returns identity metadata only; the
|
||||
* token-bearing `readProfile` is for the account pool's move-in, never a route.
|
||||
* The rules are cc-profiles.js's: one account is one profile (`EDUPLICATE`), a
|
||||
* switch saves the outgoing login back into its own profile first and refuses
|
||||
* one that was never saved (`EUNSAVED`), and the only saved copy of the live
|
||||
* login is not deletable (`EACTIVE`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
const cp = require("node:child_process");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
|
||||
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
|
||||
const LABEL_MAX = 60;
|
||||
|
||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||
const sha = (text) => crypto.createHash("sha256").update(String(text)).digest("hex");
|
||||
|
||||
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
|
||||
function writeJson(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.agenthub-tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Codex ──────────────────────────────────────────────────────────────
|
||||
|
||||
const codexAuthFile = () => path.join(getCodexHome(), "auth.json");
|
||||
|
||||
/** The id_token's claims, or null. Not signature-checked: a label, not a proof. */
|
||||
function jwtClaims(token) {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const codex = {
|
||||
label: "Codex",
|
||||
read() {
|
||||
const auth = readJson(codexAuthFile());
|
||||
return auth && typeof auth === "object" && (auth.tokens || auth.OPENAI_API_KEY) ? auth : null;
|
||||
},
|
||||
write(secret) {
|
||||
writeJson(codexAuthFile(), secret);
|
||||
return [codexAuthFile()];
|
||||
},
|
||||
identify(auth) {
|
||||
if (auth.tokens && auth.tokens.id_token) {
|
||||
const c = jwtClaims(auth.tokens.id_token) || {};
|
||||
const claim = c["https://api.openai.com/auth"] || {};
|
||||
const account = auth.tokens.account_id || claim.chatgpt_account_id || null;
|
||||
return {
|
||||
key: `chatgpt:${account || c.email || sha(auth.tokens.id_token).slice(0, 16)}`,
|
||||
kind: "chatgpt",
|
||||
email: c.email || null,
|
||||
detail: claim.chatgpt_plan_type || null,
|
||||
};
|
||||
}
|
||||
const key = String(auth.OPENAI_API_KEY || "");
|
||||
return {
|
||||
key: `apikey:${sha(key).slice(0, 16)}`,
|
||||
kind: "apikey",
|
||||
email: null,
|
||||
detail: key ? `…${key.slice(-4)}` : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ── Oh My Pi ───────────────────────────────────────────────────────────
|
||||
|
||||
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
|
||||
|
||||
function ompBin() {
|
||||
const bin = process.env.AGENTHUB_OMP_BIN || require("./ai-tools").detect("omp").path;
|
||||
if (!bin) throw err("ENOTINSTALLED", "Oh My Pi (omp) is not installed on this machine");
|
||||
return bin;
|
||||
}
|
||||
|
||||
function ompRun(args, input) {
|
||||
return cp.execFileSync(ompBin(), args, {
|
||||
encoding: "utf8",
|
||||
input,
|
||||
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||
timeout: 20000,
|
||||
maxBuffer: 8 << 20,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** One credential's stable part: who it is, never the rotating token. */
|
||||
function ompCredentialId(c) {
|
||||
const cred = (c && c.credential) || {};
|
||||
const who =
|
||||
cred.email ||
|
||||
cred.accountId ||
|
||||
(cred.type === "oauth" ? "oauth" : sha(JSON.stringify(cred)).slice(0, 12));
|
||||
return `${c.provider}:${who}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty omp's credential store: the credentials and what hangs off them (rate
|
||||
* blocks, refresh leases). omp's own triggers bump `auth_change_revision`, so
|
||||
* a running omp re-reads. ponytail: this writes omp's schema directly (it has
|
||||
* no logout command); a schema change there surfaces as an error here, and the
|
||||
* caller puts the outgoing set back.
|
||||
*/
|
||||
function ompClear() {
|
||||
const file = path.join(ompDir(), "agent", "agent.db");
|
||||
if (!fs.existsSync(file)) return;
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
const db = new DatabaseSync(file);
|
||||
try {
|
||||
db.exec("PRAGMA busy_timeout = 5000");
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
for (const t of [
|
||||
"auth_credential_blocks",
|
||||
"auth_credential_refresh_leases",
|
||||
"auth_credentials",
|
||||
]) {
|
||||
db.exec(`DELETE FROM ${t}`);
|
||||
}
|
||||
db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const omp = {
|
||||
label: "Oh My Pi",
|
||||
read() {
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(ompRun(["auth", "export"]));
|
||||
} catch (e) {
|
||||
if (e.code === "ENOTINSTALLED") throw e;
|
||||
throw err("EOMP", `omp auth export failed: ${(e.stderr || e.message || "").trim()}`);
|
||||
}
|
||||
return out && Array.isArray(out.credentials) && out.credentials.length > 0 ? out : null;
|
||||
},
|
||||
write(secret, outgoing) {
|
||||
ompClear();
|
||||
try {
|
||||
ompRun(["auth", "import", "-"], JSON.stringify(secret));
|
||||
} catch (e) {
|
||||
if (outgoing) {
|
||||
try {
|
||||
ompRun(["auth", "import", "-"], JSON.stringify(outgoing));
|
||||
} catch {
|
||||
/* the outgoing set is still in its profile file */
|
||||
}
|
||||
}
|
||||
throw err("EOMP", `omp auth import failed: ${(e.stderr || e.message || "").trim()}`);
|
||||
}
|
||||
return [path.join(ompDir(), "agent", "agent.db")];
|
||||
},
|
||||
identify(exp) {
|
||||
const ids = [...new Set(exp.credentials.map(ompCredentialId))].sort();
|
||||
const email = exp.credentials.map((c) => c.credential && c.credential.email).find(Boolean);
|
||||
return {
|
||||
key: ids.join("|"),
|
||||
kind: "credentials",
|
||||
email: email || null,
|
||||
detail: [...new Set(exp.credentials.map((c) => c.provider))].join(", ") || null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
let ADAPTERS = { codex, omp };
|
||||
|
||||
/** Tools listed in the Profiles panel that cannot be switched, and why. */
|
||||
const UNSUPPORTED = {
|
||||
antigravity:
|
||||
"Antigravity keeps its login in the system keyring (Secret Service), which AgentHub does not read or write. Sign in or out inside agy itself.",
|
||||
};
|
||||
|
||||
// ── The store ──────────────────────────────────────────────────────────
|
||||
|
||||
function adapter(tool) {
|
||||
const a = ADAPTERS[tool];
|
||||
if (!a) {
|
||||
throw err(
|
||||
UNSUPPORTED[tool] ? "EUNSUPPORTED" : "EBADTOOL",
|
||||
UNSUPPORTED[tool] || `no profiles for tool "${tool}"`
|
||||
);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const profilesDir = (tool) => path.join(getClaudeHome(), "agenthub-profiles", tool);
|
||||
|
||||
function profileFile(tool, name) {
|
||||
adapter(tool);
|
||||
if (typeof name !== "string" || !NAME_RE.test(name)) {
|
||||
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
|
||||
}
|
||||
return path.join(profilesDir(tool), `${name}.json`);
|
||||
}
|
||||
|
||||
function normalizeLabel(label) {
|
||||
const clean = typeof label === "string" ? label.trim() : "";
|
||||
if (!clean || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||
throw err("EBADLABEL", `label must be 1-${LABEL_MAX} characters and no control characters`);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
const base = String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
return NAME_RE.test(base) ? base : "profile";
|
||||
}
|
||||
|
||||
function freeName(tool, base) {
|
||||
let name = base;
|
||||
for (let n = 2; fs.existsSync(path.join(profilesDir(tool), `${name}.json`)); n += 1) {
|
||||
name = `${base}-${n}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** One stored profile WITH its secret, or null. Never for a route. */
|
||||
function readProfile(tool, name) {
|
||||
const stored = readJson(profileFile(tool, name));
|
||||
return stored && typeof stored === "object" && stored.secret ? stored : null;
|
||||
}
|
||||
|
||||
/** Token-free view of the live login, or null when the tool holds none. */
|
||||
function liveIdentity(tool) {
|
||||
const a = adapter(tool);
|
||||
const live = a.read();
|
||||
return live ? { live, identity: a.identify(live) } : { live: null, identity: null };
|
||||
}
|
||||
|
||||
const publicIdentity = (id) => (id ? { kind: id.kind, email: id.email, detail: id.detail } : null);
|
||||
|
||||
/**
|
||||
* Every stored profile of `tool` plus the login that is live right now.
|
||||
* @returns `{ tool, items, active }` — `items[].active` marks the profile of
|
||||
* the live login. Secrets and identity keys are never included.
|
||||
*/
|
||||
function listProfiles(tool) {
|
||||
const { identity: live } = liveIdentity(tool);
|
||||
let names = [];
|
||||
try {
|
||||
names = fs
|
||||
.readdirSync(profilesDir(tool))
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.slice(0, -5));
|
||||
} catch {
|
||||
/* none saved yet */
|
||||
}
|
||||
const items = [];
|
||||
for (const name of names) {
|
||||
if (!NAME_RE.test(name)) continue;
|
||||
let stored;
|
||||
try {
|
||||
stored = readProfile(tool, name);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!stored) continue;
|
||||
const id = ADAPTERS[tool].identify(stored.secret);
|
||||
items.push({
|
||||
name,
|
||||
label: typeof stored.label === "string" && stored.label.trim() ? stored.label.trim() : name,
|
||||
savedAt: stored.savedAt || null,
|
||||
active: Boolean(live) && id.key === live.key,
|
||||
...publicIdentity(id),
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
|
||||
return { tool, items, active: publicIdentity(live) };
|
||||
}
|
||||
|
||||
/** The saved profile holding the same login as `identity`, other than `except`. */
|
||||
function holderOf(tool, key, except = null) {
|
||||
for (const p of listProfiles(tool).items) {
|
||||
if (p.name === except) continue;
|
||||
const stored = readProfile(tool, p.name);
|
||||
if (stored && ADAPTERS[tool].identify(stored.secret).key === key) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the login `tool` holds right now. Re-saving into the login's own
|
||||
* profile refreshes it; a second copy under another name is `EDUPLICATE`.
|
||||
*/
|
||||
function saveProfile(tool, name) {
|
||||
const { live, identity } = liveIdentity(tool);
|
||||
if (!live) {
|
||||
throw err("ENOAUTH", `${ADAPTERS[tool].label} holds no login on this machine - sign in first`);
|
||||
}
|
||||
const explicit = name !== undefined && name !== null;
|
||||
const readable =
|
||||
(identity.email || "").split("@")[0] ||
|
||||
(identity.detail ? `${identity.kind}-${identity.detail}` : identity.kind);
|
||||
let target;
|
||||
if (explicit) {
|
||||
profileFile(tool, name);
|
||||
target = name;
|
||||
} else {
|
||||
const own = holderOf(tool, identity.key);
|
||||
target = own ? own.name : freeName(tool, slugify(readable));
|
||||
}
|
||||
const clash = holderOf(tool, identity.key, target);
|
||||
if (clash) {
|
||||
throw err(
|
||||
"EDUPLICATE",
|
||||
`this login is already saved as "${clash.label}" (${clash.name}) - rename that profile instead of saving a second copy of it`
|
||||
);
|
||||
}
|
||||
const existing = readProfile(tool, target);
|
||||
const label = (existing && existing.label) || (explicit ? target : readable);
|
||||
writeJson(profileFile(tool, target), {
|
||||
name: target,
|
||||
label,
|
||||
savedAt: Date.now(),
|
||||
secret: live,
|
||||
});
|
||||
return { ok: true, name: target, label, ...publicIdentity(identity) };
|
||||
}
|
||||
|
||||
function renameProfile(tool, name, label) {
|
||||
const stored = readProfile(tool, name);
|
||||
if (!stored) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
const clean = normalizeLabel(label);
|
||||
const taken = listProfiles(tool).items.find(
|
||||
(p) => p.name !== name && p.label.toLowerCase() === clean.toLowerCase()
|
||||
);
|
||||
if (taken) throw err("ELABELTAKEN", `another profile is already called "${taken.label}"`);
|
||||
writeJson(profileFile(tool, name), { ...stored, label: clean });
|
||||
return { ok: true, name, label: clean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a stored profile the tool's live login. The outgoing login is saved back
|
||||
* into its own profile first (its tokens may have rotated since), and a switch
|
||||
* away from a login that was never saved is refused.
|
||||
*/
|
||||
function activateProfile(tool, name) {
|
||||
const a = adapter(tool);
|
||||
if (!readProfile(tool, name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
const { live, identity } = liveIdentity(tool);
|
||||
let savedOutgoing = null;
|
||||
if (live) {
|
||||
const mine = holderOf(tool, identity.key);
|
||||
if (!mine) {
|
||||
throw err(
|
||||
"EUNSAVED",
|
||||
"the login in use right now is not saved as a profile - save it first, or it is lost on switch"
|
||||
);
|
||||
}
|
||||
savedOutgoing = saveProfile(tool, mine.name).name;
|
||||
}
|
||||
const target = readProfile(tool, name);
|
||||
const written = a.write(target.secret, live);
|
||||
return { ok: true, name, savedOutgoing, written };
|
||||
}
|
||||
|
||||
function deleteProfile(tool, name) {
|
||||
const file = profileFile(tool, name);
|
||||
if (!readProfile(tool, name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
const entry = listProfiles(tool).items.find((p) => p.name === name);
|
||||
if (entry && entry.active) {
|
||||
throw err(
|
||||
"EACTIVE",
|
||||
"cannot delete the saved copy of the login in use right now - switch away first"
|
||||
);
|
||||
}
|
||||
fs.rmSync(file, { force: true });
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
/** Remove a profile whatever it holds: the pool took its login (routes/accounts.js). */
|
||||
function dropProfile(tool, name) {
|
||||
fs.rmSync(profileFile(tool, name), { force: true });
|
||||
}
|
||||
|
||||
/** The tools this module covers, switchable or not, for the Profiles panel. */
|
||||
function tools() {
|
||||
return [
|
||||
...Object.keys(ADAPTERS).map((id) => ({ tool: id, supported: true })),
|
||||
...Object.entries(UNSUPPORTED).map(([id, reason]) => ({ tool: id, supported: false, reason })),
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tools,
|
||||
listProfiles,
|
||||
saveProfile,
|
||||
renameProfile,
|
||||
activateProfile,
|
||||
deleteProfile,
|
||||
// Token-bearing: for routes/accounts.js's move into the pool, never a response.
|
||||
readProfile,
|
||||
dropProfile,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
ompCredentialId,
|
||||
__setAdapter: (tool, a) => {
|
||||
ADAPTERS = { ...ADAPTERS, [tool]: a };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,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
|
||||
@@ -309,9 +309,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"),
|
||||
},
|
||||
@@ -500,7 +501,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 +516,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 +530,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"),
|
||||
},
|
||||
|
||||
@@ -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
+228
-724
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,798 @@
|
||||
/**
|
||||
* @file OpenAPI fragment for the AI-tool configuration routes the base spec
|
||||
* and the other fragments left out: AI Proxy profiles (`/api/ai-proxies`, also
|
||||
* mounted at the pre-rename `/api/ai-routers`), the non-Claude login profiles
|
||||
* (`/api/tool-profiles`), a tool's catalog-listed config files, model list and
|
||||
* usage (`/api/ai-tools/{id}/…`), the Claude plugin/marketplace CLI routes and
|
||||
* project-memory bootstrap under `/api/cc-config` (plus its Codex twin), and
|
||||
* AgentHub's own git credential store (`/api/git-credentials`). No operation
|
||||
* here returns key material — profiles and credentials report `hasKey` /
|
||||
* `hasToken` only. Exports `{ tags, schemas, paths }` for
|
||||
* server/openapi-extra.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "AI Proxies",
|
||||
description:
|
||||
"AI Proxy profiles (endpoint + key + model) a new Claude/Codex session may name. Also mounted at /api/ai-routers. No route returns a key.",
|
||||
},
|
||||
{
|
||||
name: "Tool Profiles",
|
||||
description:
|
||||
"Saved login profiles of the AI tools other than Claude Code (Codex, Oh My Pi). Identity metadata only, never a token.",
|
||||
},
|
||||
{
|
||||
name: "Git Credentials",
|
||||
description:
|
||||
"AgentHub's own git credential store (one login per protocol + host). Passwords go in, never out.",
|
||||
},
|
||||
];
|
||||
|
||||
const err = { $ref: "#/components/schemas/ErrorResponse" };
|
||||
const json = (schema, description) => ({
|
||||
description,
|
||||
content: { "application/json": { schema } },
|
||||
});
|
||||
const errResp = (description) => json(err, description);
|
||||
const body = (schema, required = true) => ({
|
||||
required,
|
||||
content: { "application/json": { schema } },
|
||||
});
|
||||
|
||||
const schemas = {
|
||||
AiProxyProfile: {
|
||||
type: "object",
|
||||
description:
|
||||
"The browser-safe view of a profile: every stored field except the key, plus `hasKey` and `scope`.",
|
||||
properties: {
|
||||
name: { type: "string", description: "Server-made slug; never changes once saved." },
|
||||
label: { type: "string" },
|
||||
baseUrl: { type: "string", description: "The proxy's ROOT (a trailing /v1 is dropped)." },
|
||||
model: { type: "string" },
|
||||
models: { type: "object", additionalProperties: { type: "string" } },
|
||||
behavesAs: {
|
||||
type: "string",
|
||||
description: "User-chosen Claude model the proxy model acts as; empty when unset.",
|
||||
},
|
||||
savedAt: { type: "string", format: "date-time" },
|
||||
builtin: { type: "boolean", description: "True only for the built-in `native` profile." },
|
||||
scope: { type: "string", enum: ["shared", "personal"] },
|
||||
hasKey: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
AiProxyWriteRequest: {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string" },
|
||||
baseUrl: { type: "string" },
|
||||
apiKey: {
|
||||
type: "string",
|
||||
description: "Write-only. On PATCH an omitted/blank key keeps the stored one.",
|
||||
},
|
||||
model: { type: "string" },
|
||||
models: { type: "object", additionalProperties: { type: "string" } },
|
||||
behavesAs: { type: "string" },
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["shared", "personal"],
|
||||
description:
|
||||
"POST only (server mode). `shared` is admin-only; a non-admin always writes a personal profile.",
|
||||
},
|
||||
},
|
||||
},
|
||||
AiProxyTestResult: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
status: {
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
description: "HTTP status the proxy answered with; null when unreachable.",
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description:
|
||||
"The model id on success, else up to 300 chars of the proxy's reply or the transport error.",
|
||||
},
|
||||
},
|
||||
},
|
||||
ToolProfileIdentity: {
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string" },
|
||||
email: { type: "string", nullable: true },
|
||||
detail: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
ToolProfileItem: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
label: { type: "string" },
|
||||
savedAt: { type: "integer", nullable: true, description: "Epoch ms." },
|
||||
active: { type: "boolean", description: "This profile holds the login that is live now." },
|
||||
kind: { type: "string" },
|
||||
email: { type: "string", nullable: true },
|
||||
detail: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
ToolProfilesListResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tools: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tool: { type: "string", example: "codex" },
|
||||
supported: { type: "boolean" },
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Why the tool cannot be switched (supported: false).",
|
||||
},
|
||||
items: { type: "array", items: { $ref: "#/components/schemas/ToolProfileItem" } },
|
||||
active: {
|
||||
allOf: [{ $ref: "#/components/schemas/ToolProfileIdentity" }],
|
||||
nullable: true,
|
||||
},
|
||||
error: { allOf: [{ $ref: "#/components/schemas/ErrorObject" }], nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ToolProfileNameRequest: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Profile name (`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`)." },
|
||||
label: { type: "string", description: "PATCH only: the new display label." },
|
||||
},
|
||||
},
|
||||
GitCredentialItem: {
|
||||
type: "object",
|
||||
properties: {
|
||||
protocol: { type: "string", enum: ["http", "https"] },
|
||||
host: { type: "string", example: "git.example.com" },
|
||||
username: { type: "string" },
|
||||
savedAt: { type: "string" },
|
||||
hasToken: { type: "boolean", enum: [true] },
|
||||
},
|
||||
},
|
||||
GitCredentialsListResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
items: { type: "array", items: { $ref: "#/components/schemas/GitCredentialItem" } },
|
||||
},
|
||||
},
|
||||
CcConfigCliResult: {
|
||||
type: "object",
|
||||
description:
|
||||
"A finished `claude plugin …` run. A non-zero exit answers 422 `ECLIEXIT` with `code`, `stdout`, `stderr` beside the error.",
|
||||
properties: {
|
||||
ok: { type: "boolean", enum: [true] },
|
||||
stdout: { type: "string" },
|
||||
stderr: { type: "string" },
|
||||
},
|
||||
},
|
||||
ProjectMemoryCreateRequest: {
|
||||
type: "object",
|
||||
required: ["dir", "content"],
|
||||
properties: {
|
||||
dir: { type: "string", description: "Absolute path of an existing directory." },
|
||||
content: { type: "string", description: "File text, at most 1 MB." },
|
||||
},
|
||||
},
|
||||
ProjectMemoryCreateResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file: { type: "string" },
|
||||
size: { type: "integer" },
|
||||
created: { type: "boolean", enum: [true] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cliErrors = {
|
||||
400: errResp("`EBADARG` (invalid identifier, source or scope) or `EBADACTION`."),
|
||||
422: errResp(
|
||||
"The CLI exited non-zero (`ECLIEXIT`); the body also carries `code`, `stdout`, `stderr`."
|
||||
),
|
||||
502: errResp("`ECLIFAILED`."),
|
||||
503: errResp("`ECLINOTFOUND` — no `claude` binary."),
|
||||
504: errResp("`ETIMEOUT`."),
|
||||
};
|
||||
const cliOk = json(
|
||||
{ $ref: "#/components/schemas/CcConfigCliResult" },
|
||||
"The CLI succeeded; a `cc_config_changed` WebSocket message is broadcast."
|
||||
);
|
||||
|
||||
const nameParam = { name: "name", in: "path", required: true, schema: { type: "string" } };
|
||||
const toolParam = {
|
||||
name: "tool",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string", example: "codex" },
|
||||
description: "A tool the profiles module covers (`codex`, `omp`).",
|
||||
};
|
||||
const toolErrors = {
|
||||
400: errResp("`EBADNAME` / `EBADLABEL`."),
|
||||
404: errResp("`EBADTOOL` (unknown tool) or `ENOTFOUND` (no such profile)."),
|
||||
409: errResp(
|
||||
"`EUNSUPPORTED`, `ENOTINSTALLED`, `EBADJSON`, `EACTIVE`, `EUNSAVED`, `ENOAUTH`, `EDUPLICATE` or `ELABELTAKEN`."
|
||||
),
|
||||
502: errResp("`EOMP` — Oh My Pi's own auth command failed."),
|
||||
};
|
||||
const toolOk = json(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean", enum: [true] },
|
||||
name: { type: "string" },
|
||||
label: { type: "string" },
|
||||
},
|
||||
},
|
||||
"Done."
|
||||
);
|
||||
const idParam = {
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "AI Tools catalog id.",
|
||||
};
|
||||
const indexParam = {
|
||||
name: "index",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "integer", minimum: 0 },
|
||||
description: "Index into the tool's catalog-declared `config` list.",
|
||||
};
|
||||
const configErrors = {
|
||||
400: errResp("`EBADFIELD` (text not a string) or `EBADSYNTAX` (JSON/JSONC/TOML does not parse)."),
|
||||
403: errResp("`ESECRET` — the file (or its symlink target) has a credential-like name."),
|
||||
404: errResp("`ENOTOOL`, `ENOFILE` (no such index) or `ENOENT` (missing / not a regular file)."),
|
||||
409: errResp("`ESTALE` — the file changed since it was opened (PUT)."),
|
||||
413: errResp("`ETOOBIG` — over 1 MB."),
|
||||
415: errResp("`EBINARY` — not a text file."),
|
||||
};
|
||||
|
||||
const projectMemoryOp = (tool, file, operationId, tag) => ({
|
||||
post: {
|
||||
tags: [tag],
|
||||
summary: `Create ${file} in a project directory`,
|
||||
description: `Creates \`<dir>/${file}\` for a project the dashboard has never run in — the one ${tag} write that takes a caller-chosen directory. \`dir\` must be an absolute, existing directory (resolved with realpath); an existing \`${file}\` — a symlink included — is refused (\`EEXISTS\`), never overwritten. Same-origin guarded; admin-only in server mode. Broadcasts \`${tool === "claude" ? "cc_config_changed" : "codex_config_changed"}\`.`,
|
||||
operationId,
|
||||
requestBody: body({ $ref: "#/components/schemas/ProjectMemoryCreateRequest" }),
|
||||
responses: {
|
||||
200: json({ $ref: "#/components/schemas/ProjectMemoryCreateResponse" }, "Created."),
|
||||
400: errResp("`EBADDIR`, `EBADCONTENT` or `EOUTOFROOT`."),
|
||||
409: errResp("`EEXISTS`."),
|
||||
413: errResp("`ETOOLARGE`."),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const paths = {
|
||||
"/api/ai-proxies": {
|
||||
get: {
|
||||
tags: ["AI Proxies"],
|
||||
summary: "List AI Proxy profiles",
|
||||
description:
|
||||
"Every profile the viewer may choose: the built-in `native` first, then the shared ones, then the viewer's own personal ones (nobody lists another user's personal profiles, an admin included). Never carries a key — `hasKey` only.",
|
||||
operationId: "listAiProxies",
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
items: { type: "array", items: { $ref: "#/components/schemas/AiProxyProfile" } },
|
||||
},
|
||||
},
|
||||
"Profiles."
|
||||
),
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["AI Proxies"],
|
||||
summary: "Create an AI Proxy profile",
|
||||
description:
|
||||
'Saves a profile under a server-made name derived from `label`. In server mode a non-admin always creates a PERSONAL profile; `scope: "shared"` from a non-admin is `EADMIN`. Local mode writes the shared store. Same-origin guarded. The response is the browser-safe view (no key).',
|
||||
operationId: "createAiProxy",
|
||||
requestBody: body({ $ref: "#/components/schemas/AiProxyWriteRequest" }),
|
||||
responses: {
|
||||
201: json({ $ref: "#/components/schemas/AiProxyProfile" }, "Created."),
|
||||
400: errResp("`EBADNAME`, `EBADLABEL`, `EBADURL` or `EBADMODEL`."),
|
||||
403: errResp("`EADMIN` — only an admin may write a shared profile."),
|
||||
409: errResp("`EBUILTIN` (the name `native`) or `EBADJSON`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-proxies/9router": {
|
||||
get: {
|
||||
tags: ["AI Proxies"],
|
||||
summary: "Is 9router installed",
|
||||
description:
|
||||
"Reports whether a `9router` binary is on the server's login-shell PATH, so Settings can offer a link to its own UI. A file lookup only; nothing is spawned.",
|
||||
operationId: "getAiProxy9router",
|
||||
responses: {
|
||||
200: json(
|
||||
{ type: "object", properties: { installed: { type: "boolean" } } },
|
||||
"Lookup result."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-proxies/test": {
|
||||
post: {
|
||||
tags: ["AI Proxies"],
|
||||
summary: "Test an AI Proxy endpoint",
|
||||
description:
|
||||
"Probes `<baseUrl>/v1/messages` (Claude Code's shape, 1 output token) and `<baseUrl>/v1/responses` (Codex's, 16 tokens) in parallel, 15 s timeout, redirects not followed. A reachable proxy is a 200 whatever it replies. A blank `apiKey` falls back to the stored key of profile `name` ONLY while the submitted `baseUrl` equals the saved one (and only for the profile's owner); otherwise `EBADKEY`. The key is never echoed. Same-origin guarded.",
|
||||
operationId: "testAiProxy",
|
||||
requestBody: body({
|
||||
type: "object",
|
||||
required: ["baseUrl", "model"],
|
||||
properties: {
|
||||
baseUrl: { type: "string" },
|
||||
model: { type: "string" },
|
||||
apiKey: { type: "string" },
|
||||
name: { type: "string", description: "Saved profile whose key to fall back to." },
|
||||
},
|
||||
}),
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
claude: { $ref: "#/components/schemas/AiProxyTestResult" },
|
||||
codex: { $ref: "#/components/schemas/AiProxyTestResult" },
|
||||
},
|
||||
},
|
||||
"Probe outcomes."
|
||||
),
|
||||
400: errResp("`EBADKEY` (no key to test with), `EBADURL` or `EBADMODEL`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-proxies/{name}": {
|
||||
patch: {
|
||||
tags: ["AI Proxies"],
|
||||
summary: "Update an AI Proxy profile",
|
||||
description:
|
||||
"Merges the given fields; the name never changes and an omitted/blank `apiKey` keeps the stored one. Also rewrites the per-session settings files that copy this profile, so a rotated key reaches live sessions. A personal profile is writable only by its owner (anyone else gets 404); a shared one only by an admin. Same-origin guarded.",
|
||||
operationId: "updateAiProxy",
|
||||
parameters: [nameParam],
|
||||
requestBody: body({ $ref: "#/components/schemas/AiProxyWriteRequest" }),
|
||||
responses: {
|
||||
200: json({ $ref: "#/components/schemas/AiProxyProfile" }, "Updated."),
|
||||
400: errResp("`EBADLABEL`, `EBADURL` or `EBADMODEL`."),
|
||||
403: errResp("`EADMIN`."),
|
||||
404: errResp("`ENOTFOUND`."),
|
||||
409: errResp("`EBUILTIN` or `EBADJSON`."),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["AI Proxies"],
|
||||
summary: "Delete an AI Proxy profile",
|
||||
description:
|
||||
"Removes the profile and the per-session files that copy its key. Same ownership rules as PATCH. Same-origin guarded.",
|
||||
operationId: "deleteAiProxy",
|
||||
parameters: [nameParam],
|
||||
responses: {
|
||||
200: json({ type: "object", properties: { removed: { type: "string" } } }, "Removed."),
|
||||
403: errResp("`EADMIN`."),
|
||||
404: errResp("`ENOTFOUND`."),
|
||||
409: errResp("`EBUILTIN`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/tool-profiles": {
|
||||
get: {
|
||||
tags: ["Tool Profiles"],
|
||||
summary: "List login profiles of every non-Claude tool",
|
||||
description:
|
||||
"One entry per covered tool. A tool that cannot be switched (Antigravity keeps its login in the OS keyring) is `supported: false` with a `reason`; one tool failing (Oh My Pi not installed) sets that entry's `error` without hiding the others. Identity metadata only, never a token. Staff-only in server mode. Claude's own profiles are `/api/cc-config/profiles`.",
|
||||
operationId: "listToolProfiles",
|
||||
responses: {
|
||||
200: json({ $ref: "#/components/schemas/ToolProfilesListResponse" }, "Profiles by tool."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tool-profiles/{tool}": {
|
||||
post: {
|
||||
tags: ["Tool Profiles"],
|
||||
summary: "Save the tool's live login as a profile",
|
||||
description:
|
||||
"Snapshots the login the tool holds right now (Codex: `auth.json`; Oh My Pi: `omp auth export`). Without `name`, re-saves into the login's own profile or picks a free name. A second copy of the same account is `EDUPLICATE`; no live login is `ENOAUTH`. Same-origin guarded; admin-only in server mode.",
|
||||
operationId: "saveToolProfile",
|
||||
parameters: [toolParam],
|
||||
requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }, false),
|
||||
responses: { 200: toolOk, ...toolErrors },
|
||||
},
|
||||
patch: {
|
||||
tags: ["Tool Profiles"],
|
||||
summary: "Rename a tool login profile",
|
||||
description:
|
||||
"Changes the display `label` of profile `name` (`ELABELTAKEN` when another profile already uses it).",
|
||||
operationId: "renameToolProfile",
|
||||
parameters: [toolParam],
|
||||
requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }),
|
||||
responses: { 200: toolOk, ...toolErrors },
|
||||
},
|
||||
delete: {
|
||||
tags: ["Tool Profiles"],
|
||||
summary: "Delete a tool login profile",
|
||||
description:
|
||||
"Deletes profile `name` (body). The saved copy of the login in use right now is not deletable (`EACTIVE`).",
|
||||
operationId: "deleteToolProfile",
|
||||
parameters: [toolParam],
|
||||
requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }),
|
||||
responses: {
|
||||
200: json(
|
||||
{ type: "object", properties: { ok: { type: "boolean" }, name: { type: "string" } } },
|
||||
"Deleted."
|
||||
),
|
||||
...toolErrors,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tool-profiles/{tool}/activate": {
|
||||
post: {
|
||||
tags: ["Tool Profiles"],
|
||||
summary: "Switch the tool to a saved login",
|
||||
description:
|
||||
"Makes profile `name` the tool's live login. The outgoing login is first saved back into its own profile; switching away from a login that was never saved is refused (`EUNSAVED`). Changes the HOST's login — admin-only in server mode, same-origin guarded.",
|
||||
operationId: "activateToolProfile",
|
||||
parameters: [toolParam],
|
||||
requestBody: body({ $ref: "#/components/schemas/ToolProfileNameRequest" }),
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
name: { type: "string" },
|
||||
savedOutgoing: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "Profile the outgoing login was saved into.",
|
||||
},
|
||||
written: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Files the switch wrote.",
|
||||
},
|
||||
},
|
||||
},
|
||||
"Switched."
|
||||
),
|
||||
...toolErrors,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/ai-tools/{id}/models": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Model choices for a tool",
|
||||
description:
|
||||
"The models a run of this tool offers, from the tool's own source (Claude's and Codex's lists, else the catalog file's, plus models seen in its recorded token usage), and the user's chosen default. `flag` is false when the tool takes no model flag.",
|
||||
operationId: "getAiToolModels",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
flag: { type: "boolean" },
|
||||
models: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
label: { type: "string" },
|
||||
hint: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
"Models."
|
||||
),
|
||||
404: errResp("`ENOTOOL`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}/usage": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "How much a tool has been used",
|
||||
description:
|
||||
"Count of the tool's sessions and the priced cost of their token usage (same `calculateCost` as everywhere else). A developer in server mode sees only their own sessions.",
|
||||
operationId: "getAiToolUsage",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
type: "object",
|
||||
properties: { sessions: { type: "integer" }, cost: { type: "number" } },
|
||||
},
|
||||
"Usage."
|
||||
),
|
||||
404: errResp("`ENOTOOL`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}/config": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "List a tool's editable config files",
|
||||
description:
|
||||
"The `config` files a RELEASE catalog file declares for this tool (never `DASHBOARD_AI_TOOLS_DIR`), resolved under the home directory, with whether each exists. Staff-only in server mode (they may hold keys).",
|
||||
operationId: "listAiToolConfig",
|
||||
parameters: [idParam],
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
index: { type: "integer" },
|
||||
path: { type: "string" },
|
||||
format: { type: "string", example: "json" },
|
||||
exists: { type: "boolean" },
|
||||
size: { type: "integer", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Declared files."
|
||||
),
|
||||
404: errResp("`ENOTOOL`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/ai-tools/{id}/config/{index}": {
|
||||
get: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Read one config file",
|
||||
description:
|
||||
"Returns the text of an existing regular text file ≤ 1 MB from the tool's declared list, with its sha to save against. Refuses a credential-named file (`ESECRET`, checked on the basename and its symlink target). The guard is by name only — a settings file may still contain an API key and is returned whole. Staff-only in server mode.",
|
||||
operationId: "readAiToolConfig",
|
||||
parameters: [idParam, indexParam],
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
format: { type: "string" },
|
||||
text: { type: "string" },
|
||||
sha: { type: "string" },
|
||||
},
|
||||
},
|
||||
"File text."
|
||||
),
|
||||
...configErrors,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["AI Tools"],
|
||||
summary: "Save one config file",
|
||||
description:
|
||||
"Overwrites an EXISTING declared file (never creates one) only when `baseSha` matches the file on disk (`ESTALE` otherwise); JSON/JSONC/TOML text must parse. The old text is backed up under `<data dir>/ai-tool-config-backups/` first. Same-origin guarded; admin-only in server mode.",
|
||||
operationId: "writeAiToolConfig",
|
||||
parameters: [idParam, indexParam],
|
||||
requestBody: body({
|
||||
type: "object",
|
||||
required: ["text", "baseSha"],
|
||||
properties: { text: { type: "string" }, baseSha: { type: "string" } },
|
||||
}),
|
||||
responses: {
|
||||
200: json(
|
||||
{ type: "object", properties: { sha: { type: "string" }, backup: { type: "string" } } },
|
||||
"Saved."
|
||||
),
|
||||
...configErrors,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/plugins/available": {
|
||||
get: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "List installable plugins",
|
||||
description:
|
||||
"Runs `claude plugin list --available --json` (30 s timeout) and returns its installed and available lists. Read-only; skips the CLI mutation queue.",
|
||||
operationId: "ccConfigListAvailablePlugins",
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
installed: { type: "array", items: { type: "object" } },
|
||||
available: { type: "array", items: { type: "object" } },
|
||||
},
|
||||
},
|
||||
"CLI output."
|
||||
),
|
||||
502: errResp("`ECLIFAILED` — the CLI failed or printed unparseable JSON."),
|
||||
503: errResp("`ECLINOTFOUND`."),
|
||||
504: errResp("`ETIMEOUT`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cc-config/plugins/install": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Install a plugin",
|
||||
description:
|
||||
"Runs `claude plugin install <spec> --scope <scope> -y` as an argv array on the serialized CLI queue. `spec` is `<name>` or `<name>@<marketplace>`; `scope` is `user`, `project` or `local`. Admin-only in server mode.",
|
||||
operationId: "ccConfigInstallPlugin",
|
||||
requestBody: body({
|
||||
type: "object",
|
||||
required: ["spec", "scope"],
|
||||
properties: {
|
||||
spec: { type: "string" },
|
||||
scope: { type: "string", enum: ["user", "project", "local"] },
|
||||
},
|
||||
}),
|
||||
responses: { 200: cliOk, ...cliErrors },
|
||||
},
|
||||
},
|
||||
"/api/cc-config/plugins/action": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Enable, disable, update or uninstall a plugin",
|
||||
description:
|
||||
"Runs `claude plugin <action> <key>` (`update` adds `-y`). With `reload: true` and a successful run, `/reload-plugins` + `/reload-skills` are typed into the open Claude lane panes (`reloaded` in the response); a failed reload never fails the action. Admin-only in server mode.",
|
||||
operationId: "ccConfigPluginAction",
|
||||
requestBody: body({
|
||||
type: "object",
|
||||
required: ["key", "action"],
|
||||
properties: {
|
||||
key: { type: "string", example: "agenthub@agenthub" },
|
||||
action: { type: "string", enum: ["enable", "disable", "update", "uninstall"] },
|
||||
reload: { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
responses: {
|
||||
200: json(
|
||||
{
|
||||
allOf: [
|
||||
{ $ref: "#/components/schemas/CcConfigCliResult" },
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
reloaded: { type: "object", description: "Reload outcome, when requested." },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"The CLI succeeded."
|
||||
),
|
||||
...cliErrors,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cc-config/marketplaces/add": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Add a plugin marketplace",
|
||||
description:
|
||||
"Runs `claude plugin marketplace add <source> --scope <scope>`. `source` is `owner/repo`, an http(s) URL or an absolute path (no `..`, no leading `-`). Admin-only in server mode.",
|
||||
operationId: "ccConfigAddMarketplace",
|
||||
requestBody: body({
|
||||
type: "object",
|
||||
required: ["source", "scope"],
|
||||
properties: {
|
||||
source: { type: "string" },
|
||||
scope: { type: "string", enum: ["user", "project", "local"] },
|
||||
},
|
||||
}),
|
||||
responses: { 200: cliOk, ...cliErrors },
|
||||
},
|
||||
},
|
||||
"/api/cc-config/marketplaces/action": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Remove or update a plugin marketplace",
|
||||
description:
|
||||
"Runs `claude plugin marketplace remove|update <name>`. Admin-only in server mode.",
|
||||
operationId: "ccConfigMarketplaceAction",
|
||||
requestBody: body({
|
||||
type: "object",
|
||||
required: ["name", "action"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
action: { type: "string", enum: ["remove", "update"] },
|
||||
},
|
||||
}),
|
||||
responses: { 200: cliOk, ...cliErrors },
|
||||
},
|
||||
},
|
||||
"/api/cc-config/project-memory": projectMemoryOp(
|
||||
"claude",
|
||||
"CLAUDE.md",
|
||||
"ccConfigCreateProjectMemory",
|
||||
"CcConfig"
|
||||
),
|
||||
"/api/codex-config/project-memory": projectMemoryOp(
|
||||
"codex",
|
||||
"AGENTS.md",
|
||||
"codexConfigCreateProjectMemory",
|
||||
"CodexConfig"
|
||||
),
|
||||
|
||||
"/api/git-credentials": {
|
||||
get: {
|
||||
tags: ["Git Credentials"],
|
||||
summary: "List stored git credentials",
|
||||
description:
|
||||
"One row per protocol + host. Never returns a password — `hasToken` only. Staff-only in server mode.",
|
||||
operationId: "listGitCredentials",
|
||||
responses: {
|
||||
200: json({ $ref: "#/components/schemas/GitCredentialsListResponse" }, "Credentials."),
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Git Credentials"],
|
||||
summary: "Save or replace a git credential",
|
||||
description:
|
||||
"Stores a username + password/token for `protocol` (`http`/`https`) + plain `host[:port]` in AgentHub's own store (0600), replacing any record for that key. Same-origin guarded; admin-only in server mode. Returns the updated list.",
|
||||
operationId: "saveGitCredential",
|
||||
requestBody: body({
|
||||
type: "object",
|
||||
required: ["protocol", "host", "username", "password"],
|
||||
properties: {
|
||||
protocol: { type: "string", enum: ["http", "https"] },
|
||||
host: { type: "string" },
|
||||
username: { type: "string" },
|
||||
password: { type: "string", description: "Write-only." },
|
||||
},
|
||||
}),
|
||||
responses: {
|
||||
200: json({ $ref: "#/components/schemas/GitCredentialsListResponse" }, "Saved."),
|
||||
400: errResp("`EBADHOST` or `EBADCRED`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/git-credentials/{protocol}/{host}": {
|
||||
delete: {
|
||||
tags: ["Git Credentials"],
|
||||
summary: "Delete a git credential",
|
||||
description:
|
||||
"Removes the record for this protocol + host. Same-origin guarded; admin-only in server mode.",
|
||||
operationId: "deleteGitCredential",
|
||||
parameters: [
|
||||
{
|
||||
name: "protocol",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string", enum: ["http", "https"] },
|
||||
},
|
||||
{ name: "host", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: json(
|
||||
{ $ref: "#/components/schemas/GitCredentialsListResponse" },
|
||||
"Deleted; the remaining list."
|
||||
),
|
||||
400: errResp("`EBADHOST`."),
|
||||
404: errResp("`ENOTFOUND`."),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
@@ -22,7 +22,9 @@
|
||||
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
|
||||
* Users page's Edit of one account — exactly those users run on it; `GET /`
|
||||
* lists the pins under `pins`.
|
||||
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
|
||||
* A Codex account may also be a saved Codex `profile` (tool-profiles.js), moved
|
||||
* in the same way. `GET /profiles[?tool=codex]` lists the saved profiles that
|
||||
* can be moved in (identity only). Like every Users-page change
|
||||
* they need the admin's own password again (`currentPassword`), are audited
|
||||
* (the account NAME only), and refuse in local mode. The secret only goes IN:
|
||||
* no route answers with it (`/accounts` is in ADMIN_WRITE_PREFIXES).
|
||||
@@ -35,6 +37,7 @@ const codexPool = require("../lib/codex-pool");
|
||||
const auth = require("../lib/auth");
|
||||
const { recordAccountChange } = require("../lib/audit");
|
||||
const profiles = require("../lib/cc-profiles");
|
||||
const toolProfiles = require("../lib/tool-profiles");
|
||||
const pins = require("../lib/account-pins");
|
||||
|
||||
const router = Router();
|
||||
@@ -57,8 +60,19 @@ router.get("/", async (req, res) => {
|
||||
});
|
||||
|
||||
/** Saved switch-user profiles an admin may move into the pool — never a token. */
|
||||
router.get("/profiles", (_req, res) => {
|
||||
router.get("/profiles", (req, res) => {
|
||||
try {
|
||||
if (req.query.tool === "codex") {
|
||||
return res.json({
|
||||
profiles: toolProfiles.listProfiles("codex").items.map((p) => ({
|
||||
name: p.name,
|
||||
label: p.label,
|
||||
email: p.email,
|
||||
subscriptionType: p.detail,
|
||||
active: p.active,
|
||||
})),
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
profiles: profiles.listProfiles().items.map((p) => ({
|
||||
name: p.name,
|
||||
@@ -185,6 +199,20 @@ function fromProfile(name, poolName) {
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a saved Codex profile (Settings → AI tools → Profiles) into the pool.
|
||||
* The profile file goes, as for Claude: the pool is now the login's holder.
|
||||
* The host's own CODEX_HOME is left as it is — Codex's old refresh token still
|
||||
* refreshes (codex-pool.js), so the host keeps working on it.
|
||||
*/
|
||||
function fromCodexProfile(name, poolName) {
|
||||
const stored = toolProfiles.readProfile("codex", name);
|
||||
if (!stored) throw Object.assign(new Error(`no saved profile ${name}`), { code: "ENOTFOUND" });
|
||||
const account = codexPool.addAccount(poolName || name, { auth: stored.secret });
|
||||
toolProfiles.dropProfile("codex", name);
|
||||
return account;
|
||||
}
|
||||
|
||||
/** Codex takes an API key, or the text of an auth.json (`codex login`'s). */
|
||||
function codexSecret(token) {
|
||||
const text = String(token || "").trim();
|
||||
@@ -203,7 +231,9 @@ router.post(
|
||||
poolChange(async (_req, body) => {
|
||||
const account =
|
||||
body.tool === "codex"
|
||||
? codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
? body.profile
|
||||
? fromCodexProfile(String(body.profile), body.name)
|
||||
: codexPool.addAccount(body.name, codexSecret(body.token))
|
||||
: body.profile
|
||||
? fromProfile(String(body.profile), body.name)
|
||||
: body.credentials
|
||||
|
||||
+18
-17
@@ -14,7 +14,8 @@
|
||||
* MCP server add/remove delegated to the `claude mcp …` CLI. Every file
|
||||
* write is preceded by a timestamped backup; the CLI-delegated writes are
|
||||
* not files this server touches directly, so there is nothing to back up —
|
||||
* the CLI owns `~/.claude.json`.
|
||||
* the CLI owns `~/.claude.json`. Every write carries `sameOriginGuard`: an
|
||||
* MCP server or a plugin is code Claude Code later runs.
|
||||
*
|
||||
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
|
||||
* account the local CLI runs as; those carry identity metadata only, never
|
||||
@@ -199,7 +200,7 @@ router.get("/keybindings", (_req, res) => {
|
||||
// PUT /api/cc-config/keybindings — structured overwrite of the user's
|
||||
// ~/.claude/keybindings.json. Body: { groups: [{ context, bindings: [{ key,
|
||||
// action }] }] }. Backs the file up first and preserves top-level metadata.
|
||||
router.put("/keybindings", (req, res) => {
|
||||
router.put("/keybindings", sameOriginGuard, (req, res) => {
|
||||
const { groups } = req.body || {};
|
||||
if (!Array.isArray(groups)) {
|
||||
return res
|
||||
@@ -245,7 +246,7 @@ router.get("/file", (req, res) => {
|
||||
// Plugins, MCP, hooks-in-settings, and settings.json files are intentionally
|
||||
// not mutable here. See cc-mutate.js for the rationale.
|
||||
|
||||
router.put("/file", (req, res) => {
|
||||
router.put("/file", sameOriginGuard, (req, res) => {
|
||||
const { scope, type, name, content, project } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
@@ -271,7 +272,7 @@ router.put("/file", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/file", (req, res) => {
|
||||
router.delete("/file", sameOriginGuard, (req, res) => {
|
||||
const { scope, type, name, project } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
@@ -355,7 +356,7 @@ router.get("/plugins/available", async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/plugins/install", async (req, res) => {
|
||||
router.post("/plugins/install", sameOriginGuard, async (req, res) => {
|
||||
const { spec, scope } = req.body || {};
|
||||
try {
|
||||
const result = await ccCli.pluginInstall({ spec, scope });
|
||||
@@ -372,7 +373,7 @@ const PLUGIN_ACTIONS = {
|
||||
uninstall: ccCli.pluginUninstall,
|
||||
};
|
||||
|
||||
router.post("/plugins/action", async (req, res) => {
|
||||
router.post("/plugins/action", sameOriginGuard, async (req, res) => {
|
||||
const { key, action, reload } = req.body || {};
|
||||
const run = PLUGIN_ACTIONS[action];
|
||||
if (!run) {
|
||||
@@ -409,7 +410,7 @@ router.post("/plugins/action", async (req, res) => {
|
||||
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||
* mutation chain nor emits `cc_config_changed`.
|
||||
*/
|
||||
router.post("/reload", async (req, res) => {
|
||||
router.post("/reload", sameOriginGuard, async (req, res) => {
|
||||
const { kinds } = req.body || {};
|
||||
if (kinds !== undefined && !Array.isArray(kinds)) {
|
||||
return res.status(400).json({
|
||||
@@ -436,7 +437,7 @@ router.get("/plugins/update-all", (_req, res) => {
|
||||
res.json({ batch: readUpdateAll() });
|
||||
});
|
||||
|
||||
router.post("/plugins/update-all", async (req, res) => {
|
||||
router.post("/plugins/update-all", sameOriginGuard, async (req, res) => {
|
||||
if (isRunning()) {
|
||||
return res.status(409).json({
|
||||
error: { code: "EBUSY", message: "an update-all batch is already running" },
|
||||
@@ -481,7 +482,7 @@ router.post("/plugins/update-all", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/marketplaces/add", async (req, res) => {
|
||||
router.post("/marketplaces/add", sameOriginGuard, async (req, res) => {
|
||||
const { source, scope } = req.body || {};
|
||||
try {
|
||||
const result = await ccCli.marketplaceAdd({ source, scope });
|
||||
@@ -496,7 +497,7 @@ const MARKETPLACE_ACTIONS = {
|
||||
update: ccCli.marketplaceUpdate,
|
||||
};
|
||||
|
||||
router.post("/marketplaces/action", async (req, res) => {
|
||||
router.post("/marketplaces/action", sameOriginGuard, async (req, res) => {
|
||||
const { name, action } = req.body || {};
|
||||
const run = MARKETPLACE_ACTIONS[action];
|
||||
if (!run) {
|
||||
@@ -523,7 +524,7 @@ router.post("/marketplaces/action", async (req, res) => {
|
||||
// /mcp above stays on `readMcpServers`; the client refetches it after either
|
||||
// call below succeeds.
|
||||
|
||||
router.post("/mcp/add", async (req, res) => {
|
||||
router.post("/mcp/add", sameOriginGuard, async (req, res) => {
|
||||
const { name, scope, transport, command, args, url, headers, env } = req.body || {};
|
||||
try {
|
||||
const result = await ccMcpCli.mcpAdd({
|
||||
@@ -542,7 +543,7 @@ router.post("/mcp/add", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/remove", async (req, res) => {
|
||||
router.post("/mcp/remove", sameOriginGuard, async (req, res) => {
|
||||
const { name, scope } = req.body || {};
|
||||
try {
|
||||
const result = await ccMcpCli.mcpRemove({ name, scope });
|
||||
@@ -560,7 +561,7 @@ router.post("/mcp/remove", async (req, res) => {
|
||||
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
|
||||
// target chosen by the caller would let any request create a settings.json
|
||||
// anywhere on disk. Reads keep the override; writes do not.
|
||||
router.patch("/settings", (req, res) => {
|
||||
router.patch("/settings", sameOriginGuard, (req, res) => {
|
||||
const { scope, set, unset } = req.body || {};
|
||||
try {
|
||||
const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset });
|
||||
@@ -607,7 +608,7 @@ router.get("/profiles", async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/profiles", async (req, res) => {
|
||||
router.post("/profiles", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const owner = await ccUsage.liveOwner();
|
||||
const result = ccProfiles.saveProfile((req.body || {}).name, { owner });
|
||||
@@ -619,7 +620,7 @@ router.post("/profiles", async (req, res) => {
|
||||
});
|
||||
|
||||
// Rename is a label edit; the profile's `name` identifier never moves.
|
||||
router.patch("/profiles", (req, res) => {
|
||||
router.patch("/profiles", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = ccProfiles.renameProfile(body.name, body.label);
|
||||
@@ -630,7 +631,7 @@ router.patch("/profiles", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/profiles/activate", async (req, res) => {
|
||||
router.post("/profiles/activate", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const owner = await ccUsage.liveOwner();
|
||||
const result = ccProfiles.activateProfile((req.body || {}).name, { owner });
|
||||
@@ -673,7 +674,7 @@ router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
|
||||
// Identifier travels in the body, not the URL — same reason as DELETE /file.
|
||||
router.delete("/profiles", (req, res) => {
|
||||
router.delete("/profiles", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const result = ccProfiles.deleteProfile((req.body || {}).name);
|
||||
emitChanged({ action: "delete", type: "profile", name: result.name, project: null });
|
||||
|
||||
@@ -135,7 +135,7 @@ router.get("/file", (req, res) => {
|
||||
// anywhere on disk (same reasoning cc-config.js's PATCH /settings gives for
|
||||
// refusing `?cwd=` there). Reads keep the override.
|
||||
|
||||
router.put("/file", (req, res) => {
|
||||
router.put("/file", sameOriginGuard, (req, res) => {
|
||||
const { scope, type, name, content } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
@@ -151,7 +151,7 @@ router.put("/file", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/file", (req, res) => {
|
||||
router.delete("/file", sameOriginGuard, (req, res) => {
|
||||
const { scope, type, name } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
@@ -282,7 +282,7 @@ router.get("/marketplaces", async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/add", async (req, res) => {
|
||||
router.post("/mcp/add", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const result = await codexCli.mcpAdd(req.body || {});
|
||||
return cliResult(res, result, { action: "add", type: "mcp", name: (req.body || {}).name });
|
||||
@@ -291,7 +291,7 @@ router.post("/mcp/add", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/remove", async (req, res) => {
|
||||
router.post("/mcp/remove", sameOriginGuard, async (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
try {
|
||||
const result = await codexCli.mcpRemove(name);
|
||||
@@ -306,7 +306,7 @@ const PLUGIN_ACTIONS = {
|
||||
remove: codexCli.pluginRemove,
|
||||
};
|
||||
|
||||
router.post("/plugins/action", async (req, res) => {
|
||||
router.post("/plugins/action", sameOriginGuard, async (req, res) => {
|
||||
const { spec, action } = req.body || {};
|
||||
const run = PLUGIN_ACTIONS[action];
|
||||
if (!run) {
|
||||
@@ -325,7 +325,7 @@ router.post("/plugins/action", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/marketplaces/add", async (req, res) => {
|
||||
router.post("/marketplaces/add", sameOriginGuard, async (req, res) => {
|
||||
const { url } = req.body || {};
|
||||
try {
|
||||
const result = await codexCli.marketplaceAdd(url);
|
||||
@@ -339,7 +339,7 @@ const MARKETPLACE_ACTIONS = {
|
||||
remove: codexCli.marketplaceRemove,
|
||||
};
|
||||
|
||||
router.post("/marketplaces/action", async (req, res) => {
|
||||
router.post("/marketplaces/action", sameOriginGuard, async (req, res) => {
|
||||
const { name, action } = req.body || {};
|
||||
const run = MARKETPLACE_ACTIONS[action];
|
||||
if (!run) {
|
||||
|
||||
+7
-5
@@ -27,8 +27,8 @@
|
||||
* owner) may not change the owner's login. Each change is audited.
|
||||
* `/avatar` (server mode) is the account's own picture (server/lib/avatars.js):
|
||||
* GET serves it (`nosniff`, `sandbox` CSP), PUT takes the raw image bytes
|
||||
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` downloads the same
|
||||
* from an https link (public addresses only, server/lib/avatar-url.js) and
|
||||
* (PNG/JPEG/GIF/WebP, at most 1 MB), `POST /avatar/url` stores an https link
|
||||
* instead, which `GET /avatar` redirects to (server/lib/avatar-url.js) and
|
||||
* DELETE removes it — all writes are account writes like the profile's. The sidebar shows it beside the name.
|
||||
*
|
||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||
@@ -264,11 +264,13 @@ router.post(
|
||||
);
|
||||
|
||||
const avatars = require("../lib/avatars");
|
||||
const { sendAvatarLink } = require("../lib/avatar-redirect");
|
||||
|
||||
router.get("/avatar", (req, res) => {
|
||||
if (!auth.isServerMode()) return res.status(404).json({ error: { code: "ENOAVATAR" } });
|
||||
const user = req.user || auth.userFromRequest(req);
|
||||
if (!user) return unauth(res);
|
||||
if (sendAvatarLink(res, user.id)) return;
|
||||
const file = avatars.avatarFile(user.id);
|
||||
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
|
||||
res.set("Content-Type", file.type);
|
||||
@@ -289,13 +291,13 @@ router.put(
|
||||
})
|
||||
);
|
||||
|
||||
// The same picture from a link: the server downloads it (server/lib/avatar-url.js).
|
||||
// A picture by link: stored as it is, never downloaded — GET /avatar redirects
|
||||
// the browser to it (server/lib/avatar-url.js).
|
||||
router.post(
|
||||
"/avatar/url",
|
||||
sameOriginGuard,
|
||||
accountWrite(async (_req, user, body) => {
|
||||
const buf = await require("../lib/avatar-url").fetchAvatarImage(body.url);
|
||||
avatars.setAvatar(user.id, buf);
|
||||
avatars.setAvatarLink(user.id, body.url);
|
||||
return { out: accountView(user.id), action: "own avatar set from a link" };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file tool-profiles.js
|
||||
* @description `/api/tool-profiles` — the login profiles of the AI tools other
|
||||
* than Claude Code (server/lib/tool-profiles.js; Claude's stay under
|
||||
* `/api/cc-config/profiles`, and Settings → AI tools → Profiles shows both).
|
||||
* `GET /` lists every covered tool (`supported: false` + `reason` for one that
|
||||
* cannot be switched); `POST /:tool` saves the live login, `PATCH /:tool`
|
||||
* renames, `POST /:tool/activate` switches, `DELETE /:tool` deletes — the
|
||||
* profile name always in the body. Identity metadata only, never a token. The
|
||||
* writes change the HOST's own login, so `/tool-profiles` is in
|
||||
* ADMIN_WRITE_PREFIXES and STAFF_ONLY_PREFIXES like `/cc-config`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const profiles = require("../lib/tool-profiles");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = Router();
|
||||
// Every write swaps a live login: never from a cross-site form.
|
||||
router.use((req, res, next) => (req.method === "GET" ? next() : sameOriginGuard(req, res, next)));
|
||||
|
||||
const STATUS = {
|
||||
EBADNAME: 400,
|
||||
EBADLABEL: 400,
|
||||
EBADTOOL: 404,
|
||||
ENOTFOUND: 404,
|
||||
EUNSUPPORTED: 409,
|
||||
ENOTINSTALLED: 409,
|
||||
EBADJSON: 409,
|
||||
EACTIVE: 409,
|
||||
EUNSAVED: 409,
|
||||
ENOAUTH: 409,
|
||||
EDUPLICATE: 409,
|
||||
ELABELTAKEN: 409,
|
||||
EOMP: 502,
|
||||
};
|
||||
|
||||
const handle = (fn) => async (req, res) => {
|
||||
try {
|
||||
res.json(await fn(req));
|
||||
} catch (e) {
|
||||
res
|
||||
.status(STATUS[e.code] || 500)
|
||||
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
};
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
handle(() => ({
|
||||
tools: profiles.tools().map((t) => {
|
||||
if (!t.supported) return { ...t, items: [], active: null };
|
||||
try {
|
||||
return { ...t, ...profiles.listProfiles(t.tool), error: null };
|
||||
} catch (e) {
|
||||
// One tool's failure (omp not installed) never hides the others.
|
||||
return { ...t, items: [], active: null, error: { code: e.code, message: e.message } };
|
||||
}
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
const name = (req) => (req.body || {}).name;
|
||||
|
||||
router.post(
|
||||
"/:tool",
|
||||
handle((req) => profiles.saveProfile(req.params.tool, name(req)))
|
||||
);
|
||||
router.patch(
|
||||
"/:tool",
|
||||
handle((req) => profiles.renameProfile(req.params.tool, name(req), (req.body || {}).label))
|
||||
);
|
||||
router.post(
|
||||
"/:tool/activate",
|
||||
handle((req) => profiles.activateProfile(req.params.tool, name(req)))
|
||||
);
|
||||
router.delete(
|
||||
"/:tool",
|
||||
handle((req) => profiles.deleteProfile(req.params.tool, name(req)))
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -209,6 +209,12 @@ router.get("/:id", (req, res) => {
|
||||
|
||||
router.get("/:id/avatar", (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (
|
||||
Number.isSafeInteger(id) &&
|
||||
id > 0 &&
|
||||
require("../lib/avatar-redirect").sendAvatarLink(res, id)
|
||||
)
|
||||
return;
|
||||
const file = Number.isSafeInteger(id) && id > 0 ? require("../lib/avatars").avatarFile(id) : null;
|
||||
if (!file) return res.status(404).json({ error: { code: "ENOAVATAR", message: "no avatar" } });
|
||||
res.set("Content-Type", file.type);
|
||||
|
||||
Reference in New Issue
Block a user