feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars

- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 17:21:33 +07:00
parent 8ed5018a2f
commit 7a88b74016
63 changed files with 3833 additions and 354 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.7.9 (2026-10-14) — Add workspace has no AI tool picker (default tool)._ 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.0 (2026-10-14) — Artifacts uploads and plugin links, tab combos, account avatars._ 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.
@@ -59,7 +59,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `sudo agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch turns installs off. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
+6 -2
View File
@@ -4232,7 +4232,7 @@ async function cmdCodexAccounts(sub, name, flags) {
}
pool.resetQuota();
table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
pool
.poolStatus()
.map((a) => [
@@ -4241,6 +4241,8 @@ async function cmdCodexAccounts(sub, name, flags) {
a.livePanes,
a.spend5h.toFixed(2),
a.spend7d.toFixed(2),
a.tokens5h,
a.tokens7d,
a.quota
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
: "–",
@@ -4281,7 +4283,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
await pool.refreshQuotas({ force: true });
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
table(
["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"],
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
pool
.poolStatus()
.map((a) => [
@@ -4289,6 +4291,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
a.livePanes,
a.spend5h.toFixed(2),
a.spend7d.toFixed(2),
a.tokens5h,
a.tokens7d,
pct(a.quota?.fiveHour),
pct(a.quota?.sevenDay),
a.expired
+55 -17
View File
@@ -1,6 +1,6 @@
/**
* @file Sidebar.tsx
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -79,6 +79,7 @@ import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
import { useAuth, usePermissions } from "./AuthGate";
import { UserAvatar } from "./users/UserAvatar";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -540,6 +541,59 @@ export function Sidebar({
)}
</div>
{/* Server mode: who is signed in — avatar, name and role, opening
Settings → Account — and Log out. On the rail they stack. */}
{auth.state.mode === "server" &&
auth.state.user &&
(() => {
const user = auth.state.user;
const name = user.displayName || user.username;
const role = t(`users:roles.${user.role}`);
const signedIn = t("common:auth.signedInAs", { name, role });
return (
<div
data-testid="sidebar-account"
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
collapsed ? "flex-col items-center" : "items-center"
}`}
>
<NavLink
to={settingsHref("account", location)}
title={signedIn}
aria-label={signedIn}
className={`flex min-w-0 items-center gap-2 rounded-md transition-colors hover:bg-surface-3 ${
collapsed ? "justify-center p-1" : "flex-1 px-1.5 py-1"
}`}
>
<UserAvatar
name={name}
avatarAt={user.avatarAt}
className={collapsed ? "h-7 w-7 text-[11px]" : "h-8 w-8 text-xs"}
/>
{!collapsed && (
<span className="min-w-0 leading-tight">
<span className="block truncate text-sm font-medium text-fg-primary">
{name}
</span>
<span className="block truncate text-[11px] text-fg-muted">{role}</span>
</span>
)}
</NavLink>
<button
type="button"
onClick={() => void auth.logout()}
title={`${signedIn} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "w-full py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
</div>
);
})()}
{/* Settings and the collapse toggle stick to the foot, out of the
scrolling nav. On the rail they stack. */}
<div
@@ -555,22 +609,6 @@ export function Sidebar({
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
{auth.state.mode === "server" && auth.state.user && (
<button
type="button"
onClick={() => void auth.logout()}
title={`${t("common:auth.signedInAs", {
name: auth.state.user.displayName || auth.state.user.username,
role: auth.state.user.role,
})} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onToggle}
+11 -8
View File
@@ -10,8 +10,8 @@
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
* analyst: EVERY workspace, grouped under its owner (`owner_display_name`,
* else `owner_name`; yours
* analyst: EVERY workspace, grouped under its owner (their avatar —
* `owner_avatar_at`, `UserAvatar` — and `owner_display_name`, else `owner_name`; yours
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
@@ -49,6 +49,7 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { UserAvatar } from "./users/UserAvatar";
// The sidebar is in the entry chunk; its two modals (and the run form the add
// modal pulls in) load when first opened.
@@ -366,12 +367,14 @@ export function WorkspaceTree() {
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)}
<span
aria-hidden
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
>
{ownerShown(lane)[0] ?? "?"}
</span>
<UserAvatar
name={ownerShown(lane) || "?"}
userId={lane.owner_id ?? undefined}
avatarAt={lane.owner_avatar_at}
letters={1}
className="h-4 w-4 text-[9px]"
initialsClassName="bg-surface-4 text-fg-secondary"
/>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
{sorted.filter((l) => ownerKey(l) === owner).length}
@@ -0,0 +1,73 @@
/**
* @file Sidebar.account.test.tsx
* @description The sidebar's account row (server mode): the signed-in user's
* avatar — or initials without one — name and role, linking to Settings →
* Account, beside Sign out; local mode shows no row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
const auth = vi.hoisted(() => ({
state: { mode: "local" } as Record<string, unknown>,
logout: vi.fn(),
updateUser: vi.fn(),
}));
vi.mock("../AuthGate", () => ({
useAuth: () => auth,
usePermissions: () => ({
isAdmin: auth.state.mode !== "server",
local: auth.state.mode !== "server",
seesAll: true,
canEditLane: () => true,
}),
}));
import { Sidebar } from "../Sidebar";
const renderSidebar = (collapsed = false) =>
render(
<MemoryRouter>
<Sidebar
wsConnected
collapsed={collapsed}
onToggle={() => {}}
connectionOpen={false}
onConnectionOpenChange={() => {}}
/>
</MemoryRouter>
);
describe("Sidebar account row", () => {
it("shows the avatar, name and role, linking to Settings → Account", () => {
auth.state = {
mode: "server",
user: { id: 2, username: "mia", role: "developer", displayName: "Mia Tran", avatarAt: "t1" },
};
renderSidebar();
const row = screen.getByTestId("sidebar-account");
expect(row.textContent).toContain("Mia Tran");
expect(row.textContent).toContain("Developer");
expect(within(row).getByTestId("user-avatar-img").getAttribute("src")).toBe(
"/api/me/avatar?v=t1"
);
expect(within(row).getAllByRole("link")[0]?.getAttribute("href")).toBe("/settings/account");
expect(within(row).getByRole("button", { name: "Sign out" })).toBeTruthy();
});
it("falls back to initials, and keeps only the avatar on the rail", () => {
auth.state = { mode: "server", user: { id: 3, username: "leo", role: "admin" } };
renderSidebar(true);
const row = screen.getByTestId("sidebar-account");
expect(within(row).getByTestId("user-avatar-initials").textContent).toBe("le");
expect(row.textContent).not.toContain("Admin");
});
it("has no account row in local mode", () => {
auth.state = { mode: "local" };
renderSidebar();
expect(screen.queryByTestId("sidebar-account")).toBeNull();
});
});
@@ -1,7 +1,8 @@
/**
* @file WorkspaceTree.owners.test.tsx
* @description Server mode, the sidebar workspace list for an analyst: every
* user's workspaces, grouped under their owner (yours first, no owner last),
* user's workspaces, grouped under their owner (with their avatar, else an
* initial; yours first, no owner last),
* groups folding and remembered; another user's workspace is read only — no
* menu, no new session, and a session opens its analysis page, never a pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -30,7 +31,11 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
}) as unknown as Lane;
const lanes = [
{ ...lane(1, 3, "bob", "Bob app"), owner_display_name: "Bob Nguyen" } as Lane,
{
...lane(1, 3, "bob", "Bob app"),
owner_display_name: "Bob Nguyen",
owner_avatar_at: "t1",
} as Lane,
lane(2, null, null, "Old"),
lane(3, ME.id, "ana", "Mine"),
lane(4, 1, "admin", "Admin site"),
@@ -61,6 +66,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
})),
},
run: { list: vi.fn(async () => ({ items: [] })) },
users: {
avatarUrl: (id: number, at?: string | null) =>
at ? `/api/users/${id}/avatar?v=${at}` : null,
},
me: { avatarUrl: () => null },
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
},
};
@@ -118,6 +128,15 @@ describe("WorkspaceTree for an analyst", () => {
await screen.findByTestId("workspace-row-1");
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
// Bob set a picture: shown beside the name; Ana did not: her initial.
expect(
within(screen.getByTestId("workspace-owner-3"))
.getByTestId("user-avatar-img")
.getAttribute("src")
).toBe("/api/users/3/avatar?v=t1");
expect(
within(screen.getByTestId("workspace-owner-2")).getByTestId("user-avatar-initials")
).toHaveTextContent("a");
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
});
@@ -0,0 +1,928 @@
/**
* @file ArtifactAddPanels.tsx
* @description The Artifacts page's Add dialogs for a skill, a plugin and an
* uploaded file (server/lib/artifacts.js):
* - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown
* file that becomes SKILL.md, several files, a whole folder — picked or
* dropped — or a .zip/.skill; an upload holding several skills makes one
* artifact each), **Write** (name, description, instructions) and
* **Existing** (a folder in the user's ~/.claude/skills or a workspace's
* .claude/skills). The user names the skill; the name is previewed as the
* folder and `/command` it installs as (`skillSlug`, the server's rule).
* - `PluginAddPanel` — paste a marketplace link (a git https link, a forge
* web link, `owner/repo` or a marketplace.json link), read its plugin list
* and tick the ones to add; or type `name@marketplace` by hand, which is
* also where a link the server cannot read (ssh, a private repo) ends up.
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { useTranslation } from "react-i18next";
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
const label = "block text-[11px] font-medium text-fg-secondary";
const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
export function skillSlug(text: string): string {
return text
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[-._]+|-+$/g, "")
.slice(0, 100);
}
/** `field:` of a Markdown file's frontmatter, or "". */
export function frontmatterField(text: string, name: string): string {
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!fm) return "";
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? "");
return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2");
}
type Picked = { file: File; path: string };
/** A small file's text ("" on failure), only to prefill the name/description. */
function readText(file: File): Promise<string> {
if (file.size > 1024 * 1024) return Promise.resolve("");
return new Promise((ok) => {
const r = new FileReader();
r.onload = () => ok(typeof r.result === "string" ? r.result : "");
r.onerror = () => ok("");
r.readAsText(file);
});
}
const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p);
const isArchive = (p: string) => /\.(zip|skill)$/i.test(p);
const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p);
/** Every file under what was dropped, folders walked (webkitGetAsEntry). */
async function droppedFiles(e: DragEvent): Promise<Picked[]> {
const items = Array.from(e.dataTransfer.items || []);
const entries = items
.map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null))
.filter((x): x is FileSystemEntry => !!x);
if (!entries.length) {
return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name }));
}
const out: Picked[] = [];
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
if (entry.isFile) {
const file = await new Promise<File>((ok, fail) =>
(entry as FileSystemFileEntry).file(ok, fail)
);
out.push({ file, path: prefix + entry.name });
return;
}
const reader = (entry as FileSystemDirectoryEntry).createReader();
// readEntries answers in batches until an empty one.
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((ok, fail) =>
reader.readEntries(ok, fail)
);
if (!batch.length) break;
for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
}
};
for (const entry of entries) await walk(entry, "");
return out;
}
/** What an upload will make, guessed the way the server decides it. */
function describeUpload(files: Picked[]): {
kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md";
count: number;
folder: string;
} {
const real = files.filter((f) => !JUNK.test(f.path));
if (!real.length) return { kind: "none", count: 0, folder: "" };
if (real.length === 1 && isArchive(real[0]!.path))
return { kind: "archive", count: 0, folder: "" };
const roots = [
...new Set(
real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/"))
),
].sort((a, b) => a.length - b.length);
const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`);
const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
if (!top.length) {
return real.length === 1 && isText(real[0]!.path)
? { kind: "single-md", count: 1, folder: "" }
: { kind: "no-skill-md", count: 0, folder: "" };
}
if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" };
return { kind: "many", count: top.length, folder: "" };
}
function Tabs<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { id: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div role="tablist" className="flex rounded border border-border bg-surface-2 p-0.5">
{options.map((o) => (
<button
key={o.id}
type="button"
role="tab"
aria-selected={value === o.id}
onClick={() => onChange(o.id)}
className={`flex-1 rounded px-2 py-1 text-xs ${
value === o.id
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{o.label}
</button>
))}
</div>
);
}
/** The name field with the folder/command it becomes. */
function SkillNameField({
value,
onChange,
disabled,
required,
}: {
value: string;
onChange: (v: string) => void;
disabled?: boolean;
required?: boolean;
}) {
const { t } = useTranslation("artifacts");
const slug = skillSlug(value);
return (
<div className="space-y-0.5">
<label className={label}>
{t("skill.name")}
{required ? " *" : ""}
<input
className={`${field} mt-0.5`}
value={value}
disabled={disabled}
placeholder="release-notes"
onChange={(e) => onChange(e.target.value)}
/>
</label>
{value.trim() && (
<p className={hint} data-testid="skill-slug">
{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}
</p>
)}
</div>
);
}
// ── Skill ─────────────────────────────────────────────────────────────────
export function SkillAddPanel({
lanes,
onAdded,
onClose,
}: {
lanes: Lane[];
onAdded: () => void;
onClose: () => void;
}) {
const { t } = useTranslation("artifacts");
const [mode, setMode] = useState<"upload" | "write" | "existing">("upload");
const [busy, setBusy] = useState(false);
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
const [description, setDescription] = useState("");
const [descTouched, setDescTouched] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [files, setFiles] = useState<Picked[]>([]);
const [dragging, setDragging] = useState(false);
const [laneId, setLaneId] = useState<number | null>(null);
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
// `webkitdirectory` is not in React's typings.
useEffect(() => {
folderInput.current?.setAttribute("webkitdirectory", "");
}, [mode]);
useEffect(() => {
if (mode !== "existing") return;
api.artifacts
.skills(laneId ?? undefined)
.then((r) => setSkills(r.items))
.catch(() => setSkills([]));
}, [mode, laneId]);
const shape = useMemo(() => describeUpload(files), [files]);
// Fill the name and description from what was picked, unless typed.
useEffect(() => {
if (!files.length) return;
const md =
files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ??
(shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined);
const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
let live = true;
void (async () => {
const text = md ? await readText(md.file) : "";
if (!live) return;
const fmName = frontmatterField(text, "name");
const fmDesc = frontmatterField(text, "description");
if (!nameTouched) {
const guess =
shape.kind === "single-md"
? fmName || stem(md?.path ?? "")
: shape.kind === "one"
? fmName || shape.folder
: shape.kind === "archive"
? stem(files[0]!.path)
: "";
setName(guess);
}
if (!descTouched && fmDesc) setDescription(fmDesc);
})();
return () => {
live = false;
};
// Refill only when the pick changes, never on a typed name.
}, [files, shape]);
const pick = (list: FileList | null) => {
if (!list?.length) return;
setFiles(
Array.from(list).map((file) => ({
file,
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
}))
);
};
const run = async (fn: () => Promise<number>) => {
setBusy(true);
try {
const n = await fn();
toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const many = shape.kind === "many";
const total = files.reduce((n, f) => n + f.file.size, 0);
const meta = (
<>
<label className={label}>
{t("skill.description")}
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
disabled={many}
placeholder={t("skill.descriptionHint")}
onChange={(e) => {
setDescTouched(true);
setDescription(e.target.value);
}}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
disabled={many}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
</>
);
return (
<div className="space-y-3">
<Tabs
value={mode}
onChange={setMode}
options={[
{ id: "upload", label: t("skill.tab.upload") },
{ id: "write", label: t("skill.tab.write") },
{ id: "existing", label: t("skill.tab.existing") },
]}
/>
{mode === "upload" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
const r = await api.artifacts.uploadSkills(files, {
name: many ? undefined : name.trim(),
description: many ? undefined : description.trim(),
title: many ? undefined : title.trim(),
});
return r.items.length;
});
}}
>
<div
data-testid="skill-drop"
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void droppedFiles(e).then((list) => list.length && setFiles(list));
}}
className={`rounded-lg border border-dashed p-4 text-center ${
dragging ? "border-accent bg-accent/10" : "border-border"
}`}
>
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
<div className="mt-2 flex flex-wrap justify-center gap-2">
<button
type="button"
className="btn-ghost text-xs"
onClick={() => fileInput.current?.click()}
>
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => folderInput.current?.click()}
>
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
</button>
</div>
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="skill-files"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
<input
ref={folderInput}
type="file"
multiple
hidden
data-testid="skill-folder"
onChange={(e) => {
pick(e.target.files);
e.target.value = "";
}}
/>
</div>
{files.length > 0 && (
<div className="rounded border border-border bg-surface-2 p-2">
<div className="mb-1 flex items-center gap-2">
<p className="flex-1 text-xs text-fg-primary" data-testid="skill-shape">
{t(`skill.shape.${shape.kind}`, { count: shape.count })}
</p>
<span className={hint}>
{t("files", { count: files.length })} · {Math.ceil(total / 1024)} KB
</span>
<button
type="button"
aria-label={t("skill.clear")}
className="text-fg-muted hover:text-fg-primary"
onClick={() => {
setFiles([]);
setNameTouched(false);
setDescTouched(false);
}}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-28 overflow-auto font-mono text-[11px] text-fg-secondary">
{files.slice(0, 200).map((f) => (
<li
key={f.path}
className={`truncate ${JUNK.test(f.path) ? "line-through opacity-50" : ""}`}
>
{f.path}
</li>
))}
</ul>
</div>
)}
{many ? (
<p className={hint}>{t("skill.manyHint")}</p>
) : (
<SkillNameField
value={name}
onChange={(v) => {
setNameTouched(true);
setName(v);
}}
required={shape.kind === "single-md"}
/>
)}
{meta}
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!files.length ||
shape.kind === "no-skill-md" ||
(shape.kind === "single-md" && !skillSlug(name))
}
>
{busy ? t("skill.uploading") : t("form.save")}
</button>
</div>
</form>
)}
{mode === "write" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(async () => {
await api.artifacts.writeSkill({
name: name.trim(),
description: description.trim(),
content,
title: title.trim() || undefined,
});
return 1;
});
}}
>
<SkillNameField value={name} onChange={setName} required />
<label className={label}>
{t("skill.description")} *
<textarea
className={`${field} mt-0.5`}
rows={2}
value={description}
placeholder={t("skill.descriptionHint")}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className={label}>
{t("skill.content")} *
<textarea
className={`${field} mt-0.5 font-mono text-xs`}
rows={10}
value={content}
placeholder={t("skill.contentHint")}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={
busy ||
!skillSlug(name) ||
!content.trim() ||
(!description.trim() && !frontmatterField(content, "description"))
}
>
{t("form.save")}
</button>
</div>
</form>
)}
{mode === "existing" && (
<div className="space-y-2">
<select
className={field}
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t("form.pickWorkspace")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
{skills && skills.length === 0 && <p className={hint}>{t("form.noSkills")}</p>}
{skills && skills.length > 0 && (
<ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
</span>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(async () => {
await api.artifacts.addSkill({
name: s.name,
...(s.from === "workspace" && laneId ? { laneId } : {}),
title: title || undefined,
description: description || undefined,
});
return 1;
})
}
>
{t("form.save")}
</button>
</li>
))}
</ul>
)}
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
)}
</div>
);
}
// ── Plugin ────────────────────────────────────────────────────────────────
const MANUAL_CODES = new Set(["ENEEDLOGIN", "ESSHMARKET", "ENOTMARKET", "EMARKETFETCH"]);
export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onClose: () => void }) {
const { t } = useTranslation("artifacts");
const [link, setLink] = useState("");
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<MarketplaceInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [have, setHave] = useState<Set<string>>(new Set());
const [manual, setManual] = useState(false);
const [key, setKey] = useState("");
const [market, setMarket] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
api.artifacts
.list({ scope: "mine", kind: "plugin" })
.then((r) => setHave(new Set(r.items.map((i) => i.source ?? ""))))
.catch(() => setHave(new Set()));
}, []);
const read = async () => {
setBusy(true);
setError(null);
setInfo(null);
try {
const r = await api.artifacts.inspectMarket(link.trim());
setInfo(r);
setPicked(
new Set(
r.plugins
.filter((p) => !have.has(p.key))
.slice(0, 1)
.map((p) => p.key)
)
);
setMarket(r.market);
} catch (err) {
const e = err as Error & { code?: string };
setError(e.message);
if (MANUAL_CODES.has(e.code ?? "")) {
setManual(true);
setMarket(link.trim());
}
} finally {
setBusy(false);
}
};
const addPicked = async () => {
if (!info) return;
setBusy(true);
let n = 0;
try {
for (const p of info.plugins.filter((x) => picked.has(x.key))) {
await api.artifacts.addPlugin({
key: p.key,
market: info.market,
title: p.name,
description: p.description,
});
n++;
}
toast.success(t("plugin.addedMany", { count: n }));
onAdded();
onClose();
} catch (err) {
toast.error(err);
if (n) onAdded();
} finally {
setBusy(false);
}
};
const toggle = (k: string) =>
setPicked((s) => {
const next = new Set(s);
if (next.has(k)) next.delete(k);
else next.add(k);
return next;
});
return (
<div className="space-y-3">
<form
className="space-y-1"
onSubmit={(e) => {
e.preventDefault();
if (link.trim()) void read();
}}
>
<label className={label}>
{t("plugin.link")}
<div className="mt-0.5 flex gap-2">
<input
className={field}
value={link}
autoFocus
placeholder="https://git.example.com/org/claude-skills.git"
onChange={(e) => setLink(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !link.trim()}
>
{busy && !info ? t("plugin.reading") : t("plugin.read")}
</button>
</div>
</label>
<p className={hint}>{t("plugin.linkHint")}</p>
</form>
{error && (
<p
role="alert"
className="rounded border border-status-danger/40 px-2 py-1 text-xs text-status-danger"
>
{error}
</p>
)}
{info && (
<div className="space-y-2" data-testid="market-plugins">
<div>
<p className="text-sm font-medium text-fg-primary">
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
{info.name}
</p>
{info.description && <p className={hint}>{info.description}</p>}
</div>
{info.plugins.length === 0 ? (
<p className={hint}>{t("plugin.none")}</p>
) : (
<>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={picked.size === info.plugins.length}
onChange={(e) =>
setPicked(new Set(e.target.checked ? info.plugins.map((p) => p.key) : []))
}
/>
{t("plugin.all")}
</label>
<ul className="max-h-64 space-y-1 overflow-auto">
{info.plugins.map((p) => (
<li key={p.key}>
<label className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 hover:bg-surface-2">
<input
type="checkbox"
className="mt-0.5"
checked={picked.has(p.key)}
onChange={() => toggle(p.key)}
/>
<span className="min-w-0 flex-1">
<span className="text-xs font-medium text-fg-primary">{p.name}</span>
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">{p.key}</span>
{p.version && (
<span className="ml-1.5 text-[10px] text-fg-muted">v{p.version}</span>
)}
{have.has(p.key) && (
<span className="ml-1.5 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
{t("plugin.have")}
</span>
)}
{p.description && (
<span className="block text-[11px] text-fg-muted">{p.description}</span>
)}
</span>
</label>
</li>
))}
</ul>
<div className="flex justify-end">
<button
type="button"
className="btn-primary text-sm"
disabled={busy || picked.size === 0}
onClick={() => void addPicked()}
>
{t("plugin.addPicked", { count: picked.size })}
</button>
</div>
</>
)}
</div>
)}
<div className="border-t border-border-light pt-2">
<button
type="button"
className="text-xs text-accent hover:underline"
aria-expanded={manual}
onClick={() => setManual((v) => !v)}
>
{t("plugin.manual")}
</button>
{manual && (
<form
className="mt-2 space-y-2"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
try {
await api.artifacts.addPlugin({
key: key.trim(),
market: market.trim(),
title,
description,
});
toast.success(t("added"));
onAdded();
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<input
className={field}
placeholder="my-plugin@my-marketplace"
aria-label={t("form.pluginKey")}
value={key}
onChange={(e) => setKey(e.target.value)}
/>
<input
className={field}
placeholder="owner/repo · https://… · git@host:org/repo.git"
aria-label={t("form.market")}
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={busy || !key.trim() || !market.trim()}
>
{t("form.save")}
</button>
</div>
</form>
)}
</div>
</div>
);
}
// ── File upload ───────────────────────────────────────────────────────────
export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
const { t } = useTranslation("artifacts");
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const tooBig = !!file && file.size > 25 * 1024 * 1024;
return (
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
if (!file) return;
setBusy(true);
try {
await api.artifacts.uploadFile(file, { title: title.trim() || undefined });
toast.success(t("added"));
setFile(null);
setTitle("");
onAdded();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
>
<label className={label}>
{t("file.fromComputer")}
<input
type="file"
data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{tooBig && <p className="text-xs text-status-danger">{t("file.tooBig")}</p>}
<div className="flex gap-2">
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button
type="submit"
className="btn-primary shrink-0 text-sm"
disabled={busy || !file || tooBig}
>
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
</button>
</div>
</form>
);
}
+73 -3
View File
@@ -25,7 +25,10 @@
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
* of another tab, a tab (or a pane header) joins that tab instead
* (`joinGroup`: side by side, then under the right pane, then under the left
* one, up to four panes). A right-click on a tab
* one, up to four panes). A pane drag held over another tab for `SPRING_MS` shows that tab
* (spring-loaded, as a file manager's folder), so the pane can be dropped on
* any of its panes' edges; near either end of an overflowing bar a drag
* scrolls it. A right-click on a tab
* opens the same actions as a menu (`ContextMenu`). With tabs from every
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
* and `workspaceOf` names each tab's workspace before its own name. Settings
@@ -57,7 +60,14 @@ import { isSubmitEnter } from "../../lib/keys";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import {
PANE_DRAG_TYPE,
RunIcon,
paneDragSlot,
setDragChip,
startPaneDrag,
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import {
type PaneGroups,
@@ -155,6 +165,11 @@ interface Tab {
const URGENCY: RunState[] = ["awaiting", "error", "running"];
/** How long a pane drag rests on a tab before that tab is shown. */
export const SPRING_MS = 600;
/** Within this many px of an overflowing bar's end, a drag scrolls it. */
const EDGE_SCROLL_PX = 40;
/** The Settings tab's key among the tabs' (a group's key is its slots). */
const SETTINGS_KEY = "settings";
@@ -256,8 +271,59 @@ export function ConsoleTabs({
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
};
// A pane drag resting on a tab (its drawn index) shows it after SPRING_MS.
const spring = useRef<{ at: number; timer: ReturnType<typeof setTimeout> } | null>(null);
const stopSpring = () => {
if (spring.current) clearTimeout(spring.current.timer);
spring.current = null;
};
useEffect(() => stopSpring, []);
const hoveredIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
return els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left && x <= b.right;
});
};
const springAt = (e: DragEvent) => {
const slot = paneDragSlot();
const i = hoveredIndexAt(e.clientX);
const tab = tabs[i];
// Only a pane drag, onto a tab not on screen that does not hold it.
const target =
slot !== null &&
tab &&
!settings?.active &&
tab.index !== groups.active &&
!tab.slots.includes(slot)
? i
: null;
if ((spring.current?.at ?? null) === target) return;
stopSpring();
if (target === null || !tab) return;
const index = tab.index;
spring.current = {
at: target,
timer: setTimeout(() => {
spring.current = null;
if (paneDragSlot() === null) return;
onGroupsChange((g) =>
g.active === index || !g.groups[index] ? g : { ...g, active: index }
);
}, SPRING_MS),
};
};
// An overflowing bar has no scrollbar: near either end, a drag scrolls it.
const edgeScroll = (x: number) => {
const list = listRef.current;
if (!list || list.scrollWidth <= list.clientWidth) return;
const b = list.getBoundingClientRect();
if (x < b.left + EDGE_SCROLL_PX) list.scrollLeft -= 16;
else if (x > b.right - EDGE_SCROLL_PX) list.scrollLeft += 16;
};
const endDrag = () => {
dragged.current = [];
stopSpring();
setDropAt(null);
setJoinAt(null);
};
@@ -266,6 +332,8 @@ export function ConsoleTabs({
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
edgeScroll(e.clientX);
springAt(e);
const join = joinIndexAt(e.clientX);
const at = join === null ? dropIndexAt(e.clientX) : null;
setJoinAt((d) => (d === join ? d : join));
@@ -273,6 +341,7 @@ export function ConsoleTabs({
},
onDragLeave: (e: DragEvent) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
stopSpring();
setDropAt(null);
setJoinAt(null);
},
@@ -504,10 +573,11 @@ export function ConsoleTabs({
draggable
onDragStart={(e) => {
dragged.current = tab.slots;
if (single) startPaneDrag(e, single.slot ?? 0);
if (single) startPaneDrag(e, single.slot ?? 0, label(single));
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
setDragChip(e, tabLabel(tab));
}
}}
onContextMenu={(e) => tabCtx.open(e, tab)}
+62 -9
View File
@@ -14,7 +14,13 @@
* one tab's panes; `onChange` applies through `updateActive`, so a session
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
* shows its state dot, as its tab does.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). While a
* drag is on, the pane it carries is dimmed, the browser's ghost is a small
* chip with its name (`setDragChip`) instead of a whole header, and the drop
* preview names what the drop will do (`dropAction`). The dragged pane's
* header stays mounted even once the tab bar has switched tabs under the
* drag (`ConsoleTabs` spring-loads a hovered tab) — removing the drag's
* source node would lose its `dragend`. A
* right-click on a header offers the swap, a split with a new terminal and
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
* moves only the line and commits the ratio on release, so the layout is
@@ -69,11 +75,31 @@ export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
let dragSlot: number | null = null;
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
export function startPaneDrag(e: React.DragEvent, slot: number): void {
/** The slot a pane drag carries, or null (no pane drag is on). */
export const paneDragSlot = (): number | null => dragSlot;
/** The drag's ghost: a small chip naming what moves, not a snapshot of a
* whole pane header or tab. Drawn off screen for the browser to copy. */
export function setDragChip(e: React.DragEvent, label: string): void {
const dt = e.dataTransfer;
if (!label || typeof dt?.setDragImage !== "function") return;
const chip = document.createElement("div");
chip.textContent = label;
chip.className =
"fixed -top-96 left-0 max-w-[16rem] truncate rounded-md border border-accent/60 bg-surface-2 px-2.5 py-1 text-xs font-medium text-fg-primary shadow-lg";
document.body.appendChild(chip);
dt.setDragImage(chip, 12, 12);
// The browser copies it during dragstart; it may go right after.
window.setTimeout(() => chip.remove(), 0);
}
/** Starts dragging a pane or a tab (`slot`) onto the grid; `label` names it
* on the drag's chip. */
export function startPaneDrag(e: React.DragEvent, slot: number, label?: string): void {
dragSlot = slot;
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
if (label) setDragChip(e, label);
}
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
@@ -140,6 +166,14 @@ export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
}
/** What a drop does, as an i18n key (`lanes` namespace): the center swaps a
* pane already shown, or shows one from another tab in the target's place
* (the target gets a tab of its own); an edge places it on that side. */
export function dropAction(l: PaneLayout, src: number, zone: Zone): string {
if (zone !== "center") return `panes.drop.${zone}`;
return leaves(l.root).includes(src) ? "panes.drop.swap" : "panes.drop.replace";
}
/** A small anchored menu: a trigger button and a list that closes on a pick,
* an outside click or Escape. */
function PaneMenu({
@@ -398,6 +432,8 @@ export function PaneGrid({
const { t } = useTranslation("lanes");
const gridRef = useRef<HTMLDivElement>(null);
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
// The slot a pane drag carries (a header here, or a tab from the bar).
const [dragging, setDragging] = useState<number | null>(null);
const { panes, dividers } = layoutRects(layout.root);
const shown = leaves(layout.root);
const rectOf = new Map(panes.map((p) => [p.slot, p]));
@@ -416,6 +452,7 @@ export function PaneGrid({
const end = () => {
dragSlot = null;
setDrop(null);
setDragging(null);
};
window.addEventListener("dragend", end);
window.addEventListener("drop", end);
@@ -479,6 +516,7 @@ export function PaneGrid({
if (!isPaneDrag(e)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
if (dragSlot !== dragging) setDragging(dragSlot);
const z = zoneAt(e);
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
setDrop((d) =>
@@ -495,6 +533,7 @@ export function PaneGrid({
const z = zoneAt(e);
dragSlot = null;
setDrop(null);
setDragging(null);
if (!z || !Number.isInteger(src) || src === z.slot) return;
onChange((l) => applyDrop(l, src, z.slot, z.zone));
}}
@@ -515,6 +554,7 @@ export function PaneGrid({
const r = rectOf.get(slot);
const run = runOf(slot);
const focused = layout.focus === slot;
const moving = dragging === slot;
return (
<div
key={key}
@@ -522,17 +562,23 @@ export function PaneGrid({
data-focused={r && focused ? "true" : undefined}
// A menu opened in this pane stays inside it (`menuRoom`).
data-menu-bounds
className={r ? "absolute flex flex-col" : "hidden"}
data-dragging={moving ? "true" : undefined}
className={r ? `absolute flex flex-col${moving ? " opacity-40" : ""}` : "hidden"}
style={r ? pct(r) : undefined}
onPointerDownCapture={() => r && focus(slot)}
// Tabbing into a pane focuses it as a click does.
onFocusCapture={() => r && focus(slot)}
>
{multi && r && (
{/* The dragged pane's header stays while the drag lasts, even
hidden in another tab: it is the drag's source node. */}
{((multi && r) || moving) && (
<div
data-testid={`pane-header-${slot}`}
draggable
onDragStart={(e) => startPaneDrag(e, slot)}
onDragStart={(e) => {
startPaneDrag(e, slot, run ? label(run) : t("panes.empty"));
setDragging(slot);
}}
onContextMenu={(e) => paneCtx.open(e, slot)}
title={t("panes.drag")}
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
@@ -667,12 +713,19 @@ export function PaneGrid({
}
/>
))}
{preview && (
{preview && drop && (
<div
data-testid="pane-drop-preview"
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
className="pointer-events-none absolute z-20 flex items-center justify-center rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
style={pct(preview)}
/>
>
<span
data-testid="pane-drop-label"
className="max-w-[90%] truncate rounded-md bg-accent-solid px-2.5 py-1 text-xs font-medium text-white shadow-lg"
>
{t(dropAction(layout, drop.src, drop.zone))}
</span>
</div>
)}
</div>
);
@@ -0,0 +1,126 @@
/**
* @file ConsoleTabs.test.tsx
* @description The tab bar's spring-loaded tabs: a pane drag resting on
* another tab shows it after `SPRING_MS`, so the pane can be dropped on that
* tab's panes; moving off before then, or resting on the tab already holding
* the pane, shows nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { RunHandle } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { pane, split, type PaneGroups } from "../../../lib/paneLayout";
import { ConsoleTabs, SPRING_MS } from "../ConsoleTabs";
import { PANE_DRAG_TYPE, paneDragSlot } from "../PaneGrid";
vi.mock("../../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
},
}));
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
const run = (slot: number): RunHandle => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
cwd: "/w",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
tool: "shell",
});
// Tab 0: pane 0 alone (on screen); tab 1: panes 1 and 2.
const GROUPS: PaneGroups = {
groups: [
{ root: pane(0), focus: 0 },
{ root: split("row", pane(1), pane(2)), focus: 1 },
],
active: 0,
};
function setup() {
let groups = GROUPS;
const onGroupsChange = vi.fn((fn: (g: PaneGroups) => PaneGroups) => {
groups = fn(groups);
});
render(
<MemoryRouter>
<ConsoleTabs
lane={LANE}
tabs={[run(0), run(1), run(2)]}
groups={GROUPS}
onGroupsChange={onGroupsChange}
onOpen={vi.fn()}
onClosed={vi.fn()}
/>
</MemoryRouter>
);
// jsdom lays nothing out: tab i spans x 100i … 100i+100.
for (const el of document.querySelectorAll<HTMLElement>("[data-tab-index]")) {
const i = Number(el.dataset.tabIndex);
el.getBoundingClientRect = () =>
({
left: i * 100,
right: i * 100 + 100,
width: 100,
top: 0,
bottom: 40,
height: 40,
}) as DOMRect;
}
return { onGroupsChange, groups: () => groups };
}
const dataTransfer = () => ({
types: [PANE_DRAG_TYPE],
setData: vi.fn(),
getData: () => "",
effectAllowed: "",
dropEffect: "",
});
function drag(type: "dragStart" | "dragOver", el: Element, clientX = 0) {
const ev = createEvent[type](el, { dataTransfer: dataTransfer() });
Object.defineProperty(ev, "clientX", { value: clientX });
fireEvent(el, ev);
}
const tabEl = (key: string) => screen.getByTestId(`console-tab-${key}`).parentElement as Element;
describe("ConsoleTabs spring-loaded tabs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("a pane drag resting on another tab shows that tab", () => {
const { onGroupsChange, groups } = setup();
drag("dragStart", tabEl("0"));
expect(paneDragSlot()).toBe(0);
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS - 1));
expect(onGroupsChange).not.toHaveBeenCalled();
act(() => void vi.advanceTimersByTime(1));
expect(groups().active).toBe(1);
});
it("moving off the tab first, or resting on the pane's own tab, shows nothing", () => {
const { onGroupsChange } = setup();
drag("dragStart", tabEl("0"));
drag("dragOver", tabEl("1-2"), 150);
act(() => void vi.advanceTimersByTime(SPRING_MS / 2));
drag("dragOver", tabEl("0"), 50);
act(() => void vi.advanceTimersByTime(SPRING_MS * 2));
expect(onGroupsChange).not.toHaveBeenCalled();
});
});
@@ -2,12 +2,13 @@
* @file PaneGrid.test.ts
* @description The pane drop preview: it draws where the dragged pane will
* actually land (`dropPreview` over `applyDrop`), not half of the target's
* current box, and nothing for a drop that changes nothing.
* current box, and nothing for a drop that changes nothing; and the label
* naming what a drop does (`dropAction`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { dropPreview } from "../PaneGrid";
import { dropAction, dropPreview } from "../PaneGrid";
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
@@ -34,3 +35,15 @@ describe("dropPreview", () => {
expect(dropPreview(full, 5, 0, "left")).toBeNull();
});
});
describe("dropAction", () => {
it("an edge places the pane on that side", () => {
expect(dropAction(AB, 0, "left")).toBe("panes.drop.left");
expect(dropAction(AB, 2, "bottom")).toBe("panes.drop.bottom");
});
it("the center swaps a shown pane, or shows one from another tab in its place", () => {
expect(dropAction(AB, 0, "center")).toBe("panes.drop.swap");
expect(dropAction(AB, 2, "center")).toBe("panes.drop.replace");
});
});
@@ -2,7 +2,9 @@
* @file AccountSettings.tsx
* @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its display name
* 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; the
* sidebar shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST
@@ -11,9 +13,17 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { LogOut, RefreshCw } from "lucide-react";
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
@@ -21,6 +31,7 @@ import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { UserAvatar } from "../users/UserAvatar";
import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
@@ -42,8 +53,12 @@ export function AccountSettings() {
(r: MyAccount) => {
if (r.mode !== "server") return;
setAccount(r);
// The sidebar shows the display name: let it follow a save at once.
updateUser({ displayName: r.user.displayName, email: r.user.email });
// The sidebar shows the name and avatar: let them follow a save at once.
updateUser({
displayName: r.user.displayName,
email: r.user.email,
avatarAt: r.user.avatarAt ?? null,
});
},
[updateUser]
);
@@ -72,7 +87,7 @@ export function AccountSettings() {
return (
<div className="space-y-6">
<ProfileCard account={account} />
<ProfileCard account={account} onSaved={apply} />
<ProfileForm account={account} onSaved={apply} />
<PasswordForm />
<SignOutOthers logins={account.logins} onDone={load} />
@@ -80,19 +95,84 @@ export function AccountSettings() {
);
}
function ProfileCard({ account }: { account: ServerAccount }) {
/** The largest avatar the server takes (server/lib/avatars.js). */
const MAX_AVATAR_BYTES = 1024 * 1024;
function ProfileCard({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const { user } = account;
const shown = user.displayName || user.username;
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const run = async (op: () => Promise<MyAccount>, done: string) => {
setBusy(true);
try {
onSaved(await op());
toast.success(done);
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const pick = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again
if (!file) return;
if (file.size > MAX_AVATAR_BYTES) {
toast.error(t("account.avatar.tooBig"));
return;
}
void run(() => api.me.setAvatar(file), t("account.avatar.saved"));
};
return (
<section className="card p-5" data-testid="account-card">
<div className="flex items-center gap-4">
<span
aria-hidden
className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-lg font-semibold uppercase text-accent"
>
{shown.slice(0, 2)}
</span>
<div className="flex flex-shrink-0 flex-col items-center gap-1.5">
<UserAvatar name={shown} avatarAt={user.avatarAt} className="h-14 w-14 text-lg" />
<div className="flex items-center gap-0.5">
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => fileRef.current?.click()}
disabled={busy}
title={t("account.avatar.change")}
aria-label={t("account.avatar.change")}
data-testid="account-avatar-change"
>
<Camera className="h-3.5 w-3.5" />
</button>
{user.avatarAt && (
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => void run(() => api.me.removeAvatar(), t("account.avatar.removed"))}
disabled={busy}
title={t("account.avatar.remove")}
aria-label={t("account.avatar.remove")}
data-testid="account-avatar-remove"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="hidden"
onChange={pick}
data-testid="account-avatar-input"
/>
</div>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
<p className="truncate text-xs text-fg-muted">
@@ -0,0 +1,45 @@
/**
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
* admin's server-wide switch that allows or blocks installing a shared skill
* or plugin from the Hub into a user's own account (`GET/PUT
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
* or for anyone the server does not let manage it (`canManage`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type ArtifactSettings } from "../../lib/api";
import { toast } from "../../lib/toast";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ArtifactsHubSettings() {
const { t } = useTranslation("artifacts");
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
useEffect(() => {
api.artifacts
.settings()
.then(setSettings)
.catch(() => setSettings(null));
}, []);
if (!settings?.serverMode || !settings.canManage) return null;
return (
<SettingsSection id="artifacts-hub" title={t("title")}>
<SettingsSwitchRow
label={t("installsSwitch")}
description={t("installsSwitchHint")}
checked={settings.installsEnabled}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
} catch (err) {
toast.error(err);
}
}}
/>
</SettingsSection>
);
}
@@ -2,8 +2,8 @@
* @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new
* passwords differ, sends the current + new password, and asks before signing
* the other devices out.
* passwords differ, sends the current + new password, asks before signing
* the other devices out, and uploads/removes the avatar.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,6 +16,9 @@ const me = vi.hoisted(() => ({
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
setAvatar: vi.fn(),
removeAvatar: vi.fn(),
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -44,7 +47,7 @@ const account = {
};
beforeEach(() => {
for (const fn of Object.values(me)) fn.mockReset();
for (const fn of Object.values(me)) if ("mockReset" in fn) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
@@ -53,6 +56,26 @@ beforeEach(() => {
});
describe("AccountSettings", () => {
it("uploads a picked avatar and removes it again", async () => {
const ue = userEvent.setup();
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
me.setAvatar.mockResolvedValue(withAvatar);
me.removeAvatar.mockResolvedValue(account);
render(<AccountSettings />);
await screen.findByTestId("account-card");
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
await ue.upload(screen.getByTestId("account-avatar-input"), file);
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
"/api/me/avatar?v=2026-10-14T00:00:00Z"
);
await ue.click(screen.getByTestId("account-avatar-remove"));
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
});
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
@@ -0,0 +1,46 @@
/**
* @file Tests for ArtifactsHubSettings — the admin's Hub install switch in
* Settings → AI tools: hidden in local mode and for a non-manager, and a
* toggle saves through PUT /api/artifacts/settings.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "../../../i18n";
import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
}));
const settings = vi.mocked(api.artifacts.settings);
const setInstalls = vi.mocked(api.artifacts.setInstallsEnabled);
const base = { serverMode: true, canShare: true, installsEnabled: true, canManage: true };
describe("ArtifactsHubSettings", () => {
beforeEach(() => vi.clearAllMocks());
it("lets an admin turn Hub installs off", async () => {
settings.mockResolvedValue(base);
setInstalls.mockResolvedValue({ installsEnabled: false });
render(<ArtifactsHubSettings />);
const sw = await screen.findByRole("switch", { name: "Allow installing from the Hub" });
fireEvent.click(sw);
await waitFor(() => expect(setInstalls).toHaveBeenCalledWith(false));
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
});
it("renders nothing for a non-manager or in local mode", async () => {
settings.mockResolvedValue({ ...base, canManage: false });
const { container, unmount } = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
unmount();
settings.mockResolvedValue({ ...base, serverMode: false });
const r = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
expect(r.container).toBeEmptyDOMElement();
});
});
@@ -2,7 +2,7 @@
* @file AccountPoolCard.tsx
* @description The Users page's account pool card (`GET /api/accounts`): the
* Claude and Codex accounts every user's new sessions are spread over, each
* with its live panes, $ in the last 5 h / 7 d and quota — so nobody signs in
* 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
@@ -23,7 +23,7 @@ import {
type PoolProfile,
type PoolTool,
} from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
@@ -40,6 +40,8 @@ type Row = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -175,9 +177,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
</td>
<td className="px-5 py-2.5 text-right tabular-nums">
<div className="text-sm text-fg-primary">{fmtCost(r.spend5h)}</div>
<div className="text-sm text-fg-primary">
{fmtCost(r.spend5h)}
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
</div>
<div className="text-[11px] text-fg-muted">
{t("pool.spend7d", { cost: fmtCost(r.spend7d) })}
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</div>
</td>
{isAdmin && (
@@ -0,0 +1,59 @@
/**
* @file UserAvatar.tsx
* @description An account's round picture: the signed-in account's own
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups and Settings → Account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { api } from "../../lib/api";
export function UserAvatar({
name,
avatarAt,
userId,
letters = 2,
className = "h-7 w-7 text-[11px]",
initialsClassName = "bg-accent/15 text-accent",
}: {
name: string;
avatarAt?: string | null;
/** Another account's (staff only); omitted = the signed-in account's own. */
userId?: number;
/** How many letters of the name stand in for a missing picture. */
letters?: number;
/** Size and font size: the parts that differ between call sites. */
className?: string;
/** The initials' colours. */
initialsClassName?: string;
}) {
const url = userId != null ? api.users.avatarUrl(userId, avatarAt) : api.me.avatarUrl(avatarAt);
// A broken image falls back to the initials, per URL.
const [failed, setFailed] = useState<string | null>(null);
const base = `flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full ${className}`;
if (url && failed !== url) {
return (
<img
src={url}
alt=""
aria-hidden
draggable={false}
onError={() => setFailed(url)}
className={`${base} object-cover`}
data-testid="user-avatar-img"
/>
);
}
return (
<span
aria-hidden
className={`${base} ${initialsClassName} font-semibold uppercase`}
data-testid="user-avatar-initials"
>
{name.slice(0, letters)}
</span>
);
}
@@ -40,6 +40,8 @@ const POOL: AccountPool = {
livePanes: 2,
spend5h: 1.5,
spend7d: 12,
tokens5h: 250_000,
tokens7d: 3_400_000,
quotaPercent: 42,
full: false,
},
@@ -52,6 +54,8 @@ const POOL: AccountPool = {
livePanes: 0,
spend5h: 0,
spend7d: 0,
tokens5h: 0,
tokens7d: 0,
quotaPercent: null,
full: false,
},
@@ -82,6 +86,8 @@ describe("AccountPoolCard", () => {
expect(await screen.findByText("team-1")).toBeInTheDocument();
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.getByText("42%")).toBeInTheDocument();
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
+39 -2
View File
@@ -35,7 +35,7 @@
"installs": "{{count}} installs",
"installed": "Installed to {{path}}",
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
"rename": "Rename",
"rename": "Edit",
"delete": "Delete",
"deleteTitle": "Delete “{{title}}”?",
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
@@ -71,5 +71,42 @@
"files": "{{count}} files",
"close": "Close",
"fromSession": "From a session",
"form.saveEdit": "Save"
"form.saveEdit": "Save",
"skill.tab.upload": "Upload files",
"skill.tab.write": "Write new",
"skill.tab.existing": "From my skills",
"skill.name": "Skill name",
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
"skill.description": "Description — when Claude should use this skill",
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
"skill.content": "Instructions (the body of SKILL.md)",
"skill.contentHint": "Write the steps Claude should follow. You can also paste a whole SKILL.md, frontmatter included.",
"skill.dropHint": "Drop a SKILL.md, any Markdown file, a skill folder, several files or a .zip/.skill here",
"skill.pickFiles": "Choose files",
"skill.pickFolder": "Choose folder",
"skill.clear": "Clear",
"skill.uploading": "Uploading…",
"skill.shape.none": "Nothing to upload",
"skill.shape.single-md": "This Markdown file becomes the skill's SKILL.md",
"skill.shape.archive": "An archive — it is unpacked; each folder with a SKILL.md becomes a skill",
"skill.shape.one": "One skill folder",
"skill.shape.many": "{{count}} skills — each is added on its own, named by its folder",
"skill.shape.no-skill-md": "No SKILL.md here — add one, or upload a single Markdown file",
"skill.manyHint": "Several skills: each keeps its own folder name and the description in its SKILL.md.",
"skill.addedMany": "Added {{count}} skills",
"plugin.link": "Marketplace link",
"plugin.linkHint": "A git repository with .claude-plugin/marketplace.json (https link, the forge's web link, or owner/repo for GitHub), or a link to a marketplace.json.",
"plugin.read": "Read plugins",
"plugin.reading": "Reading…",
"plugin.none": "This marketplace lists no plugins.",
"plugin.all": "Select all",
"plugin.have": "already added",
"plugin.addPicked": "Add {{count}} plugin(s)",
"plugin.addedMany": "Added {{count}} plugin(s)",
"plugin.manual": "Type name@marketplace by hand (ssh link, private repository…)",
"file.fromComputer": "From your computer (≤ 25 MB)",
"file.upload": "Upload",
"file.tooBig": "The file is larger than 25 MB.",
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
}
+7 -1
View File
@@ -436,7 +436,13 @@
"tabs.settings": "Settings",
"tabs.closeSettings": "Close Settings",
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap; hold it over another tab to open that tab",
"panes.drop.left": "Place on the left",
"panes.drop.right": "Place on the right",
"panes.drop.top": "Place on top",
"panes.drop.bottom": "Place below",
"panes.drop.swap": "Swap places",
"panes.drop.replace": "Show here — this pane moves to its own tab",
"panes.pick": "Show another session in this pane",
"panes.empty": "No session",
"panes.split.right": "Split right",
+7
View File
@@ -1267,6 +1267,13 @@
"appliesTo": "Applies to"
},
"account": {
"avatar": {
"change": "Change picture",
"remove": "Remove picture",
"saved": "Picture updated",
"removed": "Picture removed",
"tooBig": "The picture must be at most 1 MB"
},
"loadFailed": "Could not load your account: {{message}}",
"memberSince": "Member since",
"workspaces": "Workspaces",
+9 -1
View File
@@ -7,6 +7,11 @@
"sessions": "Sessions",
"last7d": "7 days",
"last30d": "30 days",
"storage": "Storage",
"storageHint": "{{files}} files · measured {{at}}",
"storagePartial": "at least — part of the folder could not be read in time",
"storageNone": "No home folder of their own",
"storageMeasuring": "Measuring…",
"disabled": "disabled",
"you": "you",
"tokens": "{{n}} tokens",
@@ -61,6 +66,8 @@
"deleted": "{{name}} was deleted."
},
"errors": {
"EBADIMAGE": "Only a PNG, JPEG, GIF or WebP picture",
"ETOOBIG": "The picture must be at most 1 MB",
"EREAUTH": "Your password is wrong.",
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
"ESELF": "You cannot change your own role or disable yourself.",
@@ -128,7 +135,8 @@
"livePanes": "Live",
"quota": "Quota",
"spend": "5 hours",
"spend7d": "{{cost}} in 7 days",
"spend7d": "{{cost}} · {{tokens}} tokens in 7 days",
"tokens": "{{tokens}} tokens",
"added": "added {{at}}",
"expired": "expired",
"full": "full",
+39 -2
View File
@@ -35,7 +35,7 @@
"installs": "{{count}} lượt cài",
"installed": "Đã cài vào {{path}}",
"installedPlugin": "Đã cài {{name}} — mở phiên mới (hoặc /reload-plugins) để dùng",
"rename": "Đổi tên",
"rename": "Sửa",
"delete": "Xoá",
"deleteTitle": "Xoá “{{title}}”?",
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
@@ -71,5 +71,42 @@
"files": "{{count}} file",
"close": "Đóng",
"fromSession": "Từ một phiên",
"form.saveEdit": "Lưu"
"form.saveEdit": "Lưu",
"skill.tab.upload": "Tải file lên",
"skill.tab.write": "Viết mới",
"skill.tab.existing": "Từ skill có sẵn",
"skill.name": "Tên skill",
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
"skill.content": "Hướng dẫn (nội dung SKILL.md)",
"skill.contentHint": "Viết các bước Claude cần làm. Có thể dán nguyên một file SKILL.md, kể cả frontmatter.",
"skill.dropHint": "Kéo thả vào đây: SKILL.md, một file Markdown bất kỳ, thư mục skill, nhiều file hoặc file .zip/.skill",
"skill.pickFiles": "Chọn file",
"skill.pickFolder": "Chọn thư mục",
"skill.clear": "Bỏ chọn",
"skill.uploading": "Đang tải lên…",
"skill.shape.none": "Chưa có gì để tải lên",
"skill.shape.single-md": "File Markdown này sẽ thành SKILL.md của skill",
"skill.shape.archive": "File nén — sẽ được giải nén; mỗi thư mục có SKILL.md thành một skill",
"skill.shape.one": "Một thư mục skill",
"skill.shape.many": "{{count}} skill — mỗi skill được thêm riêng, đặt tên theo thư mục",
"skill.shape.no-skill-md": "Không có SKILL.md — thêm một file SKILL.md, hoặc chỉ tải lên một file Markdown",
"skill.manyHint": "Nhiều skill: mỗi skill giữ tên thư mục và mô tả trong SKILL.md của nó.",
"skill.addedMany": "Đã thêm {{count}} skill",
"plugin.link": "Link marketplace",
"plugin.linkHint": "Repo git có .claude-plugin/marketplace.json (link https, link trang web của repo, hoặc owner/repo với GitHub), hoặc link tới file marketplace.json.",
"plugin.read": "Đọc danh sách plugin",
"plugin.reading": "Đang đọc…",
"plugin.none": "Marketplace này không có plugin nào.",
"plugin.all": "Chọn tất cả",
"plugin.have": "đã thêm",
"plugin.addPicked": "Thêm {{count}} plugin",
"plugin.addedMany": "Đã thêm {{count}} plugin",
"plugin.manual": "Nhập tay name@marketplace (link ssh, repo riêng tư…)",
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
"file.upload": "Tải lên",
"file.tooBig": "File lớn hơn 25 MB.",
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi"
}
+7 -1
View File
@@ -436,7 +436,13 @@
"tabs.settings": "Cài đặt",
"tabs.closeSettings": "Đóng Cài đặt",
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ; giữ trên một tab khác để mở tab đó",
"panes.drop.left": "Đặt bên trái",
"panes.drop.right": "Đặt bên phải",
"panes.drop.top": "Đặt phía trên",
"panes.drop.bottom": "Đặt phía dưới",
"panes.drop.swap": "Đổi chỗ",
"panes.drop.replace": "Hiện ở đây — pane này sang tab riêng",
"panes.pick": "Hiện session khác trong pane này",
"panes.empty": "Không có session",
"panes.split.right": "Tách sang phải",
+7
View File
@@ -1267,6 +1267,13 @@
"appliesTo": "Áp dụng cho"
},
"account": {
"avatar": {
"change": "Đổi ảnh đại diện",
"remove": "Xoá ảnh đại diện",
"saved": "Đã cập nhật ảnh đại diện",
"removed": "Đã xoá ảnh đại diện",
"tooBig": "Ảnh tối đa 1 MB"
},
"loadFailed": "Không tải được tài khoản: {{message}}",
"memberSince": "Tham gia từ",
"workspaces": "Workspace",
+9 -1
View File
@@ -7,6 +7,11 @@
"sessions": "Phiên",
"last7d": "7 ngày",
"last30d": "30 ngày",
"storage": "Dung lượng",
"storageHint": "{{files}} tệp · đo lúc {{at}}",
"storagePartial": "ít nhất — một phần thư mục chưa đọc được kịp",
"storageNone": "Không có thư mục home riêng",
"storageMeasuring": "Đang đo…",
"disabled": "đã khoá",
"you": "bạn",
"tokens": "{{n}} token",
@@ -61,6 +66,8 @@
"deleted": "Đã xoá {{name}}."
},
"errors": {
"EBADIMAGE": "Chỉ nhận ảnh PNG, JPEG, GIF hoặc WebP",
"ETOOBIG": "Ảnh tối đa 1 MB",
"EREAUTH": "Mật khẩu của bạn không đúng.",
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
@@ -128,7 +135,8 @@
"livePanes": "Đang chạy",
"quota": "Quota",
"spend": "5 giờ",
"spend7d": "{{cost}} trong 7 ngày",
"spend7d": "{{cost}} · {{tokens}} tokens trong 7 ngày",
"tokens": "{{tokens}} tokens",
"added": "thêm lúc {{at}}",
"expired": "hết hạn",
"full": "đầy",
+117 -5
View File
@@ -544,6 +544,14 @@ export type ServerUserUsage = AuthUser & {
logins?: number;
lastLoginAt?: string | null;
};
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
export type UserStorage = {
bytes: number;
files: number;
/** The walk timed out or met an unreadable folder: a lower bound. */
partial: boolean;
measuredAt: string;
} | null;
/** GET /api/users/:id — one account plus the workspaces it owns. */
export type UserDetail = {
user: AuthUser & { disabled: boolean; logins?: number; lastLoginAt?: string | null };
@@ -585,6 +593,8 @@ export type PoolAccount = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -596,6 +606,8 @@ export type CodexPoolAccount = {
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
};
@@ -644,6 +656,8 @@ export type AuthUser = {
/** Optional profile fields — absent from an older server's answers. */
displayName?: string | null;
email?: string | null;
/** When the account's avatar was last set (null = none) — versions its URL. */
avatarAt?: string | null;
createdAt?: string;
};
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
@@ -679,6 +693,10 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
...(token ? { "x-dashboard-token": token } : {}),
...((options?.headers as Record<string, string>) || {}),
};
// A multipart upload: the browser writes Content-Type with its boundary.
if (typeof FormData !== "undefined" && options?.body instanceof FormData) {
delete headers["Content-Type"];
}
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (!res.ok) {
// Try to recover a structured error message from the JSON body; if the body
@@ -857,14 +875,35 @@ export const api = {
}),
/** POST /api/me/sign-out-others — end every login of this account but this one. */
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
avatarUrl: (avatarAt?: string | null) =>
avatarAt ? `${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
setAvatar: (file: Blob) =>
request<MyAccount>("/me/avatar", {
method: "PUT",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** DELETE /api/me/avatar — back to the initials. */
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions and admins'
* account changes, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
/** GET /api/users/storage — each user's home size, measured in the background
* (`measuring` = more numbers are coming); `refresh` re-measures every home. */
storage: (refresh = false) =>
request<{ storage: Record<string, UserStorage>; measuring: boolean }>(
`/users/storage${refresh ? "?refresh=1" : ""}`
),
// Account changes (admin only). Each carries the admin's OWN password
// again (`currentPassword`): the server refuses without it (EREAUTH).
/** POST /api/users — a new account. */
@@ -2683,18 +2722,75 @@ export const api = {
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
),
addPage: (body: { url: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/page", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/page", {
method: "POST",
body: JSON.stringify(body),
}),
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
request<{ item: Artifact }>("/artifacts/file", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/file", {
method: "POST",
body: JSON.stringify(body),
}),
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/skill", { method: "POST", body: JSON.stringify(body) }),
request<{ item: Artifact }>("/artifacts/skill", {
method: "POST",
body: JSON.stringify(body),
}),
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/plugin", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: { title?: string; description?: string; shared?: boolean }) =>
request<{ item: Artifact }>(`/artifacts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
/** Read a marketplace (git link, owner/repo or marketplace.json link) and list its plugins. */
inspectMarket: (market: string) =>
request<MarketplaceInfo>("/artifacts/plugin/inspect", {
method: "POST",
body: JSON.stringify({ market }),
}),
/** A skill written in the dialog: SKILL.md gets `name`/`description` in its frontmatter. */
writeSkill: (body: { name: string; description: string; content: string; title?: string }) =>
request<{ item: Artifact; items: Artifact[] }>("/artifacts/skill/new", {
method: "POST",
body: JSON.stringify(body),
}),
/** Skills from files: one Markdown file, a folder, several files, a .zip/.skill, or
* several skills at once (one artifact each). `path` keeps a folder's layout. */
uploadSkills: (
files: { file: File; path: string }[],
fields: { name?: string; title?: string; description?: string } = {}
) => {
const form = new FormData();
for (const f of files) form.append("files", f.file, f.file.name);
form.append("paths", JSON.stringify(files.map((f) => f.path)));
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
method: "POST",
body: form,
});
},
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
const form = new FormData();
form.append("files", file, file.name);
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
},
update: (
id: number,
body: {
title?: string;
description?: string;
shared?: boolean;
/** plugin only: `name@marketplace`. */
source?: string;
/** plugin only: the marketplace source. */
market?: string;
}
) =>
request<{ item: Artifact }>(`/artifacts/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
@@ -3929,6 +4025,22 @@ export interface Artifact {
updatedAt: string;
}
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
export interface MarketplaceInfo {
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
market: string;
name: string;
description: string;
plugins: {
name: string;
/** `name@marketplace`, what `claude plugin install` takes. */
key: string;
description: string;
version: string;
category: string;
}[];
}
export interface ArtifactCandidate {
path: string;
sessionId: string;
+9
View File
@@ -251,6 +251,15 @@ export const SETTINGS_INDEX: {
descKey: "aiTools.description",
keywords: ["install", "update", "default"],
},
{
section: "agents",
titleKey: "artifacts:installsSwitch",
descKey: "artifacts:installsSwitchHint",
keywords: ["artifacts", "hub", "skill", "plugin", "cài đặt"],
to: "#artifacts-hub",
adminOnly: true,
serverOnly: true,
},
{ section: "agents/claude-code", titleKey: "hooks.title" },
{ section: "agents/claude-code", titleKey: "claudeHome.title" },
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.skills", to: "?tab=skills" },
+2
View File
@@ -2673,6 +2673,8 @@ export interface Lane {
owner_name?: string | null;
/** The owner's display name (`users.display_name`); null when none is set. */
owner_display_name?: string | null;
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
owner_avatar_at?: string | null;
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
+58 -125
View File
@@ -2,11 +2,14 @@
* @file Artifacts.tsx
* @description `/artifacts` — the Artifacts hub (`/api/artifacts`,
* server/lib/artifacts.js): claude.ai pages sessions published, pinned
* workspace files, skills and plugins. **My library** lists the caller's
* workspace files (or one uploaded from the computer), skills (picked,
* uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and
* plugins (read from a marketplace link, or typed). **My library** lists the caller's
* own; in server mode the **Hub** tab lists what every user shared, and a
* shared skill or plugin is installed into the viewer's own account from
* there (after a confirmation that lists what will be written or run). An
* admin's switch turns Hub installs off for everyone. Previews of a file are
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
* installs off for everyone. Previews of a file are
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
* shared HTML file carries runs on the dashboard's origin.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -40,7 +43,11 @@ import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog";
import { Switch } from "../components/Switch";
import {
FileUploadPanel,
PluginAddPanel,
SkillAddPanel,
} from "../components/artifacts/ArtifactAddPanels";
const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const;
const KINDS: ArtifactKind[] = ["page", "file", "skill", "plugin"];
@@ -176,26 +183,6 @@ export function Artifacts() {
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
</div>
<div className="flex items-center gap-3">
{server && settings?.canManage && (
<label
className="flex items-center gap-2 text-xs text-fg-secondary"
title={t("installsSwitchHint")}
>
<Switch
checked={settings.installsEnabled}
label={t("installsSwitch")}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
} catch (err) {
toast.error(err);
}
}}
/>
{t("installsSwitch")}
</label>
)}
<div className="relative">
<button
type="button"
@@ -301,16 +288,7 @@ export function Artifacts() {
</ul>
)}
{adding && (
<AddDialog
kind={adding}
onClose={() => setAdding(null)}
onAdded={() => {
toast.success(t("added"));
load();
}}
/>
)}
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
{renaming && (
<RenameDialog
@@ -372,9 +350,7 @@ function ArtifactCard({
</span>
)}
</div>
{a.description && (
<p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>
)}
{a.description && <p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>}
<p className="text-[11px] text-fg-muted">
{server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""}
{new Date(a.createdAt).toLocaleString()}
@@ -495,6 +471,9 @@ function RenameDialog({
const { t } = useTranslation("artifacts");
const [title, setTitle] = useState(item.title);
const [description, setDescription] = useState(item.description);
const [source, setSource] = useState(item.source ?? "");
const [market, setMarket] = useState(item.market ?? "");
const plugin = item.kind === "plugin";
const field =
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
return (
@@ -504,7 +483,12 @@ function RenameDialog({
onSubmit={async (e) => {
e.preventDefault();
try {
await api.artifacts.update(item.id, { title, description });
await api.artifacts.update(item.id, {
title,
description,
...(plugin && source.trim() !== item.source ? { source: source.trim() } : {}),
...(plugin && market.trim() !== item.market ? { market: market.trim() } : {}),
});
onSaved();
} catch (err) {
toast.error(err);
@@ -525,8 +509,30 @@ function RenameDialog({
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{plugin && (
<>
<input
className={field}
aria-label={t("form.pluginKey")}
placeholder="my-plugin@my-marketplace"
value={source}
onChange={(e) => setSource(e.target.value)}
/>
<input
className={field}
aria-label={t("form.market")}
placeholder="owner/repo · https://…"
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
</>
)}
<div className="flex justify-end">
<button type="submit" className="btn-primary text-sm" disabled={!title.trim()}>
<button
type="submit"
className="btn-primary text-sm"
disabled={!title.trim() || (plugin && (!source.trim() || !market.trim()))}
>
{t("form.saveEdit")}
</button>
</div>
@@ -602,14 +608,9 @@ function AddDialog({
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [url, setUrl] = useState("");
const [key, setKey] = useState("");
const [market, setMarket] = useState("");
const [lanes, setLanes] = useState<Lane[]>([]);
const [laneId, setLaneId] = useState<number | null>(null);
const [candidates, setCandidates] = useState<ArtifactCandidate[] | null>(null);
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(
null
);
useEffect(() => {
if (kind !== "file" && kind !== "skill") return;
@@ -631,18 +632,13 @@ function AddDialog({
toast.error(err);
});
}
if (kind === "skill") {
api.artifacts
.skills(laneId ?? undefined)
.then((r) => setSkills(r.items))
.catch(() => setSkills([]));
}
}, [kind, laneId]);
const run = async (fn: () => Promise<unknown>, close = true) => {
setBusy(true);
try {
await fn();
toast.success(t("added"));
onAdded();
if (close) onClose();
} catch (err) {
@@ -719,6 +715,10 @@ function AddDialog({
{kind === "file" && (
<>
<FileUploadPanel onAdded={onAdded} />
<p className="border-t border-border-light pt-2 text-[11px] font-medium text-fg-secondary">
{t("file.fromWorkspace")}
</p>
{workspacePicker}
{laneId && candidates === null && (
<p className="text-xs text-fg-muted">{t("loading")}</p>
@@ -730,7 +730,10 @@ function AddDialog({
<ul className="max-h-80 space-y-0.5 overflow-auto">
{candidates.map((c) => (
<li key={c.path} className="flex items-center gap-2 text-xs">
<span className="min-w-0 flex-1 truncate font-mono text-fg-secondary" title={c.path}>
<span
className="min-w-0 flex-1 truncate font-mono text-fg-secondary"
title={c.path}
>
{c.path}
</span>
{!c.exists ? (
@@ -750,7 +753,9 @@ function AddDialog({
sessionId: c.sessionId,
});
setCandidates((list) =>
(list ?? []).map((x) => (x.path === c.path ? { ...x, pinned: true } : x))
(list ?? []).map((x) =>
x.path === c.path ? { ...x, pinned: true } : x
)
);
}, false)
}
@@ -765,81 +770,9 @@ function AddDialog({
</>
)}
{kind === "skill" && (
<>
{workspacePicker}
{skills && skills.length === 0 && (
<p className="text-xs text-fg-muted">{t("form.noSkills")}</p>
)}
{skills && skills.length > 0 && (
<ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
</span>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(() =>
api.artifacts.addSkill({
name: s.name,
...(s.from === "workspace" && laneId ? { laneId } : {}),
title: title || undefined,
description: description || undefined,
})
)
}
>
{t("form.save")}
</button>
</li>
))}
</ul>
)}
{meta}
</>
)}
{kind === "skill" && <SkillAddPanel lanes={lanes} onAdded={onAdded} onClose={onClose} />}
{kind === "plugin" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(() => api.artifacts.addPlugin({ key, market, title, description }));
}}
>
<input
className={field}
placeholder="my-plugin@my-marketplace"
aria-label={t("form.pluginKey")}
value={key}
autoFocus
onChange={(e) => setKey(e.target.value)}
/>
<input
className={field}
placeholder="owner/repo"
aria-label={t("form.market")}
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
{meta}
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={busy || !key.trim() || !market.trim()}
>
{t("form.save")}
</button>
</div>
</form>
)}
{kind === "plugin" && <PluginAddPanel onAdded={onAdded} onClose={onClose} />}
</div>
</Modal>
);
+3
View File
@@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { useAiTools } from "../hooks/useAiTools";
@@ -292,6 +293,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<div className="space-y-6">
<AgentInstructionsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
</div>
),
},
+81 -10
View File
@@ -2,8 +2,10 @@
* @file Users.tsx
* @description The Users page (`/users`, a sidebar item), server mode only and
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
* 30 days (`GET /api/users`) and how much disk its home folder takes
* (`GET /api/users/storage`, measured in the background — polled while the
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
* as one change), and the Claude/Codex account pool every user's sessions run
@@ -30,8 +32,14 @@ import {
UserPlus,
Users as UsersIcon,
} from "lucide-react";
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
import { fmt, fmtCost, formatDateTime } from "../lib/format";
import {
api,
type AuditEntry,
type AuthUser,
type ServerUserUsage,
type UserStorage,
} from "../lib/api";
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
import { toast } from "../lib/toast";
import { isSubmitEnter } from "../lib/keys";
import { useSpin } from "../hooks/useSpin";
@@ -56,10 +64,25 @@ export function Users() {
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
const [measuring, setMeasuring] = useState(false);
const loadStorage = useCallback(
(remeasure = false) =>
api.users
.storage(remeasure)
.then((r) => {
setStorage(r.storage);
setMeasuring(r.measuring);
})
.catch(() => setMeasuring(false)),
[]
);
const load = useCallback(
() =>
(remeasure = false) =>
Promise.all([
loadStorage(remeasure),
api.users
.list()
.then((r) => {
@@ -72,14 +95,23 @@ export function Users() {
.then((r) => setAudit(r.entries))
.catch(() => setAudit([])),
]),
[]
[loadStorage]
);
const [spinning, refresh] = useSpin(load);
// The Refresh button also re-measures every home; a plain load reuses the
// server's cached numbers.
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load]));
useEffect(() => {
if (me && me.role !== "developer") void load();
}, [me, load]);
// Homes are measured in the background: ask again until every number is in.
useEffect(() => {
if (!measuring) return;
const id = setTimeout(() => void loadStorage(), 4000);
return () => clearTimeout(id);
}, [measuring, storage, loadStorage]);
// Local mode, or a developer: there is no Users page for them.
if (!me || me.role === "developer") return <Navigate to="/" replace />;
@@ -168,7 +200,7 @@ export function Users() {
)}
<div className="card overflow-x-auto">
<table className="w-full min-w-[760px]" data-testid="users-table">
<table className="w-full min-w-[860px]" data-testid="users-table">
<thead>
<tr className="border-b border-border text-left">
<th className={th}>{t("name")}</th>
@@ -177,6 +209,7 @@ export function Users() {
<th className={`${th} text-right`}>{t("sessions")}</th>
<th className={`${th} text-right`}>{t("last7d")}</th>
<th className={`${th} text-right`}>{t("last30d")}</th>
<th className={`${th} text-right`}>{t("storage")}</th>
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
</tr>
</thead>
@@ -186,8 +219,8 @@ export function Users() {
Array.from({ length: 4 }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={isAdmin ? 7 : 6}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
columns={isAdmin ? 8 : 7}
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]}
/>
))}
{users?.map((u) => {
@@ -244,6 +277,10 @@ export function Users() {
</td>
<UsageCell usage={u.last7d} />
<UsageCell usage={u.last30d} />
<StorageCell
storage={storage ? (storage[u.id] ?? null) : undefined}
measuring={measuring}
/>
{isAdmin && (
<td className="px-5 py-2 text-right whitespace-nowrap">
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
@@ -341,6 +378,40 @@ function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
);
}
/**
* A user's home folder size (`undefined` = not loaded yet). `null` while the
* server is still measuring reads "Measuring…", otherwise "—" (no home of
* their own).
*/
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) {
const { t } = useTranslation("users");
if (!storage) {
const pending = storage === undefined || measuring;
return (
<td
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted"
title={pending ? undefined : t("storageNone")}
>
{pending ? t("storageMeasuring") : "—"}
</td>
);
}
return (
<td
className="px-5 py-3 text-right tabular-nums"
title={storage.partial ? t("storagePartial") : undefined}
>
<div className="text-sm text-fg-primary">
{storage.partial ? "≥ " : ""}
{formatBytes(storage.bytes)}
</div>
<div className="text-[11px] text-fg-muted">
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
</div>
</td>
);
}
function RowAction({
label,
disabled,
+106 -2
View File
@@ -2,11 +2,14 @@
* @file Tests for the `/artifacts` page: the library lists what the server
* answers, the Hub tab and Share appear only in server mode, a skill is
* installed only after a confirmation (and a taken name asks to replace),
* and a file preview is a sandboxed iframe with no `allow-*` token.
* and a file preview is a sandboxed iframe with no `allow-*` token. Adding:
* a plugin is picked from the list its marketplace link answers (a link the
* server cannot read opens the by-hand form), a skill is uploaded from one
* Markdown file under the name the user types, or written in the dialog.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Artifacts } from "../Artifacts";
@@ -24,6 +27,14 @@ vi.mock("../../lib/api", () => ({
remove: vi.fn(),
install: vi.fn(),
setInstallsEnabled: vi.fn(),
skills: vi.fn(),
addSkill: vi.fn(),
addPlugin: vi.fn(),
inspectMarket: vi.fn(),
uploadSkills: vi.fn(),
writeSkill: vi.fn(),
uploadFile: vi.fn(),
candidates: vi.fn(),
rawUrl: (id: number, path?: string) => `/api/artifacts/${id}/raw?path=${path ?? ""}`,
},
},
@@ -75,6 +86,99 @@ beforeEach(() => {
});
vi.mocked(confirmDialog).mockReset();
vi.mocked(api.artifacts.install).mockReset();
vi.mocked(api.lanes.list).mockResolvedValue({ lanes: [], counts: {} } as never);
vi.mocked(api.artifacts.skills).mockResolvedValue({ items: [] });
});
const addMenu = async (user: ReturnType<typeof userEvent.setup>, label: string) => {
open();
await screen.findByText("deploy");
await user.click(screen.getByTestId("artifacts-add"));
await user.click(screen.getByRole("button", { name: label }));
};
describe("Adding", () => {
it("adds the plugins picked from a marketplace link", async () => {
const user = userEvent.setup();
vi.mocked(api.artifacts.inspectMarket).mockResolvedValue({
market: "https://git.x.vn/o/skills.git",
name: "sg",
description: "",
plugins: [
{ name: "sg-dev", key: "sg-dev@sg", description: "Dev", version: "", category: "" },
{ name: "sg-common", key: "sg-common@sg", description: "", version: "", category: "" },
],
});
vi.mocked(api.artifacts.addPlugin).mockResolvedValue({ item: item({ kind: "plugin" }) });
await addMenu(user, "Plugin");
await user.type(screen.getByLabelText("Marketplace link"), "https://git.x.vn/o/skills.git");
await user.click(screen.getByRole("button", { name: "Read plugins" }));
await screen.findByTestId("market-plugins");
await user.click(screen.getByLabelText("Select all"));
await user.click(screen.getByRole("button", { name: "Add 2 plugin(s)" }));
await waitFor(() => expect(api.artifacts.addPlugin).toHaveBeenCalledTimes(2));
expect(api.artifacts.addPlugin).toHaveBeenCalledWith({
key: "sg-dev@sg",
market: "https://git.x.vn/o/skills.git",
title: "sg-dev",
description: "Dev",
});
});
it("opens the by-hand form when the link cannot be read", async () => {
const user = userEvent.setup();
vi.mocked(api.artifacts.inspectMarket).mockRejectedValue(
Object.assign(new Error("needs a login"), { code: "ENEEDLOGIN" })
);
await addMenu(user, "Plugin");
await user.type(screen.getByLabelText("Marketplace link"), "https://git.x.vn/o/private.git");
await user.click(screen.getByRole("button", { name: "Read plugins" }));
expect(await screen.findByRole("alert")).toHaveTextContent("needs a login");
expect(screen.getByLabelText("Marketplace (owner/repo or git URL)")).toHaveValue(
"https://git.x.vn/o/private.git"
);
});
it("uploads one Markdown file as a skill under the typed name", async () => {
const user = userEvent.setup();
vi.mocked(api.artifacts.uploadSkills).mockResolvedValue({ items: [item({ kind: "skill" })] });
await addMenu(user, "Skill");
const md = new File(["# Steps"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("skill-files"), md);
expect(await screen.findByTestId("skill-shape")).toHaveTextContent("becomes the skill's SKILL.md");
const name = screen.getByLabelText(/Skill name/);
await waitFor(() => expect(name).toHaveValue("notes"));
await user.clear(name);
await user.type(name, "Viết báo cáo");
expect(screen.getByTestId("skill-slug")).toHaveTextContent("/Viet-bao-cao");
await user.type(screen.getByLabelText(/Description/), "Use for reports");
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Add" }));
await waitFor(() => expect(api.artifacts.uploadSkills).toHaveBeenCalled());
const [files, fields] = vi.mocked(api.artifacts.uploadSkills).mock.calls[0]!;
expect(files.map((f) => f.path)).toEqual(["notes.md"]);
expect(fields).toMatchObject({ name: "Viết báo cáo", description: "Use for reports" });
});
it("writes a new skill", async () => {
const user = userEvent.setup();
vi.mocked(api.artifacts.writeSkill).mockResolvedValue({ item: item({}), items: [] });
await addMenu(user, "Skill");
await user.click(screen.getByRole("tab", { name: "Write new" }));
const add = within(screen.getByRole("dialog")).getByRole("button", { name: "Add" });
await user.type(screen.getByLabelText(/Skill name/), "standup");
await user.type(screen.getByLabelText(/Instructions/), "Summarise.");
expect(add).toBeDisabled(); // no description yet
await user.type(screen.getByLabelText(/Description/), "Daily standup");
await user.click(add);
await waitFor(() =>
expect(api.artifacts.writeSkill).toHaveBeenCalledWith({
name: "standup",
description: "Daily standup",
content: "Summarise.",
title: undefined,
})
);
});
});
describe("Artifacts", () => {
@@ -61,6 +61,9 @@ vi.mock("../../components/settings/AiToolsSettings", () => ({
vi.mock("../../components/settings/AgentInstructionsSettings", () => ({
AgentInstructionsSettings: () => <div data-testid="agent-instructions" />,
}));
vi.mock("../../components/settings/ArtifactsHubSettings", () => ({
ArtifactsHubSettings: () => null,
}));
vi.mock("../../components/settings/PricingSettings", () => ({
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
}));
+25 -1
View File
@@ -10,7 +10,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { AuthState, ServerUserUsage } from "../../lib/api";
@@ -34,6 +34,7 @@ const row = (id: number, username: string, role: ServerUserUsage["role"]): Serve
const users = vi.hoisted(() => ({
list: vi.fn(),
audit: vi.fn(),
storage: vi.fn(),
create: vi.fn(),
update: vi.fn(),
setPassword: vi.fn(),
@@ -74,6 +75,7 @@ beforeEach(() => {
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
});
users.audit.mockResolvedValue({ entries: [] });
users.storage.mockResolvedValue({ storage: {}, measuring: false });
users.get.mockImplementation(async (id: number) => ({
user: { id, username: "x", role: "developer", disabled: false },
workspaces: [],
@@ -91,6 +93,28 @@ describe("Users page", () => {
expect(users.list).not.toHaveBeenCalled();
});
it("shows each user's home size, and re-measures on Refresh", async () => {
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
users.storage.mockResolvedValue({
storage: {
1: null,
2: {
bytes: 3 * 1024 ** 3,
files: 1200,
partial: false,
measuredAt: "2026-10-14T09:00:00Z",
},
},
measuring: false,
});
renderPage();
expect(await screen.findByText("3.0 GB")).toBeTruthy();
expect(screen.getByText("Storage")).toBeTruthy();
expect(users.storage).toHaveBeenLastCalledWith(false);
fireEvent.click(screen.getByRole("button", { name: /Refresh/ }));
await waitFor(() => expect(users.storage).toHaveBeenLastCalledWith(true));
});
it("shows an analyst the accounts with no controls", async () => {
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
renderPage();
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.7.9 (rebuild client to match)
UI build v2.8.0 (rebuild client to match)
</span>
</p>
</div>
+17 -8
View File
@@ -85,11 +85,11 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (both `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
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.
@@ -102,10 +102,13 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, avatarAt, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
| `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 |
| `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` (never `bypassPermissions` — bypass stays the per-session switch), `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.
@@ -2945,15 +2948,19 @@ A snapshot freezes a session as it is at that moment into one immutable `session
```http
GET /api/artifacts?scope=mine|hub&kind=page|file|skill|plugin&q= {items}
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode)
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
POST /api/artifacts/page {url, title?, description?}
POST /api/artifacts/file {laneId, path, title?, sessionId?}
POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item}
POST /api/artifacts/skill {name, laneId?, title?, description?}
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL, title?, description?}
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description? → {items}
POST /api/artifacts/skill/new {name, description, content, title?} → {item, items}
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
GET /api/artifacts/:id {item}
PATCH /api/artifacts/:id {title?, description?, shared?}
PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?} (source/market: plugin only)
DELETE /api/artifacts/:id
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
@@ -2964,7 +2971,9 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner.
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo` or a `validateRemoteUrl` URL); no files.
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**.
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` (as the member through `write-as` under S3); a taken name is `409 EEXISTS` unless `replace: true`, which moves the old one to `~/.claude/agenthub-replaced-skills/<name>-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. Share, unshare, a shared row's delete and every install by someone else are written to `audit_log`.
+1 -1
View File
@@ -108,7 +108,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
| `agenthub server 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, $ in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
### Interactive REPL
+2 -2
View File
@@ -410,9 +410,9 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
Keyboard: the terminal keeps Tab and Shift+Tab (`claude` cycles its permission mode on Shift+Tab), so **Ctrl+M** toggles *Tab moves focus* — VS Code's way out of its terminal — and Tab then leaves the pane; **Cmd+F** (macOS) / **Ctrl+Shift+F** opens a find bar. URLs open in a new tab; a selection tmux copies (OSC 52) lands in the browser clipboard, and that path is write-only — nothing in the pane can read your clipboard. `agenthub.terminal.screenReader` = `"1"` in `localStorage` turns on xterm's screen-reader mode. An input method that edits the terminal's text box without composition events — macOS's Vietnamese Telex, in Chrome and Safari, which writes each letter as `insertText` and rewrites the word being typed as `insertReplacementText` — is sent to the pane by `client/src/lib/imeInput.ts` as backspaces plus the new tail of the word; xterm 5.5 on its own dropped those rewrites and any capital typed with Shift. While the socket is down a **Reconnecting…** overlay says keystrokes are not reaching the pane; it re-attaches by itself. In the session view, outside a terminal or a text field, `Alt+1`–`Alt+4` focuses pane 1–4, `Alt+\` splits the focused pane right with a new terminal, and `Alt+W` closes the focused pane (its session stays a tab).
- **Clipboard in a pane** — the workspace's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
- **Scrollbar in a pane** — every pane gets tmux's `mouse on` and `pane-scrollbars on` with a visible style (`pane-scrollbars-style fg=brightwhite,bg=black,width=1`, `enableMouse` in `server/lib/tmux.js`; tmux 3.7+ — one column, since tmux 3.7c reserves `width` columns but paints only the first and a wider bar left a column of stale cells that read as a second scrollbar). tmux keeps no history and draws no scrollbar on the alternate screen, so a `claude` pane under `"tui": "fullscreen"` (`~/.claude/settings.json`) scrolls inside Claude's own UI instead. AgentHub no longer forces it inline (`CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1`): inline, every time the pane narrowed tmux re-wrapped Claude's full-width rules and pushed lines into history that its redraw could not clear, duplicating the banner and transcript. A pane started before this change keeps inline mode until it is restarted. Every pane's CLI (and a Terminal tab's shell) also starts only once a client is attached — 2 s at most — so nothing is drawn at tmux's 80×24 birth size and re-wrapped on the attach (`WAIT_FOR_CLIENT` in `server/lib/pty-run.js`, issue #55).
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named `<workspace> · <session>`, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=<section>` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). A pane drag (a pane's header, or a one-pane tab) held over another tab for 0.6 s opens that tab (`SPRING_MS`), so the pane can be dropped on any edge of that tab's panes instead of the fixed join order; on a bar wider than the screen, dragging near either end scrolls it. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named `<workspace> · <session>`, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=<section>` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two —, its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two (a session from another tab takes the pane's place and that one moves to a tab of its own) —; while the drag lasts the moved pane is dimmed, the drag shows a small chip with its name, and the drop preview says what will happen (*Place on the left*, *Swap places*…, `dropAction`), its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
+28 -7
View File
@@ -115,7 +115,7 @@ their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
hidden for a session that is not theirs. The sidebar's workspace list shows an
admin or analyst EVERY workspace, grouped under its owner (each lane carries
`owner_name` and `owner_display_name`; the group shows the display name when
`owner_name`, `owner_display_name` and `owner_avatar_at`; the group shows the owner's picture (`GET /api/users/:id/avatar`) and the display name when
the owner set one, else the username; yours first, no owner last, each group folds): for an analyst
another user's workspace there is read only — no menu, rename or new session,
no live tabs, and a session opens its analysis page (`/sessions/<id>`). An analyst's alert feed
@@ -144,6 +144,17 @@ Workspaces made before server mode, or by the host account, have none.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days — the
Users page in the sidebar shows it, with the audit log under it.
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
how much disk each user's home takes (their Linux home under S3, their
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
`null`). Homes are walked in a worker thread, one at a time, `du -x`-like
(allocated blocks, `lstat` only, no symlink followed, a hard link once),
and cached for 15 minutes; the answer comes back at once with
`{storage: {<userId>: {bytes, files, partial, measuredAt} | null}, measuring}`
and the page asks again while `measuring`. `?refresh=1` (the page's Refresh)
re-measures. A walk over `AGENTHUB_STORAGE_TIMEOUT_MS` (default 120 s) or
one meeting an unreadable folder is a lower bound, `partial: true` (shown
as `≥`).
A developer also cannot open the host's own configuration, or any view across all
users: Claude Code / Codex config (except the command and skill lists the
@@ -283,8 +294,10 @@ workspace runs as the host account that every admin shares. The tick is never
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 display name
and email; **Change password** (the current password is required, the login
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
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
devices**. These are taken from the login cookie only — an agent in a pane,
which acts as its workspace owner through its pane token, cannot change its
@@ -316,7 +329,7 @@ stays empty (changing nothing) until you add an account.
claude setup-token
# Add it to the pool (the token is typed hidden, or piped on stdin)
agenthub accounts add team-1
agenthub accounts list # live panes, $ in the last 5 h / 7 d, quota, state
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
agenthub accounts remove team-1
```
@@ -345,7 +358,10 @@ Code reads it from no env var, so before a member's pane starts, AgentHub writes
it as that member's `~/.claude/.credentials.json`, as the member (0600). It is
never written into a host pane's home, never over a login the member made
themselves, and never a second account into a home where a pane of theirs runs
on one. Host panes therefore get only setup-token accounts. The refresh token of
on one. Host panes therefore get only setup-token accounts — and the login the
host itself was moved in from (the profile marked active), as long as the
host's `~/.claude/.credentials.json` still holds that pair: the pane runs on it
as it is and its usage is booked to that account. The refresh token of
a login is single use, so the server is the one that refreshes it, before it
expires (`claude-pool.refreshLogins`, checked every 60 s; `DASHBOARD_POOL_REFRESH_MS=0` turns it off). It then writes the new pair into every
home it placed the login in, and Claude Code picks up tokens another process
@@ -363,8 +379,13 @@ How a new session picks its account (`server/lib/claude-pool.js`):
weekly window goes last. It is still used if every account is that full.
2. Next, the account with the fewest live panes, so concurrent sessions spread out.
3. Then the account with the least $ spent in the last 5 hours, then the last
7 days. AgentHub sums this from the usage of the sessions it started on each
account (`dashboard_runs.account`).
7 days. AgentHub sums this from the usage of the sessions its runs held on
each account (`dashboard_runs.account`), over the hours each run held them —
a session resumed on another account counts there from then on — with
sub-agent usage included and the same prices as the cost pages
(`server/lib/pool-usage.js`). The table also shows the tokens behind it.
Sessions started outside AgentHub on the same account are not counted; the
quota column is Anthropic's own reading and does include them.
- The pane gets the token through tmux `-e CLAUDE_CODE_OAUTH_TOKEN=…`. That
token outranks the machine's own `/login` for that process only.
+3 -3
View File
@@ -9,9 +9,9 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
| Kind | What it is | Where it comes from |
|---|---|---|
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin |
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>` |
| `plugin` | A plugin by marketplace source + name (no files) | the user's installed plugins (`claude plugin list`) |
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; or uploaded from the computer |
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
- **Local mode:** one personal library, no sharing UI.
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it; a shared artifact appears in the **Hub** for every signed-in user. A shared skill or plugin can be **installed** into the viewer's own account.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.7.9",
"version": "2.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.7.9",
"version": "2.8.0",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.7.9",
"version": "2.8.0",
"bin": {
"agenthub": "bin/agenthub.js"
},
+181 -1
View File
@@ -4,7 +4,11 @@
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
* symlink/secret refusal, install into the caller's Claude home with
* replace moving the old one aside), plugins (marketplace allowlist, install
* argv), sharing refused in local mode, visibility for a server-mode
* argv; a marketplace link read into its plugin list, the same plugin added
* twice kept as one row, key/market edited), skills uploaded (one Markdown
* file, a folder, a zip, several skills at once — name and frontmatter set,
* junk skipped, nothing stored when one fails) or written in the dialog, a
* file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, and same-origin writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -219,6 +223,182 @@ describe("plugins", () => {
});
});
function upload(p, files, fields = {}) {
const form = new FormData();
const paths = [];
for (const [name, content] of files) {
form.append("files", new Blob([content]), name.split("/").pop());
paths.push(name);
}
form.append("paths", JSON.stringify(paths));
for (const [k, v] of Object.entries(fields)) form.append(k, v);
return fetch(`${base}${p}`, { method: "POST", body: form }).then(async (r) => ({
status: r.status,
body: await r.json(),
}));
}
const snapText = (item, file = "SKILL.md") =>
fs.readFileSync(
path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot, file),
"utf8"
);
describe("plugins from a marketplace link", () => {
const MARKET = JSON.stringify({
name: "sg",
description: "Smartgift skills",
plugins: [
{ name: "sg-dev", source: "./plugins/dev", description: "Dev team" },
{ name: "sg-common", source: "./plugins/common" },
{ name: "-bad", source: "./x" },
],
});
it("normalizes what a user pastes into a marketplace source", () => {
const n = artifacts.normalizeMarket;
assert.equal(n("https://github.com/o/r"), "o/r");
assert.equal(n("https://github.com/o/r.git/"), "o/r");
assert.equal(
n("https://git.example.vn/Org/skills/src/branch/main/"),
"https://git.example.vn/Org/skills"
);
assert.equal(n("https://git.example.vn/Org/skills.git"), "https://git.example.vn/Org/skills.git");
assert.throws(() => n("file:///etc"), { code: "EBADMARKET" });
assert.throws(() => n("http://x/y.git"), { code: "EBADMARKET" });
});
it("lists a marketplace's plugins and adds a picked one once", async () => {
let asked = null;
artifacts.__setInspectImpl(async (m) => {
asked = m;
return MARKET;
});
const r = await request("POST", "/api/artifacts/plugin/inspect", {
market: "https://git.example.vn/Org/skills.git",
});
artifacts.__setInspectImpl(null);
assert.equal(r.status, 200, r.text);
assert.equal(asked, "https://git.example.vn/Org/skills.git");
assert.equal(r.body.name, "sg");
assert.deepEqual(r.body.plugins.map((p) => p.key), ["sg-dev@sg", "sg-common@sg"]);
const add = (title) =>
request("POST", "/api/artifacts/plugin", { key: "sg-dev@sg", market: r.body.market, title });
const a = await add("Dev");
const b = await add("Dev team");
assert.equal(a.status, 201, a.text);
assert.equal(b.body.item.id, a.body.item.id);
assert.equal(b.body.item.title, "Dev team");
const edit = await request("PATCH", `/api/artifacts/${a.body.item.id}`, {
source: "sg-common@sg",
market: "https://github.com/o/r",
});
assert.equal(edit.status, 200, edit.text);
assert.equal(edit.body.item.source, "sg-common@sg");
assert.equal(edit.body.item.market, "o/r");
const bad = await request("PATCH", `/api/artifacts/${a.body.item.id}`, { source: "nope" });
assert.equal(bad.body.error.code, "EBADPLUGIN");
});
it("refuses a marketplace.json with no usable name", async () => {
artifacts.__setInspectImpl(async () => "{}");
const r = await request("POST", "/api/artifacts/plugin/inspect", { market: "o/r" });
artifacts.__setInspectImpl(null);
assert.equal(r.body.error.code, "EBADMARKET");
});
});
describe("skills from files or written", () => {
it("makes one Markdown file the SKILL.md, named by the user, with a frontmatter", async () => {
const r = await upload("/api/artifacts/skill/upload", [["Notes cho đội.md", "# Do this\nStep 1\n"]], {
name: "Hướng dẫn deploy",
description: "Use when deploying",
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const [item] = r.body.items;
assert.equal(item.source, "Huong-dan-deploy");
assert.deepEqual(item.files.map((f) => f.path), ["SKILL.md"]);
const text = snapText(item);
assert.match(text, /^---\nname: Huong-dan-deploy\ndescription: Use when deploying\n---\n/);
assert.match(text, /# Do this/);
});
it("refuses a skill with no description anywhere", async () => {
const r = await upload("/api/artifacts/skill/upload", [["x.md", "body"]], { name: "x" });
assert.equal(r.body.error.code, "ENODESCRIPTION");
});
it("takes a picked folder: the top folder is stripped, junk skipped, the name from the frontmatter", async () => {
const r = await upload("/api/artifacts/skill/upload", [
["my-skill/SKILL.md", "---\nname: release-notes\ndescription: Write notes\n---\nGo."],
["my-skill/scripts/run.sh", "echo"],
["my-skill/.DS_Store", "junk"],
]);
assert.equal(r.status, 201, JSON.stringify(r.body));
const [item] = r.body.items;
assert.equal(item.source, "release-notes");
assert.equal(item.description, "Write notes");
assert.deepEqual(item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
});
it("unpacks a zip holding several skills into one artifact each", async () => {
const AdmZip = require("adm-zip");
const zip = new AdmZip();
zip.addFile("pack/alpha/SKILL.md", Buffer.from("---\nname: alpha\ndescription: A\n---\n"));
zip.addFile("pack/alpha/ref/more.md", Buffer.from("x"));
zip.addFile("pack/beta/skill.md", Buffer.from("---\ndescription: B\n---\nb"));
zip.addFile("__MACOSX/pack/._alpha", Buffer.from("junk"));
const r = await upload("/api/artifacts/skill/upload", [["pack.zip", zip.toBuffer()]]);
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.deepEqual(r.body.items.map((i) => i.source).sort(), ["alpha", "beta"]);
const beta = r.body.items.find((i) => i.source === "beta");
assert.match(snapText(beta), /^---\nname: beta\ndescription: B\n---/);
});
it("stores nothing when one skill of an upload is bad, and refuses a secret or a path out", async () => {
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n;
const r = await upload("/api/artifacts/skill/upload", [
["a/SKILL.md", "---\ndescription: A\n---\n"],
["b/SKILL.md", "no frontmatter"],
]);
assert.equal(r.body.error.code, "ENODESCRIPTION");
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill'").get().n, before);
const secret = await upload(
"/api/artifacts/skill/upload",
[["s/SKILL.md", "---\ndescription: S\n---\n"], ["s/.env", "K=1"]]
);
assert.equal(secret.body.error.code, "ESECRET");
const out = await upload("/api/artifacts/skill/upload", [["../SKILL.md", "x"]], { description: "d" });
assert.equal(out.body.error.code, "EBADPATH");
const none = await upload("/api/artifacts/skill/upload", [["a.png", "x"], ["b.png", "y"]]);
assert.equal(none.body.error.code, "ENOSKILLMD");
});
it("writes a skill from the dialog", async () => {
const r = await request("POST", "/api/artifacts/skill/new", {
name: "standup",
description: "Use for the daily standup: summarise yesterday",
content: "Summarise.",
});
assert.equal(r.status, 201, r.text);
assert.match(snapText(r.body.item), /^---\nname: standup\ndescription: "Use for the daily standup: summarise yesterday"\n---\n\nSummarise\./);
const bad = await request("POST", "/api/artifacts/skill/new", { name: "!!!", description: "d", content: "c" });
assert.equal(bad.body.error.code, "EBADNAME");
});
it("keeps an uploaded file as a snapshot, by its own base name", async () => {
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], { title: "Report" });
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.item.kind, "file");
assert.equal(r.body.item.title, "Report");
const raw = await request("GET", `/api/artifacts/${r.body.item.id}/raw`);
assert.equal(raw.text, "<h1>x</h1>");
assert.match(raw.headers.get("content-security-policy"), /sandbox/);
});
});
describe("sharing and visibility", () => {
it("refuses to share in local mode", async () => {
const item = (await request("GET", "/api/artifacts")).body.items[0];
@@ -120,6 +120,28 @@ describe("a pool login", () => {
);
});
it("is a host pane's account when it is the host's own login, and nothing is written", () => {
const hostCreds = path.join(TMP, ".claude", ".credentials.json");
fs.mkdirSync(path.dirname(hostCreds), { recursive: true });
fs.writeFileSync(hostCreds, JSON.stringify({ claudeAiOauth: oauth("h") }));
try {
pool.addLoginAccount("mine", { oauth: oauth("h"), account: ACCOUNT, hostHolds: true });
const spawn = pool.forSpawn(null);
assert.equal(spawn.name, "mine", "the host pane's usage is booked to it");
assert.deepEqual(spawn.env, {});
spawn.place();
assert.equal(
JSON.parse(fs.readFileSync(hostCreds, "utf8")).claudeAiOauth.refreshToken,
oauth("h").refreshToken
);
// The host logged in as someone else: no longer the pool's.
fs.writeFileSync(hostCreds, JSON.stringify({ claudeAiOauth: oauth("other") }));
assert.equal(pool.forSpawn(null), null);
} finally {
fs.rmSync(hostCreds, { force: true });
}
});
it("is never written over a member's own login", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
fs.mkdirSync(path.dirname(CREDS), { recursive: true });
+44 -10
View File
@@ -27,21 +27,33 @@ const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
function run(id, account, { status = "completed", session = null, started } = {}) {
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, account)
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?)`
).run(id, session, status, started || new Date().toISOString(), account);
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, started_at, ended_at, account)
VALUES (?, ?, 'interactive', '/tmp', ?, ?, ?, ?)`
).run(id, session, status, started || new Date().toISOString(), ended, account);
}
function spend(session, tokens) {
const MODEL = "claude-sonnet-4-5-20250929";
const hoursAgo = (h) => new Date(Date.now() - h * 3600e3).toISOString();
/**
* `tokens` output tokens of `session` spent `atHoursAgo` hours ago — the
* hourly row plus the session total, as the hook writer keeps them both.
* `{hourly: false}` = a total with no hourly row (a sub-agent's bucket).
*/
function spend(session, tokens, { atHoursAgo = 0, hourly = true, model = MODEL } = {}) {
db.prepare(
"INSERT OR IGNORE INTO sessions (id, name, status, cwd) VALUES (?, ?, 'completed', '/tmp')"
).run(session, session);
"INSERT OR IGNORE INTO sessions (id, name, status, cwd, started_at) VALUES (?, ?, 'completed', '/tmp', ?)"
).run(session, session, hoursAgo(atHoursAgo));
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES (?, ?, 'claude-sonnet-4-5-20250929', ?)`
).run(session, new Date().toISOString().slice(0, 13), tokens);
`INSERT INTO token_usage (session_id, model, output_tokens) VALUES (?, ?, ?)
ON CONFLICT DO UPDATE SET output_tokens = output_tokens + excluded.output_tokens`
).run(session, model, tokens);
if (!hourly) return;
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES (?, ?, ?, ?)`
).run(session, hoursAgo(atHoursAgo).slice(0, 13), model, tokens);
}
before(() => {
@@ -62,6 +74,8 @@ describe("claude pool", () => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
db.prepare("DELETE FROM token_usage_hourly").run();
db.prepare("DELETE FROM token_usage").run();
db.prepare("DELETE FROM sessions").run();
pool.__quotaCache.clear();
});
@@ -109,6 +123,26 @@ describe("claude pool", () => {
assert.equal(pool.pickAccount().name, "b");
});
it("books a session to an account only for the hours its runs held it, sub-agents included", () => {
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
// s1 ran on a 30 h ago, then was resumed on b 2 h ago.
run("r-a", "a", { session: "s1", started: hoursAgo(30), ended: hoursAgo(29) });
run("r-b", "b", { session: "s1", started: hoursAgo(2), status: "running" });
spend("s1", 1000, { atHoursAgo: 30 });
spend("s1", 3000, { atHoursAgo: 2 });
// s2 (on a, now): its main agent and a Haiku sub-agent with no hourly row.
run("r-a2", "a", { session: "s2" });
spend("s2", 500);
spend("s2", 200, { hourly: false, model: "claude-haiku-4-5-20251001" });
const status = Object.fromEntries(pool.poolStatus().map((x) => [x.name, x]));
assert.equal(status.a.tokens5h, 700, "s2, sub-agent included; s1 is b's now");
assert.equal(status.a.tokens7d, 1700);
assert.equal(status.b.tokens5h, 3000, "not the hours a held s1");
assert.equal(status.b.tokens7d, 3000);
assert.ok(status.a.spend7d > status.a.spend5h && status.a.spend5h > 0);
assert.ok(status.b.spend5h > 0);
});
it("puts an account whose quota is nearly used up last", async () => {
for (const n of ["a", "b"]) pool.addAccount(n, TOKEN(n));
ccUsage.fetchUsage = async (token) => ({
+137
View File
@@ -0,0 +1,137 @@
/**
* @file me-avatar.test.js
* @description Settings → Account's avatar (`/api/me/avatar`,
* server/lib/avatars.js): an account sets, reads and removes its own picture;
* only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB,
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
* 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "me-avatar-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_DATA_DIR = TMP;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
let server;
let BASE;
const PNG = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(32, 1),
]);
const JPG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 2)]);
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return res.headers.get("set-cookie").split(";")[0];
}
const put = (cookie, body) =>
fetch(BASE + "/api/me/avatar", {
method: "PUT",
headers: { cookie, "Content-Type": "application/octet-stream" },
body,
});
before(async () => {
process.env.AGENTHUB_MODE = "server";
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
await auth.createUser("ava", "ava-pass-1", "developer");
await auth.createUser("bob", "bob-pass-1", "developer");
await auth.createUser("ann", "ann-pass-1", "analyst");
});
after(() => {
delete process.env.AGENTHUB_MODE;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
it("sets, serves, replaces and removes the account's own avatar", async () => {
const ava = await login("ava", "ava-pass-1");
const bob = await login("bob", "bob-pass-1");
assert.equal((await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava } })).status, 404);
const set = await put(ava, PNG);
assert.equal(set.status, 200);
const at = (await set.json()).user.avatarAt;
assert.ok(at);
const me = await (await fetch(BASE + "/api/auth/me", { headers: { cookie: ava } })).json();
assert.equal(me.user.avatarAt, at);
const got = await fetch(BASE + "/api/me/avatar", { headers: { cookie: ava } });
assert.equal(got.status, 200);
assert.equal(got.headers.get("content-type"), "image/png");
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
assert.match(got.headers.get("content-security-policy"), /sandbox/);
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
// Another account sees only its own (none).
assert.equal((await fetch(BASE + "/api/me/avatar", { headers: { cookie: bob } })).status, 404);
// A JPEG replaces the PNG — one file per account.
assert.equal((await put(ava, JPG)).status, 200);
const files = fs.readdirSync(path.join(TMP, "avatars"));
assert.deepEqual(files, [files.find((f) => f.endsWith(".jpg"))]);
const del = await fetch(BASE + "/api/me/avatar", { method: "DELETE", headers: { cookie: ava } });
assert.equal(del.status, 200);
assert.equal((await del.json()).user.avatarAt, null);
assert.equal(fs.readdirSync(path.join(TMP, "avatars")).length, 0);
});
it("refuses an SVG, an empty body, a too-large image and no login", async () => {
const ava = await login("ava", "ava-pass-1");
const svg = await put(ava, Buffer.from("<svg xmlns='http://www.w3.org/2000/svg'/>"));
assert.equal(svg.status, 400);
assert.equal((await svg.json()).error.code, "EBADIMAGE");
assert.equal((await put(ava, Buffer.alloc(0))).status, 400);
const big = await put(ava, Buffer.concat([PNG, Buffer.alloc(1024 * 1024)]));
assert.equal(big.status, 413);
assert.equal((await big.json()).error.code, "ETOOBIG");
assert.equal((await put("", PNG)).status, 401);
});
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
const ava = await login("ava", "ava-pass-1");
const ann = await login("ann", "ann-pass-1");
const bob = await login("bob", "bob-pass-1");
const id = auth.findUser("ava").id;
const { db } = require("../db");
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES ('/w/ava', 'claude', ?)").run(id);
assert.equal((await put(ava, PNG)).status, 200);
const got = await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: ann } });
assert.equal(got.status, 200);
assert.equal(got.headers.get("x-content-type-options"), "nosniff");
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
assert.equal(
(await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status,
403
);
const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json())
.lanes;
assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at);
});
it("deleting the account deletes its avatar", async () => {
const bob = await login("bob", "bob-pass-1");
assert.equal((await put(bob, PNG)).status, 200);
auth.deleteUser("bob");
assert.ok(!fs.readdirSync(path.join(TMP, "avatars")).some((f) => f.startsWith("u2.")));
});
+107
View File
@@ -0,0 +1,107 @@
/**
* @file user-storage.test.js
* @description The Users page's Storage column (server/lib/user-storage.js,
* `GET /api/users/storage`): the walk counts allocated blocks, a hard link
* once and never follows a symlink; a server-mode user's folder under
* AGENTHUB_USERS_ROOT is measured in the background and reported to staff,
* a member is refused, and a user with no folder of their own reads null.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "user-storage-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const USERS_ROOT = path.join(TMP, "users");
fs.mkdirSync(USERS_ROOT);
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const { walk } = require("../lib/user-storage");
let server;
let BASE;
const ids = {};
async function login(username, password) {
const res = await fetch(BASE + "/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
assert.equal(res.status, 200);
return res.headers.get("set-cookie").split(";")[0];
}
async function storage(cookie, q = "") {
const res = await fetch(`${BASE}/api/users/storage${q}`, { headers: { cookie } });
return { status: res.status, body: await res.json().catch(() => null) };
}
before(async () => {
process.env.AGENTHUB_MODE = "server";
process.env.AGENTHUB_USERS_ROOT = USERS_ROOT;
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
ids.boss = (await auth.createUser("boss", "admin-pass-1", "admin")).id;
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
const home = path.join(USERS_ROOT, "mia");
fs.mkdirSync(path.join(home, "src"), { recursive: true });
fs.writeFileSync(path.join(home, "src", "a.bin"), Buffer.alloc(64 * 1024, 1));
});
after(() => {
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("walk", () => {
it("counts blocks, a hard link once, and never follows a symlink", () => {
const dir = fs.mkdtempSync(path.join(TMP, "walk-"));
fs.writeFileSync(path.join(dir, "big"), Buffer.alloc(128 * 1024, 1));
fs.linkSync(path.join(dir, "big"), path.join(dir, "big-link"));
fs.symlinkSync("/usr", path.join(dir, "usr"));
const r = walk(dir, Date.now() + 10_000);
assert.equal(r.partial, false);
assert.equal(r.files, 2); // big + the symlink itself; the hard link is skipped
assert.ok(r.bytes >= 128 * 1024 && r.bytes < 1024 * 1024, `bytes ${r.bytes}`);
});
it("stops at the deadline as a lower bound", () => {
const r = walk(TMP, Date.now() - 1);
assert.equal(r.partial, true);
});
});
describe("GET /api/users/storage", () => {
it("measures a user's folder in the background for staff", async () => {
const admin = await login("boss", "admin-pass-1");
let r = await storage(admin);
assert.equal(r.status, 200);
for (let i = 0; i < 50 && (r.body.measuring || !r.body.storage[ids.mia]); i++) {
await new Promise((ok) => setTimeout(ok, 100));
r = await storage(admin);
}
const mia = r.body.storage[ids.mia];
assert.ok(mia, "mia's folder was measured");
assert.ok(mia.bytes >= 64 * 1024);
assert.equal(mia.files, 1);
assert.equal(mia.partial, false);
assert.ok(mia.measuredAt);
// The admin has no folder under the users root: nothing to measure.
assert.equal(r.body.storage[ids.boss], null);
});
it("refuses a developer", async () => {
const mia = await login("mia", "member-pass-1");
const r = await storage(mia);
assert.equal(r.status, 403);
});
});
+4 -2
View File
@@ -1855,8 +1855,10 @@ try {
}
}
// An account's own display name and email (the Users page's Edit user and
// Settings → Account). Additive; after the rebuild above, which predates them.
for (const col of ["display_name", "email"]) {
// 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"]) {
try {
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
} catch {
+489 -9
View File
@@ -382,6 +382,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
);
}
/**
* A file uploaded from the user's computer: kept as a snapshot like a pinned
* one, under a name made from the upload's own (no folder, no path).
*/
function uploadFile(req, { name, data, title, description }) {
if (!Buffer.isBuffer(data) || !data.length) throw fail("ENOFILE", "the file is empty");
if (data.length > FILE_MAX_BYTES) throw fail("ETOOBIG", "a file artifact is at most 25 MB");
const base = path.posix.basename(String(name || "").replace(/\\/g, "/"));
// eslint-disable-next-line no-control-regex
const clean = base.replace(/[\x00-\x1f/]/g, "").replace(/^\.+/, "").slice(0, 200) || "file";
const snap = newSnapshotDir();
try {
fs.writeFileSync(path.join(snap.dir, clean), data, { mode: 0o600 });
} catch (err) {
dropSnapshot(snap.id);
throw err;
}
return view(
insert({
kind: "file",
title: cleanTitle(title, clean),
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : null,
source: clean,
snapshot: snap.id,
files: JSON.stringify([{ path: clean, size: data.length }]),
size: data.length,
}),
req
);
}
// ── skill ─────────────────────────────────────────────────────────────────
/**
@@ -499,16 +531,45 @@ function addSkill(req, { name, laneId, title, description }) {
throw fail("ESYMLINK", "the skill folder is a link elsewhere");
}
if (!fs.existsSync(path.join(real, "SKILL.md"))) throw fail("ENOFILE", "no SKILL.md in that folder");
const { files, total } = walkSkill(real);
const { files } = walkSkill(real);
const runAs = require("./run-as");
return storeSkill(req, {
name,
title,
description,
laneId: lane ? lane.id : null,
files: files.map((f) => ({
path: f.path,
data: () => runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }),
})),
});
}
/** `description:` of a SKILL.md's frontmatter, or "". */
function frontmatterField(text, field) {
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
if (!fm) return "";
const m = new RegExp(`^${field}:\\s*(.*)$`, "m").exec(fm[1]);
return (m?.[1] || "").trim().replace(/^(["'])(.*)\1$/, "$2");
}
/**
* Snapshot a skill's files and record the row. `files`: `[{path, data}]`,
* `data` a Buffer or a function returning one (read only while copying).
*/
function storeSkill(req, { name, files, title, description, laneId = null }) {
const snap = newSnapshotDir();
let total = 0;
const recorded = [];
try {
for (const f of files) {
const data = typeof f.data === "function" ? f.data() : f.data;
total += data.length;
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
const target = path.join(snap.dir, ...f.path.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
fs.writeFileSync(target, runAs.readFileSafe(f.abs, null, { max: SKILL_MAX_BYTES }), {
mode: 0o600,
});
fs.writeFileSync(target, data, { mode: 0o600 });
recorded.push({ path: f.path, size: data.length });
}
} catch (err) {
dropSnapshot(snap.id);
@@ -516,8 +577,7 @@ function addSkill(req, { name, laneId, title, description }) {
}
let summary = "";
try {
const head = fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8");
summary = /^description:\s*(.+)$/m.exec(head)?.[1]?.trim() || "";
summary = frontmatterField(fs.readFileSync(path.join(snap.dir, "SKILL.md"), "utf8"), "description");
} catch {
/* no frontmatter */
}
@@ -527,16 +587,263 @@ function addSkill(req, { name, laneId, title, description }) {
title: cleanTitle(title, name),
description: cleanDescription(description) || summary.slice(0, DESCRIPTION_MAX),
owner_id: req?.user ? req.user.id : null,
lane_id: lane ? lane.id : null,
lane_id: laneId,
source: name,
snapshot: snap.id,
files: JSON.stringify(files.map((f) => ({ path: f.path, size: f.size }))),
files: JSON.stringify(recorded),
size: total,
}),
req
);
}
// ── skill: written or uploaded ────────────────────────────────────────────
// Junk an OS or an editor leaves in a folder or a zip: skipped, never an error.
const JUNK_RE = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
const ZIP_RE = /\.(zip|skill)$/i;
const TEXT_RE = /\.(md|markdown|txt)$/i;
const ZIP_MAX_ENTRIES = 2000;
/**
* A skill folder name from free text: what the user typed, a frontmatter
* `name:`, a folder or a file name. Spaces and other characters become `-`;
* Vietnamese letters lose their marks. "" when nothing usable is left.
*/
function skillSlug(text) {
return String(text || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/đ/g, "d")
.replace(/Đ/g, "D")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[-._]+|-+$/g, "")
.slice(0, 100);
}
function validSkillName(raw) {
const name = skillSlug(raw);
if (!SKILL_NAME_RE.test(name)) {
throw fail("EBADNAME", "skill name: letters, digits, '.', '_' or '-' (it becomes the folder and the /command)");
}
return name;
}
/** One uploaded path, made safe: posix, relative, no `..`, no secret, not too deep. */
function cleanUploadPath(raw) {
const p = String(raw || "")
.replace(/\\/g, "/")
.replace(/^(\.\/)+/, "")
.replace(/^\/+/, "");
// eslint-disable-next-line no-control-regex
if (!p || /[\x00-\x1f]/.test(p)) throw fail("EBADPATH", "an uploaded file has no usable name");
const parts = p.split("/").filter(Boolean);
if (parts.some((x) => x === ".." || x === ".")) throw fail("EBADPATH", `not a path inside the skill: ${p}`);
if (parts.length > SKILL_MAX_DEPTH + 2) throw fail("ETOOBIG", "the skill folder is nested too deep");
return parts.join("/");
}
/** A zip's (or a .skill's) regular files: sizes checked from its directory before inflating. */
function unzipSkill(buf) {
const AdmZip = require("adm-zip");
let entries;
try {
entries = new AdmZip(buf).getEntries();
} catch {
throw fail("EBADZIP", "not a readable zip file");
}
if (entries.length > ZIP_MAX_ENTRIES) throw fail("ETOOBIG", "the zip has too many entries");
let total = 0;
const out = [];
for (const e of entries) {
if (e.isDirectory || JUNK_RE.test(e.entryName)) continue;
const mode = (e.attr >>> 16) & 0o170000;
if (mode === 0o120000) throw fail("ESYMLINK", `the zip holds a symlink: ${e.entryName}`);
total += e.header.size;
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
const data = e.getData();
if (data.length !== e.header.size) throw fail("EBADZIP", `a damaged zip entry: ${e.entryName}`);
out.push({ path: e.entryName, data });
}
return out;
}
/**
* Upload → skills. Every case a user may hand over:
* - one Markdown/text file (any name) → that file is the skill's SKILL.md;
* - a folder or several files with a SKILL.md (at the top or in one folder
* under it) → that folder is the skill;
* - a .zip / .skill archive → unpacked, then the same rules;
* - several skill folders at once (each with its own SKILL.md) → one
* artifact per skill, named by its folder.
* `name` (the user's) names the skill when there is exactly one.
*/
function skillsFromUpload(uploads, { name, description }) {
let files = [];
for (const u of uploads) {
const p = cleanUploadPath(u.path);
if (JUNK_RE.test(p)) continue;
if (ZIP_RE.test(p) && uploads.length === 1) {
files.push(...unzipSkill(u.data).map((f) => ({ path: cleanUploadPath(f.path), data: f.data })));
} else {
files.push({ path: p, data: u.data });
}
}
files = files.filter((f) => !JUNK_RE.test(f.path));
if (!files.length) throw fail("ENOFILE", "nothing to add: the upload is empty");
const seen = new Set();
for (const f of files) {
if (seen.has(f.path)) throw fail("EBADPATH", `the same path twice: ${f.path}`);
seen.add(f.path);
}
const isSkillMd = (p) => path.posix.basename(p).toLowerCase() === "skill.md";
let roots = files.filter((f) => isSkillMd(f.path)).map((f) => path.posix.dirname(f.path));
if (!roots.length) {
if (files.length === 1 && TEXT_RE.test(files[0].path)) {
const only = files[0];
const stem = path.posix.basename(only.path).replace(/\.[^.]+$/, "");
return [
{
fallbackName: frontmatterField(only.data.toString("utf8"), "name") || stem,
files: [{ path: "SKILL.md", data: only.data }],
},
];
}
throw fail(
"ENOSKILLMD",
"no SKILL.md in the upload — add one, or upload a single Markdown file to use as SKILL.md"
);
}
// A SKILL.md inside another skill's folder belongs to that skill.
roots = [...new Set(roots)].sort((a, b) => a.length - b.length);
const under = (dir, p) => dir === "." || p === dir || p.startsWith(`${dir}/`);
roots = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
return roots.map((root) => {
const own = files
.filter((f) => under(root, f.path))
.map((f) => ({
path: root === "." ? f.path : f.path.slice(root.length + 1),
data: f.data,
}))
.map((f) => (f.path.toLowerCase() === "skill.md" ? { ...f, path: "SKILL.md" } : f));
const md = own.find((f) => f.path === "SKILL.md");
const folder = root === "." ? "" : path.posix.basename(root);
return {
fallbackName: frontmatterField(md.data.toString("utf8"), "name") || folder,
files: own,
};
});
}
/** Check one skill's files like a folder on disk is checked. */
function checkSkillFiles(files) {
if (files.length > SKILL_MAX_FILES) throw fail("ETOOBIG", "the skill has too many files");
let total = 0;
for (const f of files) {
if (f.path.split("/").length > SKILL_MAX_DEPTH + 1) {
throw fail("ETOOBIG", "the skill folder is nested too deep");
}
if (SECRET_NAME_RE.test(path.posix.basename(f.path))) {
throw fail("ESECRET", `the skill holds a file that looks like a secret: ${f.path}`);
}
total += f.data.length;
}
if (total > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
}
/**
* SKILL.md with the chosen `name:` and a `description:` in its frontmatter —
* Claude Code reads both (the description decides when the skill is used).
* A file with no frontmatter gets one; an existing one keeps every other line.
*/
function withFrontmatter(text, name, description) {
const body = String(text).replace(/^\uFEFF/, "");
const fm = /^---\r?\n([\s\S]*?)\r?\n---[^\n]*(\r?\n|$)/.exec(body);
const desc = String(description || "").trim().replace(/\s+/g, " ");
const quote = (v) => (/^[\w .,()/-]*$/.test(v) && !/^[\d.]+$/.test(v) ? v : JSON.stringify(v));
if (!fm) {
if (!desc) {
throw fail(
"ENODESCRIPTION",
"a skill needs a description — Claude reads it to decide when to use the skill"
);
}
return `---\nname: ${name}\ndescription: ${quote(desc)}\n---\n\n${body.replace(/^\s+/, "")}`;
}
let lines = fm[1].split(/\r?\n/);
const has = (k) => lines.some((l) => new RegExp(`^${k}:`).test(l));
lines = has("name")
? lines.map((l) => (/^name:/.test(l) ? `name: ${name}` : l))
: [`name: ${name}`, ...lines];
if (desc) {
lines = has("description")
? lines.map((l) => (/^description:/.test(l) ? `description: ${quote(desc)}` : l))
: [...lines, `description: ${quote(desc)}`];
} else if (!frontmatterField(body, "description")) {
throw fail(
"ENODESCRIPTION",
"a skill needs a description — Claude reads it to decide when to use the skill"
);
}
return `---\n${lines.join("\n")}\n---\n${body.slice(fm[0].length)}`;
}
/** Finish one skill: its name, its SKILL.md frontmatter, the checks, the snapshot. */
function saveSkillFiles(req, { name, files, title, description }) {
const md = files.find((f) => f.path === "SKILL.md");
const text = md.data.toString("utf8");
if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file");
const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8");
const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)];
checkSkillFiles(all);
return storeSkill(req, { name, files: all, title, description });
}
/**
* Add skills from uploaded files (multipart: `files` + their relative
* `paths`). Answers every skill it made. Nothing is stored unless every
* skill in the upload passes.
*/
function uploadSkills(req, { uploads, name, title, description }) {
const found = skillsFromUpload(uploads || [], { name, description });
const typed = typeof name === "string" && name.trim();
const prepared = found.map((sk) => ({
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
files: sk.files,
}));
const dup = prepared.map((p) => p.name).find((n, i, a) => a.indexOf(n) !== i);
if (dup) throw fail("EBADNAME", `two skills in the upload are both named ${dup}`);
for (const p of prepared) {
const md = p.files.find((f) => f.path === "SKILL.md");
// Checked up front so a bad second skill stores nothing.
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
checkSkillFiles(p.files);
}
return prepared.map((p) =>
saveSkillFiles(req, {
name: p.name,
files: p.files,
title: found.length === 1 ? title : "",
description: found.length === 1 ? description : "",
})
);
}
/** A skill written in the dialog: a name, a description and its instructions. */
function writeSkill(req, { name, description, content, title }) {
const n = validSkillName(name);
const text = typeof content === "string" ? content : "";
if (!text.trim()) throw fail("EBADSKILL", "write the skill's instructions");
if (Buffer.byteLength(text) > SKILL_MAX_BYTES) throw fail("ETOOBIG", "the skill is larger than 5 MB");
return saveSkillFiles(req, {
name: n,
files: [{ path: "SKILL.md", data: Buffer.from(text, "utf8") }],
title,
description,
});
}
// ── plugin ────────────────────────────────────────────────────────────────
/** A marketplace a plugin may be installed from: `owner/repo` or a remote URL. */
@@ -550,9 +857,164 @@ function validMarket(market) {
}
}
const MARKET_JSON_MAX = 1024 * 1024;
const MARKET_TIMEOUT_MS = 45_000;
let inspectImpl = null; // tests: (market) => marketplace.json text
/**
* What a user pasted, made into a marketplace source Claude Code takes:
* `owner/repo`, a git URL (a forge's web link `…/src/branch/main`,
* `…/tree/main` or a trailing slash trimmed off), or an https link to a
* `marketplace.json`.
*/
function normalizeMarket(raw) {
let m = typeof raw === "string" ? raw.trim() : "";
const gh = /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(m);
if (gh && GITHUB_REPO_RE.test(gh[1])) return gh[1];
if (/^https:\/\//.test(m) && !/\.json$/i.test(m)) {
m = m.replace(/\/(?:src|tree|blob)\/.*$/, "").replace(/\/+$/, "");
}
return validMarket(m);
}
/** An https URL git can clone for `market` (owner/repo = GitHub), or null (ssh). */
function cloneUrl(market) {
if (GITHUB_REPO_RE.test(market)) return `https://github.com/${market}.git`;
return market.startsWith("https://") ? market : null;
}
/** The text of a marketplace's `.claude-plugin/marketplace.json`. */
async function fetchMarketJson(market) {
if (inspectImpl) return inspectImpl(market);
if (/^https:\/\/.*\.json$/i.test(market)) {
let res;
try {
res = await fetch(market, { signal: AbortSignal.timeout(MARKET_TIMEOUT_MS), redirect: "follow" });
} catch (e) {
throw fail("EMARKETFETCH", `could not read ${market}: ${e.message}`);
}
if (!res.url.startsWith("https://")) throw fail("EMARKETFETCH", "the link redirected off https");
if (!res.ok) throw fail("EMARKETFETCH", `${market} answered ${res.status}`);
const text = await res.text();
if (text.length > MARKET_JSON_MAX) throw fail("ETOOBIG", "marketplace.json is larger than 1 MB");
return text;
}
const url = cloneUrl(market);
if (!url) {
throw fail(
"ESSHMARKET",
"an ssh link cannot be read from here — paste the https link to list its plugins, or type name@marketplace by hand"
);
}
const { git } = require("./worktree");
const tmp = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "agenthub-market-"));
// No login is ever sent: the machine's credential helpers are switched off,
// so a URL a browser names never receives a token stored for another use.
const env = { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "" };
const safe = [
"-c", "credential.helper=",
"-c", "protocol.allow=never",
"-c", "protocol.https.allow=always",
"-c", "core.hooksPath=/dev/null",
];
try {
try {
await git(tmp, [...safe, "clone", "--depth", "1", "--filter=blob:none", "--no-checkout", "--quiet", "--", url, "repo"], {
literal: true,
timeout: MARKET_TIMEOUT_MS,
env,
});
} catch (e) {
const why = String(e?.git?.stderr || e?.stderr || e?.message || "");
if (/terminal prompts disabled|could not read Username|Authentication failed|403|401/i.test(why)) {
throw fail(
"ENEEDLOGIN",
"the repository does not exist, or it needs a login to read — check the link, or type the plugin as name@marketplace by hand (installing uses each user's own git login)"
);
}
if (/not found|does not exist|404/i.test(why)) throw fail("EMARKETFETCH", "no repository at that link");
throw fail("EMARKETFETCH", `could not read the repository: ${why.trim().split("\n").pop()}`);
}
const repo = path.join(tmp, "repo");
const show = (p) =>
git(repo, ["show", `HEAD:${p}`], { literal: true, timeout: MARKET_TIMEOUT_MS, env, maxBuffer: MARKET_JSON_MAX }).then(
(r) => (typeof r === "string" ? r : r.stdout),
() => null
);
const text = await show(".claude-plugin/marketplace.json");
if (text != null) return text;
if ((await show(".claude-plugin/plugin.json")) != null) {
throw fail(
"ENOTMARKET",
"this repository is one plugin, not a marketplace — Claude Code installs plugins from a marketplace (a repo with .claude-plugin/marketplace.json). Add one to it, or share its skills as Skill artifacts"
);
}
throw fail("ENOTMARKET", "no .claude-plugin/marketplace.json in this repository");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}
/**
* Read a marketplace and list its plugins:
* `{ market, name, description, plugins: [{ name, key, description, version, category }] }`.
*/
async function inspectMarket(raw) {
const market = normalizeMarket(raw);
const text = await fetchMarketJson(market);
let json;
try {
json = JSON.parse(text);
} catch {
throw fail("EBADMARKET", "marketplace.json is not valid JSON");
}
const name = typeof json?.name === "string" ? json.name.trim() : "";
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
throw fail("EBADMARKET", "marketplace.json has no usable name");
}
const str = (v, n = DESCRIPTION_MAX) => (typeof v === "string" ? v.trim().slice(0, n) : "");
const plugins = (Array.isArray(json.plugins) ? json.plugins : [])
.filter((p) => p && typeof p.name === "string" && PLUGIN_RE.test(`${p.name.trim()}@${name}`))
.slice(0, 500)
.map((p) => ({
name: p.name.trim(),
key: `${p.name.trim()}@${name}`,
description: str(p.description),
version: str(p.version, 50),
category: str(p.category, 50),
}));
return { market, name, description: str(json.description || json.metadata?.description), plugins };
}
function __setInspectImpl(fn) {
inspectImpl = fn;
}
/**
* Add a plugin by `name@marketplace` and its marketplace source. The same
* plugin added again by its owner updates that row instead of a duplicate.
*/
function addPlugin(req, { key, market, title, description }) {
const k = typeof key === "string" ? key.trim() : "";
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
const ownerId = req?.user ? req.user.id : null;
const src = normalizeMarket(market);
const same = sql("SELECT * FROM artifacts WHERE kind = 'plugin' AND source = ? AND owner_id IS ?").get(
k,
ownerId
);
if (same) {
sql(
`UPDATE artifacts SET market = ?, title = ?, description = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(
src,
cleanTitle(title, same.title),
cleanDescription(description) || same.description,
same.id
);
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(same.id), req);
}
return view(
insert({
kind: "plugin",
@@ -560,7 +1022,7 @@ function addPlugin(req, { key, market, title, description }) {
description: cleanDescription(description),
owner_id: req?.user ? req.user.id : null,
source: k,
market: validMarket(market),
market: src,
}),
req
);
@@ -595,6 +1057,16 @@ function update(req, id, body = {}) {
sets.push("description = ?");
params.push(cleanDescription(body.description));
}
if (row.kind === "plugin" && "source" in body) {
const k = typeof body.source === "string" ? body.source.trim() : "";
if (!PLUGIN_RE.test(k)) throw fail("EBADPLUGIN", "plugin: name@marketplace");
sets.push("source = ?");
params.push(k);
}
if (row.kind === "plugin" && "market" in body) {
sets.push("market = ?");
params.push(normalizeMarket(body.market));
}
let shareChange = null;
if ("shared" in body) {
if (typeof body.shared !== "boolean") throw fail("EBADFIELD", "shared: true or false");
@@ -771,6 +1243,13 @@ module.exports = {
pinFile,
availableSkills,
addSkill,
uploadSkills,
writeSkill,
uploadFile,
skillSlug,
withFrontmatter,
normalizeMarket,
inspectMarket,
addPlugin,
update,
remove,
@@ -779,4 +1258,5 @@ module.exports = {
installsEnabled,
setInstallsEnabled,
__setExecImpl,
__setInspectImpl,
};
+3
View File
@@ -142,6 +142,8 @@ const publicUser = (u) =>
disabled: Boolean(u.disabled),
displayName: u.display_name ?? null,
email: u.email ?? null,
// When the avatar was last set (null = none): the client's cache-buster.
avatarAt: u.avatar_at ?? null,
createdAt: u.created_at ?? null,
};
@@ -349,6 +351,7 @@ function deleteUser(username) {
const lanesLib = require("./lanes");
for (const lane of lanes) lanesLib.deleteLane(lane.id);
sql("DELETE FROM user_prefs WHERE user_key = ?").run(`u${u.id}`);
require("./avatars").removeAvatar(u.id);
try {
sql("DELETE FROM push_subscriptions WHERE user_id = ?").run(u.id);
} catch {
+71
View File
@@ -0,0 +1,71 @@
/**
* @file avatars.js
* @description A server-mode account's own avatar (Settings → Account, shown
* in the sidebar). One raster image per account, stored as
* `<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.
* 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>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getDataDir } = require("./claude-home");
const { sniffImage } = require("./pets");
const MAX_AVATAR_BYTES = 1024 * 1024;
const EXTS = ["png", "jpg", "gif", "webp"];
const TYPES = { png: "image/png", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
const fail = (code, message) => Object.assign(new Error(message), { code });
const avatarsDir = () => path.join(getDataDir(), "avatars");
function fileName(userId, ext) {
const id = Number(userId);
if (!Number.isSafeInteger(id) || id <= 0) throw fail("EBADFIELD", "bad account id");
return path.join(avatarsDir(), `u${id}.${ext}`);
}
const db = () => require("../db").db;
/** Deletes every stored avatar file of the account and clears `avatar_at`. */
function removeAvatar(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));
}
/** Stores `buf` as the account's avatar (EBADIMAGE / ETOOBIG); returns `avatar_at`. */
function setAvatar(userId, buf) {
if (!Buffer.isBuffer(buf) || buf.length === 0) throw fail("EBADIMAGE", "no image sent");
if (buf.length > MAX_AVATAR_BYTES) throw fail("ETOOBIG", "the image is too large");
const ext = sniffImage(buf);
if (!ext) throw fail("EBADIMAGE", "only PNG, JPEG, GIF or WebP images");
const target = fileName(userId, ext);
fs.mkdirSync(avatarsDir(), { recursive: true, mode: 0o700 });
// Written beside, then renamed: a reader never sees half an image.
const tmp = `${target}.${process.pid}.tmp`;
fs.writeFileSync(tmp, buf, { mode: 0o600 });
fs.renameSync(tmp, target);
for (const other of EXTS) {
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));
return at;
}
/** The account's avatar file and its type, or null when it has none. */
function avatarFile(userId) {
for (const ext of EXTS) {
const file = fileName(userId, ext);
const st = fs.lstatSync(file, { throwIfNoEntry: false });
if (st?.isFile()) return { path: file, type: TYPES[ext] };
}
return null;
}
module.exports = { MAX_AVATAR_BYTES, setAvatar, removeAvatar, avatarFile, avatarsDir };
+18 -53
View File
@@ -15,9 +15,9 @@
* 1. an account whose last quota reading is ≥ `BLOCK_PERCENT` in either
* window goes last (still used when every account is that full);
* 2. fewest live panes (spreads concurrent sessions);
* 3. least $ spent in the last 5 hours, then in the last 7 days — summed from
* `token_usage_hourly` for the sessions its runs (`dashboard_runs.account`)
* started.
* 3. least $ spent in the last 5 hours, then in the last 7 days — the
* sessions its runs (`dashboard_runs.account`) held, over the hours they
* held them (pool-usage.js).
* The quota is read with the account's own token in the background, at most
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
* reading, and ranking falls back to spend.
@@ -375,47 +375,8 @@ function quotaPercent(name) {
// ── What AgentHub recorded per account ─────────────────────────────────────
const USAGE_COLS = [
"input_tokens",
"output_tokens",
"cache_read_tokens",
"cache_write_tokens",
"cache_write_1h_tokens",
"web_search_requests",
"web_fetch_requests",
"code_execution_requests",
"long_input_tokens",
"long_output_tokens",
"long_cache_read_tokens",
"long_cache_write_tokens",
"long_cache_write_1h_tokens",
];
/**
* $ spent on `account` since `sinceHour` (`YYYY-MM-DDTHH`, UTC): the hourly
* usage of every session one of its runs started, from that run's start hour.
* ponytail: a session later resumed on ANOTHER account keeps counting here
* too; split by run end time if that ever skews a ranking.
*/
function spendSince(account, sinceHour) {
const { db, stmts } = require("../db");
const { calculateCost } = require("../routes/pricing");
const rows = db
.prepare(
`WITH s AS (
SELECT session_id, MIN(substr(started_at, 1, 13)) AS since
FROM dashboard_runs
WHERE tool = 'claude' AND account = ? AND session_id IS NOT NULL
GROUP BY session_id)
SELECT h.model, h.speed, h.inference_geo, h.service_tier,
${USAGE_COLS.map((c) => `SUM(h.${c}) AS ${c}`).join(", ")}
FROM s JOIN token_usage_hourly h
ON h.session_id = s.session_id AND h.hour >= s.since AND h.hour >= ?
GROUP BY h.model, h.speed, h.inference_geo, h.service_tier`
)
.all(account, sinceHour);
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
}
/** $ and tokens of the last 5 h / 7 d (pool-usage.js). */
const usageOf = (account) => require("./pool-usage").accountUsage("claude", account);
function livePanes(account) {
const { db } = require("../db");
@@ -426,8 +387,6 @@ function livePanes(account) {
.get(account).n;
}
const hourAgo = (hours) => new Date(Date.now() - hours * 3600e3).toISOString().slice(0, 13);
/** Every account with what the balancer sees — never a token. */
function poolStatus() {
return allAccounts().map((a) => {
@@ -442,8 +401,7 @@ function poolStatus() {
expiresAt: expiresAt(a),
expired: expired(a),
livePanes: livePanes(a.name),
spend5h: spendSince(a.name, hourAgo(5)),
spend7d: spendSince(a.name, hourAgo(24 * 7)),
...usageOf(a.name),
quota: q
? {
fiveHour: q.fiveHour || null,
@@ -481,8 +439,11 @@ function pick(member) {
let usable = allAccounts().filter((a) => !expired(a));
if (usable.length === 0) return null;
if (!member) {
// A host pane's home holds the host's own login.
usable = usable.filter((a) => a.kind === "token");
// A host pane's home holds the host's own login — a pool account only when
// it was moved in from there (`hostHolds`) and is still the pair on disk:
// the pane then runs on it as it is, and its usage is that account's.
const disk = homeCreds(HOST_ACCT);
usable = usable.filter((a) => a.kind === "token" || ownsHome(a, HOST, disk));
} else {
const disk = homeCreds(member);
const placed = disk && usable.find((a) => ownsHome(a, member.name, disk));
@@ -501,13 +462,14 @@ function pick(member) {
const ranked = usable
.map((a) => {
const percent = quotaPercent(a.name);
const u = usageOf(a.name);
return {
a,
key: [
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
livePanes(a.name),
spendSince(a.name, hourAgo(5)),
spendSince(a.name, hourAgo(24 * 7)),
u.spend5h,
u.spend7d,
],
};
})
@@ -524,7 +486,8 @@ function pick(member) {
* The account a new Claude pane should run on — `{name, kind, token?}` — or
* null when the pool has nothing this pane may use. Synchronous (a spawn
* cannot wait on the network): it ranks on the cached quota and starts a
* background re-read. A `login` is only ever offered for a member.
* background re-read. A `login` is offered for a member, and for a host pane
* only when it is the host's own login (`hostHolds`), which stays as it is.
*/
function pickAccount(member = null) {
const a = pick(member);
@@ -545,6 +508,8 @@ function forSpawn(member = null) {
if (a.kind === "token") {
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
}
// The host's own login is already in place; the server keeps it fresh.
if (!member) return { name: a.name, env: {}, place: () => {} };
return {
name: a.name,
env: {},
+7 -24
View File
@@ -155,29 +155,12 @@ function livePanes(account) {
}
/**
* $ of the Codex sessions this account's runs started since `hours` ago.
* $ and tokens of the last 5 h / 7 d (pool-usage.js).
* ponytail: Codex usage has no hourly table, so a session counts whole once
* its run started in the window; add hourly rows if that skews a ranking.
*/
function spendSince(account, hours) {
const { db, stmts } = require("../db");
const { calculateCost } = require("../routes/pricing");
const since = new Date(Date.now() - hours * 3600e3).toISOString();
const rows = db
.prepare(
`SELECT model, speed, inference_geo, service_tier,
SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens,
SUM(cache_read_tokens) AS cache_read_tokens,
SUM(cache_write_tokens) AS cache_write_tokens
FROM token_usage
WHERE session_id IN (SELECT session_id FROM dashboard_runs
WHERE tool = 'codex' AND account = ?
AND session_id IS NOT NULL AND started_at >= ?)
GROUP BY model, speed, inference_geo, service_tier`
)
.all(account, since);
return rows.length ? calculateCost(rows, stmts.listPricing.all()).total_cost : 0;
}
const usageOf = (account) =>
require("./pool-usage").accountUsage("codex", account, { wholeSession: true });
// ── Quota, from the account's own rollouts ─────────────────────────────────
@@ -262,8 +245,7 @@ function poolStatus() {
kind: a.kind,
addedAt: a.addedAt,
livePanes: livePanes(a.name),
spend5h: spendSince(a.name, 5),
spend7d: spendSince(a.name, 24 * 7),
...usageOf(a.name),
quota: quotas().get(a.name) || null,
quotaPercent: percent,
full: percent != null && percent >= BLOCK_PERCENT,
@@ -374,13 +356,14 @@ function pick(member) {
const ranked = usable
.map((a) => {
const percent = quotaPercent(a.name);
const u = usageOf(a.name);
return {
a,
key: [
percent != null && percent >= BLOCK_PERCENT ? 1 : 0,
livePanes(a.name),
spendSince(a.name, 5),
spendSince(a.name, 24 * 7),
u.spend5h,
u.spend7d,
],
};
})
+10 -5
View File
@@ -736,12 +736,17 @@ function inferredDisplayStage(lane) {
return lane.detected_stage;
}
/** The owner's username and display name — the staff sidebar groups
* workspaces by owner and shows the display name when one is set. */
/** The owner's username, display name and avatar version — the staff
* sidebar groups workspaces by owner and shows the display name and picture
* (`GET /api/users/:id/avatar`) when one is set. */
function ownerNames(id) {
if (id == null) return { owner_name: null, owner_display_name: null };
const u = db.prepare("SELECT username, display_name FROM users WHERE id = ?").get(id);
return { owner_name: u?.username ?? null, owner_display_name: u?.display_name ?? null };
if (id == null) return { owner_name: null, owner_display_name: null, owner_avatar_at: null };
const u = db.prepare("SELECT username, display_name, avatar_at FROM users WHERE id = ?").get(id);
return {
owner_name: u?.username ?? null,
owner_display_name: u?.display_name ?? null,
owner_avatar_at: u?.avatar_at ?? null,
};
}
function lanePayload(lane, ageSec = null) {
+1
View File
@@ -333,5 +333,6 @@ module.exports = {
removePet,
spriteFile,
webpSize,
sniffImage,
__setFetch,
};
+64
View File
@@ -0,0 +1,64 @@
/**
* @file pool-usage.js
* @description $ and tokens one account-pool account spent in the last 5 hours
* and 7 days, for the balancer and `GET /api/accounts` (claude-pool.js,
* codex-pool.js). Tokens are booked to hours by `hourlyTokenRows` — the same
* booking as the daily cost split, so sub-agent buckets and pre-compaction
* baselines count — and priced by `calculateCost`, never a second formula.
* A Claude session counts only for the hours one of THIS account's runs held
* it (start hour to end hour), so a session resumed on another account is
* not booked twice; Codex has no hourly rows, so its session counts whole
* once a run of the account started in the window, as before.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const WINDOWS = { "5h": 5, "7d": 24 * 7 };
const hourAgo = (hours, now) => new Date(now - hours * 3600e3).toISOString().slice(0, 13);
const tokensOf = (r) =>
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
/**
* `{spend5h, spend7d, tokens5h, tokens7d}` of `account` on `tool`. With
* `wholeSession`, a session's every row counts in a window its run started in.
*/
function accountUsage(tool, account, { wholeSession = false, now = Date.now() } = {}) {
const { db, stmts } = require("../db");
const { calculateCost, hourlyTokenRows } = require("../routes/pricing");
const since7d = new Date(now - WINDOWS["7d"] * 3600e3).toISOString();
const runs = db
.prepare(
`SELECT session_id, substr(started_at, 1, 13) AS start_hour, started_at,
substr(COALESCE(ended_at, '9999'), 1, 13) AS end_hour
FROM dashboard_runs
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
AND COALESCE(ended_at, '9999') >= ?`
)
.all(tool, account, since7d);
const out = { spend5h: 0, spend7d: 0, tokens5h: 0, tokens7d: 0 };
if (runs.length === 0) return out;
const runsOf = new Map();
for (const r of runs) runsOf.set(r.session_id, [...(runsOf.get(r.session_id) || []), r]);
const rows = hourlyTokenRows(
`WHERE s.id IN (SELECT session_id FROM dashboard_runs
WHERE tool = ? AND account = ? AND session_id IS NOT NULL
AND COALESCE(ended_at, '9999') >= ?)`,
[tool, account, since7d]
);
const rules = stmts.listPricing.all();
for (const [label, hours] of Object.entries(WINDOWS)) {
const sinceHour = hourAgo(hours, now);
const sinceIso = new Date(now - hours * 3600e3).toISOString();
const held = rows.filter((row) =>
(runsOf.get(row.session_id) || []).some((run) =>
wholeSession
? run.started_at >= sinceIso
: row.date >= sinceHour && row.date >= run.start_hour && row.date <= run.end_hour
)
);
out[`spend${label}`] = held.length ? calculateCost(held, rules).total_cost : 0;
out[`tokens${label}`] = Math.round(held.reduce((n, r) => n + tokensOf(r), 0));
}
return out;
}
module.exports = { accountUsage };
+172
View File
@@ -0,0 +1,172 @@
/**
* @file user-storage.js
* @description How much disk each server-mode user's HOME folder takes
* (`homes.homeOf`: their own Linux home under S3, their folder under
* AGENTHUB_USERS_ROOT otherwise) — the Users page's Storage column
* (`GET /api/users/storage`). A home is walked in a worker thread, never on
* the event loop (a member decides how many files their home holds), one home
* at a time, under a time limit (`AGENTHUB_STORAGE_TIMEOUT_MS`, default 120 s
* — past it the number is a lower bound, `partial`). The walk is `du`-like and
* read-only: `lstat` only (no file is opened, a symlink is never followed),
* stays on the home's file system, counts allocated blocks (a sparse file
* costs what it really uses) and a hard-linked inode once. Results are cached
* in memory for `FRESH_MS`; a read returns the cache at once and measures the
* stale homes in the background. A user whose "home" is only the host
* account's own (an admin with no folder of their own) is not measured.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Worker, isMainThread, workerData, parentPort } = require("node:worker_threads");
const fs = require("node:fs");
const path = require("node:path");
const FRESH_MS = 15 * 60_000;
/** Walk `root`: `{ bytes, files, dirs, partial }`. Runs in the worker. */
function walk(root, deadline) {
const out = { bytes: 0, files: 0, dirs: 0, partial: false };
let top;
try {
top = fs.lstatSync(root, { bigint: false });
} catch {
return { ...out, partial: true };
}
if (!top.isDirectory()) return out;
const seen = new Set(); // dev:ino of hard-linked files already counted
const stack = [root];
while (stack.length) {
if (Date.now() > deadline) {
out.partial = true;
break;
}
const dir = stack.pop();
let names;
try {
names = fs.readdirSync(dir);
} catch {
out.partial = true; // unreadable (no CAP_DAC_READ_SEARCH, a race): a lower bound
continue;
}
for (const name of names) {
const p = path.join(dir, name);
let st;
try {
st = fs.lstatSync(p);
} catch {
continue; // gone meanwhile
}
if (st.nlink > 1 && !st.isDirectory()) {
const key = `${st.dev}:${st.ino}`;
if (seen.has(key)) continue;
seen.add(key);
}
out.bytes += st.blocks * 512;
if (st.isDirectory()) {
out.dirs += 1;
if (st.dev === top.dev) stack.push(p); // one file system, like du -x
} else {
out.files += 1;
}
}
}
out.bytes += top.blocks * 512;
return out;
}
if (!isMainThread && workerData?.agenthubStorageWorker) {
const { dir, timeoutMs } = workerData;
parentPort.postMessage(walk(dir, Date.now() + timeoutMs));
}
function timeoutMs() {
return Number(process.env.AGENTHUB_STORAGE_TIMEOUT_MS) || 120_000;
}
/** Measure one folder in a fresh worker; never rejects. */
function measure(dir) {
return new Promise((resolve) => {
let w;
const fail = () => resolve({ bytes: 0, files: 0, dirs: 0, partial: true });
try {
w = new Worker(__filename, {
workerData: { agenthubStorageWorker: true, dir, timeoutMs: timeoutMs() },
});
} catch {
return fail();
}
// The walk stops itself at the deadline; this only catches a worker stuck
// inside one syscall (a hung network mount).
const kill = setTimeout(() => {
w.terminate().catch(() => {});
fail();
}, timeoutMs() + 30_000);
kill.unref?.();
w.once("message", (r) => {
clearTimeout(kill);
resolve(r);
w.terminate().catch(() => {});
});
w.once("error", () => {
clearTimeout(kill);
fail();
});
});
}
const cache = new Map(); // dir → { bytes, files, dirs, partial, measuredAt }
const queue = []; // dirs waiting to be measured
let running = null; // dir being measured
function pump() {
if (running || !queue.length) return;
running = queue.shift();
const dir = running;
measure(dir).then((r) => {
cache.set(dir, { ...r, measuredAt: new Date().toISOString() });
running = null;
pump();
});
}
function enqueue(dir) {
if (running === dir || queue.includes(dir)) return;
queue.push(dir);
pump();
}
/**
* The storage of each of `users` (rows from `auth.listUsers()`):
* `{ storage: { [id]: { bytes, files, partial, measuredAt } | null }, measuring }`.
* `null` = nothing to measure (no home of their own) or not measured yet —
* `measuring` says whether more is coming. `refresh` re-measures every home.
*/
function storageFor(users, { refresh = false } = {}) {
const os = require("node:os");
const { homeOf } = require("./homes");
const hostHome = path.resolve(os.homedir());
const storage = {};
let measuring = false;
for (const u of users) {
let dir = null;
try {
dir = homeOf(u);
} catch {
dir = null;
}
if (!dir || path.resolve(dir) === hostHome) {
storage[u.id] = null;
continue;
}
dir = path.resolve(dir);
const hit = cache.get(dir);
const stale = !hit || refresh || Date.now() - Date.parse(hit.measuredAt) > FRESH_MS;
if (stale) enqueue(dir);
if (running === dir || queue.includes(dir)) measuring = true;
storage[u.id] = hit
? { bytes: hit.bytes, files: hit.files, partial: hit.partial, measuredAt: hit.measuredAt }
: null;
}
return { storage, measuring };
}
module.exports = { storageFor, walk, _cache: cache };
+63
View File
@@ -11,7 +11,9 @@
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
* - 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
* - 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)
* Exports `{ tags, schemas, paths }`, combined into the spec by
* server/openapi-extra.js. See docs/SERVER-MODE.md.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -44,6 +46,11 @@ const schemas = {
disabled: { type: "boolean" },
displayName: { type: "string", nullable: true },
email: { type: "string", nullable: true },
avatarAt: {
type: "string",
nullable: true,
description: "When the avatar was last set (null = none)",
},
createdAt: { type: "string", nullable: true },
},
},
@@ -283,6 +290,45 @@ const paths = {
},
},
},
"/api/me/avatar": {
get: {
tags: ["Auth"],
operationId: "getMyAvatar",
summary: "The account's own picture (nosniff, sandbox CSP)",
responses: {
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
401: err("EAUTH"),
404: err("ENOAVATAR"),
},
},
put: {
tags: ["Auth"],
operationId: "setMyAvatar",
summary:
"Set the account's picture from the raw image bytes (PNG/JPEG/GIF/WebP, at most 1 MB). Login cookie only, audited",
requestBody: {
required: true,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
responses: {
200: { description: "Set", content: accountBody },
400: err("EBADIMAGE"),
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("ELOCALMODE"),
413: err("ETOOBIG"),
},
},
delete: {
tags: ["Auth"],
operationId: "removeMyAvatar",
summary: "Remove the account's picture",
responses: {
200: { description: "Removed", content: accountBody },
401: err("EAUTH — no login cookie (a pane token is refused)"),
403: err("ELOCALMODE"),
},
},
},
"/api/users": {
get: {
tags: ["Auth"],
@@ -363,6 +409,19 @@ const paths = {
},
},
},
"/api/users/{id}/avatar": {
get: {
tags: ["Auth"],
operationId: "getUserAvatar",
summary: "One account's picture, for the sidebar's owner groups (admin/analyst)",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
403: err("EADMIN — a developer"),
404: err("ENOAVATAR"),
},
},
},
"/api/users/{id}": {
get: {
tags: ["Auth"],
@@ -493,6 +552,8 @@ const paths = {
livePanes: { type: "integer" },
spend5h: { type: "number" },
spend7d: { type: "number" },
tokens5h: { type: "integer" },
tokens7d: { type: "integer" },
quota: { type: "object", nullable: true },
quotaPercent: { type: "number", nullable: true },
full: { type: "boolean" },
@@ -512,6 +573,8 @@ const paths = {
livePanes: { type: "integer" },
spend5h: { type: "number" },
spend7d: { type: "number" },
tokens5h: { type: "integer" },
tokens7d: { type: "integer" },
quota: { type: "object", nullable: true },
quotaPercent: { type: "number", nullable: true },
full: { type: "boolean" },
+92 -1
View File
@@ -1,7 +1,9 @@
/**
* @file artifacts.js (routes)
* @description `/api/artifacts` — the Artifacts hub (server/lib/artifacts.js):
* list, add (a claude.ai page, a pinned workspace file, a skill, a plugin),
* list, add (a claude.ai page, a pinned workspace file or an uploaded one, a
* skill — picked, uploaded (file, folder, zip) or written —, a plugin, after
* reading its marketplace's plugin list from a link),
* rename/share/unshare, delete, read a snapshot file (preview/download, under
* a `sandbox` CSP with `nosniff`, so nothing shared runs on the dashboard's
* origin), and install a shared skill or plugin into the caller's own
@@ -32,6 +34,8 @@ const STATUS = {
EUPGRADE: 409,
ENOTSHARED: 409,
EPLUGIN: 502,
EMARKETFETCH: 502,
ENEEDLOGIN: 502,
ETOOBIG: 413,
};
@@ -152,6 +156,93 @@ router.post(
})
);
// Read a marketplace (a git link, owner/repo, or a marketplace.json link)
// and list its plugins to pick from. A POST: it reaches the network.
router.post(
"/plugin/inspect",
sameOriginGuard,
wrap(async (req, res) => res.json(await artifacts.inspectMarket(req.body?.market)))
);
// A skill written in the dialog: {name, description, content, title?}.
router.post(
"/skill/new",
sameOriginGuard,
wrap((req, res) => {
const item = artifacts.writeSkill(req, req.body || {});
changed(item);
res.status(201).json({ item, items: [item] });
})
);
/** multer, in memory, for one upload route; errors answered like ours. */
function upload(limits) {
return (req, res, next) => {
const multer = require("multer");
multer({ storage: multer.memoryStorage(), limits }).array("files")(req, res, (err) => {
if (!err) return next();
const big = err.code === "LIMIT_FILE_SIZE" || err.code === "LIMIT_FILE_COUNT";
return res.status(big ? 413 : 400).json({
error: { code: big ? "ETOOBIG" : "EBADUPLOAD", message: err.message },
});
});
};
}
/** Each uploaded file with the relative path the browser sent in `paths` (a folder pick keeps it). */
function uploads(req) {
let paths = [];
try {
paths = JSON.parse(req.body?.paths || "[]");
} catch {
paths = [];
}
return (req.files || []).map((f, i) => ({
path: typeof paths[i] === "string" && paths[i] ? paths[i] : f.originalname,
data: f.buffer,
}));
}
// Skills from files: one Markdown file, a folder, several files, a .zip/.skill,
// or several skills at once (server/lib/artifacts.js `skillsFromUpload`).
router.post(
"/skill/upload",
sameOriginGuard,
upload({ files: 400, fileSize: 10 * 1024 * 1024, fields: 10 }),
wrap((req, res) => {
const b = req.body || {};
const items = artifacts.uploadSkills(req, {
uploads: uploads(req),
name: b.name,
title: b.title,
description: b.description,
});
for (const item of items) changed(item);
res.status(201).json({ items });
})
);
// One file from the user's computer, kept like a pinned workspace file.
router.post(
"/file/upload",
sameOriginGuard,
upload({ files: 1, fileSize: 25 * 1024 * 1024, fields: 10 }),
wrap((req, res) => {
const f = (req.files || [])[0];
if (!f) {
return res.status(400).json({ error: { code: "ENOFILE", message: "attach the file as `files`" } });
}
const item = artifacts.uploadFile(req, {
name: f.originalname,
data: f.buffer,
title: req.body?.title,
description: req.body?.description,
});
changed(item);
res.status(201).json({ item });
})
);
router.post(
"/plugin",
sameOriginGuard,
+50 -1
View File
@@ -22,13 +22,18 @@
* `POST /sign-out-others` ends every login but this one. These writes take the
* account from the login COOKIE only — a pane token (an agent acting as its
* 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) and DELETE removes it — both 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
* the theme in their own Claude Code settings, written as them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const express = require("express");
const { Router } = express;
const { db } = require("../db");
const auth = require("../lib/auth");
const { sameOriginGuard } = require("../lib/same-origin");
@@ -162,6 +167,8 @@ const ACCOUNT_STATUS = {
EBADFIELD: 400,
EBADEMAIL: 400,
EBADPASSWORD: 400,
EBADIMAGE: 400,
ETOOBIG: 413,
EREAUTH: 403,
ETOOMANY: 429,
};
@@ -237,6 +244,48 @@ router.post(
})
);
const avatars = require("../lib/avatars");
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);
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);
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
// The client versions the URL with `avatarAt`, so a new picture is a new URL.
res.set("Cache-Control", "private, max-age=86400");
res.sendFile(file.path);
});
router.put(
"/avatar",
sameOriginGuard,
express.raw({ type: () => true, limit: avatars.MAX_AVATAR_BYTES }),
accountWrite(async (req, user) => {
avatars.setAvatar(user.id, req.body);
return { out: accountView(user.id), action: "own avatar set" };
})
);
router.delete(
"/avatar",
sameOriginGuard,
accountWrite(async (_req, user) => {
avatars.removeAvatar(user.id);
return { out: accountView(user.id), action: "own avatar removed" };
})
);
// An avatar past its limit reaches here as express.raw's 413.
router.use((e, _req, res, next) => {
if (e && e.type === "entity.too.large")
return res.status(413).json({ error: { code: "ETOOBIG", message: "the image is too large" } });
return next(e);
});
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
function followClaudeTheme(req, mode) {
if (mode !== "dark" && mode !== "light") return;
+35 -5
View File
@@ -287,9 +287,32 @@ const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
* its session's `tool`, for the per-tool split.
*/
function dailyTokenRows(tzModifier, whereClause, params) {
return bookedTokenRows(
{ total: "DATE(s.started_at, ?)", hour: "DATE(h.hour || ':00:00', ?)", args: [tzModifier] },
whereClause,
params
);
}
/**
* The same booking per UTC hour (`YYYY-MM-DDTHH` in `date`) and per session
* (`session_id`): what the account pool sums over a run's own hours. The rest
* of a total goes to the session's start hour.
*/
function hourlyTokenRows(whereClause, params) {
return bookedTokenRows(
{ total: "substr(s.started_at, 1, 13)", hour: "h.hour", args: [] },
whereClause,
params,
{ bySession: true }
);
}
/** `slot.total`/`slot.hour` name the period of a total and of an hourly row (`slot.args` bound once each). */
function bookedTokenRows(slot, whereClause, params, { bySession = false } = {}) {
const totals = db
.prepare(
`SELECT tu.session_id, DATE(s.started_at, ?) as date, s.tool,
`SELECT tu.session_id, ${slot.total} as date, s.tool,
${BUCKET_COLS.map((c) => `tu.${c}`).join(", ")},
tu.input_tokens + tu.baseline_input as input_tokens,
tu.output_tokens + tu.baseline_output as output_tokens,
@@ -304,10 +327,10 @@ function dailyTokenRows(tzModifier, whereClause, params) {
JOIN sessions s ON s.id = tu.session_id
${whereClause}`
)
.all(tzModifier, ...params);
.all(...slot.args, ...params);
const hourly = db
.prepare(
`SELECT h.session_id, DATE(h.hour || ':00:00', ?) as date,
`SELECT h.session_id, ${slot.hour} as date,
${BUCKET_COLS.map((c) => `h.${c}`).join(", ")},
${USAGE_COLS.map((c) => `SUM(h.${c}) as ${c}`).join(", ")}
FROM token_usage_hourly h
@@ -315,7 +338,7 @@ function dailyTokenRows(tzModifier, whereClause, params) {
${whereClause}
GROUP BY h.session_id, 2, ${BUCKET_COLS.map((c) => `h.${c}`).join(", ")}`
)
.all(tzModifier, ...params);
.all(...slot.args, ...params);
const keyOf = (r) => [r.session_id, ...BUCKET_COLS.map((c) => r[c])].join("\u0001");
const hoursByBucket = new Map();
@@ -327,12 +350,18 @@ function dailyTokenRows(tzModifier, whereClause, params) {
const out = new Map();
const add = (date, bucket, values) => {
const k = [date, bucket.tool, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
const k = [
date,
bucket.tool,
...(bySession ? [bucket.session_id] : []),
...BUCKET_COLS.map((c) => bucket[c]),
].join("\u0001");
let row = out.get(k);
if (!row) {
row = {
date,
tool: bucket.tool,
...(bySession ? { session_id: bucket.session_id } : {}),
...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])),
};
for (const c of USAGE_COLS) row[c] = 0;
@@ -697,6 +726,7 @@ function attachAgentCosts(agents) {
module.exports = router;
module.exports.calculateCost = calculateCost;
module.exports.dailyTokenRows = dailyTokenRows;
module.exports.hourlyTokenRows = hourlyTokenRows;
module.exports.costWhere = costWhere;
module.exports.computeCost = computeCost;
module.exports.agentOwnCost = agentOwnCost;
+22 -2
View File
@@ -5,9 +5,12 @@
* `GET /` lists every account with its workspaces, sessions and $ spent in the
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* (server/lib/audit.js).
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
*
* `GET /:id` is one account with the workspaces it owns, for the Edit user form.
* `GET /:id` is one account with the workspaces it owns, for the Edit user form;
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
* owner groups — staff only like the rest of `/users`.
*
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
* user form: display name, email, role, disabled, a new password, workspaces
@@ -84,6 +87,11 @@ router.get("/", (_req, res) => {
res.json({ users });
});
router.get("/storage", (req, res) => {
const { storageFor } = require("../lib/user-storage");
res.json(storageFor(listUsers(), { refresh: req.query.refresh === "1" }));
});
router.get("/audit", (req, res) => {
res.json({ entries: listAudit(req.query.limit) });
});
@@ -149,6 +157,18 @@ router.get("/:id", (req, res) => {
});
});
router.get("/:id/avatar", (req, res) => {
const id = Number(req.params.id);
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);
res.set("X-Content-Type-Options", "nosniff");
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
// Versioned by the client with the owner's `avatarAt`.
res.set("Cache-Control", "private, max-age=86400");
res.sendFile(file.path);
});
/** A list of workspace ids from the body, or [] when absent. */
function laneIds(value, field) {
if (value === undefined) return [];