Compare commits

...

6 Commits

Author SHA1 Message Date
nntrivi2001 631ea96f19 chore: v2.8.26 — React 19 / Express 5 / xterm 6, bigger file drops, hidden-tab terminal size fix 2026-10-26 16:36:49 +07:00
nntrivi2001 dce32b96b7 fix(drop): stream every dropped file to disk; no local size cap
- POST /api/run/drop-file pipes the body to its file as it arrives in every
  case — a member's (S3) through `write-as … replace` with the bytes on
  stdin — instead of buffering up to 20 MB in memory for a remote browser.
- Remote limit (server mode, a LAN browser) 20 MB → 500 MB, as a Files upload.
- A loopback browser in local mode has no cap any more, only the data dir's
  free space less a 1 GB reserve; ETOOBIG's max_bytes is what still fits, so
  the UI's "at most N MB" stays true.
- drop-image keeps its 20 MB in-memory limit, now checked on its own.
- fake-sudo gains a spawn for run-as.__setSpawnImpl.
2026-10-26 16:36:29 +07:00
nntrivi2001 a0cd808456 fix(terminal): a pane on a hidden tab never shrinks its tmux window
A terminal mounted on a hidden tab sits in a 0x0 box; its first fit shrank
xterm to 12x6 and the attach carried that size to tmux, so every background
pane was squeezed (Claude's TUI redrawn at 12 columns). It now neither fits
nor reports a size until shown, attaches with no size, and the server spawns
such an attach at the window's own size (tmux.windowSize). A shown tab
reports its size even when the fit did not change xterm's.

Settings > Agents shows the host's login profiles to staff only: a developer
got two 403s from reads that were never theirs.
2026-10-26 14:53:29 +07:00
nntrivi2001 e3745d7504 fix(build): target ES2021 so esbuild keeps xterm 6's requestMode variable
Vite's es2020 default made esbuild 0.25 lower `let r; …(r||={})` to
`void 0||(i={})` without the declaration, so every DECRQM query tmux sends
threw "i is not defined" inside the terminal's parser. Found by a headless
smoke run of the preview build.
2026-10-26 14:43:01 +07:00
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00
nntrivi2001 cc2ece94c3 fix(ui): drop the git clone hints from Git identity and the background toast on closing the update dialog 2026-10-26 14:22:18 +07:00
115 changed files with 4426 additions and 2149 deletions
+2 -2
View File
@@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
![Model Context Protocol](https://img.shields.io/badge/Model_Context_Protocol-1.0-0f766e?style=flat-square&logo=modelcontextprotocol&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white)
![Python](https://img.shields.io/badge/Python-%3E%3D3.6-3776AB?style=flat-square&logo=python&logoColor=white)
![Express](https://img.shields.io/badge/Express-4.21-000000?style=flat-square&logo=express&logoColor=white)
![Express](https://img.shields.io/badge/Express-5-000000?style=flat-square&logo=express&logoColor=white)
![ws](https://img.shields.io/badge/ws-WebSocket_server-010101?style=flat-square&logo=socketdotio&logoColor=white)
![web-push](https://img.shields.io/badge/web--push-VAPID-3b82f6?style=flat-square&logo=javascript&logoColor=white)
![swagger-ui-express](https://img.shields.io/badge/swagger--ui--express-5.0-85EA2D?style=flat-square&logo=swagger&logoColor=white)
@@ -2557,7 +2557,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
| **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled |
| **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module |
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
| **React 19** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
+3 -3
View File
File diff suppressed because one or more lines are too long
+67 -69
View File
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
// Asked of the SERVER, not of this shell: a run is a tmux session the server
// spawns, so the only PATH that decides whether Start Run works is its own —
// a systemd --user service never reads your shell profile. Missing tmux is a
// warning, not a failure: it costs the run console and `lanes shell`, nothing
// warning, not a failure: it costs the run console and `workspace shell`, nothing
// else.
try {
const { available, upgrade } = await get("/api/run/tmux");
@@ -1693,7 +1693,7 @@ function cmdOpen() {
}
/**
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
*/
async function cmdLanesAdd(args) {
const flag = (name) => {
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
if (base) body.base = base;
if (slug) body.slug = slug;
if (tool) body.tool = tool;
const { lane: provisioning } = await post("/api/lanes/worktree", body);
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
const deadline = Date.now() + 30_000;
let lane = provisioning;
while (lane.status === "provisioning" && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250));
({ lane } = await get(`/api/lanes/${provisioning.id}`));
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
}
if (lane.status === "provisioning") {
console.error(
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
const body = { cwd, title };
if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body);
const { lane } = await post("/api/workspaces", body);
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
}
/**
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
* (owning session died) and cap oversized hook logs, across every lane on
* this machine. Machine-wide, not one lane's; pure local process/file
* action, no HTTP round-trip.
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
}
/**
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
* talk to the dashboard server at all.
*/
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
}
/**
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
* resolution (unlike every other `lanes` subcommand) — this is meant to run
* resolution (unlike every other `workspace` subcommand) — this is meant to run
* against a bare repo right after `profile init`.
*/
async function cmdLanesProfileCheck(args) {
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
}
}
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
async function cmdLanesLifecycle(action, args) {
const id = args.find((arg) => !arg.startsWith("--"));
const fields = LANE_PREFLIGHT_FIELDS[action];
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
return;
}
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) {
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
const body = { confirm: true, expect };
if (args.includes("--force")) body.force = true;
if (keepDb) body.keepDb = true;
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
if (result.status) {
const error = result.data?.error || {};
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
}
/**
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
* whether the driving session is still breathing.
*/
/**
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
}
async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes");
const { lanes, counts } = await get("/api/workspaces");
if (!lanes.length) {
console.log(
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
*
* Only the FIRST positional counts as an id, and only when it is all digits.
* Scanning the whole argv for a number would swallow flag values —
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
*
* @param {string[]} args - Arguments after the subcommand.
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
return i > -1 ? args[i + 1] : undefined;
};
const explicit = flagValue("lane") || flagValue("workspace");
const explicit = flagValue("workspace");
if (explicit) return { laneId: explicit, rest: args };
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
const tool = flagValue("tool") || toolFromEnv();
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
const { lanes } = await get("/api/lanes");
const { lanes } = await get("/api/workspaces");
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
// against the live lane list AND the directory: a pane outlives the lane it
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
}
/**
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
* lane id.
*
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
const { laneId, rest: laneArgs } = resolved;
if (sub === "runtime") {
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
return;
}
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
const body = {};
if (laneArgs.includes("--no-build")) body.build = false;
if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
if (result.status) {
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
const deadline = Date.now() + 15 * 60 * 1000;
for (;;) {
await new Promise((r) => setTimeout(r, 2000));
const runtime = await get(`/api/lanes/${laneId}/runtime`);
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
if (runtime.healthy) {
printRuntime(runtime);
return;
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
}
if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
if (result.status) {
console.error(
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
}
const i = laneArgs.indexOf("--tail");
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) {
console.log("no logs for this workspace yet.");
return;
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
return;
}
const result = await post(
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
{ args: laneArgs.slice(1) },
{ allowError: true }
);
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
: "merge";
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
const result = await post(
`/api/lanes/${laneId}/sync-base`,
`/api/workspaces/${laneId}/sync-base`,
{ mode, branch },
{ allowError: true }
);
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
const resolved = await resolveLaneArg(argsAfterName);
if (!resolved) return null;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
}
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
async function cmdFeatureList(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
if (!features.length) {
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
return;
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
};
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return;
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
slug,
title: flag("title"),
});
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
}
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
* by anything else in this codebase; a session or hook calls it explicitly. */
async function cmdLanesProofLink(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
console.log(
linked
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
);
}
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
* profiles. Never run automatically; a session calls it explicitly. */
async function cmdLanesMcpSync(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
if (result.status) {
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
}
}
/** `agenthub lanes integration <name> [<id>]` — check whether a named
/** `agenthub workspace integration <name> [<id>]` — check whether a named
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
async function cmdLanesIntegration(args) {
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
if (!resolved) return;
const { enabled } = await get(
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
);
console.log(enabled ? "on" : "off");
process.exitCode = enabled ? 0 : 1;
}
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
const resolved = await resolveLaneArg(args.slice(2));
if (!resolved) return;
const result = await get(
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
undefined,
{ allowError: true }
);
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
}
/**
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
* lane's pipeline instead of the whole thing.
*
* Every node already carries the one command that drives it (`command`, added
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
* pasted it into a session by hand. This runs it, in the lane's own cwd:
*
* - a slash command starts `claude` with it as the prompt;
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
*
* With no node id it lists the nodes and their commands, which is also the
* error path's answer. `--print` shows the argv and runs nothing.
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
const { flags, positional } = parseArgs(args);
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
// the template does. Merge, so the listing can still mark the current node.
const { pipelines } = await get("/api/lanes/pipelines");
const { pipelines } = await get("/api/workspaces/pipelines");
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
...n,
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
}
/**
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `agenthub lanes add --pipeline` could only set
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
* template a lane renders against. `agenthub workspace add --pipeline` could only set
* this at creation time, so every lane added from the dashboard's "+ Add lane"
* was stuck on `default` with no way back to a 16-node template.
*/
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
if (!resolved) return;
if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
const { pipelines } = await get("/api/workspaces/pipelines");
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return;
}
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
console.log(
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
async function cmdLanesShell(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
const sessionName = `agenthub-lane-${lane.id}`;
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
stdio: "inherit",
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return;
const result = await get(
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
undefined,
{ allowError: true }
);
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
if (!resolved) return;
const laneId = resolved.laneId;
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
stage,
status: flag("status"),
evidence: flag("evidence"),
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
require it to be running (${c.bold("agenthub start")} brings one up in the background).
@@ -3512,8 +3512,8 @@ function replHelp() {
/** Detailed help for one command (or command prefix): every catalog entry
* whose invocation starts with the token. Returns false if none matched. */
function commandHelp(token) {
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
// so `help lanes` finds the `workspace …` rows and vice versa.
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
// `help workspaces` finds the `workspace …` rows and vice versa.
const canon = (words) => {
const [head, ...tail] = words.split(" ");
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
// prompt. Handlers that need the server throw ServerDownError; the caller
// decides whether to fall back to an offline reader or report the refusal.
/** Command names that are another command under the user-facing word.
* `workspace` is the name to use; `lanes` (the handler's own name) and
* `--lane` are the OLD names, still working but deprecated. Removing them is
* step 4 of docs/plans/workspace-rename/README.md — not before the third
* release after the first one that shipped these aliases. */
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
/** Command names that are another command under the user-facing word. */
const COMMAND_ALIASES = { workspaces: "workspace" };
const canonicalArgv = (argv) =>
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
/** One stderr line when an old name is typed, so people and agents move over
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
function warnOldNames(argv) {
const old = [];
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
if (old.length) {
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
* — never "Unknown command" or help, so a script written before says why. */
function refuseOldNames(argv) {
if (argv[0] === "lanes") {
console.error(c.red("✖ renamed: use agenthub workspace …"));
process.exit(1);
}
if (argv.includes("--lane")) {
console.error(c.red("✖ renamed: use --workspace <id>"));
process.exit(1);
}
}
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
for (const [name] of items) set.add(name.split(" ")[0]);
}
set.add("help");
// The old names stay typeable (and completable) for scripts written before.
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
return [...set];
})();
@@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
* for usage/validation failures. `cmd === "repl"` is handled by the entry
* points, not here, to avoid recursive REPLs. */
async function runCommand(argv) {
warnOldNames(argv);
refuseOldNames(argv);
const [cmd, ...rest] = canonicalArgv(argv);
const { flags, positional } = parseArgs(rest);
switch (cmd) {
@@ -4571,7 +4569,7 @@ async function runCommand(argv) {
return cmdReinstallHooks();
case "update-check":
return cmdUpdateCheck();
case "lanes":
case "workspace":
if (rest[0] === "add") {
return cmdLanesAdd(rest.slice(1));
}
+3 -3
View File
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
The client is a single-page application (SPA) built with modern web technologies:
- **React 18.3** - Component-based UI with hooks and concurrent features
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
- **React 19** - Component-based UI with hooks and concurrent features
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
@@ -1215,7 +1215,7 @@ graph TB
The client is a production-ready React application with:
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
- ⚡ **Real-time** - WebSocket integration for live updates
- 🧪 **Tested** - Vitest + React Testing Library
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
+427 -139
View File
@@ -15,18 +15,18 @@
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"d3": "^7.9.0",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.474.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^1.61.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4",
@@ -34,17 +34,17 @@
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^27.0.1",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript": "^7.0.2",
"vite": "^6.1.0",
"vitest": "^3.2.4"
}
@@ -450,10 +450,9 @@
}
},
"node_modules/@codemirror/merge": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
"license": "MIT",
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
@@ -478,10 +477,9 @@
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
"license": "MIT",
"version": "6.43.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
@@ -1661,7 +1659,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
@@ -2108,30 +2105,21 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"license": "MIT",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.3.0"
}
},
"node_modules/@types/unist": {
@@ -2140,6 +2128,326 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -2282,59 +2590,38 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-canvas": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/addon-clipboard": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
"license": "MIT",
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
"dependencies": {
"js-base64": "^3.7.5"
},
"peerDependencies": {
"@xterm/xterm": "^5.4.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
},
"node_modules/@xterm/addon-search": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
},
"node_modules/@xterm/addon-web-links": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
},
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
},
"node_modules/agent-base": {
"version": "7.1.4",
@@ -3706,9 +3993,9 @@
}
},
"node_modules/i18next": {
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
"version": "26.5.11",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
"funding": [
{
"type": "individual",
@@ -3723,7 +4010,6 @@
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
@@ -3734,12 +4020,11 @@
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"version": "8.2.3",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
"dependencies": {
"@babel/runtime": "^7.23.2"
"@babel/runtime": "^7.29.2"
}
},
"node_modules/iconv-lite": {
@@ -3924,6 +4209,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
@@ -4022,18 +4308,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -4052,12 +4326,17 @@
}
},
"node_modules/lucide-react": {
"version": "0.474.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
"license": "ISC",
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/lz-string": {
@@ -5378,35 +5657,28 @@
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"version": "19.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"version": "19.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.3.1"
}
},
"node_modules/react-i18next": {
"version": "17.0.16",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
"license": "MIT",
"version": "17.1.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
@@ -5755,13 +6027,9 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
},
"node_modules/semver": {
"version": "6.3.1",
@@ -5774,9 +6042,9 @@
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
},
"node_modules/siginfo": {
"version": "2.0.0",
@@ -6183,17 +6451,37 @@
"license": "Apache-2.0"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
"tsc": "bin/tsc"
},
"engines": {
"node": ">=14.17"
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/unified": {
+13 -13
View File
@@ -18,18 +18,18 @@
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/maple-mono": "^5.3.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"d3": "^7.9.0",
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.474.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"lucide-react": "^1.61.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-i18next": "^17.0.4",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4",
@@ -37,17 +37,17 @@
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^27.0.1",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3",
"typescript": "^7.0.2",
"vite": "^6.1.0",
"vitest": "^3.2.4"
}
+1 -1
View File
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
moment = null,
}: SagiAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>();
const rafRef = useRef<number | undefined>(undefined);
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
+3 -3
View File
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const lastBubbleAt = useRef(0);
const burst = useRef<Burst | null>(null);
const momentKey = useRef(0);
+1 -1
View File
@@ -279,7 +279,7 @@ function QuotaChips() {
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
const closeTimer = useRef<number>();
const closeTimer = useRef<number | undefined>(undefined);
const show = () => {
window.clearTimeout(closeTimer.current);
setOpen(true);
+1 -2
View File
@@ -260,9 +260,8 @@ export function UpdateNotifier() {
// poll that reloads the page keep running, and a failure re-opens it.
const closeForNow = useCallback(() => {
if (!dismissKey) return;
if (applying || restarting) toast.info(t("backgroundNote"));
setClosedKey(dismissKey);
}, [applying, restarting, dismissKey, t]);
}, [dismissKey]);
const dismiss = useCallback(() => {
if (busy || !dismissKey) return;
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
import type { Agent } from "../../lib/types";
import { formatModelName, fmtCost } from "../../lib/format";
function renderCard(element: JSX.Element) {
function renderCard(element: React.JSX.Element) {
return render(<MemoryRouter>{element}</MemoryRouter>);
}
@@ -151,7 +151,7 @@ import { api } from "../../lib/api";
import { StatusBar } from "../StatusBar";
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
const at = (url: string, ui: JSX.Element) =>
const at = (url: string, ui: React.JSX.Element) =>
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
describe("StatusBar", () => {
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib",
});
expect(box).toBeChecked();
// The default ticks land in an effect after the list renders.
await waitFor(() => expect(box).toBeChecked());
await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder);
+1 -1
View File
@@ -765,7 +765,7 @@ export function Divider({
onRatio,
label,
}: {
gridRef: React.RefObject<HTMLDivElement>;
gridRef: React.RefObject<HTMLDivElement | null>;
dir: SplitDir;
at: number;
box: Rect;
+68 -17
View File
@@ -48,7 +48,9 @@
* most every 150 ms. Several browsers on one pane share ONE size: the server
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
* here takes the lead back (`resize` with `drive: true`).
* here takes the lead back (`resize` with `drive: true`). Cells are drawn by
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
* the word-move keys xterm 5 sent (`altArrowSeq`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
@@ -63,7 +65,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
import type { WebglAddon } from "@xterm/addon-webgl";
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
import type { SearchAddon } from "@xterm/addon-search";
import { ArrowDown, ArrowUp, X } from "lucide-react";
@@ -123,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
* Workspace chunk. The result is cached, so every later pane (and a test that
* awaits `loadXterm()` first) sets up synchronously. */
const importXterm = async () => {
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-canvas"),
import("@xterm/addon-webgl"),
import("@xterm/addon-clipboard"),
import("@xterm/addon-search"),
import("@xterm/addon-web-links"),
@@ -134,7 +136,7 @@ const importXterm = async () => {
return {
Terminal: xterm.Terminal,
FitAddon: fit.FitAddon,
CanvasAddon: canvas.CanvasAddon,
WebglAddon: webgl.WebglAddon,
SearchAddon: search.SearchAddon,
WebLinksAddon: links.WebLinksAddon,
// The addon's typings declare one argument, its code takes (base64, provider).
@@ -158,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
return xtermLoading;
}
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
* readline's backward-word / forward-word and whose up/down stay Alt's. */
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
switch (key) {
case "ArrowLeft":
return mac ? "\x1bb" : "\x1b[1;5D";
case "ArrowRight":
return mac ? "\x1bf" : "\x1b[1;5C";
case "ArrowUp":
return mac ? null : "\x1b[1;5A";
case "ArrowDown":
return mac ? null : "\x1b[1;5B";
default:
return null;
}
}
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000;
@@ -369,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
const attach = ({
Terminal,
FitAddon,
CanvasAddon,
WebglAddon,
SearchAddon,
WebLinksAddon,
ClipboardWithProvider,
@@ -379,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
// substitutes those specific glyphs from a different face with a
// mismatched advance width, corrupting the cell it's drawn into even
// under the canvas renderer (which only stops a glyph from shoving its
// under the WebGL renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too. Settings →
@@ -425,17 +446,30 @@ export const TerminalView = memo(function TerminalView({
// face whose advance width differs, so glyphs shove their neighbours along
// and the row's tail spills onto the row below. The buffer is correct
// throughout (copying the line yields clean text); only the painting drifts,
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
// context keeps the DOM renderer rather than losing the terminal.
let canvas: CanvasAddon | null = null;
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
// keeps the DOM renderer rather than losing the terminal, and so does a
// pane whose context the browser takes back (GPU reset, or past the
// browser's cap of live contexts with many panes open) — disposing the
// addon hands the drawing back to the DOM renderer.
let gpu: WebglAddon | null = null;
try {
canvas = new CanvasAddon();
term.loadAddon(canvas);
const addon = new WebglAddon();
term.loadAddon(addon);
gpu = addon;
addon.onContextLoss(() => {
addon.dispose();
if (gpu === addon) gpu = null;
});
} catch {
canvas = null;
gpu = null;
}
fit.fit();
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
// reach tmux with the attach. It stays unfitted until it is shown.
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
if (hasBox()) fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went
// nowhere. A click elsewhere still moves focus away, as in any editor.
@@ -522,6 +556,17 @@ export const TerminalView = memo(function TerminalView({
term.input("\x1f", true);
return false;
}
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
// ESC b / ESC f for left/right on macOS — the same keys tmux,
// bash and claude's prompt have always been handed from this pane.
const altArrow =
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
if (altArrow) {
ev.preventDefault();
term.input(altArrow, true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -564,7 +609,7 @@ export const TerminalView = memo(function TerminalView({
*/
const sendSizeNow = (drive = false) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (term.cols < 2 || term.rows < 2) return;
if (!hasBox() || term.cols < 2 || term.rows < 2) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
};
// This browser is the one in use now: back to its own size, and the
@@ -601,7 +646,10 @@ export const TerminalView = memo(function TerminalView({
// scrollback as torn fragments along the left margin, visible as soon as
// you scroll back (issue #33). Happens on every attach: page load,
// lane switch, and each reconnect.
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
// A hidden terminal sends none: the server attaches it at the window's
// own size, so a pane on a background tab is never resized by it.
const size =
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
ws = sock;
wsRef.current = sock;
@@ -696,6 +744,9 @@ export const TerminalView = memo(function TerminalView({
if (!el || !el.clientWidth || !el.clientHeight) return;
if (follow) return scaleToFit(); // the driver's grid, not ours
fit.fit(); // a real change fires onResize, which reports it
// A tab shown for the first time may fit to the very size xterm already
// had (no onResize), yet tmux has never heard it: report it anyway.
sendSize();
};
const resizeObserver = new ResizeObserver(refit);
if (containerRef.current) resizeObserver.observe(containerRef.current);
@@ -734,7 +785,7 @@ export const TerminalView = memo(function TerminalView({
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
canvas?.dispose();
gpu?.dispose();
term.dispose();
termRef.current = null;
};
@@ -6,10 +6,12 @@
* included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching.
* recolours live on a dark-theme change without re-attaching, draws through
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
* Alt+arrow word moves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
import {
render,
cleanup,
@@ -19,7 +21,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { TerminalView, loadXterm } from "../TerminalView";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
@@ -109,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
vi.mock("@xterm/addon-canvas", () => ({
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
// The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
const addon = {
__webgl: true,
dispose: vi.fn(),
onContextLoss: (fn: () => void) => {
onLoss = fn;
return { dispose: vi.fn() };
},
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon;
}),
}));
class MockWebSocket {
@@ -136,6 +154,17 @@ class MockWebSocket {
// @ts-expect-error test override
global.WebSocket = MockWebSocket;
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
* tab); returns the restore. */
function withBox(width = 800, height = 600) {
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
return () => {
w.mockRestore();
h.mockRestore();
};
}
describe("TerminalView", () => {
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
beforeAll(() => loadXterm());
@@ -167,11 +196,51 @@ describe("TerminalView", () => {
// it: tmux resizes the window to the newest client, so attaching at the
// server's default 80x24 makes claude redraw its UI at 80 columns and
// leaves that width in the scrollback (issue #33).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
const restore = withBox();
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
} finally {
restore();
}
});
it("a terminal on a hidden tab sends no size until it is shown", () => {
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
// that size resized the real tmux pane under every other client.
let observed: (() => void) | null = null;
const RO = globalThis.ResizeObserver;
// @ts-expect-error test override
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
observed = cb;
}
observe() {}
disconnect() {}
};
vi.useFakeTimers();
let restore = () => {};
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
ws.onopen?.();
expect(ws.sent).toEqual([]);
// The tab is shown: the box gets a size and its size reaches tmux.
restore = withBox();
observed!();
vi.advanceTimersByTime(200);
expect(ws.sent).toEqual([
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
]);
} finally {
restore();
vi.useRealTimers();
globalThis.ResizeObserver = RO;
}
});
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
@@ -397,34 +466,75 @@ describe("TerminalView", () => {
expect(onExit).toHaveBeenCalledTimes(1);
});
it("renders through the canvas addon, not xterm's DOM renderer", () => {
it("renders through the WebGL addon, not xterm's DOM renderer", () => {
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
// fallback face with a different advance, so glyphs shove their neighbours
// and the row's tail spills onto the row below — buffer clean, painting
// wrong. The canvas renderer draws each cell at a fixed position instead.
// wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe(
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true
);
});
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const addon = gpu.last!;
expect(addon.dispose).not.toHaveBeenCalled();
addon.lose();
expect(addon.dispose).toHaveBeenCalledTimes(1);
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
expect(altArrowSeq("ArrowUp", true)).toBeNull();
expect(altArrowSeq("a", false)).toBeNull();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
// Another modifier with it is xterm's own sequence.
expect(
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
).toBe(true);
});
describe("size reporting (tmux must never be wider than the terminal)", () => {
// A tmux pane wider than xterm wraps each over-long row's tail onto the
// next row's left margin, and tmux never repaints a row it believes
// unchanged — the stray characters glued to the left edge after scrolling.
it("reports its size when the socket opens", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
const restore = withBox();
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
} finally {
restore();
}
});
it("reports a size change xterm made on its own, with no container resize", async () => {
// Webfont load and page zoom both move cols without resizing the element
// ResizeObserver watches, which is why onResize is the hook.
const restore = withBox();
onTestFinished(restore);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
@@ -79,14 +79,6 @@ function Step({ n, children }: { n: number; children: ReactNode }) {
);
}
function Code({ children }: { children: ReactNode }) {
return (
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
{children}
</code>
);
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
@@ -642,10 +634,6 @@ export function GitIdentitySettings() {
onRemove={() => removeKey("ed25519")}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
{namedOf("ed25519").map((k) => {
const rows = rowsOf(otherRows, k);
@@ -668,11 +656,6 @@ export function GitIdentitySettings() {
{id.writable && rows.length === 0 && (
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
)}
{rows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/&lt;org&gt;/&lt;repo&gt;.git
</Code>
))}
</Step>
</KeyCard>
);
@@ -720,16 +703,6 @@ export function GitIdentitySettings() {
</>
)}
</Step>
{rows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{rows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
)}
</KeyCard>
);
})}
@@ -156,7 +156,6 @@ describe("GitIdentitySettings", () => {
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
// A CodeCommit host never shows up again under "other hosts".
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
@@ -260,7 +259,6 @@ describe("GitIdentitySettings", () => {
expect(
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
).toBeInTheDocument();
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
// The default key's card does not list the named key's login.
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
expect(within(def).queryByText(/APKA2/)).toBeNull();
@@ -301,7 +299,7 @@ describe("GitIdentitySettings", () => {
key: "id_ed25519_git-2",
});
expect(
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
await within(work).findByText("ssh://git-2/ → git@github.com")
).toBeInTheDocument();
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* @file useCcTarget — whose Claude Code config the Config Explorer manages for
* this login (GET /api/cc-config/target). Local mode and an admin: the host's,
* answered at once without a request (nothing changes for them). A non-admin
* in server mode asks the server: `own` when they have their own Linux account
* (S3) — Settings then shows "Your Claude Code config" — else `none` and the
* page stays hidden. The server's refusal (403 ENOACCOUNT/EADMIN) is the rule;
* this only hides what would be refused.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { api, type CcTarget } from "../lib/api";
import { usePermissions } from "../components/AuthGate";
const HOST: CcTarget = { kind: "host", readOnly: false, claudeHome: null };
/** `null` while a non-admin's answer is on its way. */
export function useCcTarget(): CcTarget | null {
const { isAdmin } = usePermissions();
const [target, setTarget] = useState<CcTarget | null>(isAdmin ? HOST : null);
useEffect(() => {
if (isAdmin) {
setTarget(HOST);
return;
}
let live = true;
api.ccConfig
.target()
.then((t) => {
if (live) setTarget(t);
})
.catch(() => {
if (live) setTarget({ kind: "none", readOnly: true, claudeHome: null });
});
return () => {
live = false;
};
}, [isAdmin]);
return target;
}
+2 -2
View File
@@ -27,9 +27,9 @@ const FOCUSABLE =
*/
export function useDialog(
open: boolean,
panelRef: RefObject<HTMLElement>,
panelRef: RefObject<HTMLElement | null>,
onClose?: () => void,
initialFocus?: RefObject<HTMLElement>
initialFocus?: RefObject<HTMLElement | null>
): void {
// Read through a ref so an inline `onClose` does not re-run the effect, which
// would steal focus back to the first control on every parent render.
+3 -3
View File
@@ -63,7 +63,7 @@ type MessageHandler = (msg: WSMessage) => void;
* auto-reconnects with capped exponential backoff on close - plus an
* immediate reconnect attempt on tab focus/network-online/visibility-change
* so the socket recovers quickly after a server restart or laptop sleep.
* Guards against React 18 StrictMode's mount→cleanup→remount cycle opening a
* Guards against React StrictMode's mount→cleanup→remount cycle opening a
* duplicate socket (see the inline comment in `connect`).
* @param onMessage Called with every message parsed from the socket; the
* latest reference is used even across reconnects (no stale closures).
@@ -73,7 +73,7 @@ export function useWebSocket(onMessage: MessageHandler) {
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<MessageHandler>(onMessage);
const [connected, setConnected] = useState(false);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const mountedRef = useRef(true);
const reconnectAttempts = useRef(0);
@@ -82,7 +82,7 @@ export function useWebSocket(onMessage: MessageHandler) {
const connect = useCallback(() => {
if (!mountedRef.current) return;
// Don't open a second socket if one is already alive or in flight.
// Without this, React 18 StrictMode (mount → cleanup → remount in dev)
// Without this, React StrictMode (mount → cleanup → remount in dev)
// and the close→reconnect race could leave two sockets connected at the
// same time. Both would receive every server broadcast, producing
// duplicate stream_event deltas (doubled text, duplicate assistant
+5
View File
@@ -1,6 +1,10 @@
{
"title": "Claude Config Explorer",
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
"own": {
"label": "Your Claude Code config — {{home}}",
"subtitle": "Only your own account's setup: every change here runs as your account and reaches only your own sessions."
},
"refresh": "Refresh",
"refreshing": "Refreshing…",
"lastUpdated": "Last updated {{time}}",
@@ -30,6 +34,7 @@
},
"overview": {
"rootsTitle": "Roots",
"claudeHome": "Your .claude",
"projectClaudeDir": "Project .claude",
"projectRoot": "Project root",
"claudeJson": "claude.json",
+4 -2
View File
@@ -884,7 +884,6 @@
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubSub": "ED25519 key — the git@… SSH address",
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
"awsTitle": "AWS CodeCommit",
"awsSub": "RSA 4096 key — AWS accepts only RSA",
"stepCopy": "Copy the public key.",
@@ -894,7 +893,6 @@
"awsRegion": "Region",
"awsSave": "Save",
"awsSaved": "SSH key ID saved for {{region}}",
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
"otherTitle": "Other SSH hosts (advanced)",
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
"otherHost": "Host",
@@ -1035,6 +1033,10 @@
"title": "Claude Code",
"description": "Claude Code's own configuration, and what AgentHub reads from it."
},
"claudeCodeOwn": {
"title": "Your Claude Code config",
"description": "The skills, agents, plugins, MCP servers and settings your own sessions use."
},
"codex": {
"title": "Codex",
"description": "Codex's own configuration, and what AgentHub reads from it."
-1
View File
@@ -36,7 +36,6 @@
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
"autoFixWorkspace": "AgentHub update",
"autoFixError": "Could not start the Auto fix session",
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
"chipTitle": "Updating to {{version}}…",
"chipHint": "The update runs in the background — click to show it",
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
+5
View File
@@ -1,6 +1,10 @@
{
"title": "Trình khám phá cấu hình Claude",
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
"own": {
"label": "Cấu hình Claude Code của bạn — {{home}}",
"subtitle": "Chỉ thiết lập của tài khoản bạn: mọi thay đổi ở đây chạy dưới tài khoản của bạn và chỉ áp dụng cho các phiên của chính bạn."
},
"refresh": "Làm mới",
"refreshing": "Đang làm mới…",
"lastUpdated": "Cập nhật lần cuối {{time}}",
@@ -30,6 +34,7 @@
},
"overview": {
"rootsTitle": "Thư mục gốc",
"claudeHome": ".claude của bạn",
"projectClaudeDir": "Dự án .claude",
"projectRoot": "Thư mục dự án",
"claudeJson": "claude.json",
+4 -2
View File
@@ -872,7 +872,6 @@
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
"awsTitle": "AWS CodeCommit",
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
"stepCopy": "Copy public key.",
@@ -882,7 +881,6 @@
"awsRegion": "Region",
"awsSave": "Lưu",
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
"otherTitle": "Host SSH khác (nâng cao)",
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
"otherHost": "Host",
@@ -1023,6 +1021,10 @@
"title": "Claude Code",
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
},
"claudeCodeOwn": {
"title": "Cấu hình Claude Code của bạn",
"description": "Skill, agent, plugin, MCP server và thiết lập mà các phiên của chính bạn dùng."
},
"codex": {
"title": "Codex",
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
-1
View File
@@ -36,7 +36,6 @@
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
"autoFixWorkspace": "Cập nhật AgentHub",
"autoFixError": "Không mở được phiên Tự sửa",
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
"chipTitle": "Đang cập nhật lên {{version}}…",
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
+5
View File
@@ -717,6 +717,11 @@
.xterm-viewport::-webkit-scrollbar {
display: none;
}
/* xterm 6 draws its own scrollbar (VS Code's scrollable element) instead of
the viewport's native one: the same dead affordance, hidden the same way. */
.xterm .xterm-scrollable-element > .scrollbar {
display: none;
}
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
@@ -49,6 +49,16 @@ describe("artifactView", () => {
expect(isArtifact("server.js", true)).toBe(false);
});
it("never takes the project's own HTML pages for artifacts", () => {
expect(isArtifact("/tmp/out/report.html", true)).toBe(true);
expect(isArtifact("/tmp/out/report.html", false)).toBe(false);
expect(isArtifact("/repo/index.html", true)).toBe(false);
expect(isArtifact("/repo/client/INDEX.HTM", true)).toBe(false);
expect(isArtifact("/repo/public/about.html", true)).toBe(false);
expect(isArtifact("C:\\repo\\templates\\mail.html", true)).toBe(false);
expect(isArtifact("/repo/docs/chart.html", true)).toBe(true);
});
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
const msgs = [
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
@@ -31,9 +31,9 @@ describe("git targets", () => {
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
await api.lanes.git(gitTarget(3, null));
const urls = spy.mock.calls.map((c) => String(c[0]));
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
expect(urls[0]).toMatch(/\/workspaces\/3\/git\/changes\?range=work$/);
expect(urls[1]).toMatch(/\/workspaces\/3\/git\/changes\?range=work&session=s-1$/);
expect(urls[2]).toMatch(/\/workspaces\/3\/git\/stage\?session=s-1$/);
expect(urls[3]).toMatch(/\/workspaces\/3\/git$/);
});
});
@@ -25,7 +25,7 @@ function stubFetch(body: unknown) {
}
describe("api.lanes.git", () => {
it("requests /api/lanes/<id>/git and returns the parsed facts", async () => {
it("requests /api/workspaces/<id>/git and returns the parsed facts", async () => {
const facts = {
available: true,
branch: "feat/x",
@@ -41,7 +41,7 @@ describe("api.lanes.git", () => {
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
expect(String(call[0]).endsWith("/api/lanes/3/git")).toBe(true);
expect(String(call[0]).endsWith("/api/workspaces/3/git")).toBe(true);
expect(call[1]?.method ?? "GET").toBe("GET");
expect(result).toEqual(facts);
});
+56 -41
View File
@@ -915,7 +915,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
return `/workspaces/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
@@ -929,7 +929,7 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
const qs = new URLSearchParams(q).toString();
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
}
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
return gitUrl(place, sub, q).replace(/^(\/workspaces\/\d+)\/git/, "$1/fs");
}
/**
@@ -2010,7 +2010,7 @@ export const api = {
purged_agents: number;
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
gc: (body: { dryRun?: boolean } = {}) =>
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/workspaces/gc", {
method: "POST",
body: JSON.stringify(body),
}),
@@ -2297,6 +2297,13 @@ export const api = {
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
* script - for the dashboard's "CC Config" explorer/editor pages. */
ccConfig: {
/**
* GET /api/cc-config/target - whose Claude Code config this login manages
* here: `own` (server mode, their own account's), `host` (an admin, local
* mode, or an analyst's read-only view), `none` (no account: the page is
* not theirs).
*/
target: () => request<CcTarget>("/cc-config/target"),
/**
* GET /api/cc-config/overview - counts of every artifact kind, for the
* explorer's landing page.
@@ -3790,7 +3797,7 @@ export const api = {
* GET /api/lanes — list all lanes with pipeline progress and liveness.
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
*/
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/workspaces"),
/**
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
* @param body The cwd and optional title. When `cwd` is omitted the server
@@ -3806,7 +3813,7 @@ export const api = {
/** Only a lane on THIS directory counts, not one on a parent of it. */
exact?: boolean;
}) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
request<{ lane: Lane; created: boolean }>("/workspaces/ensure", {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3829,7 +3836,7 @@ export const api = {
/** This clone's name for {@link lanes.cloneProgress}. */
progressToken?: string;
}) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3839,7 +3846,7 @@ export const api = {
* carrying `progressToken` arrives, and a minute after it ends.
*/
cloneProgress: (token: string) =>
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
request<CloneProgressView>(`/workspaces/clone/progress/${encodeURIComponent(token)}`),
/**
* GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
@@ -3847,7 +3854,7 @@ export const api = {
*/
pipelines: () =>
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
"/lanes/pipelines"
"/workspaces/pipelines"
),
/**
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
@@ -3857,7 +3864,7 @@ export const api = {
*/
pipelineStats: (laneId?: number | null) =>
request<PipelineStats>(
`/lanes/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
`/workspaces/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
),
/**
* GET /api/lanes/branches — a candidate source repo's local branches.
@@ -3869,7 +3876,7 @@ export const api = {
*/
branches: (repo: string) =>
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
`/lanes/branches?repo=${encodeURIComponent(repo)}`
`/workspaces/branches?repo=${encodeURIComponent(repo)}`
),
/**
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as
@@ -3887,7 +3894,7 @@ export const api = {
pipeline?: string;
tool?: LaneTool;
}) =>
request<{ lane: Lane }>("/lanes/worktree", {
request<{ lane: Lane }>("/workspaces/worktree", {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3922,7 +3929,7 @@ export const api = {
file?: boolean;
mtime?: number | null;
}[];
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
}>(`/workspaces/browse${qs ? `?${qs}` : ""}`);
},
/**
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
@@ -3930,7 +3937,7 @@ export const api = {
* `EFOLDER` (outside the caller's own folder).
*/
mkdir: (parent: string, name: string) =>
request<{ path: string }>("/lanes/browse/mkdir", {
request<{ path: string }>("/workspaces/browse/mkdir", {
method: "POST",
body: JSON.stringify({ parent, name }),
}),
@@ -3944,7 +3951,7 @@ export const api = {
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
*/
pickPath: (kind: "dir" | "file", start?: string) =>
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
request<{ path?: string; cancelled?: boolean }>("/workspaces/pick-path", {
method: "POST",
body: JSON.stringify({ kind, start }),
}),
@@ -3953,7 +3960,7 @@ export const api = {
* @param id The lane id.
* @returns `{ lane }` — the {@link Lane} record.
*/
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`),
get: (id: number) => request<{ lane: Lane }>(`/workspaces/${id}`),
/**
* GET /api/lanes/:id/git — the lane's working-copy facts.
* @param id The lane id.
@@ -4087,7 +4094,7 @@ export const api = {
*/
removeSessionWorktree: (id: number, session: string) =>
request<{ removed: true; branch: string | null; branchKept: boolean }>(
`/lanes/${id}/session-worktree/remove`,
`/workspaces/${id}/session-worktree/remove`,
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
),
gitBranches: (id: GitTarget) =>
@@ -4181,7 +4188,7 @@ export const api = {
* @param id The lane id.
* @returns {@link LaneRuntime}
*/
runtime: (id: number) => request<LaneRuntime>(`/lanes/${id}/runtime`),
runtime: (id: number) => request<LaneRuntime>(`/workspaces/${id}/runtime`),
/**
* POST /api/lanes/:id/up — boot the lane's stack through its profile hooks.
* Returns 202: the boot runs in the background and streams progress as
@@ -4190,7 +4197,7 @@ export const api = {
* @param body `build: false` reuses an existing build.
*/
up: (id: number, body: { build?: boolean } = {}) =>
request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, {
request<{ ok: true; laneId: number }>(`/workspaces/${id}/up`, {
method: "POST",
body: JSON.stringify(body),
}),
@@ -4201,7 +4208,7 @@ export const api = {
* @returns The pids stopped and the resulting runtime facts.
*/
down: (id: number) =>
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/lanes/${id}/down`, {
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/workspaces/${id}/down`, {
method: "POST",
body: JSON.stringify({}),
}),
@@ -4211,7 +4218,7 @@ export const api = {
* @returns `{ lane }` — the created {@link Lane}.
*/
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }),
request<{ lane: Lane }>("/workspaces", { method: "POST", body: JSON.stringify(body) }),
/**
* PATCH /api/lanes/:id — partial update of a lane.
* @param id The lane id.
@@ -4220,17 +4227,17 @@ export const api = {
*/
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
sources: (id: number) => request<LaneSourcesView>(`/workspaces/${id}/sources`),
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
* (a parent folder of several repos), each a Changes section read with
* `?repo=<path>`; `root` = the folder itself is in a repo. */
repos: (id: number, refresh = false) =>
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}`
`/workspaces/${id}/repos${refresh ? "?refresh=1" : ""}`
),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
request<LaneSourcesView>(`/workspaces/${id}/instructions`, {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
@@ -4248,19 +4255,19 @@ export const api = {
progressToken?: string;
}
) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
method: "POST",
body: JSON.stringify(body),
}),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
method: "POST",
body: JSON.stringify({ path }),
}),
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
addFile: (id: number, file: File) =>
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
method: "POST",
body: file,
headers: {
@@ -4271,11 +4278,11 @@ export const api = {
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */
removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
request<{ lane: Lane }>(`/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
/**
* POST /api/lanes/:id/stage — advance the pipeline stage.
* @param id The lane id.
@@ -4286,14 +4293,14 @@ export const api = {
id: number,
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
) =>
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
request<{ lane: Lane }>(`/workspaces/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
/**
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
* action-specific facts that a destructive request must echo as `expect`.
*/
preflight: (id: number, action: "reset" | "remove" | "purge") =>
request<import("./types").LanePreflight>(
`/lanes/${id}/preflight?${new URLSearchParams({ action }).toString()}`
`/workspaces/${id}/preflight?${new URLSearchParams({ action }).toString()}`
),
/**
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
@@ -4305,7 +4312,7 @@ export const api = {
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
// `run_id` answers a `start`: the session it opened, which for a split
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/workspaces/${id}/${action}`, {
method: "POST",
body: JSON.stringify(body),
}),
@@ -4316,7 +4323,7 @@ export const api = {
*/
features: {
list: (id: number) =>
request<{ features: import("./types").LaneFeature[] }>(`/lanes/${id}/features`),
request<{ features: import("./types").LaneFeature[] }>(`/workspaces/${id}/features`),
/**
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
* @param id The lane id.
@@ -4325,7 +4332,7 @@ export const api = {
*/
show: (id: number, slug: string) =>
request<{ feature: import("./types").LaneFeature }>(
`/lanes/${id}/features/${encodeURIComponent(slug)}`
`/workspaces/${id}/features/${encodeURIComponent(slug)}`
),
},
/**
@@ -4335,7 +4342,7 @@ export const api = {
*/
proof: {
list: (id: number) =>
request<{ features: import("./types").ProofFeature[] }>(`/lanes/${id}/proof`),
request<{ features: import("./types").ProofFeature[] }>(`/workspaces/${id}/proof`),
/**
* URL for a proof image, ready to use in an <img src>.
* @param id The lane id.
@@ -4345,7 +4352,7 @@ export const api = {
* @returns A URL string pointing to the image.
*/
imageUrl: (id: number, slug: string, group: string, file: string): string =>
`/api/lanes/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
`/api/workspaces/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
/**
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
* @param id The lane id.
@@ -4354,19 +4361,19 @@ export const api = {
* @returns `{ deleted }` — count of deleted images.
*/
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
request<{ deleted: number }>(`/lanes/${id}/proof/${encodeURIComponent(slug)}`, {
request<{ deleted: number }>(`/workspaces/${id}/proof/${encodeURIComponent(slug)}`, {
method: "DELETE",
body: JSON.stringify(body),
}),
},
mcpSync: (id: number) =>
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
request<{ servers: string[]; profilesSeeded: string[] }>(`/workspaces/${id}/mcp/sync`, {
method: "POST",
body: "{}",
}),
profileInit: (id: number, force = false) =>
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
`/lanes/${id}/profile/init`,
`/workspaces/${id}/profile/init`,
{ method: "POST", body: JSON.stringify({ force }) }
),
},
@@ -4627,6 +4634,13 @@ export interface CcProfileUsage {
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
/** GET /api/cc-config/target — whose Claude Code config the page manages. */
export interface CcTarget {
kind: "own" | "host" | "none";
readOnly: boolean;
claudeHome: string | null;
}
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
* var Claude Code reads; a proxy that names its models once fills them all
* from the profile's `model`. */
@@ -5207,8 +5221,9 @@ export interface CcOverview {
/** Key filesystem locations the explorer reads from. */
roots: {
claudeHome: string;
projectClaudeDir: string;
projectRoot: string;
/** Null for a member's own config with no workspace named: no project layer. */
projectClaudeDir: string | null;
projectRoot: string | null;
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
claudeJson: string;
};
+31 -4
View File
@@ -43,13 +43,40 @@ export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
"image",
]);
/** An edit worth a card in the conversation: a page, a document, a diagram, a
* table — and a React component the model wrote whole (a `.tsx` it merely
* edited is project code, not an artifact). */
/** Folders an app keeps its own pages in: an HTML file under one of them is
* the project's code, never a page the model made to show. */
const PROJECT_DIRS = new Set([
"src",
"public",
"templates",
"views",
"pages",
"app",
"static",
"dist",
"build",
"components",
"layouts",
]);
/** An HTML file the project serves itself: an `index.html`, or one under a
* folder in {@link PROJECT_DIRS}. */
function isProjectPage(path: string): boolean {
const parts = path.split(/[\\/]/);
const name = (parts.pop() || "").toLowerCase();
if (name === "index.html" || name === "index.htm") return true;
return parts.some((d) => PROJECT_DIRS.has(d.toLowerCase()));
}
/** An edit worth a card in the conversation: a document, a diagram, a table —
* and a page or React component the model wrote whole (a `.tsx` or `.html` it
* merely edited, and an HTML page of the project itself, are project code,
* not an artifact). */
export function isArtifact(path: string, created: boolean): boolean {
const k = artifactKind(path);
if (k === "react") return created;
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
if (k === "html") return created && !isProjectPage(path);
return k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
}
/** A code fence language for the Code tab. */
+1 -1
View File
@@ -7,7 +7,7 @@
*
* The order lives in the browser rather than the lanes table on purpose: it is
* a per-viewer preference, and nothing
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
* server-side (the CLI's `agenthub workspaces`, the pipeline-stats picker) has an
* opinion about it. Ceiling: the order does not follow the user to another
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
* must.
+1 -1
View File
@@ -2633,7 +2633,7 @@ export interface LaneNode {
icon: string;
gate: boolean;
/** The one command that drives this node — a slash command
* (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook ci-gate`),
* (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook ci-gate`),
* from the pipeline template. Empty string for a node with no single driver
* (`done`, or a gate another agent declares). Display text only; the
* dashboard never runs it. Optional in the type only because the archived
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @file main.tsx
* @description Vite client entry point. Bootstraps React 18, loads global styles,
* @description Vite client entry point. Bootstraps React 19, loads global styles,
* registers the service worker, prefetches the Workspace chunk on a `/workspace`
* deep link, and mounts {@link App} into `#root` once the active locale is loaded.
*
+80 -26
View File
@@ -16,6 +16,11 @@
* files, and the `MdItemList` artifacts) also opens a
* right-click menu that repeats those buttons — same handlers, same
* confirm path — plus Copy name/path.
* Server mode, a non-admin with their own Linux account (S3): the page is
* THEIR config (`useCcTarget`, labelled as such, no project layer unless a
* workspace is named), and the host-wide controls — Switch profile, Update
* all, AgentHub's own Claude settings — are an admin's and hidden (the server
* refuses them anyway).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -126,6 +131,8 @@ import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
import { usePermissions } from "../components/AuthGate";
import { useCcTarget } from "../hooks/useCcTarget";
import {
Empty,
SkeletonRows,
@@ -516,8 +523,14 @@ export function CcConfig() {
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const projectRoot = data.overview?.roots.projectRoot ?? null;
// Project files under the plugin cache are wiped by the next plugin update.
const projectDisabled = isPluginCachePath(projectRoot);
// Server mode: whose config this is. The host-wide controls are an admin's.
const { isAdmin } = usePermissions();
const ccTarget = useCcTarget();
const ownHome = ccTarget?.kind === "own" ? ccTarget.claudeHome : null;
// Project files under the plugin cache are wiped by the next plugin update;
// a member's own config has no project layer at all.
const projectDisabled =
isPluginCachePath(projectRoot) || (!!data.overview && projectRoot === null);
useEffect(() => {
if (projectDisabled && scope === "project") setScope("all");
}, [projectDisabled, scope]);
@@ -681,6 +694,8 @@ export function CcConfig() {
}}
onOpenBackups={() => setBackupsOpen(true)}
wsConnected={wsConnected}
showProfiles={isAdmin}
ownHome={ownHome}
/>
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
@@ -777,7 +792,7 @@ export function CcConfig() {
{/* AgentHub's own Claude Code settings follow the overview, outside the
list's search box and scope. */}
{tab === "overview" && (
{tab === "overview" && isAdmin && (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
)}
@@ -820,10 +835,22 @@ interface HeaderProps {
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
/** The host's login profiles link: an admin's (and local mode's). */
showProfiles?: boolean;
/** A member's own Claude home (server mode, S3): the page says whose it is. */
ownHome?: string | null;
}
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
function Header({
loading,
lastUpdated,
onRefresh,
onOpenBackups,
wsConnected,
showProfiles = true,
ownHome = null,
}: HeaderProps) {
const { t } = useTranslation("ccConfig");
const settingsHref = useSettingsHref();
const { t: tCommon } = useTranslation("common");
@@ -848,18 +875,30 @@ function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }:
{tCommon("offline")}
</span>
)}
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
{ownHome ? (
<div className="min-w-0">
<p className="text-xs font-medium text-fg-primary" data-testid="cc-own-config">
{t("own.label", { home: ownHome })}
</p>
<p className="text-xs text-fg-muted max-w-2xl">{t("own.subtitle")}</p>
</div>
) : (
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
)}
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
<Link
to={settingsHref("agents#tool-profiles")}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</Link>
{/* Every AI tool's login profiles live in one place: Settings → AI tools.
The host's logins: an admin's. */}
{showProfiles && (
<Link
to={settingsHref("agents#tool-profiles")}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</Link>
)}
<button
type="button"
onClick={onOpenBackups}
@@ -1290,18 +1329,31 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.projectClaudeDir")}
value={roots.projectClaudeDir}
/>
<RootRow
icon={FolderTree}
tone="violet"
label={t("overview.projectRoot")}
value={roots.projectRoot}
/>
{/* A member's own config has no project layer: their Claude home instead. */}
{roots.projectRoot === null && (
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.claudeHome")}
value={roots.claudeHome}
/>
)}
{roots.projectClaudeDir !== null && (
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.projectClaudeDir")}
value={roots.projectClaudeDir}
/>
)}
{roots.projectRoot !== null && (
<RootRow
icon={FolderTree}
tone="violet"
label={t("overview.projectRoot")}
value={roots.projectRoot}
/>
)}
<RootRow
icon={FileText}
tone="amber"
@@ -1718,6 +1770,7 @@ function PluginsPanel({
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { isAdmin } = usePermissions();
const { busy, run } = useConfigAction(onToast, onChanged);
const [input, setInput] = useState("");
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
@@ -1785,7 +1838,8 @@ function PluginsPanel({
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
/>
<UpdateAllCard onToast={onToast} onChanged={onChanged} />
{/* Update all runs on the host's plugins (AgentHub's own included): an admin's. */}
{isAdmin && <UpdateAllCard onToast={onToast} onChanged={onChanged} />}
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
+17 -5
View File
@@ -27,6 +27,9 @@
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
* while the tab is hidden behind a session.
* Server mode hides every tool page from a non-admin, except Claude Code for a
* non-admin with their own Linux account (`useCcTarget`): "Your Claude Code
* config", which they manage as themselves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
@@ -75,6 +78,7 @@ import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
import { useAiTools } from "../hooks/useAiTools";
import { useCcTarget } from "../hooks/useCcTarget";
import {
rankSettings,
rankSettingsEntries,
@@ -248,10 +252,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
};
}, [location.key, location.hash]);
const { isAdmin, local } = usePermissions();
const { isAdmin, local, seesAll } = usePermissions();
const authState = useAuth().state;
const signedIn = authState.mode === "server" && !!authState.user;
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
// Server mode: a non-admin with their own Linux account (S3) manages their
// OWN Claude Code config here; the host's stays an admin's.
const ccTarget = useCcTarget();
const ownClaude = !isAdmin && ccTarget?.kind === "own";
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
@@ -299,8 +307,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<AgentInstructionsSettings />
<SessionDefaultsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* Every AI tool's login profiles, linked to the account pool. */}
<ToolProfilesSettings />
{/* Every AI tool's login profiles, linked to the account pool: the
host's logins, a staff-only read (a developer would get 403s). */}
{seesAll && <ToolProfilesSettings />}
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
{/* The host's omp login for members: server mode + S3, an admin's. */}
@@ -315,7 +324,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
group: "agents" as const,
tool: "claude",
Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"),
...s(ownClaude ? "claudeCodeOwn" : "claudeCode"),
render: () => <CcConfig />,
},
]
@@ -439,7 +448,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
const sections = isAdmin
? allSections
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
: allSections.filter(
(x) =>
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
);
const navSections = sections.filter((x) => !x.tool);
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
+2 -2
View File
@@ -1251,7 +1251,7 @@ export function Workspace() {
}
};
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
// Mirrors `agenthub workspace pipeline <template> <id>`: same PATCH, same
// stage-mismatch warning when the current declared stage matches no node
// in the newly chosen template.
const handlePipelineChange = async (id: number, pipeline: string) => {
@@ -1420,7 +1420,7 @@ export function Workspace() {
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
+10 -5
View File
@@ -238,12 +238,17 @@ describe("Artifacts", () => {
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
).toBeInTheDocument();
await user.keyboard("{Escape}");
// One merged list by default; Hub is a filter over it.
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
// One merged list by default; Hub is a filter over it. The Suggested
// count's own `unowned` list runs whenever settings land, so it is left
// out: only the order of the LIST loads is under test.
const listScopes = () =>
vi
.mocked(api.artifacts.list)
.mock.calls.map(([opts]) => opts?.scope)
.filter((s) => s !== "unowned");
expect(listScopes().pop()).toBe("all");
await user.click(screen.getByRole("button", { name: "Hub" }));
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
);
await waitFor(() => expect(listScopes().pop()).toBe("hub"));
});
it("splits the Hub and My library into yours and others'", async () => {
@@ -0,0 +1,133 @@
/**
* @file Covers the Claude Code config page as a MEMBER's own config (server
* mode, a non-admin with their own Linux account): it says whose config it is,
* and the host-wide controls — Switch profile, Update all, AgentHub's own
* Claude settings — are not shown (the server refuses them anyway). An admin
* still sees all three.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const perms = vi.hoisted(() => ({ isAdmin: false }));
vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>();
return {
...m,
usePermissions: () => ({ ...m.usePermissions(), local: false, isAdmin: perms.isAdmin }),
};
});
vi.mock("../../components/settings/ClaudeAgentHubSettings", () => ({
ClaudeAgentHubSettings: () => <div data-testid="agenthub-claude-settings" />,
}));
const HOME = "/srv/users/mia/.claude";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
target: r({ kind: "own", readOnly: false, claudeHome: "/srv/users/mia/.claude" }),
overview: r({
roots: {
claudeHome: "/srv/users/mia/.claude",
projectClaudeDir: null,
projectRoot: null,
claudeJson: "/srv/users/mia/.claude.json",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 0,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: null, manifestExists: false, plugins: [] }),
marketplaces: r({ items: [] }),
mcp: r({ user: [], projectScoped: [] }),
hooks: r({ items: [] }),
settings: r({ items: [] }),
keybindings: r({ groups: [] }),
statusline: r({}),
hookScripts: r({ items: [] }),
backups: r({ items: [] }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CcConfig } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function open() {
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
}
beforeEach(() => {
vi.clearAllMocks();
perms.isAdmin = false;
});
describe("CcConfig — a member's own config", () => {
it("says whose config it is and hides the host-wide controls", async () => {
await open();
expect(screen.getByTestId("cc-own-config")).toHaveTextContent(HOME);
expect(screen.queryByRole("link", { name: /switch profile/i })).not.toBeInTheDocument();
expect(screen.queryByTestId("agenthub-claude-settings")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
expect(screen.queryByRole("button", { name: /^update all$/i })).not.toBeInTheDocument();
expect(ccConfigMock.updateAllStatus).not.toHaveBeenCalled();
});
it("keeps all three for an admin", async () => {
perms.isAdmin = true;
await open();
expect(screen.queryByTestId("cc-own-config")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /switch profile/i })).toBeInTheDocument();
expect(screen.getByTestId("agenthub-claude-settings")).toBeInTheDocument();
expect(ccConfigMock.target).not.toHaveBeenCalled();
});
});
@@ -21,10 +21,15 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
// Local mode unless a test says otherwise; still an admin either way.
const perms = vi.hoisted(() => ({ local: true }));
const perms = vi.hoisted(() => ({ local: true, isAdmin: true }));
// What GET /api/cc-config/target answers a non-admin.
const ccTarget = vi.hoisted(() => ({ kind: "none" as "own" | "host" | "none" }));
vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>();
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
return {
...m,
usePermissions: () => ({ ...m.usePermissions(), local: perms.local, isAdmin: perms.isAdmin }),
};
});
vi.mock("../../components/settings/GitIdentitySettings", () => ({
GitIdentitySettings: () => <div data-testid="git-identity" />,
@@ -88,7 +93,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
// AI tools → Profiles.
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
ccConfig: {
profiles: vi.fn(async () => ({ items: [], active: null })),
target: vi.fn(async () => ({
kind: ccTarget.kind,
readOnly: ccTarget.kind !== "own",
claudeHome: ccTarget.kind === "own" ? "/srv/users/mia/.claude" : null,
})),
},
},
};
});
@@ -124,6 +136,8 @@ const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
beforeEach(() => {
perms.local = true;
perms.isAdmin = true;
ccTarget.kind = "none";
__resetAiTools();
view = {
default: "claude",
@@ -202,6 +216,29 @@ describe("SettingsHub", () => {
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
});
it("gives a non-admin with their own account their own Claude Code page, and no other tool's", async () => {
perms.local = false;
perms.isAdmin = false;
ccTarget.kind = "own";
renderAt("/settings/agents/claude-code");
expect(await screen.findByTestId("cc-config")).toBeInTheDocument();
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
.getAllByRole("tab")
.map((a) => a.textContent);
expect(tabs).toEqual(["All tools", "Your Claude Code config"]);
});
it("keeps the Claude Code page from a non-admin with no account of their own", async () => {
perms.local = false;
perms.isAdmin = false;
ccTarget.kind = "none";
renderAt("/settings/agents/claude-code");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.queryByTestId("cc-config")).not.toBeInTheDocument();
});
it("navigates from the mobile select", async () => {
renderAt("/settings/data");
await screen.findByTestId("settings-data");
File diff suppressed because it is too large Load Diff
+6
View File
@@ -99,6 +99,12 @@ export default defineConfig({
},
build: {
outDir: "dist",
// ES2021 or newer, never Vite's es2020 default: esbuild 0.25 lowers
// xterm 6's `let r; …(r||={})` (requestMode, DECRQM) to `void 0||(i={})`
// and drops the `let`, so every DECRQM query tmux sends threw
// "i is not defined" inside the terminal's parser. React 19 needs these
// browsers anyway.
target: ["es2021", "chrome100", "edge100", "firefox100", "safari15"],
// Maps are still written (for debugging a build), but no bundle points at
// them, so a browser never downloads them.
sourcemap: "hidden",
+7 -4
View File
@@ -1345,7 +1345,7 @@ POST /api/lanes/:id/mcp/sync
GET /api/lanes/:id/integrations/:name
```
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub lanes profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub workspace profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
#### CI status and base sync
@@ -2820,6 +2820,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings/agents/claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
```http
GET /api/cc-config/target
GET /api/cc-config/overview
GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
GET /api/cc-config/agents
@@ -2861,6 +2862,8 @@ POST /api/cc-config/mcp/remove Body: { name, scope? }
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
**Whose config (server mode).** The router gates itself (`server/lib/cc-target.js`): a non-admin reaches only the reads listed above and the writes that act on their own config (`PUT/DELETE /file`, `PATCH /settings`, `PUT /keybindings`, `POST /project-memory`, `/plugins/install|action`, `/marketplaces/add|action`, `/mcp/add|remove`, `/reload`); `/profiles*`, `/plugins/update-all` and any unlisted endpoint answer `403 EADMIN` (an analyst may still `GET /profiles` and `GET /plugins/update-all`). `GET /target` answers `{kind: "own"|"host"|"none", readOnly, claudeHome}`. Every other handler acts on one target: the host's Claude home for an admin and in local mode (unchanged); under S3 a non-admin's OWN `<home>/.claude` + `<home>/.claude.json` — reads through the guarded reader, writes and backups as them through `write-as`, `claude plugin|mcp` as `sudo -n -u ah-<name> HOME=… DISABLE_AUTOUPDATER=1 /opt/agenthub/tools/bin/claude …` (scope `user` only, never `-y`, no MCP `env`/`headers`: `400 EBADARG`; `503 ENOTSHARED` without a shared `claude`); `403 ENOACCOUNT` for a non-admin with no account (an analyst's reads fall back to the host's, read-only, as before; `?view=host` gives an analyst with an account that view). A member's project layer exists only with `?cwd=` naming one of their own workspaces in their own account (writes: `403 EFOLDER` otherwise; no `cwd` = no project layer, `roots.projectRoot: null`). `POST /project-memory` from a member takes only such a folder; under S3 the file is written as the folder's account, an admin's create in a member's folder included. A member's `cc_config_changed` carries `ownerId` and reaches only that user; their `/reload` (and a plugin action's `reload`) types only into panes that run as them.
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
@@ -2960,7 +2963,7 @@ POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 M
POST /api/run/clipboard-files → {files:[{path,size,dir}]}: the files a file manager's Copy put on THIS machine's clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux text/uri-list via wl-paste/xclip), each that exists here (`size` null for a folder) — local mode + loopback only, else 403 `EREMOTE`; a pasted file whose name and size match is attached by that path, no upload and no size limit
POST /api/run/selected-files → {files:[{path,size,dir}]}: the same for a DROP — the items selected in THIS machine's file manager, which is what a drag from it carries (Windows Explorer windows and the desktop via Shell.Application, macOS Finder's selection — macOS asks once to let AgentHub control Finder; Linux file managers publish none, so a Linux drop uses the browser's own `file://` list or uploads). Nothing is searched for on disk
GET /api/run/native-paste → {native, key}: whether a `claude` pane can read this machine's clipboard itself (see drop-file below), and `key` — `ctrl+v`, or `alt+v` on Windows — the chord Claude Code binds its image paste to there, which the terminal sends
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V (Alt+V on Windows, Claude Code's binding there) so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel) and only while `GET /api/run/native-paste` answers `{native:true}`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` (Wayland) or `xclip` (X11) — without one the `^V` pasted nothing, so the image is uploaded instead. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
POST /api/run/drop-file?name=&laneId= Any raw file body, streamed to disk as it arrives (S3: through `write-as` as the member) — ≤500 MB, like a Files upload; from a loopback browser outside server mode no cap, only the data dir's free space less a 1 GB reserve (Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V (Alt+V on Windows, Claude Code's binding there) so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel) and only while `GET /api/run/native-paste` answers `{native:true}`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` (Wayland) or `xclip` (X11) — without one the `^V` pasted nothing, so the image is uploaded instead. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body (a loopback drop: when it would not fit on the disk), and close the connection; of several files only the refused ones are lost
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
@@ -3450,11 +3453,11 @@ Sent when a notification is created.
#### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically.
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically. In server mode a change to the host's config reaches a developer as the bare type (`data: {}`); a change to a member's OWN config carries `ownerId` and reaches that user alone, admins and analysts included (`scope.exactAudience`).
```json
{ "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } }
+2 -2
View File
@@ -239,7 +239,7 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. `agenthub lanes …` and `--lane <id>` are the old names and keep working — the same commands as `agenthub workspace …` and `--workspace <id>` — so scripts and skills written before the rename need no change.
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. The old names `agenthub lanes …` and `--lane <id>` were removed (step 4 of the rename): they exit 1 with `renamed: use agenthub workspace …` / `renamed: use --workspace <id>`. Your workspaces, sessions and history are unchanged, and the API still answers under both `/api/workspaces` and `/api/lanes`.
| Command | Description |
| ------- | ----------- |
@@ -271,7 +271,7 @@ A workspace is a durable unit of parallel agent work — one working directory,
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
Resolution order, shared by every cwd-resolving command: `--workspace` (old name `--lane`)/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
Resolution order, shared by every cwd-resolving command: `--workspace`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack.
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -374,7 +374,7 @@ both full suites.
| Path | What |
|---|---|
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
| `client/` | React 18 + Vite + Tailwind dashboard |
| `client/` | React 19 + Vite + Tailwind dashboard |
| `bin/agenthub.js` | CLI |
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
+1 -1
View File
@@ -293,7 +293,7 @@ chạy cả hai bộ đầy đủ.
| Đường dẫn | Là gì |
|---|---|
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
| `client/` | Dashboard React 18 + Vite + Tailwind |
| `client/` | Dashboard React 19 + Vite + Tailwind |
| `bin/agenthub.js` | CLI |
| `docs/` | Architecture, API, workspaces, database |
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
+2 -2
View File
@@ -194,8 +194,8 @@ graph TB
| Layer | Technology |
|-------|------------|
| **Frontend** | React 18, TypeScript 5.7, Vite 6, Tailwind CSS |
| **Backend** | Node.js 20+, Express 4.21, WebSocket |
| **Frontend** | React 19, TypeScript 7, Vite 6, Tailwind CSS |
| **Backend** | Node.js 22.5+, Express 5, WebSocket |
| **Database** | SQLite 3 (better-sqlite3 or node:sqlite) |
| **Integration** | Claude Code Hooks, MCP Server |
+53 -4
View File
@@ -65,7 +65,7 @@ The proxy has to:
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
- set `X-Forwarded-For` and `X-Forwarded-Proto`;
- accept request bodies as large as AgentHub's own limits (Files uploads up to
500 MB, prompt drops 20 MB). nginx's default `client_max_body_size` is 1 MB,
500 MB, files dropped on a terminal or a prompt box 500 MB, dropped images 20 MB). nginx's default `client_max_body_size` is 1 MB,
and its own 413 is an HTML page the UI can only report as a failed upload.
nginx:
@@ -106,11 +106,13 @@ The login cookie gets `Secure` when the request came over HTTPS
start (`server/db.js`), and `member` is no longer accepted.
"The host's configuration" means a write (anything but GET/HEAD) under
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`,
`/api/settings`, `/updates`, `/codex-config`,
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)). A
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)) and the
host-wide parts of `/cc-config` (see
[Your own Claude Code config](#your-own-claude-code-config)). A
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
an admin can do this". An analyst's other writes are scoped like a
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
@@ -120,7 +122,8 @@ exactly like a developer's.
The UI hides what a role cannot use (`usePermissions` in
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
no Data, Pricing or per-tool pages, no scheduled reports or git credentials,
no Data, Pricing or per-tool pages (except their own Claude Code config,
for a user with their own Linux account), no scheduled reports or git credentials,
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
no install/update/enable on AI tools, no pet upload or delete, and no update
check; the AI proxy page shows them only their personal profiles' Edit. An
@@ -774,6 +777,52 @@ folder under it), and the dark/light mode picked in AgentHub becomes Claude's
theme. Accepting Bypass Permissions mode stays the member's own choice, made
in their terminal.
### Your own Claude Code config
With S3, a developer or analyst who has their own Linux account manages THEIR
OWN Claude Code configuration in Settings → AI tools → **Your Claude Code
config** — skills, subagents, commands, output styles, `CLAUDE.md` and
auto-memory, plugins, marketplaces, MCP servers, `settings.json` (hooks
included) and `keybindings.json` — the same Config Explorer an admin uses for
the host's, pointed at `<USERS_ROOT>/<name>/.claude` and
`<USERS_ROOT>/<name>/.claude.json`. How it runs:
- `/api/cc-config` gates itself (`server/lib/cc-target.js`): a non-admin
reaches only an allowlist of reads and of the writes that run as them.
`/profiles*` (the host's login switching), `/plugins/update-all` (the host's
Update all, AgentHub's own plugin included) and any endpoint not on the list
stay an admin's (`403 EADMIN`; an analyst may still READ the profiles and the
Update-all journal, as before). Every handler then resolves ONE target: the
host's Claude home for an admin (and in local mode) exactly as before, the
member's own home otherwise, and `403 ENOACCOUNT` for a non-admin with no
enabled, provisioned account — never the host's. An analyst with no account
keeps the read-only view of the host's config they had; with one, `?view=host`
gives them that view, and the file reader serves either.
- Every file write is the member's own: `write-as` (content on stdin), the
timestamped backup a `write-as copy` inside their home; their files are read
only through the guarded reader (a regular file they own, size capped), and
a directory, plugin `installPath` or marketplace `installLocation` that leads
out of their home is not followed.
- `claude plugin …`, `claude plugin marketplace …` and `claude mcp …` run as
them: `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
/opt/agenthub/tools/bin/claude …` in their home, with a timeout and the same
argument checks. User scope only, never `-y` (a plugin whose marketplace
declares an install command fails; install it from your own terminal), and
no MCP `env` values or `headers` (sudo logs its argv: add such a server with
`claude mcp add` in your own terminal). `503 ENOTSHARED` until `claude` is
in the shared tools.
- A project layer only in one of their own workspaces, named with `?cwd=`
(`403 EFOLDER` otherwise); with none named there is no project layer at all,
never the dashboard's own folder. An admin's "new CLAUDE.md in a project"
in a member's folder is written as that member too.
- A change is broadcast (`cc_config_changed`) to that member alone, and their
**Reload** (`POST /api/cc-config/reload`, or a plugin update with reload)
types `/reload-plugins` / `/reload-skills` only into the panes that run as
them. Their own config is theirs, so these writes are not audited (like
their own File Explorer writes).
Not verified on a live S3 machine yet.
### Plugin baseline (opt-in)
Write `/etc/agenthub/plugin-baseline.json` (as
+1 -1
View File
@@ -9,7 +9,7 @@
- [NEVER tmux kill-server](never-tmux-kill-server.md) — $TMUX beats TMUX_TMPDIR; it killed the real server + this session twice
- [Analytics parity plan](analytics-parity-plan.md) — phases 2–7 shipped in v2.4.0; ../agenthub-analytics now redundant; OpenAPI for new routes still missing
- [Dev/prod ports share one DB](agenthub-dev-prod-ports.md) — 4820 prod (systemd), 4830 user's dev in Warp; same dashboard.db, dev migrations hit prod
- [Workspace rename step 4](workspace-rename-step4.md) — remove `agenthub lanes`/`--lane` only ≥3 releases after aliases ship; never rename stored data
- [Workspace rename step 4](workspace-rename-step4.md) — DONE 10-26: old CLI names refused, clients call /api/workspaces, /api/lanes kept; release needs EN+VI note
- [No git stash here](no-git-stash-shared-checkout.md) — parallel sessions share this checkout; use a worktree to test clean HEAD
- [Update never kills panes](update-never-kills-panes.md) — updates/restarts keep tmux lane panes; only Quit AgentHub (#65) closes them; KillMode=process
- [Claude Code changelog ≤2.1.295](claude-code-changelog-2-1-293.md) — 2.1.285–295 (to 10-08): hooks onFailure:block (keep AgentHub fail-open), OSC 7501 tested (no use), Workflow scriptPath rules
+5 -7
View File
@@ -1,17 +1,15 @@
---
name: workspace-rename-step4
description: "Removing the old `agenthub lanes`/`--lane` CLI names is deferred to ≥3 releases after the aliases ship; never touch stored data"
description: "Rename step 4 DONE 2026-10-26: `agenthub lanes`/`--lane` now exit 1 'renamed'; client/CLI/MCP call /api/workspaces; /api/lanes kept; never touch stored data"
metadata:
node_type: memory
type: project
originSessionId: 47dc0201-8e49-4fce-9407-f087d3107588
modified: 2026-10-06T04:07:56.796Z
modified: 2026-10-26T00:00:00.000Z
---
On 2026-10-06 the user had steps 1–3 of the lane→workspace rename built (aliases `/api/workspaces`, `agenthub workspace`, `--workspace`; help/docs on the new name; a stderr deprecation note for `agenthub lanes`/`--lane`). Step 4 (removing the old CLI names, moving client+CLI requests to `/api/workspaces`) is deferred until the THIRD release after the first one that ships the aliases (the first release after v2.0.1). The plan, checklist and "never touch" list are in `docs/plans/workspace-rename/README.md`.
Step 4 of the lane→workspace rename was built on 2026-10-26 (after v2.8.25): `agenthub lanes …` and `--lane` exit 1 with `renamed: use …`; client `api.ts`, `bin/agenthub.js` and `mcp/server.js` request `/api/workspaces`; the `/api/lanes` mount is KEPT (4c default). Plan: `docs/plans/workspace-rename/README.md`.
**Why:** the user wants existing installs to update gradually and NOT lose any data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys must never be renamed.
**Why:** existing installs update gradually and must not lose data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys are never renamed.
**How to apply:** when cutting a release, check whether it is ≥ R+3 (R is recorded in that plan file — fill it in when R is cut) and remind the user that step 4 is due; keep `/api/lanes` unless the user decides otherwise (step 4c, default keep).
R = v2.1.0 (released 2026-10-06), so step 4 is allowed from the 3rd release after v2.1.0.
**How to apply:** the release that ships this needs the EN+VI note from the plan's release checklist ("`agenthub lanes` and `--lane` are removed — use `agenthub workspace` and `--workspace`…"). Do not remove `/api/lanes` unless the user asks (then 410 ERENAMED for one release first).
+2 -2
View File
@@ -32,5 +32,5 @@ Decisions (user, 2026-10-08):
- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
- [ ] prod/dev share one DB: restart prod on the new build together with the first run (migration)
- [ ] `agenthub stop` in server mode answers EAUTH (fix was blocked; user to decide)
- [x] prod/dev share one DB: restart prod on the new build together with the first run (migration) — done with the v2.5.x rollout
- [x] `agenthub stop` in server mode answers EAUTH — resolved: `cmdStop` (`bin/agenthub.js`) turns EAUTH/EADMIN into a clear message naming the admin's Quit and the `systemctl` stop commands; a host shell carries no login, so that is the intended answer
+1 -1
View File
@@ -21,7 +21,7 @@ Decided: own Linux user per account; skills/plugins = admin baseline + user's ow
- [x] Per-user AI Proxy store (+ admin shared) — S3-PLAN P1/P1b
- [x] S3 provisioning + spawn/attach as the account — S3-PLAN P2/P3
- [x] S4 shared tools in /opt — S3-PLAN P5
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike not run, member Settings not built
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike run 2026-10-09 (works from the first interactive session, S3-PLAN P6); member Settings built 2026-10-26 ("Your Claude Code config", writes as the member) — open until verified live on the S3 box
- [x] Codex account pool — S3-PLAN P7 (fake accounts only; no real Codex login tried)
- [x] Security review, docs, suites — P8 F1–F11, second review F12–F20 (2026-10-09), docs/SERVER-MODE.md runbook
+1 -1
View File
@@ -205,7 +205,7 @@ Branch: `server-s3`. Commits happen only when the user asks.
- Not checked: skills actually listed inside a logged-in session (no login on the test user).
- [x] Fallback (built, opt-in): with `/etc/agenthub/plugin-baseline.json` (`{"marketplaces": ["owner/repo", …], "plugins": ["name@marketplace", …]}`, root's, the admin edits it), creating a user runs `claude plugin marketplace add <source>` for each marketplace, then `claude plugin install --scope user <plugin>` for each plugin, AS the new member (`sudo -n -u ah-<name> HOME=… /opt/agenthub/tools/bin/claude …`, in the background; `agenthub users provision` runs it too, in the foreground, and is the retry). No file = nothing runs. An entry starting with `-` is skipped; `-y` is never passed. Sudoers: `/opt/agenthub/tools/bin/claude` joins the run-as-member list (no new power: the host already runs any command as a member through tmux). `server/lib/member-claude.js`, test `server-s3-provision.test.js` (fake sudo). Not verified live.
- [ ] A member's own plugins work from their terminal (`/plugin`) as soon as P3 lands, with no code.
- [ ] Later, optional: Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`.
- [x] Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`. Done: one target resolver + the router's own allowlist gate (`server/lib/cc-target.js`; `/cc-config` left `STAFF_ONLY_PREFIXES`/`ADMIN_WRITE_PREFIXES`), the readers/writers take the member's home (`cc-discovery`/`cc-mutate` `home`, `noProject`, `ioFor` → write-as; backups via `write-as copy`), `claude plugin|mcp` as the member's sudo argv (user scope, no `-y`, no env/headers), project writes only in their own workspaces, an admin's project-memory create in a member's folder written as the member, `cc_config_changed` to the owner only (`scope.exactAudience`), `/reload` into their own panes; UI "Your Claude Code config" (`useCcTarget`). Test `server-s3-cc-config.test.js` (fake sudo, fake `claude` spawn). Not verified live.
#### P6 spike: how to run it (a human, on the S3 box)
`scripts/server/spike-managed-plugins.sh` (never run by AgentHub). It changes `/etc/claude-code/managed-settings.json`, which EVERY Claude Code on the machine reads, the host's own sessions included, so run it in a quiet moment and undo right after.
+1 -1
View File
@@ -1,6 +1,6 @@
# Workspace redesign — overview and phase plan
_Status: specs written 2026-10-01, nothing implemented yet. Each phase is one PR, executed in order; a later phase assumes the earlier ones are merged._
_Status: specs written 2026-10-01; all four phases are built (sidebar tree + status bar + workbench `ba2c4e1`, grouped Settings with search `eb90244`, Orca themes + notification sounds `b71024f`, the lane → workspace copy rename). Kept as the design record. Each phase was one PR, executed in order._
## Why
+1 -1
View File
@@ -15,7 +15,7 @@ version here when it is cut: R = `v2.1.0`).
| 1 | Aliases: `/api/workspaces` = `/api/lanes` (same two routers mounted twice, `server/index.js` `LANE_MOUNTS`); `agenthub workspace`/`workspaces` = `agenthub lanes` (`COMMAND_ALIASES`/`canonicalArgv` in `bin/agenthub.js`); `--workspace <id>` = `--lane <id>` (`resolveLaneArg`). | R | done |
| 2 | Help, usage and hint text, the UI's command hints and the user docs say `agenthub workspace …` / `--workspace`; `docs/API.md` documents both paths. | R | done |
| 3 | Deprecation: `agenthub lanes` or `--lane` prints ONE stderr line naming the new spelling (`warnOldNames`), stdout unchanged; the help footer and `docs/CLI.md` call them the old names. | R | done |
| 4 | Remove the old CLI names (below). | **not before R+3** | open |
| 4 | Remove the old CLI names (below). | **not before R+3** | done 2026-10-26 (after v2.8.25): 4a + 4b built, 4c kept `/api/lanes` |
Three releases is the window for an install to update gradually: anyone who
runs the CLI in that time sees the step-3 note on every old-name call, and a
+1 -1
View File
@@ -142,7 +142,7 @@ const TOOLS = {
list_lanes: {
description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.",
properties: {},
path: () => "/api/lanes",
path: () => "/api/workspaces",
},
list_pricing: {
description: "Model pricing rules (USD per million tokens) the cost estimates use.",
+130 -15
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.24
version: 2.8.25
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -2178,7 +2178,7 @@ components:
type: string
description: Command arguments — present only for `stdio` servers.
example:
- '-y'
- -y
- '@modelcontextprotocol/server-github'
envNames:
type: array
@@ -2340,7 +2340,7 @@ components:
project:
type: string
description: 'Auto-memory only: the ~/.claude/projects/<slug> directory name the file belongs to.'
example: '-Users-son-repo'
example: -Users-son-repo
name:
type: string
description: 'Auto-memory only: the markdown filename.'
@@ -2868,7 +2868,7 @@ components:
project:
type: string
description: 'Required for `auto-memory`: the ~/.claude/projects/<slug> directory name. Validated against /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/ and rejected if it contains `..`.'
example: '-Users-son-repo'
example: -Users-son-repo
CcConfigFileWriteResponse:
type: object
description: Result of a successful create-or-overwrite.
@@ -2933,7 +2933,7 @@ components:
project:
type: string
description: 'Required for `auto-memory`: the projects/<slug> directory name.'
example: '-Users-son-repo'
example: -Users-son-repo
CcConfigFileDeleteResponse:
type: object
description: Result of a successful delete (a mandatory backup is created first).
@@ -2984,7 +2984,7 @@ components:
project:
type: string
description: 'Auto-memory only: the projects/<slug> directory name.'
example: '-Users-son-repo'
example: -Users-son-repo
type:
type: string
enum:
@@ -4362,8 +4362,8 @@ components:
items:
type: string
example:
- '-Users-son-code-foo'
- '-Users-son-code-bar'
- -Users-son-code-foo
- -Users-son-code-bar
RemoteSourceSyncResponse:
type: object
description: Counters from a pull-now sync against the remote source.
@@ -8432,6 +8432,52 @@ paths:
text/html:
schema:
type: string
/api/redoc/redoc.standalone.js:
get:
tags:
- Documentation
summary: ReDoc bundle
description: The ReDoc standalone script the `/api/redoc` page loads, sent from the server's own vendored copy (no CDN).
operationId: getRedocBundle
responses:
'200':
description: JavaScript bundle
content:
application/javascript:
schema:
type: string
'500':
description: The bundle could not be read
/api/cc-config/target:
get:
tags:
- CcConfig
summary: Whose Claude Code config this login manages
description: 'Server mode: `own` for a non-admin with their own Linux account (S3) — every other endpoint then reads and writes THEIR `~/.claude`, writes running as them; `host` for an admin (and in local mode), or an analyst''s read-only view of the host''s; `none` for a non-admin with no account (the other endpoints answer 403 ENOACCOUNT). Never an error.'
operationId: ccConfigGetTarget
responses:
'200':
description: The target.
content:
application/json:
schema:
type: object
properties:
kind:
type: string
enum:
- own
- host
- none
readOnly:
type: boolean
claudeHome:
type: string
nullable: true
example:
kind: own
readOnly: false
claudeHome: /srv/agenthub/mia/.claude
/api/cc-config/overview:
get:
tags:
@@ -8833,7 +8879,7 @@ paths:
kind: stdio
command: npx
args:
- '-y'
- -y
- '@modelcontextprotocol/server-github'
envNames:
- GITHUB_TOKEN
@@ -8892,7 +8938,7 @@ paths:
items:
type: string
example:
- '-y'
- -y
- server-github
env:
type: object
@@ -9116,7 +9162,7 @@ paths:
truncated: false
preview: '# Workspace & Productivity System...'
- scope: auto-memory
project: '-Users-son-repo'
project: -Users-son-repo
name: MEMORY.md
isIndex: true
file: /Users/son/.claude/projects/-Users-son-repo/memory/MEMORY.md
@@ -13040,8 +13086,8 @@ paths:
ok: true
message: Connected; found 24 project directories under ~/.claude/projects.
remoteProjects:
- '-Users-son-code-foo'
- '-Users-son-code-bar'
- -Users-son-code-foo
- -Users-son-code-bar
failure:
summary: Unreachable / auth failed
value:
@@ -15088,6 +15134,74 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
tags:
- Auth
operationId: deleteServerUser
summary: 'Admin (Delete user): the account for good — its logins, panes, workspaces'' records and prefs, and under S3 its Linux account and whole home, removed FIRST so a refusal leaves every row. Session history and the audit log stay. Never yourself (ESELF) or the last enabled admin (ELASTADMIN)'
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- currentPassword
properties:
currentPassword:
type: string
responses:
'200':
description: Deleted — the account as it was
content:
application/json:
schema:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUser'
'400':
description: EBADPASSWORD / EBADROLE / EBADFIELD
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOUSER / ENOLANE
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: ESELF — yourself / ELASTADMIN — the last enabled admin
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
description: ETOOMANY — 10 wrong passwords in 15 minutes
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'502':
description: EPROVISION — the Linux account or home could not be removed; nothing deleted
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/users/{id}/password:
post:
tags:
@@ -16979,16 +17093,17 @@ paths:
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
'400':
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
/api/me/git/ssh-user/{from}:
/api/me/git/ssh-user/{host}:
delete:
tags:
- Git identity
summary: Drop the SSH login of a host or alias (and its key link)
operationId: deleteSshUser
parameters:
- name: from
- name: host
in: path
required: true
description: The host or alias the login was saved under.
schema:
type: string
responses:
+429 -788
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.25",
"version": "2.8.26",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -71,21 +71,20 @@
"link-cli": "npm link || node -e \"console.warn('agenthub CLI not linked globally (no permission?). Link manually with: npm link')\""
},
"dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15",
"@lydell/node-pty": "^1.2.0-beta.18",
"adm-zip": "^0.6.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"js-yaml": "^4.1.0",
"multer": "^2.0.0",
"express": "^5.3.0",
"js-yaml": "^5.5.0",
"multer": "^2.5.0",
"swagger-ui-express": "^5.0.1",
"systray2": "^2.1.4",
"tar": "^7.4.3",
"uuid": "^11.1.0",
"web-push": "^3.6.7",
"ws": "^8.18.0"
},
"optionalDependencies": {
"better-sqlite3": "^12.0.0"
"better-sqlite3": "^13.0.3"
},
"devDependencies": {
"concurrently": "^9.1.2",
+1 -1
View File
@@ -239,7 +239,7 @@ function systemdStatus() {
/**
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
* `agenthub workspace shell` and the session-reload keystrokes all fail — while
* sessions, analytics, lanes and the CLI keep working. A partial feature
* loss must not fail a doctor run.
*
+1 -1
View File
@@ -26,7 +26,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { v4: uuidv4 } = require("uuid");
const { randomUUID: uuidv4 } = require("crypto");
const { db, stmts } = require("../server/db");
// ── Stable fixture IDs ─────────────────────────────────────────────────────
+3 -2
View File
@@ -441,7 +441,7 @@ All endpoints return JSON unless noted. Error responses use:
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It now documents every backend route (75 path entries). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It documents every `/api` route (332 paths, 399 operations); `server/__tests__/openapi-coverage.test.js` fails when a registered route has no operation in the spec, or an operation has no route (the `/api/workspaces` and `/api/ai-routers` alias mounts are documented once, under `/api/lanes` and `/api/ai-proxies`). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
### Core Endpoints
@@ -747,10 +747,11 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
### Claude Config Explorer (`/api/cc-config`)
Reads — and carefully gated mutations for low-risk text-file artifacts — for every Claude Code configuration surface. Mutations always create timestamped backups under `<root>/cc-config-backups/<type>/` before writing.
Reads — and carefully gated mutations for low-risk text-file artifacts — for every Claude Code configuration surface. Mutations always create timestamped backups under `<root>/cc-config-backups/<type>/` before writing. In server mode the router gates itself (`lib/cc-target.js`): an admin works on the host's Claude home, a non-admin with their own Linux account (S3) on THEIR `~/.claude` (writes as them through `write-as` / sudo `claude`), anyone else gets `403 ENOACCOUNT`; `/profiles*` and `/plugins/update-all` stay an admin's. See `docs/SERVER-MODE.md` → "Your own Claude Code config".
| Method | Path | Description |
| -------- | ------------------------------------- | ----------- |
| `GET` | `/api/cc-config/target` | Whose config this login manages: `own` / `host` / `none` |
| `GET` | `/api/cc-config/overview` | Roots + counts for every surface (used by the Overview tab) |
| `GET` | `/api/cc-config/skills` | Skills with parsed frontmatter, `?scope=user\|project\|all` |
| `GET` | `/api/cc-config/agents` | Subagents under `<scope>/.claude/agents/*.md` |
+11
View File
@@ -98,6 +98,17 @@ describe("reloadableLanes", () => {
stubTmux([]);
assert.deepEqual(reloadableLanes(), []);
});
it("keeps the host's panes and one member's apart (S3)", (t) => {
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"]);
const owners = { "agenthub-lane-2": { name: "mia" }, "agenthub-lane-3": { name: "bob" } };
t.mock.method(require("../lib/run-as"), "accountForSession", (id) => owners[id] || null);
const ids = (list) => list.map((l) => l.id);
// The host's reload never types into a member's pane…
assert.deepEqual(ids(reloadableLanes()), ["agenthub-lane-1"]);
// …and a member's reaches their own panes only.
assert.deepEqual(ids(reloadableLanes({ name: "mia" })), ["agenthub-lane-2"]);
});
});
describe("reloadOpenSessions", () => {
+12 -10
View File
@@ -1,32 +1,34 @@
/**
* @file fake-sudo.js
* @description Test helper for the S3 suites: an `execImpl` for
* `run-as.__setExecImpl` that plays `sudo -n -u <login> [VAR=v…] <bin> …`
* without root — it records the argv (so a test can assert nothing secret
* `run-as.__setExecImpl` (and a `spawn` for `__setSpawnImpl`) that plays
* `sudo -n -u <login> [VAR=v…] <bin> …` without root — it records the argv (so a test can assert nothing secret
* crossed sudo) and runs the real `scripts/server/write-as.sh` for
* `/opt/agenthub/libexec/write-as`, as this account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { execFileSync, spawn: spawnChild } = require("node:child_process");
const WRITE_AS = path.join(__dirname, "..", "..", "..", "scripts", "server", "write-as.sh");
/** `{ exec, calls }`: pass `exec` to `runAs.__setExecImpl`; `calls` = every sudo argv. */
/** `{ exec, spawn, calls }`: pass `exec` to `runAs.__setExecImpl` and `spawn`
* to `runAs.__setSpawnImpl`; `calls` = every sudo argv. */
function fakeSudo() {
const calls = [];
const exec = (cmd, argv, opts = {}) => {
// The command sudo would run, as [file, args].
const unwrap = (cmd, argv) => {
calls.push([cmd, ...argv]);
if (cmd !== "sudo") return execFileSync(cmd, argv, opts);
if (cmd !== "sudo") return [cmd, argv];
let i = argv.indexOf("-u") + 2; // past the login
while (/^[A-Z_][A-Z0-9_]*=/.test(argv[i])) i++;
const [bin, ...rest] = argv.slice(i);
if (bin === "/opt/agenthub/libexec/write-as")
return execFileSync("sh", [WRITE_AS, ...rest], opts);
return execFileSync(bin, rest, opts);
return bin === "/opt/agenthub/libexec/write-as" ? ["sh", [WRITE_AS, ...rest]] : [bin, rest];
};
return { exec, calls };
const exec = (cmd, argv, opts = {}) => execFileSync(...unwrap(cmd, argv), opts);
const spawn = (cmd, argv, opts = {}) => spawnChild(...unwrap(cmd, argv), opts);
return { exec, spawn, calls };
}
module.exports = { fakeSudo };
@@ -77,7 +77,7 @@
"label": "CI gates + preflight",
"icon": "🧪",
"gate": true,
"command": "agenthub lanes hook ci-gate",
"command": "agenthub workspace hook ci-gate",
"aliases": ["pre-push-gate", "tests", "migration-collision", "sync-conflict"],
"detect": [
{
@@ -95,7 +95,7 @@
"label": "E2e on feature branch",
"icon": "🧪",
"gate": false,
"command": "agenthub lanes up --qc",
"command": "agenthub workspace up --qc",
"aliases": ["e2e", "e2e-scoped", "booting", "live"],
"detect": [
{
@@ -143,7 +143,7 @@
"label": "Browser QC",
"icon": "🔍",
"gate": true,
"command": "agenthub lanes proof-link",
"command": "agenthub workspace proof-link",
"aliases": [],
"detect": [
{
@@ -152,7 +152,7 @@
},
{
"tool": "Bash",
"match": "\\bagenthub\\b[^;&|]*\\blanes proof-link\\b"
"match": "\\bagenthub\\b[^;&|]*\\b(?:lanes|workspace) proof-link\\b"
}
]
},
@@ -219,7 +219,7 @@
"label": "Watching PR",
"icon": "👁",
"gate": false,
"command": "agenthub lanes ci status",
"command": "agenthub workspace ci status",
"aliases": ["pr-comment-fix"],
"detect": [
{
+68 -62
View File
@@ -1,5 +1,5 @@
/**
* @file Tests for the `agenthub stage` / `agenthub lanes` CLI subcommands: lane
* @file Tests for the `agenthub stage` / `agenthub workspace` CLI subcommands: lane
* resolution from the current directory, the stage round-trip through the HTTP
* API, and the non-zero exit when the cwd belongs to no lane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -194,20 +194,26 @@ after(() => {
});
describe("agenthub workspaces", () => {
it("is the lanes command under the UI's word", async () => {
const [lanes, workspaces, workspace] = await Promise.all([
cli(["lanes"], LANE_DIR),
it("is the workspace command under its plural", async () => {
const [workspaces, workspace] = await Promise.all([
cli(["workspaces"], LANE_DIR),
cli(["workspace"], LANE_DIR),
]);
assert.equal(workspaces.status, 0, workspaces.stderr);
assert.match(workspaces.stdout, /CLI lane/);
assert.equal(workspaces.stdout, lanes.stdout);
assert.equal(workspace.stdout, lanes.stdout);
// The old name still works but says so — on stderr, never in stdout.
assert.match(lanes.stderr, /old name.*agenthub workspace/);
assert.doesNotMatch(workspaces.stderr, /old name/);
assert.doesNotMatch(lanes.stdout, /old name/);
assert.equal(workspace.stdout, workspaces.stdout);
});
it("refuses the removed old names, naming the new spelling", async () => {
const [lanes, flag] = await Promise.all([
cli(["lanes"], LANE_DIR),
cli(["stage", "review", "--lane", "1"], LANE_DIR),
]);
assert.equal(lanes.status, 1);
assert.match(lanes.stderr, /renamed: use agenthub workspace/);
assert.equal(lanes.stdout, "");
assert.equal(flag.status, 1);
assert.match(flag.stderr, /renamed: use --workspace <id>/);
});
});
@@ -216,7 +222,7 @@ describe("agenthub stage", () => {
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /review/);
const list = await cli(["lanes"], LANE_DIR);
const list = await cli(["workspace"], LANE_DIR);
assert.match(list.stdout, /review/);
});
@@ -298,7 +304,7 @@ describe("agenthub stage in a directory shared by two lanes", () => {
assert.notEqual(await stageOf(codexLane.id), "implement");
});
it("--workspace names the lane like --lane", async () => {
it("--workspace names the lane", async () => {
const r = await cli(
["stage", "review", "--workspace", String(codexLane.id)],
DUAL_DIR,
@@ -328,18 +334,18 @@ describe("agenthub stage in a directory shared by two lanes", () => {
assert.match(`${r.stdout}${r.stderr}`, /no codex workspace/i);
});
it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
// Even with two lanes at the same cwd, --lane <id> bypasses all resolution
const r = await cli(["stage", "review", "--lane", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
it("--workspace flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
// Even with two lanes at the same cwd, --workspace <id> bypasses all resolution
const r = await cli(["stage", "review", "--workspace", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "review");
assert.notEqual(await stageOf(claudeLane.id), "review");
});
it("positional numeric ID acts like --lane for commands that support it (e.g. agenthub lanes pipeline)", async () => {
// Commands like `agenthub lanes pipeline` treat a leading numeric arg as the lane ID.
it("positional numeric ID acts like --workspace for commands that support it (e.g. agenthub workspace pipeline)", async () => {
// Commands like `agenthub workspace pipeline` treat a leading numeric arg as the lane ID.
// Other commands like `agenthub stage` have their own positional argument and don't support it.
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
const r = await cli(["workspace", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`workspace #${codexLane.id}`));
});
@@ -386,15 +392,15 @@ describe("agenthub stage in a directory shared by two lanes", () => {
});
});
describe("agenthub lanes add", () => {
describe("agenthub workspace add", () => {
it("creates a lane and it appears in lanes list", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-add-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
const r = await cli(["workspace", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created workspace/i);
const list = await cli(["lanes"], addDir);
const list = await cli(["workspace"], addDir);
assert.equal(list.status, 0, list.stderr);
assert.match(list.stdout, /CLI added/);
} finally {
@@ -405,7 +411,7 @@ describe("agenthub lanes add", () => {
it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-no-pipeline-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
const r = await cli(["workspace", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created workspace/i);
@@ -423,7 +429,7 @@ describe("agenthub lanes add", () => {
try {
const r = await cli(
[
"lanes",
"workspace",
"add",
"--cwd",
addDir,
@@ -449,17 +455,17 @@ describe("agenthub lanes add", () => {
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
// `--pipeline` was creation-only, so every lane added from the dashboard
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
const show = await cli(["lanes", "pipeline"], LANE_DIR);
const show = await cli(["workspace", "pipeline"], LANE_DIR);
assert.equal(show.status, 0, show.stderr);
assert.match(show.stdout, /default/);
assert.match(show.stdout, /available:.*ship-feature/);
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const set = await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
assert.equal(set.status, 0, set.stderr);
assert.match(set.stdout, /ship-feature/);
assert.match(set.stdout, /16 nodes/);
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
const back = await cli(["workspace", "pipeline", "default"], LANE_DIR);
assert.equal(back.status, 0, back.stderr);
assert.match(back.stdout, /8 nodes/);
});
@@ -467,17 +473,17 @@ describe("agenthub lanes add", () => {
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
// getPipeline() returns the default template for an unknown id, so without
// a write-side check a typo would store, render `default`, and look fine.
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
const r = await cli(["workspace", "pipeline", "no-such-pipeline"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
const after = await cli(["lanes", "pipeline"], LANE_DIR);
const after = await cli(["workspace", "pipeline"], LANE_DIR);
assert.match(after.stdout, /default/);
});
it("provisions a managed worktree lane and reports it ready", async () => {
const r = await cli(
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
["workspace", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
SOURCE_REPO
);
assert.equal(r.status, 0, r.stderr);
@@ -504,7 +510,7 @@ describe("agenthub lanes add", () => {
);
await git(["branch", "feat"], emptyRepo);
const r = await cli(
["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
["workspace", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
emptyRepo
);
assert.notEqual(r.status, 0);
@@ -517,7 +523,7 @@ describe("agenthub lanes add", () => {
});
});
describe("agenthub lanes destructive lifecycle", () => {
describe("agenthub workspace destructive lifecycle", () => {
// Every test provisions its OWN lane. These tests used to share one mutable
// `managedLane` across describe blocks, which meant they only ever exercised
// whatever state the previous test happened to leave behind — the same
@@ -526,7 +532,7 @@ describe("agenthub lanes destructive lifecycle", () => {
async function ownLane(title) {
const slug = `cli-destructive-${(laneSeq += 1)}`;
const added = await cli(
["lanes", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
["workspace", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
SOURCE_REPO
);
assert.equal(added.status, 0, added.stderr);
@@ -542,7 +548,7 @@ describe("agenthub lanes destructive lifecycle", () => {
const dirtyFile = path.join(lane.cwd, "dirty.txt");
fs.writeFileSync(dirtyFile, "must survive\n");
const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
const r = await cli(["workspace", "reset", String(lane.id)], SOURCE_REPO);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /Preflight for reset workspace #\d+:/);
assert.match(r.stdout, /dirty\s+0/);
@@ -555,7 +561,7 @@ describe("agenthub lanes destructive lifecycle", () => {
const lane = await ownLane("CLI reset applied");
fs.writeFileSync(path.join(lane.cwd, "dirty.txt"), "to be cleaned\n");
const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
const r = await cli(["workspace", "reset", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Reset workspace #${lane.id}`));
assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
@@ -577,8 +583,8 @@ describe("agenthub lanes destructive lifecycle", () => {
const lane = await ownLane("CLI reset race");
fs.writeFileSync(path.join(lane.cwd, "race.txt"), "one more untracked file\n");
const [a, b] = await Promise.all([
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
cli(["workspace", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
cli(["workspace", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
]);
const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
assert.equal(winner.status, 0, winner.stderr);
@@ -591,7 +597,7 @@ describe("agenthub lanes destructive lifecycle", () => {
it("removes a managed lane's record with --yes, leaving its directory on disk", async () => {
const lane = await ownLane("CLI remove managed");
assert.equal(fs.existsSync(lane.cwd), true);
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
const r = await cli(["workspace", "remove", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Removed workspace #${lane.id}`));
assert.match(r.stdout, /left for you to delete yourself/);
@@ -610,14 +616,14 @@ describe("agenthub lanes destructive lifecycle", () => {
});
const id = created.body.lane.id;
const reset = await cli(["lanes", "reset", String(id), "--yes"], adoptedDir);
const reset = await cli(["workspace", "reset", String(id), "--yes"], adoptedDir);
assert.notEqual(reset.status, 0);
assert.match(`${reset.stdout}${reset.stderr}`, /points at a directory you own/i);
// The server permits `remove` for an adopted lane — it drops the dashboard
// record only — so the CLI must not refuse it, or the capability is
// unreachable from the terminal.
const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
const removed = await cli(["workspace", "remove", String(id), "--yes"], adoptedDir);
assert.equal(removed.status, 0, removed.stderr);
assert.match(removed.stdout, new RegExp(`Removed workspace #${id}`));
assert.equal((await get(`/api/lanes/${id}`)).status, 404);
@@ -634,7 +640,7 @@ describe("agenthub lanes destructive lifecycle", () => {
assert.equal(preflight.status, 200);
const { sessions, events, tokenRows } = preflight.body;
const r = await cli(["lanes", "purge", String(lane.id), "--yes"], SOURCE_REPO);
const r = await cli(["workspace", "purge", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(
r.stdout,
@@ -645,7 +651,7 @@ describe("agenthub lanes destructive lifecycle", () => {
});
});
describe("agenthub lanes — inferred stage detection", () => {
describe("agenthub workspace — inferred stage detection", () => {
it("prints the inferred stage when detection leads the declared stage", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-lead-"));
try {
@@ -666,10 +672,10 @@ describe("agenthub lanes — inferred stage detection", () => {
},
});
const r = await cli(["lanes"], dir);
const r = await cli(["workspace"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in agenthub lanes output");
assert.ok(line, "lane row not found in agenthub workspaces output");
assert.match(line, /⇢ detected:tests/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
@@ -685,7 +691,7 @@ describe("agenthub lanes — inferred stage detection", () => {
// Declare 'ship' (past 'tests' in the default pipeline) first, so the
// detection below is behind the declaration and recordDetection's
// declared-wins guard refuses to write it.
const staged = await cli(["stage", "ship", "--lane", String(id)], dir);
const staged = await cli(["stage", "ship", "--workspace", String(id)], dir);
assert.equal(staged.status, 0, staged.stderr);
await post("/api/hooks/event", {
@@ -698,10 +704,10 @@ describe("agenthub lanes — inferred stage detection", () => {
},
});
const r = await cli(["lanes"], dir);
const r = await cli(["workspace"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in agenthub lanes output");
assert.ok(line, "lane row not found in agenthub workspaces output");
assert.doesNotMatch(line, /detected:/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
@@ -709,9 +715,9 @@ describe("agenthub lanes — inferred stage detection", () => {
});
});
describe("agenthub lanes node", () => {
describe("agenthub workspace node", () => {
it("with no node id, lists every node and its command, and exits non-zero with usage", async () => {
const r = await cli(["lanes", "node"], LANE_DIR);
const r = await cli(["workspace", "node"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /\bintake\b/);
assert.match(r.stdout, /\bdone\b/);
@@ -719,17 +725,17 @@ describe("agenthub lanes node", () => {
});
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
await cli(["lanes", "pipeline", "default"], LANE_DIR);
const r = await cli(["lanes", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
await cli(["workspace", "pipeline", "default"], LANE_DIR);
const r = await cli(["workspace", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /"claude" "\/superpowers:writing-plans the export button"/);
});
it("--print shows a shell node split into argv, never a shell string", async () => {
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const r = await cli(["lanes", "node", "gates", "--print"], LANE_DIR);
await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
const r = await cli(["workspace", "node", "gates", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /"agenthub" "lanes" "hook" "ci-gate"/);
assert.match(r.stdout, /"agenthub" "workspace" "hook" "ci-gate"/);
});
it("every node of both shipped templates declares a runnable command", async () => {
@@ -744,26 +750,26 @@ describe("agenthub lanes node", () => {
it("matches a node by alias, so one slash command works on either template", async () => {
// `default` calls the step `implement`, `ship-feature` calls it
// `implementing` — /agenthub:implement must not depend on which one is active.
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const ship = await cli(["lanes", "node", "implement", "--print"], LANE_DIR);
await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
const ship = await cli(["workspace", "node", "implement", "--print"], LANE_DIR);
assert.equal(ship.status, 0, ship.stderr);
assert.match(ship.stdout, /test-driven-development/);
await cli(["lanes", "pipeline", "default"], LANE_DIR);
const def = await cli(["lanes", "node", "implementing", "--print"], LANE_DIR);
await cli(["workspace", "pipeline", "default"], LANE_DIR);
const def = await cli(["workspace", "node", "implementing", "--print"], LANE_DIR);
assert.equal(def.status, 0, def.stderr);
assert.match(def.stdout, /test-driven-development/);
});
it("names the pipeline's nodes when asked for one that is not in it", async () => {
const r = await cli(["lanes", "node", "not-a-node", "--print"], LANE_DIR);
const r = await cli(["workspace", "node", "not-a-node", "--print"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /no node "not-a-node"/);
assert.match(r.stdout, /\bintake\b/);
});
});
describe("agenthub lanes ci status", () => {
describe("agenthub workspace ci status", () => {
let restorePath;
let bin;
@@ -807,7 +813,7 @@ exit 1
});
it("exits 0 and prints success state", async () => {
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR);
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR);
assert.equal(r.status, 0);
assert.match(r.stdout, /"state": "success"/);
});
@@ -816,7 +822,7 @@ exit 1
const orig = process.env.CI_STATUS_FIXTURE;
try {
process.env.CI_STATUS_FIXTURE = "failure";
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR, {
CI_STATUS_FIXTURE: "failure",
});
assert.equal(r.status, 1);
@@ -830,7 +836,7 @@ exit 1
const orig = process.env.CI_STATUS_FIXTURE;
try {
process.env.CI_STATUS_FIXTURE = "pending";
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR, {
CI_STATUS_FIXTURE: "pending",
});
assert.equal(r.status, 3);
+1 -1
View File
@@ -151,7 +151,7 @@ describe("per-node driver command", () => {
assert.equal(byId.intake, "/superpowers:brainstorming");
assert.equal(byId.implementing, "/superpowers:test-driven-development");
assert.equal(byId.gates, "agenthub lanes hook ci-gate");
assert.equal(byId.gates, "agenthub workspace hook ci-gate");
// A terminal or gate node has no work of its own to run: reaching it IS
// the declaration, so its command is the declaration - which keeps
// `agenthub lanes node <id>` runnable for every node in the template.
+1 -1
View File
@@ -74,7 +74,7 @@ describe("mcp/server.js", () => {
}
if (p === "/api/pricing/cost") return send(200, { total_cost: 1.5, daily_costs: [] });
if (p === "/api/analytics") return send(500, { error: { message: "boom" } });
if (p === "/api/lanes") return send(200, "<!doctype html><html></html>", "text/html");
if (p === "/api/workspaces") return send(200, "<!doctype html><html></html>", "text/html");
return send(404, "Cannot GET " + p, "text/html");
});
await new Promise((r) => api.listen(0, "127.0.0.1", r));
+125
View File
@@ -0,0 +1,125 @@
/**
* @file Fails when an Express route under /api has no operation in the OpenAPI
* document. Builds the app with `createApp()`, walks every router layer with
* its mount prefix (recorded by wrapping `express.application.use` before the
* server loads), and checks each method + path against `createOpenApiSpec()`.
* Path parameters are compared by position only (`:token` and `{ref}` match),
* and the two alias mounts that the spec documents by their tag description
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
* are allowlisted. It also fails on a spec operation no route answers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const fs = require("fs");
const TEST_DB = path.join(os.tmpdir(), `dashboard-openapi-cov-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Record each app.use mount path on the layer it creates — Express keeps only
// a compiled matcher, which cannot be turned back into the prefix.
const express = require("express");
const originalUse = express.application.use;
express.application.use = function (mount, ...rest) {
const out = originalUse.call(this, mount, ...rest);
if (typeof mount === "string" || Array.isArray(mount)) {
const stack = (this.router || this._router).stack;
stack[stack.length - 1].__mount = mount;
}
return out;
};
const { createApp } = require("../index");
const { createOpenApiSpec } = require("../openapi");
/** Mounts that repeat another, documented mount by design (see the tags). */
const ALIAS_PREFIXES = ["/api/workspaces", "/api/ai-routers"];
/** Spec paths served by middleware rather than a route (Swagger UI). */
const MIDDLEWARE_PATHS = ["get /api/docs"];
const METHODS = new Set(["get", "post", "put", "patch", "delete"]);
/** `:id`, `:id(\\d+)`, `{id}` all become `{}`; a trailing slash is dropped. */
function normalize(p) {
return (
p
.replace(/:[A-Za-z_]\w*(\([^)]*\))?\??/g, "{}")
.replace(/\{[^}]+\}/g, "{}")
.replace(/\/$/, "") || "/"
);
}
function collectRoutes(app) {
const out = new Set();
const walk = (stack, prefixes) => {
for (const layer of stack) {
if (layer.route) {
const routePaths = [].concat(layer.route.path);
for (const prefix of prefixes) {
for (const rp of routePaths) {
for (const m of Object.keys(layer.route.methods)) {
if (METHODS.has(m)) out.add(`${m} ${normalize(prefix + rp)}`);
}
}
}
} else if (layer.handle && Array.isArray(layer.handle.stack)) {
const mounts = layer.__mount === undefined ? [""] : [].concat(layer.__mount);
walk(
layer.handle.stack,
prefixes.flatMap((a) => mounts.map((b) => a + b))
);
}
}
};
walk((app.router || app._router).stack, [""]);
return [...out].filter((k) => k.split(" ")[1].startsWith("/api"));
}
describe("OpenAPI coverage", () => {
let routes;
let documented;
before(() => {
routes = collectRoutes(createApp());
documented = new Set();
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
}
});
it("finds the routes it is meant to check", () => {
assert.ok(routes.length > 300, `only ${routes.length} routes found — the walk is broken`);
assert.ok(routes.includes("get /api/lanes/{}"));
});
it("documents every registered /api route", () => {
const missing = routes.filter(
(k) =>
!documented.has(k) &&
!ALIAS_PREFIXES.some((a) => {
const p = k.split(" ")[1];
return p === a || p.startsWith(a + "/");
})
);
assert.deepEqual(missing, [], `routes missing from the OpenAPI spec:\n${missing.join("\n")}`);
});
it("documents no operation that no route answers", () => {
const have = new Set(routes);
const stale = [...documented].filter((k) => !have.has(k) && !MIDDLEWARE_PATHS.includes(k));
assert.deepEqual(stale, [], `spec operations with no route:\n${stale.join("\n")}`);
});
});
process.on("exit", () => {
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`]) {
try {
fs.unlinkSync(f);
} catch {
/* already gone */
}
}
});
+20
View File
@@ -235,6 +235,26 @@ describe("pty-attach", () => {
assert.deepEqual(JSON.parse(a.__sent.at(-1).data), { type: "lead" });
});
it("attaches a terminal that names no size at the window's own size", () => {
// A terminal on a hidden tab is 0×0 and sends no size; spawning its attach
// at a default resized the pane under every other client.
const opts = [];
ptyAttach.__setSpawnImpl((_f, _a, o) => {
opts.push(o);
return makeFakePty();
});
tmux.__setExecImpl((args) => (args.includes("#{window_width} #{window_height}") ? "141 48\n" : ""));
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: undefined, rows: undefined });
assert.deepEqual([opts[0].cols, opts[0].rows], [141, 48]);
// tmux cannot answer: the old default, never a crash.
ptyAttach.__resetGroups();
tmux.__setExecImpl(() => {
throw new Error("no server");
});
ptyAttach.attach(makeFakeWs(), "agenthub-lane-2", { cols: undefined, rows: undefined });
assert.deepEqual([opts[1].cols, opts[1].rows], [80, 24]);
});
it("survives a stopped pane: pipe errors, typing and resizes after the attach exited", () => {
// Windows node-pty re-throws a read-pipe error nobody listens for, and its
// write pipe has no listener at all: Stop (kill-session) ended the attach
+8
View File
@@ -20,7 +20,14 @@ process.env.DASHBOARD_DB_PATH = TEST_DB;
const { db } = require("../db");
const { runRead } = require("../lib/read-pool");
// The pool unrefs its worker (a server must be able to exit while one idles),
// so nothing else would keep this file's event loop alive while a job runs:
// node:test then cancels the awaiting test ("Promise resolution is still
// pending but the event loop has already resolved"). A server never sees this.
let keepAlive;
before(() => {
keepAlive = setInterval(() => {}, 1000);
db.prepare("INSERT INTO sessions (id, status) VALUES ('rp-1', 'completed')").run();
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name) VALUES ('rp-1', 'PreToolUse', ?)"
@@ -34,6 +41,7 @@ afterEach(() => {
});
after(() => {
clearInterval(keepAlive);
try {
db.close();
} catch {}
+6 -4
View File
@@ -334,12 +334,12 @@ describe("POST /api/run/drop-image", () => {
});
it("refuses a file declared over the limit before its body arrives", async () => {
// 4 GB announced, 1 KB sent: the answer must come without the rest.
// A petabyte announced, 1 KB sent: the answer must come without the rest.
const { status, body } = await new Promise((resolve, reject) => {
const url = new URL("/api/run/drop-file?name=big.mp4", BASE);
const req = http.request(url, {
method: "POST",
headers: { "Content-Type": "video/mp4", "Content-Length": 4 * 1024 ** 3 },
headers: { "Content-Type": "video/mp4", "Content-Length": 1024 ** 5 },
});
req.on("response", (res) => {
let text = "";
@@ -354,8 +354,10 @@ describe("POST /api/run/drop-image", () => {
});
assert.equal(status, 413);
assert.equal(body.error.code, "ETOOBIG");
// The UI shows the server's limit: a loopback browser's, here.
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
// A loopback browser has no cap, only the disk's free space (less a
// reserve) — and that is the limit the UI shows.
const st = fs.statfsSync(process.env.DASHBOARD_DATA_DIR);
assert.ok(body.error.max_bytes <= st.bavail * st.bsize, String(body.error.max_bytes));
});
it("streams a loopback drop over the remote limit to disk", async () => {
+57
View File
@@ -0,0 +1,57 @@
/**
* @file Tests for server/lib/send-file.js: under Express 5 a server-chosen
* absolute path inside a dotted directory (`~/.claude/…`) is still served, a
* dotfile itself is still refused (Express 4's rule), and a download keeps its
* attachment name.
* @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 fs = require("fs");
const os = require("os");
const path = require("path");
const express = require("express");
const { sendFile, download } = require("../lib/send-file");
describe("send-file", () => {
let dir;
let server;
let base;
before(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "send-file-"));
fs.mkdirSync(path.join(dir, ".claude"));
fs.writeFileSync(path.join(dir, ".claude", "a.txt"), "hi");
fs.writeFileSync(path.join(dir, ".claude", ".secret"), "no");
const app = express();
app.get("/file", (_req, res) => sendFile(res, path.join(dir, ".claude", "a.txt")));
app.get("/dot", (_req, res) => sendFile(res, path.join(dir, ".claude", ".secret")));
app.get("/dl", (_req, res) => download(res, path.join(dir, ".claude", "a.txt"), "r.txt"));
await new Promise((r) => (server = app.listen(0, "127.0.0.1", r)));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
});
it("serves a file whose parent directory is a dot-directory", async () => {
const res = await fetch(`${base}/file`);
assert.equal(res.status, 200);
assert.equal(await res.text(), "hi");
});
it("still refuses a dotfile", async () => {
const res = await fetch(`${base}/dot`);
assert.equal(res.status, 404);
});
it("downloads under the given name", async () => {
const res = await fetch(`${base}/dl`);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-disposition"), /^attachment; filename="?r\.txt"?$/);
assert.equal(await res.text(), "hi");
});
});
@@ -0,0 +1,469 @@
/**
* @file server-s3-cc-config.test.js
* @description Settings for members (S3 plan, P6): a non-admin manages THEIR
* OWN Claude Code configuration through `/api/cc-config`. Covers the router's
* own allowlist gate (`cc-target.js` — no `/profiles*`, no
* `/plugins/update-all`, nothing unlisted), the one target resolver (a
* member's `~/.claude`, an admin's host home unchanged, 403 ENOACCOUNT for a
* developer with no account — never the host's), every file write running AS
* the member through `write-as` with the content on stdin (skills, settings,
* keybindings, backups), `claude plugin|mcp …` run as the member's sudo argv
* (no `-y`, user scope only, no env/headers across sudo), project writes only
* in the member's own workspaces, an admin's project-memory create in a
* member's folder written as that member, and a member's change broadcast to
* that member alone. A fake passwd (every member = this test's uid),
* server.conf, fake sudo (run-as) and a fake `claude` spawn make it run
* without root.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-cc-config-")));
const USERS = path.join(TMP, "users");
const MIA = path.join(USERS, "mia");
const BOB = path.join(USERS, "bob");
const ANA = path.join(USERS, "ana");
const HOST = path.join(TMP, "host-claude");
const TOOLS = path.join(TMP, "tools");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "data", "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.LANES_ROOT = path.join(TMP, "lanes");
process.env.CLAUDE_HOME = HOST;
process.env.CODEX_HOME = path.join(TMP, "host-codex");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
process.env.AGENTHUB_USERS_ROOT = USERS;
process.env.AGENTHUB_TOOLS_ROOT = TOOLS;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const tmux = require("../lib/tmux");
const ccCli = require("../lib/cc-plugin-cli");
const scope = require("../lib/scope");
const { fakeSudo } = require("./fixtures/fake-sudo");
const WRITE_AS = "/opt/agenthub/libexec/write-as";
const CLAUDE = path.join(TOOLS, "bin", "claude");
const UID = process.getuid();
let server;
let BASE;
const cookies = {};
let sudo;
let cliCalls = [];
const call = (who, method, p, body) =>
fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", cookie: cookies[who] },
body: body ? JSON.stringify(body) : undefined,
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
const addLane = (cwd, owner) =>
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')")
.run(cwd, idOf(owner));
/** Every sudo argv recorded since the last reset, as one string each. */
const sudoLines = () => sudo.calls.map((c) => c.join(" "));
before(async () => {
for (const d of [path.join(MIA, "proj"), path.join(MIA, "proj2"), path.join(BOB, "proj"), ANA])
fs.mkdirSync(d, { recursive: true });
fs.mkdirSync(HOST, { recursive: true });
fs.mkdirSync(path.dirname(CLAUDE), { recursive: true });
fs.writeFileSync(CLAUDE, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
fs.writeFileSync(
process.env.AGENTHUB_PASSWD,
["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n"
);
runAs.__resetCache();
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
linux.__resetConfCache();
linux.__setRunImpl(() => "ok");
sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
tmux.__setExecImpl((args) => {
if (args[0] === "-V") return "tmux 3.4";
if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 });
return "";
});
// The `claude` CLI never really runs: every spawn is recorded and answers ok.
ccCli.__setExecFileImpl((cmd, argv, opts, cb) => {
cliCalls.push({ cmd, argv, opts });
const listing = argv.includes("list")
? JSON.stringify({ installed: [], available: [{ pluginId: "p@m" }] })
: "done\n";
setImmediate(() => cb(null, listing, ""));
return { stdin: { end() {} } };
});
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
process.env.AGENTHUB_MODE = "server";
for (const [n, role] of [
["mia", "developer"],
["bob", "developer"],
["eve", "developer"], // no Linux account
["ana", "analyst"],
["boss", "admin"],
]) {
await auth.createUser(n, `${n}-pass-123`, role);
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
});
cookies[n] = login.headers.get("set-cookie").split(";")[0];
}
addLane(path.join(MIA, "proj"), "mia");
addLane(path.join(BOB, "proj"), "bob");
});
beforeEach(() => {
sudo.calls.length = 0;
cliCalls = [];
});
after(() => {
ccCli.__setExecFileImpl(null);
tmux.__reset();
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
delete process.env.AGENTHUB_TOOLS_ROOT;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("the gate: what a non-admin may reach under /api/cc-config", () => {
it("keeps the host's login profiles and Update all an admin's", async () => {
for (const [method, p] of [
["GET", "/api/cc-config/profiles"],
["POST", "/api/cc-config/profiles"],
["POST", "/api/cc-config/profiles/activate"],
["POST", "/api/cc-config/profiles/usage"],
["DELETE", "/api/cc-config/profiles"],
["GET", "/api/cc-config/Profiles"],
["GET", "/api/cc-config/%70rofiles"],
["GET", "/api/cc-config/plugins/update-all"],
["POST", "/api/cc-config/plugins/update-all"],
["GET", "/api/cc-config/no-such-route"],
["POST", "/api/cc-config/no-such-route"],
]) {
const r = await call("mia", method, p, method === "GET" ? undefined : {});
assert.equal(r.status, 403, `${method} ${p}`);
assert.equal(r.body.error.code, "EADMIN", `${method} ${p}`);
}
assert.equal(cliCalls.length, 0);
});
it("lets an analyst still read the host-wide views, never change them", async () => {
assert.equal((await call("ana", "GET", "/api/cc-config/profiles")).status, 200);
const w = await call("ana", "POST", "/api/cc-config/profiles", { name: "x" });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EADMIN");
});
it("leaves an admin every endpoint, the host's as before", async () => {
assert.equal((await call("boss", "GET", "/api/cc-config/profiles")).status, 200);
const t = await call("boss", "GET", "/api/cc-config/target");
assert.deepEqual(t.body, { kind: "host", readOnly: false, claudeHome: HOST });
});
});
describe("a developer with no Linux account", () => {
it("is refused — never served or written the host's config", async () => {
const t = await call("eve", "GET", "/api/cc-config/target");
assert.equal(t.body.kind, "none");
for (const [method, p, body] of [
["GET", "/api/cc-config/overview"],
["GET", "/api/cc-config/settings"],
["PUT", "/api/cc-config/file", { scope: "user", type: "skills", name: "x", content: "y" }],
["PATCH", "/api/cc-config/settings", { scope: "user", set: { theme: "dark" } }],
["POST", "/api/cc-config/plugins/install", { spec: "p@m" }],
]) {
const r = await call("eve", method, p, body);
assert.equal(r.status, 403, `${method} ${p}`);
assert.equal(r.body.error.code, "ENOACCOUNT", `${method} ${p}`);
}
assert.ok(!fs.existsSync(path.join(HOST, "skills", "x")));
assert.ok(!fs.existsSync(path.join(HOST, "settings.json")));
assert.equal(cliCalls.length, 0);
assert.equal(sudo.calls.length, 0);
// The run form's own lists still answer (empty), as before.
const skills = await call("eve", "GET", "/api/cc-config/skills");
assert.equal(skills.status, 200);
assert.deepEqual(skills.body.items, []);
});
});
describe("a member's own config", () => {
const own = path.join(MIA, ".claude");
it("is the one the page shows", async () => {
const t = await call("mia", "GET", "/api/cc-config/target");
assert.deepEqual(t.body, { kind: "own", readOnly: false, claudeHome: own });
const o = await call("mia", "GET", "/api/cc-config/overview");
assert.equal(o.status, 200);
assert.equal(o.body.roots.claudeHome, own);
assert.equal(o.body.roots.claudeJson, path.join(MIA, ".claude.json"));
// No workspace named: no project layer — never the dashboard's own folder.
assert.equal(o.body.roots.projectRoot, null);
});
it("writes a skill AS the member through write-as, content on stdin, with a backup", async () => {
const body = {
scope: "user",
type: "skills",
name: "mine",
content: "---\nname: mine\n---\nv1",
};
const r1 = await call("mia", "PUT", "/api/cc-config/file", body);
assert.equal(r1.status, 200, JSON.stringify(r1.body));
const file = path.join(own, "skills", "mine", "SKILL.md");
assert.equal(fs.readFileSync(file, "utf8"), body.content);
assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`)));
assert.ok(!sudoLines().some((l) => l.includes("v1")), "content crossed sudo's argv");
assert.ok(!fs.existsSync(path.join(HOST, "skills", "mine")));
sudo.calls.length = 0;
const r2 = await call("mia", "PUT", "/api/cc-config/file", { ...body, content: "v2" });
assert.equal(r2.status, 200);
assert.ok(r2.body.backupPath.startsWith(path.join(own, "cc-config-backups", "skills")));
assert.equal(fs.readFileSync(path.join(r2.body.backupPath, "SKILL.md"), "utf8"), body.content);
assert.ok(sudoLines().some((l) => l.includes(` copy ${r2.body.backupPath}`)));
const list = await call("mia", "GET", "/api/cc-config/skills?scope=user");
assert.deepEqual(
list.body.items.map((i) => i.name),
["mine"]
);
sudo.calls.length = 0;
const del = await call("mia", "DELETE", "/api/cc-config/file", {
scope: "user",
type: "skills",
name: "mine",
});
assert.equal(del.status, 200);
assert.ok(!fs.existsSync(path.join(own, "skills", "mine")));
assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "skills", "mine")} remove`)));
});
it("merges settings.json and writes keybindings.json as the member", async () => {
const r = await call("mia", "PATCH", "/api/cc-config/settings", {
scope: "user",
set: { "env.SECRETISH": "s3cr3t-value", theme: "dark" },
});
assert.equal(r.status, 200, JSON.stringify(r.body));
const settings = JSON.parse(fs.readFileSync(path.join(own, "settings.json"), "utf8"));
assert.equal(settings.theme, "dark");
assert.ok(!sudoLines().some((l) => l.includes("s3cr3t-value")));
assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "settings.json")} replace`)));
const again = await call("mia", "PATCH", "/api/cc-config/settings", {
scope: "user",
set: { theme: "light" },
});
assert.ok(again.body.backupPath.startsWith(path.join(own, ".cc-config-backups")));
const kb = await call("mia", "PUT", "/api/cc-config/keybindings", {
groups: [{ context: "Chat", bindings: [{ key: "ctrl+t", action: "toggleTodos" }] }],
});
assert.equal(kb.status, 200, JSON.stringify(kb.body));
assert.equal(kb.body.file, path.join(own, "keybindings.json"));
const read = await call("mia", "GET", "/api/cc-config/keybindings");
assert.equal(read.body.groups[0].bindings[0].action, "toggleTodos");
// Project layers need a workspace named.
const proj = await call("mia", "PATCH", "/api/cc-config/settings", {
scope: "project",
set: { theme: "dark" },
});
assert.equal(proj.status, 400);
assert.equal(proj.body.error.code, "EBADSCOPE");
assert.ok(!fs.existsSync(path.join(HOST, "settings.json")));
});
it("writes into a project only in a workspace of the member's own", async () => {
const q = (cwd) => `/api/cc-config/file?cwd=${encodeURIComponent(cwd)}`;
const body = { scope: "project", type: "agents", name: "helper", content: "agent" };
const mine = await call("mia", "PUT", q(path.join(MIA, "proj")), body);
assert.equal(mine.status, 200, JSON.stringify(mine.body));
const file = path.join(MIA, "proj", ".claude", "agents", "helper.md");
assert.equal(fs.readFileSync(file, "utf8"), "agent");
assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`)));
for (const cwd of [path.join(BOB, "proj"), path.join(MIA, "proj2"), MIA]) {
const r = await call("mia", "PUT", q(cwd), body);
assert.equal(r.status, 403, cwd);
assert.equal(r.body.error.code, "EFOLDER", cwd);
}
assert.ok(!fs.existsSync(path.join(BOB, "proj", ".claude")));
const pm = await call("mia", "POST", "/api/cc-config/project-memory", {
dir: path.join(BOB, "proj"),
content: "x",
});
assert.equal(pm.status, 403);
assert.ok(!fs.existsSync(path.join(BOB, "proj", "CLAUDE.md")));
});
it("runs `claude plugin` and `claude mcp` as the member — no -y, user scope only", async () => {
const inst = await call("mia", "POST", "/api/cc-config/plugins/install", { spec: "p@m" });
assert.equal(inst.status, 200, JSON.stringify(inst.body));
const add = await call("mia", "POST", "/api/cc-config/marketplaces/add", {
source: "owner/repo",
});
assert.equal(add.status, 200);
const mcp = await call("mia", "POST", "/api/cc-config/mcp/add", {
name: "srv",
scope: "user",
command: "npx",
args: ["srv"],
});
assert.equal(mcp.status, 200, JSON.stringify(mcp.body));
const avail = await call("mia", "GET", "/api/cc-config/plugins/available");
assert.equal(avail.status, 200);
const prefix = ["-n", "-u", "ah-mia", `HOME=${MIA}`, "DISABLE_AUTOUPDATER=1", CLAUDE];
assert.deepEqual(
cliCalls.map((c) => [c.cmd, ...c.argv]),
[
["sudo", ...prefix, "plugin", "install", "p@m", "--scope", "user"],
["sudo", ...prefix, "plugin", "marketplace", "add", "owner/repo", "--scope", "user"],
["sudo", ...prefix, "mcp", "add", "--scope", "user", "srv", "--", "npx", "srv"],
["sudo", ...prefix, "plugin", "list", "--available", "--json"],
]
);
assert.ok(cliCalls.every((c) => c.opts.cwd === MIA));
assert.ok(!cliCalls.some((c) => c.argv.some((a) => a === "-y" || a.startsWith("--danger"))));
cliCalls = [];
for (const [p, body] of [
["/api/cc-config/plugins/install", { spec: "p@m", scope: "project" }],
["/api/cc-config/marketplaces/add", { source: "owner/repo", scope: "local" }],
["/api/cc-config/mcp/add", { name: "s", scope: "local", command: "x" }],
["/api/cc-config/mcp/add", { name: "s", scope: "user", command: "x", env: { K: "v" } }],
[
"/api/cc-config/mcp/add",
{ name: "s", scope: "user", url: "https://x", headers: { a: "b" } },
],
]) {
const r = await call("mia", "POST", p, body);
assert.equal(r.status, 400, `${p} ${JSON.stringify(body)}`);
}
assert.equal(cliCalls.length, 0, "a refused call never spawned");
});
it("answers ENOTSHARED while claude is not in the shared tools", async () => {
fs.renameSync(CLAUDE, `${CLAUDE}.off`);
try {
const r = await call("mia", "POST", "/api/cc-config/plugins/action", {
key: "p@m",
action: "enable",
});
assert.equal(r.status, 503);
assert.equal(r.body.error.code, "ENOTSHARED");
assert.equal(cliCalls.length, 0);
} finally {
fs.renameSync(`${CLAUDE}.off`, CLAUDE);
}
});
});
describe("an admin", () => {
it("still writes the host's own config, as the host", async () => {
const r = await call("boss", "PUT", "/api/cc-config/file", {
scope: "user",
type: "commands",
name: "hosty",
content: "host cmd",
});
assert.equal(r.status, 200);
assert.equal(fs.readFileSync(path.join(HOST, "commands", "hosty.md"), "utf8"), "host cmd");
assert.equal(sudo.calls.length, 0);
const inst = await call("boss", "POST", "/api/cc-config/plugins/install", { spec: "p@m" });
assert.equal(inst.status, 200);
assert.deepEqual(
cliCalls.map((c) => [c.cmd, ...c.argv]),
[["claude", "plugin", "install", "p@m", "--scope", "user", "-y"]]
);
});
it("creates CLAUDE.md in a member's folder as that member, never as the host", async () => {
const dir = path.join(MIA, "proj2");
const r = await call("boss", "POST", "/api/cc-config/project-memory", {
dir,
content: "# memo",
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"), "# memo");
assert.ok(
sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${path.join(dir, "CLAUDE.md")}`))
);
});
});
describe("a member's config change on the WebSocket", () => {
it("names the owner, so it reaches that member alone", () => {
assert.deepEqual([...scope.exactAudience("cc_config_changed", { ownerId: 7, name: "x" })], [7]);
assert.equal(scope.exactAudience("cc_config_changed", { name: "x" }), null);
assert.equal(scope.exactAudience("lane_update", { ownerId: 7 }), null);
});
it("is delivered to the member and not to an admin; the host's reaches both", async () => {
const WebSocket = require("ws");
const open = (who) => {
const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", {
headers: { cookie: cookies[who] },
});
const seen = [];
ws.on("message", (m) => {
const msg = JSON.parse(String(m));
if (msg.type === "cc_config_changed") seen.push(msg.data);
});
return new Promise((resolve, reject) =>
ws.on("open", () => resolve({ ws, seen })).on("error", reject)
);
};
const mia = await open("mia");
const boss = await open("boss");
try {
await call("mia", "PUT", "/api/cc-config/file", {
scope: "user",
type: "commands",
name: "secret-plan",
content: "x",
});
await call("boss", "PUT", "/api/cc-config/file", {
scope: "user",
type: "commands",
name: "host-cmd",
content: "x",
});
await new Promise((r) => setTimeout(r, 300));
assert.ok(
mia.seen.some((d) => d.name === "secret-plan"),
JSON.stringify(mia.seen)
);
assert.ok(!JSON.stringify(boss.seen).includes("secret-plan"), JSON.stringify(boss.seen));
assert.ok(boss.seen.some((d) => d.name === "host-cmd"));
// The host's change reaches the member as the bare type (as before).
assert.ok(!JSON.stringify(mia.seen).includes("host-cmd"));
assert.ok(mia.seen.some((d) => d && Object.keys(d).length === 0));
} finally {
mia.ws.close();
boss.ws.close();
}
});
});
+5 -1
View File
@@ -97,7 +97,9 @@ before(async () => {
);
linux.__resetConfCache();
linux.__setRunImpl(() => "ok");
runAs.__setExecImpl(fakeSudo().exec);
const sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
runAs.__setSpawnImpl(sudo.spawn);
tmux.__setExecImpl(fakeTmux);
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
@@ -708,6 +710,8 @@ describe("a dropped file", () => {
byAdmin.body.path.startsWith(path.join(MIA, ".agenthub", "handoff", "drops") + path.sep),
byAdmin.body.path
);
// Streamed to the member's file through write-as, not held whole.
assert.equal(fs.readFileSync(byAdmin.body.path, "utf8"), "hello");
// A host workspace's pane runs as the host: the host's own 0700 drops dir.
const hostLane = addLane(path.join(TMP, "hostdir", "d"), "boss");
const host = await drop("boss", hostLane);
+2
View File
@@ -571,6 +571,8 @@ test.describe("ship-feature pipeline template", () => {
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
["qc", "Agent", { subagent_type: "qc-local" }],
["qc", "Bash", { command: "agenthub lanes proof-link" }],
["qc", "Bash", { command: "agenthub workspace proof-link" }],
["gates", "Bash", { command: "agenthub workspace hook ci-gate" }],
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
+9 -5
View File
@@ -19,13 +19,17 @@ describe("sizeFromQuery — the attach carries the client's real size", () => {
assert.deepEqual(sizeFromQuery(query("?cols=226&rows=29")), { cols: 226, rows: 29 });
});
it("falls back to 80x24 when the client named no size", () => {
assert.deepEqual(sizeFromQuery(query("")), { cols: 80, rows: 24 });
// No size (a terminal on a hidden tab) stays no size: the attach then takes
// the window's own, never a default that would resize the pane.
const none = { cols: undefined, rows: undefined };
it("names no size when the client named none", () => {
assert.deepEqual(sizeFromQuery(query("")), none);
});
it("refuses anything out of bounds — this number reaches an ioctl", () => {
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), { cols: 80, rows: 24 });
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), { cols: 80, rows: 24 });
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12.5")), { cols: 80, rows: 12 });
assert.deepEqual(sizeFromQuery(query("?cols=0&rows=-5")), none);
assert.deepEqual(sizeFromQuery(query("?cols=99999&rows=99999")), none);
assert.deepEqual(sizeFromQuery(query("?cols=abc&rows=12")), none);
});
});
+13 -3
View File
@@ -159,6 +159,10 @@ const APP_VERSION = (() => {
function createApp() {
const app = express();
// Express 5 parses a query string with node's `querystring` by default;
// keep Express 4's `qs` parsing, which every route and guard (`namedIds`
// refusing `?lane[]=`/`?lane[x]=`) was written against.
app.set("query parser", "extended");
const openApiSpec = createOpenApiSpec();
// Security hardening (GHSA-gr74-4xfh-6jw9): loopback-only CORS, a Host-header
@@ -181,6 +185,12 @@ function createApp() {
);
app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway);
app.use(express.json({ limit: "1mb" }));
// Express 4's parser left `req.body` as `{}` when there was no JSON body;
// Express 5's leaves it undefined, and handlers destructure it.
app.use((req, _res, next) => {
if (req.body === undefined) req.body = {};
next();
});
app.use("/api", tokenGuard);
// Server mode's login gate (server/lib/auth.js); a no-op in local mode.
app.use("/api", authGuard);
@@ -251,7 +261,7 @@ function createApp() {
// (complements Swagger UI's interactive console at /api/docs). The bundle is
// served from node_modules, never a CDN, so the reference works offline.
app.get("/api/redoc/redoc.standalone.js", (_req, res) => {
res.sendFile(redocBundlePath(), (err) => {
require("./lib/send-file").sendFile(res, redocBundlePath(), (err) => {
if (err && !res.headersSent) res.status(500).end();
});
});
@@ -341,9 +351,9 @@ function startServer(app, port) {
},
})
);
app.get("*", (_req, res) => {
app.get("/{*splat}", (_req, res) => {
res.setHeader("Cache-Control", "no-cache, must-revalidate");
res.sendFile(path.join(clientDist, "index.html"));
require("./lib/send-file").sendFile(res, path.join(clientDist, "index.html"));
});
}
+5 -8
View File
@@ -56,12 +56,13 @@ const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
/**
* API prefixes whose writes change the host account's own setup. (AI Proxy
* profiles are not here: a member writes their own personal ones, and
* `routes/ai-proxies.js` keeps the shared ones an admin's.)
* `routes/ai-proxies.js` keeps the shared ones an admin's. Nor is
* `/cc-config`: its router gates itself — `cc-target.js`'s allowlist — since a
* member there manages their OWN Claude Code config.)
*/
const ADMIN_WRITE_PREFIXES = [
"/settings",
"/updates",
"/cc-config",
"/codex-config",
// The AI tools' login profiles: switching one changes the host's own login.
"/tool-profiles",
@@ -85,10 +86,9 @@ const ADMIN_WRITE_PREFIXES = [
* API prefixes a developer may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
* reports, webhook deliveries, remote machines, imports). Admin and analyst
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a developer's own work needs.
* pass. (`/cc-config` gates itself: see ADMIN_WRITE_PREFIXES.)
*/
const STAFF_ONLY_PREFIXES = [
"/cc-config",
"/codex-config",
"/tool-profiles",
"/git-credentials",
@@ -104,8 +104,6 @@ const STAFF_ONLY_PREFIXES = [
// The machine's hardware and every user's CPU/memory share.
"/host",
];
// The run form's `/` autocomplete lists commands and skills.
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
// lower-cased form: the host's own AI-tool config files (they may hold keys).
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
@@ -723,8 +721,7 @@ function authGuard(req, res, next) {
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "developer" &&
((STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under))) ||
STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
(STAFF_ONLY_PREFIXES.some(under) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
) {
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
}
+173 -80
View File
@@ -6,10 +6,20 @@
* scripts). Powers the Claude Config Explorer page. All operations are pure
* file reads — never writes.
*
* Path containment: every read resolves under getClaudeHome(),
* getProjectClaudeDir(), or getProjectRoot() (for CLAUDE.md). Reads outside
* those roots return null. Settings are redacted of secret-like keys before
* returning.
* Path containment: every read resolves under the Claude home being read
* (`opts.home`, else getClaudeHome()), getProjectClaudeDir(), or
* getProjectRoot() (for CLAUDE.md). Reads outside those roots return null.
* Settings are redacted of secret-like keys before returning.
*
* Whose config: every reader takes `opts.home` (a Claude dir) and
* `opts.claudeJson` — a member's own (`<home>/.claude`, `<home>/.claude.json`)
* when the Config Explorer serves a member under S3 — and `opts.noProject`,
* which reads no project layer at all (a member who named no workspace: never
* the dashboard's own folder). A path inside a member's home is read only
* through run-as's guarded reader (a regular file that member owns, size
* capped) and listed only when it really lies in that home and is theirs
* (`memberPathOk`), and a plugin's or marketplace's install path that the
* member's own JSON names is followed only inside their home.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
@@ -28,6 +38,9 @@ function getProjectClaudeDir(cwd) {
return path.join(getProjectRoot(cwd), ".claude");
}
/** The Claude dir a read is about: the caller's `opts.home`, else the host's. */
const homeOf = (opts) => (opts && opts.home) || getClaudeHome();
function getClaudeJsonPath() {
// NOT path.join(getClaudeHome(), ".claude.json") — verified against the
// real `claude` binary, the two CLAUDE_HOME branches behave differently:
@@ -57,6 +70,52 @@ function isUnder(root, target) {
return t.startsWith(r + path.sep);
}
/**
* S3: the member whose home `p` lies in (`run-as.ownerForRead`), or null for a
* host path / outside S3.
*/
function memberOf(p) {
try {
return require("./run-as").ownerForRead(p);
} catch {
return null;
}
}
/**
* Whether a path may be listed or stat'ed for what it holds: always outside a
* member's home; inside one only when its real path stays in that home and
* the member owns it. The dashboard reads with CAP_DAC_READ_SEARCH, so a link
* a member planted (`~/.claude/hooks -> /root`) would otherwise list another
* account's file names.
*/
function memberPathOk(p) {
const acct = memberOf(p);
if (!acct) return true;
try {
const real = fs.realpathSync(p);
if (!isUnder(fs.realpathSync(acct.home), real)) return false;
return fs.statSync(real).uid === acct.uid;
} catch {
return false;
}
}
/**
* A path a member's OWN JSON names (a plugin's `installPath`, a marketplace's
* `installLocation`) is followed only inside that member's home; any path is
* followed for the host, as before. `claudeDir` is the home being read.
*/
function followable(claudeDir, p) {
const acct = memberOf(claudeDir);
if (!acct) return true;
try {
return isUnder(fs.realpathSync(acct.home), fs.realpathSync(p)) && memberPathOk(p);
} catch {
return false;
}
}
function readJson(absPath) {
try {
const raw = require("./run-as").readFileSafe(absPath, "utf8");
@@ -122,6 +181,8 @@ function parseFrontmatter(text) {
}
function safeReadText(absPath) {
const runAs = require("./run-as");
if (runAs.isMemberFile(absPath)) return memberReadText(runAs, absPath);
try {
const stat = fs.statSync(absPath);
if (!stat.isFile()) return null;
@@ -144,7 +205,36 @@ function safeReadText(absPath) {
}
}
/**
* `safeReadText` for a member's file: opened through run-as's guard (a regular
* file that member owns, never a FIFO or another account's file through a
* link), and only the first MAX_FILE_BYTES read — never the whole of a file
* the member made huge.
*/
function memberReadText(runAs, absPath) {
let fd;
try {
fd = runAs.openSafe(absPath);
const st = fs.fstatSync(fd);
const want = Math.min(st.size, MAX_FILE_BYTES);
const buf = Buffer.alloc(want);
let off = 0;
for (let n; off < want && (n = fs.readSync(fd, buf, off, want - off, off)) > 0;) off += n;
return {
truncated: st.size > MAX_FILE_BYTES,
size: st.size,
text: buf.toString("utf8", 0, off),
mtime: st.mtimeMs,
};
} catch {
return null;
} finally {
if (fd != null) fs.closeSync(fd);
}
}
function listDir(absPath) {
if (!memberPathOk(absPath)) return [];
try {
return fs.readdirSync(absPath, { withFileTypes: true });
} catch {
@@ -223,9 +313,9 @@ function readSkillsAt(scope, claudeDir) {
function readSkills(opts = {}) {
const out = [];
if (opts.scope !== "project") {
out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome()));
out.push(...readSkillsAt("user", opts.userClaudeDir || homeOf(opts)));
}
if (opts.scope !== "user") {
if (opts.scope !== "user" && !opts.noProject) {
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
}
return out;
@@ -262,9 +352,9 @@ function readSimpleMdSurface(subdir) {
return (opts = {}) => {
const out = [];
if (opts.scope !== "project") {
out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir));
out.push(...readMdFilesAt("user", opts.userClaudeDir || homeOf(opts), subdir));
}
if (opts.scope !== "user") {
if (opts.scope !== "user" && !opts.noProject) {
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
}
return out;
@@ -396,7 +486,11 @@ function readPluginContributions(installPath) {
if (!installPath) return null;
let pluginJson = null;
try {
const raw = fs.readFileSync(path.join(installPath, ".claude-plugin", "plugin.json"), "utf8");
const raw = require("./run-as").readFileSafe(
path.join(installPath, ".claude-plugin", "plugin.json"),
"utf8",
{ max: MAX_FILE_BYTES }
);
pluginJson = JSON.parse(raw);
} catch {
pluginJson = null;
@@ -416,13 +510,9 @@ function readPluginContributions(installPath) {
outputStyles: items.outputStyles.length,
items,
hooks: (() => {
try {
return fs
.readdirSync(path.join(installPath, "hooks"), { withFileTypes: true })
.filter((e) => isFileLike(e, path.join(installPath, "hooks", e.name))).length;
} catch {
return 0;
}
return listDir(path.join(installPath, "hooks")).filter((e) =>
isFileLike(e, path.join(installPath, "hooks", e.name))
).length;
})(),
pluginJson,
};
@@ -448,7 +538,10 @@ function readPlugins(home = getClaudeHome()) {
const installPath = inst.installPath;
let exists = false;
try {
exists = installPath ? fs.statSync(installPath).isDirectory() : false;
exists =
installPath && followable(home, installPath)
? fs.statSync(installPath).isDirectory()
: false;
} catch {
exists = false;
}
@@ -492,7 +585,7 @@ function readMcpServers(opts = {}) {
const out = { user: [], projectScoped: [] };
// ~/.claude.json is the primary CLI state file; mcpServers can live at the
// top level (legacy) or inside projects[<cwd>].mcpServers (per-project).
const claudeJson = readJson(getClaudeJsonPath());
const claudeJson = readJson(opts.claudeJson || getClaudeJsonPath());
if (claudeJson.ok && claudeJson.data) {
const top = claudeJson.data.mcpServers;
if (top && typeof top === "object") {
@@ -505,7 +598,7 @@ function readMcpServers(opts = {}) {
}
}
const projects = claudeJson.data.projects;
if (projects && typeof projects === "object") {
if (projects && typeof projects === "object" && !opts.noProject) {
const projectRoot = getProjectRoot(opts.cwd);
const projectEntry = projects[projectRoot];
if (projectEntry && projectEntry.mcpServers) {
@@ -520,7 +613,7 @@ function readMcpServers(opts = {}) {
}
}
// Also sniff settings.json for an mcpServers key (rare but supported).
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
const userSettings = readJson(path.join(homeOf(opts), "settings.json"));
if (userSettings.ok && userSettings.data && userSettings.data.mcpServers) {
for (const [name, def] of Object.entries(userSettings.data.mcpServers)) {
out.user.push({
@@ -567,9 +660,12 @@ const HOOK_EVENT_TYPES = [
"PreCompact",
];
function readHooks(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
/** The settings layers a read covers: user, plus the project's two unless `noProject`. */
function settingsSources(opts = {}) {
const user = [{ scope: "user", file: path.join(homeOf(opts), "settings.json") }];
if (opts.noProject) return user;
return [
...user,
{
scope: "project",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
@@ -579,6 +675,10 @@ function readHooks(opts = {}) {
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
},
];
}
function readHooks(opts = {}) {
const sources = settingsSources(opts);
const result = [];
for (const { scope, file } of sources) {
const j = readJson(file);
@@ -617,17 +717,7 @@ function readHooks(opts = {}) {
// ── Settings ───────────────────────────────────────────────────────────
function readSettings(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
{
scope: "project",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
},
{
scope: "project-local",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
},
];
const sources = settingsSources(opts);
return sources.map(({ scope, file }) => {
const j = readJson(file);
if (!j.ok) return { scope, file, exists: false };
@@ -643,8 +733,8 @@ function readSettings(opts = {}) {
// ── Marketplaces ───────────────────────────────────────────────────────
function readMarketplaces() {
const home = getClaudeHome();
function readMarketplaces(opts = {}) {
const home = homeOf(opts);
const knownPath = path.join(home, "plugins", "known_marketplaces.json");
const known = readJson(knownPath);
const out = [];
@@ -654,10 +744,10 @@ function readMarketplaces() {
const sourceDef = def && def.source;
let pluginCount = null;
let marketplaceJson = null;
if (installLocation) {
if (installLocation && followable(home, installLocation)) {
try {
const mfPath = path.join(installLocation, ".claude-plugin", "marketplace.json");
const raw = fs.readFileSync(mfPath, "utf8");
const raw = require("./run-as").readFileSafe(mfPath, "utf8", { max: MAX_FILE_BYTES });
marketplaceJson = JSON.parse(raw);
pluginCount = Array.isArray(marketplaceJson.plugins)
? marketplaceJson.plugins.length
@@ -687,8 +777,8 @@ function readMarketplaces() {
// ── Keybindings ────────────────────────────────────────────────────────
function readKeybindings() {
const file = path.join(getClaudeHome(), "keybindings.json");
function readKeybindings(opts = {}) {
const file = path.join(homeOf(opts), "keybindings.json");
const j = readJson(file);
if (!j.ok) return { file, exists: false };
const data = j.data && typeof j.data === "object" ? j.data : {};
@@ -710,13 +800,13 @@ function readKeybindings() {
// ── Statusline (config + script content) ──────────────────────────────
function readStatusline() {
const userSettingsPath = path.join(getClaudeHome(), "settings.json");
function readStatusline(opts = {}) {
const userSettingsPath = path.join(homeOf(opts), "settings.json");
const j = readJson(userSettingsPath);
const config = j.ok && j.data && j.data.statusLine ? j.data.statusLine : null;
const candidates = [
path.join(getClaudeHome(), "statusline.py"),
path.join(getClaudeHome(), "statusline-command.sh"),
path.join(homeOf(opts), "statusline.py"),
path.join(homeOf(opts), "statusline-command.sh"),
];
const scripts = [];
for (const file of candidates) {
@@ -736,8 +826,8 @@ function readStatusline() {
// ── Hook handler scripts dir (~/.claude/hooks/) ───────────────────────
function readHookScripts() {
const dir = path.join(getClaudeHome(), "hooks");
function readHookScripts(opts = {}) {
const dir = path.join(homeOf(opts), "hooks");
const entries = listDir(dir);
return {
dir,
@@ -747,6 +837,7 @@ function readHookScripts() {
const file = path.join(dir, e.name);
let stat;
try {
if (!memberPathOk(file)) return null;
stat = fs.statSync(file);
} catch {
return null;
@@ -774,10 +865,10 @@ const MEMORY_INDEX_RE = /^(MEMORY|INDEX)\b/i;
* mutable via cc-mutate's "auto-memory" type (create/edit/delete + backup).
*/
function readMemory(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "CLAUDE.md") },
{ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") },
];
const sources = [{ scope: "user", file: path.join(homeOf(opts), "CLAUDE.md") }];
if (!opts.noProject) {
sources.push({ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") });
}
const result = [];
for (const { scope, file } of sources) {
const r = safeReadText(file);
@@ -796,16 +887,11 @@ function readMemory(opts = {}) {
// root, an unreadable memory dir, or a single bad file must never break
// the memory tab — every layer is wrapped so we degrade to "fewer files".
try {
const projectsRoot = path.join(getClaudeHome(), "projects");
for (const proj of fs.readdirSync(projectsRoot)) {
const projectsRoot = path.join(homeOf(opts), "projects");
for (const { name: proj } of listDir(projectsRoot)) {
const memDir = path.join(projectsRoot, proj, "memory");
let files;
try {
files = fs.readdirSync(memDir);
} catch {
continue;
}
files = files
const files = listDir(memDir)
.map((e) => e.name)
.filter((f) => f.endsWith(".md"))
.sort((a, b) => {
const rank = (f) => (MEMORY_INDEX_RE.test(f) ? 0 : 1);
@@ -848,16 +934,18 @@ function readMemory(opts = {}) {
* secret, and AgentHub's own stores under CLAUDE_HOME (`agenthub-proxies`,
* `agenthub-git-credentials`, the pool accounts…) — no route returns a key.
*/
function isSecretConfigPath(resolved) {
function isSecretConfigPath(resolved, home) {
const base = path.basename(resolved);
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
const rel = path.relative(getClaudeHome(), resolved);
if (
!rel.startsWith("..") &&
!path.isAbsolute(rel) &&
rel.split(path.sep)[0].startsWith("agenthub-")
)
return true;
for (const h of new Set([getClaudeHome(), home].filter(Boolean))) {
const rel = path.relative(h, resolved);
if (
!rel.startsWith("..") &&
!path.isAbsolute(rel) &&
rel.split(path.sep)[0].startsWith("agenthub-")
)
return true;
}
// AgentHub's data dir is no Claude config: it holds the database, the push
// keys and every gateway pane's settings file WITH its live pane token.
const { getDataDir, dataDirCandidates } = require("./claude-home");
@@ -872,24 +960,28 @@ function isSecretConfigPath(resolved) {
}
function readFileSafe(absPath, opts = {}) {
const allowedRoots = [
getClaudeHome(),
getProjectClaudeDir(opts.cwd),
getProjectRoot(opts.cwd), // for CLAUDE.md only — caller must pass exact name
];
const allowedRoots = [homeOf(opts)];
if (!opts.noProject) {
allowedRoots.push(
getProjectClaudeDir(opts.cwd),
getProjectRoot(opts.cwd) // for CLAUDE.md only — caller must pass exact name
);
}
const resolved = path.resolve(absPath);
const inside = allowedRoots.some((root) => isUnder(root, resolved));
if (!inside) return { error: "path is outside allowed roots" };
// Extra guard: under project root we only allow CLAUDE.md (avoid leaking
// arbitrary repo files via this endpoint).
if (
!opts.noProject &&
isUnder(getProjectRoot(opts.cwd), resolved) &&
!isUnder(getProjectClaudeDir(opts.cwd), resolved) &&
path.basename(resolved) !== "CLAUDE.md"
) {
return { error: "only CLAUDE.md is readable from project root" };
}
if (isSecretConfigPath(resolved)) return { error: "a credential file is never served" };
if (isSecretConfigPath(resolved, homeOf(opts)))
return { error: "a credential file is never served" };
const r = safeReadText(resolved);
if (!r) return { error: "file not readable" };
return { ok: true, file: resolved, ...r };
@@ -902,13 +994,13 @@ function readOverview(opts = {}) {
const agents = readAgents(opts);
const commands = readCommands(opts);
const outputStyles = readOutputStyles(opts);
const plugins = readPlugins();
const plugins = readPlugins(homeOf(opts));
const mcp = readMcpServers(opts);
const hooks = readHooks(opts);
const settings = readSettings(opts);
const memory = readMemory(opts);
const marketplaces = readMarketplaces();
const keybindings = readKeybindings();
const marketplaces = readMarketplaces(opts);
const keybindings = readKeybindings(opts);
const countByScope = (arr) => ({
user: arr.filter((x) => x.scope === "user").length,
@@ -923,10 +1015,10 @@ function readOverview(opts = {}) {
return {
roots: {
claudeHome: getClaudeHome(),
projectClaudeDir: getProjectClaudeDir(opts.cwd),
projectRoot: getProjectRoot(opts.cwd),
claudeJson: getClaudeJsonPath(),
claudeHome: homeOf(opts),
projectClaudeDir: opts.noProject ? null : getProjectClaudeDir(opts.cwd),
projectRoot: opts.noProject ? null : getProjectRoot(opts.cwd),
claudeJson: opts.claudeJson || getClaudeJsonPath(),
},
counts: {
skills: countByScope(skills),
@@ -983,6 +1075,7 @@ module.exports = {
isUnder,
isDirLike,
isFileLike,
memberPathOk,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
};
+35 -7
View File
@@ -44,6 +44,12 @@
* route can map it to HTTP 422. Only a bad argument or a missing binary
* throws — those are programming/environment errors, not CLI outcomes.
*
* S3, a member's own config (`acct`): the CLI runs AS the member through
* sudo (cc-plugin-cli's `runClaude`), `user` scope only (their CLI runs in
* their home, no project), and with no `-e`/`--header` at all — those carry
* keys and tokens, and sudo logs its whole argv. Such a server is added from
* the member's own terminal instead.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -174,15 +180,34 @@ function headerFlags(headers) {
});
}
/**
* S3, a member's own config: `user` scope only, and no env values or headers
* — they would cross sudo's argv, which sudo logs.
*/
function assertMemberMcp({ scope, headers, env, acct }) {
if (!acct) return;
if (scope && scope !== "user") {
throw makeError("EBADARG", "only scope user can be changed for your own config");
}
const has = (o) => o && typeof o === "object" && Object.keys(o).length > 0;
if (has(headers) || has(env)) {
throw makeError(
"EBADARG",
"env values and headers would be logged by sudo: add this server with `claude mcp add` in your own terminal"
);
}
}
/**
* `claude mcp add` has two mutually exclusive forms, matching the real CLI
* (not a `--url` flag — see the file header): an http/sse server (`name`,
* positional `url`, optional `--header`) or a stdio server (`name`, optional
* `-e`, `-- command args…`).
*/
async function mcpAdd({ name, scope, transport, command, args = [], url, headers, env }) {
async function mcpAdd({ name, scope, transport, command, args = [], url, headers, env, acct }) {
const n = assertName(name);
const sc = assertMcpScope(scope, { required: true });
assertMemberMcp({ scope: sc, headers, env, acct });
if (url && command) {
throw makeError("EBADARG", "mcpAdd accepts either url or command, not both");
}
@@ -201,8 +226,9 @@ async function mcpAdd({ name, scope, transport, command, args = [], url, headers
}
const t = transport === "sse" ? "sse" : "http";
const hFlags = headerFlags(headers);
return serialize(() =>
runClaude(["mcp", "add", "--scope", sc, "--transport", t, n, u, ...hFlags])
return serialize(
() => runClaude(["mcp", "add", "--scope", sc, "--transport", t, n, u, ...hFlags], { acct }),
acct
);
}
@@ -216,16 +242,18 @@ async function mcpAdd({ name, scope, transport, command, args = [], url, headers
// env/header values, applied because of position rather than KEY=VALUE.
const cmdArgs = args.map((a, i) => assertEmbeddedValue(a, `args[${i}]`));
const eFlags = envFlags(env);
return serialize(() =>
runClaude(["mcp", "add", "--scope", sc, n, ...eFlags, "--", cmd, ...cmdArgs])
return serialize(
() => runClaude(["mcp", "add", "--scope", sc, n, ...eFlags, "--", cmd, ...cmdArgs], { acct }),
acct
);
}
async function mcpRemove({ name, scope }) {
async function mcpRemove({ name, scope, acct }) {
const n = assertName(name);
const sc = assertMcpScope(scope);
assertMemberMcp({ scope: sc, acct });
const scopeFlags = sc ? ["--scope", sc] : [];
return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags]));
return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags], { acct }), acct);
}
module.exports = {
+188 -57
View File
@@ -24,13 +24,23 @@
* any failure path.
* - Names are validated against a strict allowlist regex; resolved paths
* are double-checked to live under the expected root before any I/O.
*
* Whose config: every entry point takes `home` (a Claude dir; the host's
* getClaudeHome() when absent) and `noProject` (refuse the project layers —
* a member who named no workspace of theirs; never the dashboard's own
* folder). Every file operation goes through `ioFor(root)`: under S3 a root
* inside a member's home — their own config, or an admin's write into a
* member's workspace — is written, backed up, created and deleted AS that
* member through `write-as` (content on stdin), and read through run-as's
* guarded reader; the host never writes inside a member's home itself. Any
* other root is plain `fs`, exactly as before.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const { isUnder, MAX_FILE_BYTES } = require("./cc-discovery");
const { isUnder, MAX_FILE_BYTES, memberPathOk } = require("./cc-discovery");
const {
timestamp,
createBackup,
@@ -66,28 +76,135 @@ function getProjectClaudeDir(cwd) {
return path.join(getProjectRoot(cwd), ".claude");
}
/** The Claude dir a write is about: the caller's `home`, else the host's. */
const homeOf = (opts) => (opts && opts.home) || getClaudeHome();
/** No project layer for this caller (a member who named no workspace of theirs). */
function assertProject(opts) {
if (opts && opts.noProject) {
throw makeError("EBADSCOPE", "a project write needs ?cwd= naming one of your workspaces");
}
}
function rootForScope(scope, opts = {}) {
if (scope === "user") return getClaudeHome();
if (scope === "project") return getProjectClaudeDir(opts.cwd);
if (scope === "user") return homeOf(opts);
if (scope === "project") {
assertProject(opts);
return getProjectClaudeDir(opts.cwd);
}
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
function memoryPathForScope(scope, opts = {}) {
if (scope === "user") return path.join(getClaudeHome(), "CLAUDE.md");
if (scope === "project") return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
if (scope === "user") return path.join(homeOf(opts), "CLAUDE.md");
if (scope === "project") {
assertProject(opts);
return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
}
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
// ── File operations: as the host, or AS the member whose home it is ──────
/** Plain `fs`: the host's own config, as it always was. */
const HOST_IO = {
acct: null,
exists: (p) => fs.existsSync(p),
readText: (file) => fs.readFileSync(file, "utf8"),
mkdir: (dir) => fs.mkdirSync(dir, { recursive: true }),
write: (file, content) => atomicWriteFile(file, content),
backup: (args) => createBackup(args),
copyFile: (src, dst) => {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
},
remove: (p) => fs.rmSync(p, { recursive: true, force: true }),
trustedRootReady: () => {},
};
/**
* S3: every operation AS `acct`, through `write-as` (content on stdin, never
* sudo's argv). The member can swap any name in their own home, and a write
* by the host would follow the swap — as the member, it can only do what the
* member could. Reads go through run-as's guarded reader (their own regular
* file, size capped).
*/
function memberIo(acct) {
const runAs = require("./run-as");
const lexists = (p) => {
try {
fs.lstatSync(p);
return true;
} catch {
return false;
}
};
const copy = (src, dst) => {
runAs.mkdirAs(acct, path.dirname(dst));
runAs.copyAs(acct, src, dst);
};
return {
acct,
exists: (p) => fs.existsSync(p),
readText: (file) => {
try {
return runAs.readFileSafe(file, "utf8", { max: MAX_FILE_BYTES });
} catch (err) {
if (err.code === "ETOOBIG") throw makeError("ETOOLARGE", `${file} is too large`);
if (err.code === "EFOREIGNFILE") {
throw makeError("EOUTOFROOT", `${file} is not a file of this account's`);
}
throw err;
}
},
mkdir: (dir) => runAs.mkdirAs(acct, dir),
write: (file, content) => {
runAs.mkdirAs(acct, path.dirname(file));
runAs.writeAs(acct, file, "replace", content);
},
backup: ({ source, backupRoot, name }) => {
if (!lexists(source)) return null;
const dst = path.join(backupRoot, `${name}.${timestamp()}.bak`);
copy(source, dst);
return dst;
},
copyFile: copy,
remove: (p) => runAs.removeAs(acct, p),
// The host may not create a member's directory itself (assertNoSymlinkEscape
// would): the member does, and the check runs with `create: false`.
trustedRootReady: (root) => runAs.mkdirAs(acct, root),
};
}
/**
* How to touch files under `root`: AS the member whose home it is (S3), else
* as the host. Throws EMEMBERGONE for a member's folder with no usable
* account — never "the host then".
*/
function ioFor(root) {
const acct = require("./run-as").accountForPath(path.resolve(root));
return acct ? memberIo(acct) : HOST_IO;
}
/** `assertNoSymlinkEscape` without the host ever creating a member's directory. */
function guardTarget(io, trustedRoot, target) {
io.trustedRootReady(trustedRoot);
assertNoSymlinkEscape(trustedRoot, target, {
label: "Claude",
create: io.acct ? false : undefined,
});
}
/**
* Resolve (and validate) the memory dir for a per-project file-based memory
* store: ~/.claude/projects/<project>/memory/. Rejects slugs that could
* traverse out of the projects root.
*/
function autoMemoryDir(project) {
function autoMemoryDir(project, opts = {}) {
if (typeof project !== "string" || !PROJECT_SLUG_RE.test(project) || project.includes("..")) {
throw makeError("EBADPROJECT", `invalid project slug: ${project}`);
}
const projectsRoot = path.join(getClaudeHome(), "projects");
const projectsRoot = path.join(homeOf(opts), "projects");
const dir = path.join(projectsRoot, project, "memory");
if (!isUnder(projectsRoot, dir)) {
throw makeError("EOUTOFROOT", "project escapes the projects root");
@@ -128,7 +245,7 @@ function resolveTarget(scope, type, name, opts = {}) {
}
if (spec.kind === "auto-memory") {
const memDir = autoMemoryDir(opts.project);
const memDir = autoMemoryDir(opts.project, opts);
if (typeof name !== "string" || !MEMORY_FILE_RE.test(name) || name.includes("..")) {
throw makeError("EBADNAME", `auto-memory name must be a flat *.md filename`);
}
@@ -140,7 +257,7 @@ function resolveTarget(scope, type, name, opts = {}) {
target,
filePath: target,
containmentRoot: memDir,
trustedRoot: path.join(getClaudeHome(), "projects"),
trustedRoot: path.join(homeOf(opts), "projects"),
};
}
@@ -190,25 +307,21 @@ function autoMemoryBackupRoot(memDir) {
return path.join(memDir, ".cc-config-backups", "auto-memory");
}
function rmTreeSync(p) {
fs.rmSync(p, { recursive: true, force: true });
}
/**
* Always-on backup, routed to the bucket the (scope, type) pair backs up
* into. For files, copies to <bucket>/<name>.<ts>.bak. For dirs (skills),
* copies the whole tree. Returns the backup path (or null if there was
* nothing to back up — e.g. brand-new file).
*/
function backupTarget({ scope, type, target, opts, trustedRoot }) {
function backupTarget({ scope, type, target, opts, trustedRoot, io = HOST_IO }) {
let root;
if (type === "memory") root = memoryBackupRoot(scope, opts);
else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target));
else root = backupRoot(scope, type, opts);
// The backup DESTINATION needs the same guard as the artifact: a link at
// `<root>/cc-config-backups` sends the copied contents outside the root.
if (trustedRoot) assertNoSymlinkEscape(trustedRoot, root, { label: "Claude" });
return createBackup({ source: target, backupRoot: root, type, name: path.basename(target) });
if (trustedRoot) guardTarget(io, trustedRoot, root);
return io.backup({ source: target, backupRoot: root, type, name: path.basename(target) });
}
// ── Public API ─────────────────────────────────────────────────────────
@@ -220,12 +333,14 @@ function backupTarget({ scope, type, target, opts, trustedRoot }) {
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
*/
function writeArtifact(args) {
const { scope, type, name, content, cwd, project } = args;
const { scope, type, name, content, cwd, project, home, noProject } = args;
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const r = resolveTarget(scope, type, name, { cwd, project });
const opts = { cwd, home, noProject };
const r = resolveTarget(scope, type, name, { ...opts, project });
const io = ioFor(r.trustedRoot);
// Containment guard: even after our regex, double-check that the resolved
// path actually lives under the expected root. Defends against quirks like
@@ -235,23 +350,24 @@ function writeArtifact(args) {
}
// The lexical check above is defeated by a symlink at the target or at any
// ancestor of it — see config-write.js. Resolve before writing.
assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" });
guardTarget(io, r.trustedRoot, r.target);
const existedBefore = fs.existsSync(r.filePath);
const existedBefore = io.exists(r.filePath);
const backupPath = existedBefore
? backupTarget({
scope,
type,
target: r.kind === "dir" ? r.target : r.filePath,
opts: { cwd },
opts,
trustedRoot: r.trustedRoot,
io,
})
: null;
if (r.kind === "dir") {
fs.mkdirSync(r.target, { recursive: true });
io.mkdir(r.target);
}
atomicWriteFile(r.filePath, content);
io.write(r.filePath, content);
return {
ok: true,
@@ -267,17 +383,19 @@ function writeArtifact(args) {
* the backup fails, the original is left intact.
*/
function deleteArtifact(args) {
const { scope, type, name, cwd, project } = args;
const r = resolveTarget(scope, type, name, { cwd, project });
const { scope, type, name, cwd, project, home, noProject } = args;
const opts = { cwd, home, noProject };
const r = resolveTarget(scope, type, name, { ...opts, project });
const io = ioFor(r.trustedRoot);
if (!isUnder(r.containmentRoot, r.target)) {
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
}
// rmSync/unlinkSync only unlink the link itself, but the backup that runs
// first copies THROUGH it — so the same guard has to cover delete.
assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" });
guardTarget(io, r.trustedRoot, r.target);
if (!fs.existsSync(r.target)) {
if (!io.exists(r.target)) {
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
}
@@ -285,15 +403,14 @@ function deleteArtifact(args) {
scope,
type,
target: r.target,
opts: { cwd },
opts,
trustedRoot: r.trustedRoot,
io,
});
if (r.kind === "dir") {
rmTreeSync(r.target);
} else {
fs.unlinkSync(r.target);
}
if (io.acct) io.remove(r.target);
else if (r.kind === "dir") HOST_IO.remove(r.target);
else fs.unlinkSync(r.target);
return { ok: true, file: r.filePath, target: r.target, backupPath };
}
@@ -307,12 +424,12 @@ function deleteArtifact(args) {
// preserved and only the `bindings` array is replaced, so metadata is never
// dropped. Backups land under CLAUDE_HOME/cc-config-backups/keybindings/.
function keybindingsFile() {
return path.join(getClaudeHome(), "keybindings.json");
function keybindingsFile(opts) {
return path.join(homeOf(opts), "keybindings.json");
}
function keybindingsBackupRoot() {
return path.join(getClaudeHome(), "cc-config-backups", "keybindings");
function keybindingsBackupRoot(opts) {
return path.join(homeOf(opts), "cc-config-backups", "keybindings");
}
// A keybinding key ("ctrl+t", "escape", "shift+ctrl+f") or action id
@@ -331,7 +448,7 @@ function validKbString(s, max) {
* @param {{ groups: Array<{context:string, bindings:Array<{key:string,action:string}>}> }} args
*/
function writeKeybindings(args = {}) {
const { groups } = args;
const { groups, home } = args;
if (!Array.isArray(groups)) {
throw makeError("EBADCONTENT", "groups must be an array");
}
@@ -379,12 +496,13 @@ function writeKeybindings(args = {}) {
outBindings.push({ context, bindings: map });
}
const file = keybindingsFile();
const file = keybindingsFile({ home });
const io = ioFor(homeOf({ home }));
// Preserve any existing top-level metadata ($schema, $docs, unknown keys).
let base = {};
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
const parsed = JSON.parse(io.readText(file));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed;
} catch {
base = {};
@@ -396,16 +514,15 @@ function writeKeybindings(args = {}) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const existedBefore = fs.existsSync(file);
const existedBefore = io.exists(file);
let backupPath = null;
if (existedBefore) {
const root = keybindingsBackupRoot();
fs.mkdirSync(root, { recursive: true });
const root = keybindingsBackupRoot({ home });
backupPath = path.join(root, `keybindings.json.${timestamp()}.bak`);
fs.copyFileSync(file, backupPath);
io.copyFile(file, backupPath);
}
atomicWriteFile(file, content);
io.write(file, content);
return { ok: true, file, target: file, backupPath, created: !existedBefore };
}
@@ -417,9 +534,15 @@ function writeKeybindings(args = {}) {
// layer `local` (see cc-plugin-cli.js) — the two vocabularies are separate on
// purpose and never share a validator.
const SETTINGS_SCOPES = {
user: (opts) => path.join(getClaudeHome(), "settings.json"),
project: (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
"project-local": (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
user: (opts) => path.join(homeOf(opts), "settings.json"),
project: (opts) => {
assertProject(opts);
return path.join(getProjectClaudeDir(opts.cwd), "settings.json");
},
"project-local": (opts) => {
assertProject(opts);
return path.join(getProjectClaudeDir(opts.cwd), "settings.local.json");
},
};
/** A single dotted-path segment. `$` is allowed for `$schema`-style metadata. */
@@ -512,8 +635,8 @@ function unsetDeep(obj, parts) {
* @returns {{path:string, backupPath:string|null, created:boolean, changed:string[]}}
*/
function patchSettings(args) {
const { scope, cwd, set, unset } = args;
const opts = { cwd };
const { scope, cwd, set, unset, home, noProject } = args;
const opts = { cwd, home, noProject };
const file = settingsFileFor(scope, opts);
const setEntries =
@@ -531,13 +654,17 @@ function patchSettings(args) {
});
const parsedUnset = unsetPaths.map((key) => ({ parts: parseSettingsPath(key), key }));
const io = ioFor(path.dirname(file));
let existed = false;
let data = {};
let raw = null;
try {
raw = fs.readFileSync(file, "utf8");
raw = io.readText(file);
existed = true;
} catch {
} catch (err) {
// Too big, or not this account's own file: never "fresh" — that would
// overwrite it.
if (err.code === "ETOOLARGE" || err.code === "EOUTOFROOT") throw err;
/* fresh file */
}
if (existed) {
@@ -585,12 +712,11 @@ function patchSettings(args) {
let backupPath = null;
if (existed) {
const root = settingsBackupRoot(scope, opts);
fs.mkdirSync(root, { recursive: true });
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
fs.copyFileSync(file, backupPath);
io.copyFile(file, backupPath);
}
atomicWriteFile(file, output);
io.write(file, output);
return { path: file, backupPath, created: !existed, changed };
}
@@ -600,7 +726,9 @@ function patchSettings(args) {
*/
function listBackups(opts = {}) {
const out = [];
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
const scopes = (opts.scope ? [opts.scope] : ["user", "project"]).filter(
(s) => !(opts.noProject && s === "project")
);
// auto-memory backups live per-project, not under a user/project root — they
// are scanned separately below.
const types = (opts.type ? [opts.type] : Object.keys(TYPES)).filter((t) => t !== "auto-memory");
@@ -611,6 +739,7 @@ function listBackups(opts = {}) {
type === "memory" ? memoryBackupRoot(scope, opts) : backupRoot(scope, type, opts);
let entries = [];
try {
if (!memberPathOk(root)) continue;
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
@@ -642,11 +771,13 @@ function listBackups(opts = {}) {
(!opts.type || opts.type === "auto-memory") && (!opts.scope || opts.scope === "auto-memory");
if (wantAuto) {
try {
const projectsRoot = path.join(getClaudeHome(), "projects");
const projectsRoot = path.join(homeOf(opts), "projects");
if (!memberPathOk(projectsRoot)) throw new Error("not listable");
for (const proj of fs.readdirSync(projectsRoot)) {
const root = autoMemoryBackupRoot(path.join(projectsRoot, proj, "memory"));
let entries;
try {
if (!memberPathOk(root)) continue;
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
+123 -62
View File
@@ -21,11 +21,23 @@
* Left unset it stays unset, because the CLI's own defaults already write
* where `cc-discovery` looks — pinning it unconditionally is what once
* made every MCP add/remove write a file nothing reads.
* - S3, a member's own config (`acct` on any operation): the shared `claude`
* (`shared-tools.sharedPath`, ENOTSHARED when it is not published) runs
* AS the member — `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
* <claude> …` in their home, with their own default config dir. Only
* `--scope user`, and never `-y`: a plugin whose marketplace declares an
* install command fails instead of running unseen as them. Each account
* has its own queue, so one member's slow clone never holds up another's.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile } = require("node:child_process");
const { execFile: realExecFile } = require("node:child_process");
// Test seam: how `claude` (or `sudo … claude`) is spawned.
let execFile = realExecFile;
function __setExecFileImpl(fn) {
execFile = fn || realExecFile;
}
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
@@ -98,57 +110,48 @@ function assertMarketplaceName(value) {
return value;
}
function assertCliScope(value) {
function assertCliScope(value, acct) {
if (value === undefined || value === null || value === "") return "user";
if (!CLI_SCOPES.includes(value)) {
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
}
// A member's CLI runs in their home, which is no project: only their user
// layer is theirs to change from here.
if (acct && value !== "user") {
throw makeError("EBADARG", "only scope user can be changed for your own config");
}
return value;
}
// One in-process queue for every mutating CLI call. The dashboard is a single
// Node process, so a promise chain is the whole locking story — no file lock,
// no cross-process coordination. It does NOT protect against a human running
// `claude plugin …` in a terminal at the same moment; the CLI owns that race.
let mutationQueue = Promise.resolve();
// One in-process queue per account (the host's is "host") for every mutating
// CLI call. The dashboard is a single Node process, so a promise chain is the
// whole locking story — no file lock, no cross-process coordination. It does
// NOT protect against a human running `claude plugin …` in a terminal at the
// same moment; the CLI owns that race.
const mutationQueues = new Map();
function serialize(fn) {
const run = mutationQueue.then(fn, fn);
mutationQueue = run.then(
() => undefined,
() => undefined
function serialize(fn, acct) {
const key = acct ? acct.login : "host";
const queue = mutationQueues.get(key) || Promise.resolve();
const run = queue.then(fn, fn);
mutationQueues.set(
key,
run.then(
() => undefined,
() => undefined
)
);
return run;
}
/**
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
* normal result (`ok:false`), not a rejection — callers map it to a response
* body. Only a spawn failure (binary missing) rejects.
*
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
* @param {{timeoutMs?:number}} [opts]
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
* `[command, argv, options]` for `claude <args>`: the host's own `claude` on
* PATH, or — for `acct` (S3) — the shared one AS the member through sudo,
* with only HOME and DISABLE_AUTOUPDATER set (sudo resets the rest; their
* default config dir is their own `~/.claude`), in their home.
*/
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
// It also predates the confirmation prompt the flag answers, so the same
// command without it is the correct retry, not a weaker one.
const NO_YES_FLAG = /unknown option '-y'/;
async function runClaude(args, opts = {}) {
const result = await runClaudeOnce(args, opts);
if (!result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
return runClaudeOnce(
args.filter((a) => a !== "-y"),
opts
);
}
return result;
}
function runClaudeOnce(args, opts = {}) {
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
function claudeCommand(args, acct) {
if (!acct) {
// Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME
// (a non-default install). Left unset, the CLI's own default config dir
// is `~/.claude` and it writes `.claude.json` NEXT TO it (`~/.claude.json`)
@@ -161,13 +164,62 @@ function runClaudeOnce(args, opts = {}) {
// that branch, so both cases line up. Keep the two in step.
const env = { ...process.env };
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
return ["claude", args, { env }];
}
const claude = require("./shared-tools").sharedPath("claude");
if (!claude) throw makeError("ENOTSHARED", "claude is not in the shared tools yet");
// Never -y for a member, and never a --dangerously-* flag (none is built
// here; the validators refuse any value starting with "-").
const safe = args.filter((a) => a !== "-y");
const [cmd, argv] = require("./run-as").sudoArgv(acct, claude, safe, {
HOME: acct.home,
DISABLE_AUTOUPDATER: "1",
});
return [cmd, argv, { env: process.env, cwd: acct.home }];
}
/**
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
* normal result (`ok:false`), not a rejection — callers map it to a response
* body. Only a spawn failure (binary missing) rejects.
*
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
* @param {{timeoutMs?:number, acct?:object}} [opts] `acct`: run AS that member (S3).
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
*/
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
// It also predates the confirmation prompt the flag answers, so the same
// command without it is the correct retry, not a weaker one.
const NO_YES_FLAG = /unknown option '-y'/;
async function runClaude(args, opts = {}) {
const result = await runClaudeOnce(args, opts);
if (!opts.acct && !result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
return runClaudeOnce(
args.filter((a) => a !== "-y"),
opts
);
}
return result;
}
function runClaudeOnce(args, opts = {}) {
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
let cmd, argv, spawnOpts;
try {
[cmd, argv, spawnOpts] = claudeCommand(args, opts.acct);
} catch (err) {
reject(err);
return;
}
const child = execFile(
"claude",
args,
cmd,
argv,
{
timeout,
maxBuffer: MAX_OUTPUT_BYTES,
env,
...spawnOpts,
},
(err, stdout, stderr) => {
const out = String(stdout || "");
@@ -201,51 +253,55 @@ function runClaudeOnce(args, opts = {}) {
// Each validates first, then queues. Validation errors throw synchronously
// from the returned promise, which is what the routes turn into a 400.
async function marketplaceAdd({ source, scope }) {
// `acct` (S3): run AS that member, on their own config — see the file header.
const queued = (args, acct) => serialize(() => runClaude(args, { acct }), acct);
async function marketplaceAdd({ source, scope, acct }) {
const src = assertSource(source);
const sc = assertCliScope(scope);
return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc]));
const sc = assertCliScope(scope, acct);
return queued(["plugin", "marketplace", "add", src, "--scope", sc], acct);
}
async function marketplaceRemove({ name }) {
async function marketplaceRemove({ name, acct }) {
const n = assertMarketplaceName(name);
return serialize(() => runClaude(["plugin", "marketplace", "remove", n]));
return queued(["plugin", "marketplace", "remove", n], acct);
}
async function marketplaceUpdate({ name }) {
async function marketplaceUpdate({ name, acct }) {
const n = assertMarketplaceName(name);
return serialize(() => runClaude(["plugin", "marketplace", "update", n]));
return queued(["plugin", "marketplace", "update", n], acct);
}
async function pluginInstall({ spec, scope }) {
async function pluginInstall({ spec, scope, acct }) {
const s = assertPluginSpec(spec);
const sc = assertCliScope(scope);
const sc = assertCliScope(scope, acct);
// `-y` accepts the marketplace-declared install command without a prompt;
// required because stdin is not a TTY here.
return serialize(() => runClaude(["plugin", "install", s, "--scope", sc, "-y"]));
// required because stdin is not a TTY here. Never for a member (dropped by
// claudeCommand): there such a plugin fails instead.
return queued(["plugin", "install", s, "--scope", sc, "-y"], acct);
}
async function pluginUninstall({ spec }) {
async function pluginUninstall({ spec, acct }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "uninstall", s]));
return queued(["plugin", "uninstall", s], acct);
}
async function pluginEnable({ spec }) {
async function pluginEnable({ spec, acct }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "enable", s]));
return queued(["plugin", "enable", s], acct);
}
async function pluginDisable({ spec }) {
async function pluginDisable({ spec, acct }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "disable", s]));
return queued(["plugin", "disable", s], acct);
}
async function pluginUpdate({ spec }) {
async function pluginUpdate({ spec, acct }) {
const s = assertPluginSpec(spec);
// `-y` for the same reason as pluginInstall: the CLI requires it whenever
// stdin is not a TTY (runClaude closes stdin), and without it an update whose
// marketplace declares an install command fails on a prompt nobody can answer.
return serialize(() => runClaude(["plugin", "update", s, "-y"]));
return queued(["plugin", "update", s, "-y"], acct);
}
/**
@@ -253,10 +309,14 @@ async function pluginUpdate({ spec }) {
* registered marketplace, plus what is already installed. Read-only, so it
* skips the mutation queue.
*
* @param {{acct?: object}} [opts] `acct`: the member's own list (S3).
* @returns {Promise<{installed:object[], available:object[]}>}
*/
async function listAvailable() {
const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 });
async function listAvailable({ acct } = {}) {
const res = await runClaude(["plugin", "list", "--available", "--json"], {
timeoutMs: 30_000,
acct,
});
if (!res.ok) {
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
}
@@ -309,4 +369,5 @@ module.exports = {
assertMarketplaceName,
assertCliScope,
PLUGIN_SPEC_RE,
__setExecFileImpl,
};
+11 -5
View File
@@ -20,6 +20,11 @@
*
* Only `claude` lanes are addressed: Codex has no such command, and typing a
* slash command into a Codex pane would land as literal prompt text.
*
* Whose panes: by default the host's own (a member's pane runs on their own
* `~/.claude`, which a change to the host's config does not touch). With
* `account` (S3: a member reloading after changing their OWN config) only the
* panes that run as that member, and nobody else's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -60,7 +65,7 @@ const RELOAD_COMMANDS = {
*
* @returns {{id: string, laneId: number|null, paneCommand: string|null}[]}
*/
function reloadableLanes() {
function reloadableLanes(account = null) {
const out = [];
for (const run of listRuns()) {
if (run.status !== "running") continue;
@@ -70,7 +75,8 @@ function reloadableLanes() {
// EMEMBERGONE (a disabled member's pane) is a member's too: skip it,
// never let one throw end the reload for every other pane.
try {
if (require("./run-as").accountForSession(run.id)) continue;
const acct = require("./run-as").accountForSession(run.id);
if (account ? acct?.name !== account.name : acct) continue;
} catch {
continue;
}
@@ -99,10 +105,10 @@ function reloadableLanes() {
* separate submissions, and waiting for that without blocking the event loop is
* the only way a live dashboard keeps broadcasting while it types.
*
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [options] which
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number, account?: object}} [options] which
* commands to send; defaults to both, plugins first (a skill shipped BY a
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
* is a test seam — no route passes it.
* is a test seam — no route passes it. `account`: only that member's panes.
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
* commands: string[], lanes_total: number}>}
*/
@@ -112,7 +118,7 @@ async function reloadOpenSessions(options = {}) {
const settleMs = options.settleMs ?? SETTLE_MS;
const lanes = [];
for (const lane of reloadableLanes()) {
for (const lane of reloadableLanes(options.account || null)) {
const sent = [];
let error;
for (const command of commands) {
+135
View File
@@ -0,0 +1,135 @@
/**
* @file cc-target.js
* @description Whose Claude Code configuration a `/api/cc-config` request reads
* and writes — the ONE resolver every handler of `routes/cc-config.js` uses,
* and the allowlist of the endpoints a non-admin may reach there at all.
*
* - Local mode, or an admin: the host's Claude home (`getClaudeHome()`), with
* every handler behaving exactly as it did before members had a config page.
* - Server mode, a non-admin with an enabled, provisioned Linux account (S3):
* their OWN `<home>/.claude` and `<home>/.claude.json`. Their writes run as
* them (cc-mutate's `ioFor`, cc-plugin-cli's sudo), never as the host.
* - An analyst with no such account, reading: the host's, read-only — what an
* analyst read before. `?view=host` gives an analyst WITH an account that
* same read-only view.
* - Anyone else (a developer with no account, any non-admin write without
* one): 403 ENOACCOUNT — never "the host's then".
*
* Nothing here takes a home or a path from the request.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
/**
* What a non-admin may call under `/api/cc-config` ("METHOD /path", lower
* case, HEAD counted as GET): reads of their own config and the writes that
* run as them. Everything else — `/profiles*` (the host's login switching),
* `/plugins/update-all` (the host's Update all, AgentHub's own plugin
* included), and any endpoint added later — stays an admin's until it is
* listed here.
*/
const MEMBER_ROUTES = new Set([
"GET /target",
"GET /overview",
"GET /skills",
"GET /agents",
"GET /commands",
"GET /output-styles",
"GET /plugins",
"GET /plugins/available",
"GET /mcp",
"GET /hooks",
"GET /settings",
"GET /memory",
"GET /marketplaces",
"GET /keybindings",
"GET /statusline",
"GET /hook-scripts",
"GET /file",
"GET /backups",
"PUT /keybindings",
"PUT /file",
"DELETE /file",
"PATCH /settings",
"POST /project-memory",
"POST /plugins/install",
"POST /plugins/action",
"POST /marketplaces/add",
"POST /marketplaces/action",
"POST /mcp/add",
"POST /mcp/remove",
"POST /reload",
]);
/** The host-wide reads an analyst had before (staff read everything): still read-only. */
const ANALYST_READS = new Set(["GET /profiles", "GET /plugins/update-all"]);
/** `"METHOD /path"` as the allowlists spell it (decoded, lower case, no trailing slash). */
function routeKey(req) {
const p = require("./auth").apiPath(req).toLowerCase().replace(/\/+$/, "") || "/";
const method = req.method === "HEAD" ? "GET" : String(req.method || "").toUpperCase();
return `${method} ${p}`;
}
/** Whether `req.user` may reach this cc-config endpoint at all (local mode and admins: yes). */
function mayReach(req) {
const u = req.user;
if (!u || u.role === "admin") return true;
const key = routeKey(req);
return MEMBER_ROUTES.has(key) || (u.role === "analyst" && ANALYST_READS.has(key));
}
/** Express middleware: the router's own gate (403 EADMIN), the single rule for `/cc-config`. */
function ccGate(req, res, next) {
if (mayReach(req)) return next();
res.status(403).json({ error: { code: "EADMIN", message: "only an admin may open this" } });
}
const isRead = (req) => req.method === "GET" || req.method === "HEAD";
function host(readOnly = false) {
return {
acct: null,
own: false,
readOnly,
home: getClaudeHome(),
claudeJson: require("./cc-discovery").getClaudeJsonPath(),
};
}
/**
* The target of `req`: `{acct, own, readOnly, home, claudeJson}` — `acct` and
* `own` set for a member's own config.
* @throws ENOACCOUNT (status 403) for a non-admin with no account to act on
*/
function resolveCcTarget(req) {
const u = req.user;
if (!u || u.role === "admin") return host();
if (u.role === "analyst" && isRead(req) && req.query?.view === "host") return host(true);
let acct = null;
try {
acct = require("./run-as").accountForUser(u.username);
} catch {
acct = null; // a disabled / unprovisioned account is no account: refuse below
}
if (acct) {
return {
acct,
own: true,
readOnly: false,
home: path.join(acct.home, ".claude"),
claudeJson: path.join(acct.home, ".claude.json"),
};
}
if (u.role === "analyst" && isRead(req)) return host(true);
throw Object.assign(
new Error(
"your Claude Code config lives in your own account, and this server has none for you yet"
),
{ code: "ENOACCOUNT", status: 403 }
);
}
module.exports = { resolveCcTarget, ccGate, mayReach, routeKey, MEMBER_ROUTES, ANALYST_READS };
+1 -1
View File
@@ -11,7 +11,7 @@
const fs = require("node:fs");
const path = require("node:path");
const yaml = require("js-yaml");
const yaml = require("./yaml");
const { parseEnvFile } = require("./lane-profile");
const { isListening } = require("./ports");
const { SECRETS_PATH } = require("./secrets");
+2 -2
View File
@@ -190,7 +190,7 @@ function providerEntry() {
* member's, and is never overwritten with something it did not say.
*/
function mergedModels(text) {
const yaml = require("js-yaml");
const yaml = require("./yaml");
const doc = text && text.trim() ? yaml.load(text) : {};
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
throw new Error("models.yml is not a YAML mapping");
@@ -227,7 +227,7 @@ function writeMemberModels(acct) {
/** The host's own default omp model (`modelRoles.default` in `~/.omp/agent/config.yml`), or null. */
function hostDefaultModel() {
try {
const doc = require("js-yaml").load(
const doc = require("./yaml").load(
fs.readFileSync(path.join(ompDir(), "agent", "config.yml"), "utf8")
);
const m = doc?.modelRoles?.default;
+1 -1
View File
@@ -51,7 +51,7 @@ function loadAll() {
icon: n.icon || "",
gate: !!n.gate,
// The one command that drives THIS node — a slash command
// (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook
// (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook
// ci-gate`), mirroring whatever the node's own `detect` rules watch
// for. `doc.skill` above names the driver for the whole pipeline;
// this narrows it to the step, so a reader of the pipeline map can
+11 -1
View File
@@ -15,6 +15,9 @@
* clobber an existing memory file;
* - a symlink at `<dir>/CLAUDE.md` pointing elsewhere is refused.
*
* Under S3 the file is written AS the account whose home the folder is in
* (`run-as.accountForPath`), never by the host.
*
* The capability is not new in kind: the Run page already spawns `claude` in
* any existing directory the same way. What it must not become is a general
* "write arbitrary content to an arbitrary path" endpoint — hence the fixed
@@ -91,7 +94,14 @@ function createProjectMemory({ tool, dir, content }) {
}
if (taken) throw makeError("EEXISTS", `${filename} already exists in ${realDir}`);
atomicWriteFile(file, content);
// S3: a folder in a member's home is written AS that member (`write-as`,
// content on stdin) — an admin's create there included; the host never
// writes inside a member's home itself. EMEMBERGONE (no usable account)
// refuses, never "the host then".
const runAs = require("./run-as");
const acct = runAs.accountForPath(file);
if (acct) runAs.writeAs(acct, file, "replace", content);
else atomicWriteFile(file, content);
return { file, size: Buffer.byteLength(content, "utf8") };
}
+1 -1
View File
@@ -249,7 +249,7 @@ function deleteProof(laneId, { slug, group, images } = {}) {
* already points at the right place.
*
* Never auto-invoked by this codebase; exposed only as the explicit
* `agenthub lanes proof-link` primitive.
* `agenthub workspace proof-link` primitive.
*
* @param {object} lane - A hydrated lane row (must have `cwd`).
* @returns {{linked: boolean}} `linked: false` when nothing changed.
+7 -3
View File
@@ -162,12 +162,16 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
const [file, args] = require("./drop-caps").ptyArgv(
...(tmux.memberAttach(runId) || [attachBinary(), ["attach-session", "-t", tmux.session(runId)]])
);
// An attach with no size of its own (a terminal on a hidden tab, 0×0) takes
// the window's current size, so attaching it never shrinks the pane.
const own =
cols && rows ? { cols, rows } : (!lead && tmux.windowSize(runId)) || { cols: 80, rows: 24 };
// A follower attaches at the driver's size: tmux sizes the window to the
// newest client, so its own size would resize the driver's pane first.
const pty = spawnImpl(file, args, {
name: "xterm-256color",
cols: lead ? lead.cols : cols || 80,
rows: lead ? lead.rows : rows || 24,
cols: lead ? lead.cols : own.cols,
rows: lead ? lead.rows : own.rows,
// Windows' inbox ConPTY re-renders psmux's output instead of passing it
// through, and wherever its glyph widths disagree with xterm.js's (⏺, ✻,
// Vietnamese) it leaves stray cells: a torn left edge, bars mid-screen.
@@ -176,7 +180,7 @@ function attach(ws, runId, { cols, rows, platform = process.platform }) {
...(platform === "win32" && { useConptyDll: true }),
});
swallowPipeErrors(pty);
const me = { ws, pty, cols: cols || 80, rows: rows || 24, exited: false };
const me = { ws, pty, cols: own.cols, rows: own.rows, exited: false };
group.viewers.add(me);
if (lead) tell(me, { type: "size", cols: lead.cols, rows: lead.rows });
else group.driver = me;
+2 -2
View File
@@ -15,7 +15,7 @@
*
* Every session is named `agenthub-lane-<laneId>` so a real terminal can attach
* to the exact same session (`tmux attach -t agenthub-lane-<id>`, or
* `agenthub lanes shell`) — that's the whole point: the dashboard both creates
* `agenthub workspace shell`) — that's the whole point: the dashboard both creates
* the session (one-click Start/Resume) and is just one of possibly several
* attached clients tmux already keeps in sync.
*
@@ -719,7 +719,7 @@ function spawnRun(args) {
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
// The session exists but its pane is sitting at a bare shell prompt — a
// `agenthub lanes shell`, or a `claude` that already exited. Adopting it
// `agenthub workspace shell`, or a `claude` that already exited. Adopting it
// silently here would swallow the whole request: a Resume would spawn no
// `--resume` and an initial prompt would never be typed, while the API
// still answered 200. Run the argv in the pane the user already sees
+11
View File
@@ -603,6 +603,16 @@ function makeMemberFolder(dir) {
}
const removeAs = (acct, p) => writeAs(acct, p, "remove");
/**
* `write-as <src> copy <dest>` as `acct` (sync, bounded like every write-as):
* a recursive copy that never follows a link and never lands on an existing
* name — a backup of a member's own file, made by the member.
*/
function copyAs(acct, src, dest) {
const [cmd, argv] = sudoArgv(acct, WRITE_AS, [src, "copy", dest]);
execImpl(cmd, argv, { ...MEMBER_EXEC, input: "", stdio: ["pipe", "ignore", "pipe"] });
}
/** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */
function commandIn(cwd, command, args, env = {}) {
const acct = accountForPath(cwd);
@@ -648,6 +658,7 @@ module.exports = {
mkdirAs,
makeMemberFolder,
removeAs,
copyAs,
launchArgv,
sameOwner,
openSafe,
+15
View File
@@ -320,6 +320,7 @@ function broadcastAudience(type, data) {
return null;
// A member's prompt box refetches its command list on these, but the
// payload names the host's profiles and files: members get the type only.
// (A member's OWN config change never gets here: `exactAudience`.)
case "cc_config_changed":
case "codex_config_changed":
return { strip: true };
@@ -328,6 +329,19 @@ function broadcastAudience(type, data) {
}
}
/**
* A broadcast meant for exactly these users, whatever their role, or null for
* the role-based rules above. Pure (no DB): asked for every client. A change
* to a member's OWN Claude Code config (`cc_config_changed` with `ownerId`,
* routes/cc-config.js) names their files: it reaches that member and nobody
* else, an admin or analyst included.
*/
function exactAudience(type, data) {
const d = data && typeof data === "object" ? data : {};
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
return null;
}
/** Whether a client logged in as `user` (undefined in local mode) gets it. */
function audienceIncludes(audience, user) {
if (!user || user.role !== "developer") return true;
@@ -448,6 +462,7 @@ module.exports = {
folderTakenByOther,
scopeGuard,
broadcastAudience,
exactAudience,
audienceIncludes,
sessionOwners,
PATH_RULES,
+26
View File
@@ -0,0 +1,26 @@
/**
* @file send-file.js
* @description `res.sendFile` / `res.download` for an absolute path the server
* chose itself, with Express 4's dotfile rule. Express 5 (send 1.x) refuses
* with 404 any absolute path that has a dot-segment ANYWHERE — `~/.claude/…`
* (the plugin cache, the default data dir), `~/.local/…` — where Express 4
* looked only at the file's own name. Sending the basename against its
* directory as `root` makes send check that name alone again, so a dotfile is
* still refused and a dotted parent directory is not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("path");
/** `res.sendFile(abs, opts, cb)` with only the basename subject to `dotfiles`. */
function sendFile(res, abs, opts, cb) {
if (typeof opts === "function") [cb, opts] = [opts, undefined];
return res.sendFile(path.basename(abs), { ...opts, root: path.dirname(abs) }, cb);
}
/** `res.download(abs, name)` with only the basename subject to `dotfiles`. */
function download(res, abs, name, cb) {
return res.download(path.basename(abs), name, { root: path.dirname(abs) }, cb);
}
module.exports = { sendFile, download };
+21 -1
View File
@@ -3,7 +3,7 @@
* @description Thin wrapper around the `tmux` CLI for the terminal-run
* feature. Every dashboard-managed session is named `agenthub-lane-<id>` (see
* `pty-run.js`) so a real terminal can attach to the exact same session with
* `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes shell`). Never builds a
* `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace shell`). Never builds a
* shell string — every call is `execFileSync("tmux", [...argv])` (a member's
* listing: `execFile`) with an explicit argument array (matches this repo's
* rule for git in worktree.js).
@@ -723,6 +723,25 @@ function leaveCopyMode(name) {
}
}
/**
* The session's window size as tmux has it now, `{cols, rows}`, or null when
* tmux can't answer. An attach that brings no size of its own (a terminal on
* a hidden tab) is spawned at this, so attaching it resizes nothing.
*/
function windowSize(name) {
try {
const [cols, rows] = String(
onServerOf(name, ["display-message", "-p", "-t", pane(name), "#{window_width} #{window_height}"])
)
.trim()
.split(" ")
.map(Number);
return cols >= 2 && rows >= 2 ? { cols, rows } : null;
} catch {
return null;
}
}
/**
* The command currently running in the session's active pane (`bash`, `zsh`,
* `claude`, …). Null when tmux can't answer — callers treat that as "unknown,
@@ -1013,6 +1032,7 @@ module.exports = {
enableMouse,
leaveCopyMode,
paneCommand,
windowSize,
sendCommand,
sendText,
pasteText,
+35
View File
@@ -0,0 +1,35 @@
/**
* @file yaml.js
* @description js-yaml 5 with js-yaml 4's loading rules, for the YAML files
* AgentHub reads from a user (a repo's docker-compose file, omp's
* `models.yml`/`config.yml`). js-yaml 5's `load` uses the bare YAML 1.2 core
* schema — no `<<` merge keys, no timestamps, `!!binary`/`!!set` refused — and
* throws on an empty document; 4's default schema was core plus exactly those
* tags, and an empty document loaded as `undefined`. A member's file that
* loaded before must load the same now (and `mergedModels` rewrites it).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const yaml = require("js-yaml");
/** js-yaml 4's DEFAULT_SCHEMA. */
const V4_SCHEMA = yaml.CORE_SCHEMA.withTags([
yaml.mergeTag,
yaml.timestampTag,
yaml.binaryTag,
yaml.omapTag,
yaml.pairsTag,
yaml.setTag,
]);
/** `yaml.load` as js-yaml 4 did it: undefined for a document-less text. */
function load(text) {
try {
return yaml.load(text, { schema: V4_SCHEMA });
} catch (err) {
if (err && /input is empty/.test(err.reason || "")) return undefined;
throw err;
}
}
module.exports = { load, dump: yaml.dump, V4_SCHEMA };

Some files were not shown because too many files have changed in this diff Show More