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.
This commit is contained in:
+2
-2
@@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -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 |
|
||||
|
||||
+67
-69
@@ -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
@@ -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
|
||||
|
||||
Generated
+427
-139
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -765,7 +765,7 @@ export function Divider({
|
||||
onRatio,
|
||||
label,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
|
||||
@@ -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,14 +446,23 @@ 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();
|
||||
@@ -522,6 +552,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;
|
||||
@@ -734,7 +775,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
gpu?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* 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";
|
||||
@@ -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 {
|
||||
@@ -397,18 +415,52 @@ 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1033,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,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",
|
||||
|
||||
@@ -1021,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ừ đó."
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
@@ -252,6 +256,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
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`),
|
||||
@@ -315,7 +323,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 +447,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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
+5
-2
@@ -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.
|
||||
@@ -3454,7 +3457,7 @@ A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transp
|
||||
|
||||
#### 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
@@ -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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
+52
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Generated
+427
-786
File diff suppressed because it is too large
Load Diff
+5
-6
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,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 {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
|
||||
+13
-3
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
+1
-1
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
@@ -599,6 +599,20 @@ const paths = {
|
||||
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
|
||||
},
|
||||
},
|
||||
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: [idParam],
|
||||
requestBody: changeBody({}),
|
||||
responses: {
|
||||
...changeResponses,
|
||||
200: { description: "Deleted — the account as it was", content: userBody },
|
||||
409: err("ESELF — yourself / ELASTADMIN — the last enabled admin"),
|
||||
502: err("EPROVISION — the Linux account or home could not be removed; nothing deleted"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/users/{id}/password": {
|
||||
post: {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* servers, hooks, settings, memory, marketplaces, keybindings, statusline,
|
||||
* hook scripts, single-file body) plus the low-risk mutation surface
|
||||
* (PUT/DELETE /file), the backups listing, and the auth-profile surface
|
||||
* (`/profiles*` — swap which Anthropic account the local CLI runs as).
|
||||
* (`/profiles*` — swap which Anthropic account the local CLI runs as), and
|
||||
* `/target` — whose config a server-mode login manages (a member's own, S3).
|
||||
*
|
||||
* Exports `{ tags, schemas, paths }` for deep-merge by
|
||||
* `server/openapi-extra.js`. All schema names are prefixed with `CcConfig`
|
||||
@@ -1343,6 +1344,34 @@ const cwdParam = {
|
||||
// ── Paths ───────────────────────────────────────────────────────────────
|
||||
|
||||
const paths = {
|
||||
"/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: ["CcConfig"],
|
||||
|
||||
@@ -419,12 +419,20 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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", in: "path", required: true, schema: { type: "string" } }],
|
||||
parameters: [
|
||||
{
|
||||
name: "host",
|
||||
in: "path",
|
||||
required: true,
|
||||
description: "The host or alias the login was saved under.",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
],
|
||||
responses: { 200: { description: "The identity." }, 404: { description: "ENOTFOUND" } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3105,6 +3105,22 @@ function createOpenApiSpec() {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(issuesUrl
|
||||
? {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { randomUUID: uuidv4 } = require("crypto");
|
||||
const { stmts, db } = require("../db");
|
||||
const { broadcast } = require("../websocket");
|
||||
const {
|
||||
@@ -206,7 +206,10 @@ router.get("/", (req, res) => {
|
||||
});
|
||||
|
||||
// POST /api/alerts/:id/ack - Acknowledge one alert
|
||||
router.post("/:id(\\d+)/ack", (req, res) => {
|
||||
// (Digits only, as the Express 4 `/:id(\d+)/ack` pattern was; Express 5 routes
|
||||
// take no inline regex, so anything else falls through to the 404 as before.)
|
||||
router.post("/:id/ack", (req, res, next) => {
|
||||
if (!/^\d+$/.test(req.params.id)) return next("route");
|
||||
const alert = stmts.getAlertEvent.get(req.params.id);
|
||||
if (!alert) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Alert not found" } });
|
||||
|
||||
+255
-52
@@ -21,6 +21,18 @@
|
||||
* account the local CLI runs as; those carry identity metadata only, never
|
||||
* token material.
|
||||
*
|
||||
* Whose config (server mode): the router's own gate (`cc-target.ccGate`) is
|
||||
* the single rule for who reaches what — an admin everything; a non-admin only
|
||||
* the allowlisted reads and member-capable writes (never `/profiles*` or
|
||||
* `/plugins/update-all`; an unlisted endpoint is an admin's). Every handler
|
||||
* then acts on ONE target (`cc-target.resolveCcTarget`): the host's Claude
|
||||
* home for an admin and in local mode, exactly as before; a member's own
|
||||
* `~/.claude` under S3, with every write run AS them (write-as, sudo `claude`)
|
||||
* and a project layer only in a workspace of their own (`?cwd=`); 403
|
||||
* ENOACCOUNT for a non-admin with no account — never the host's. A member's
|
||||
* change is broadcast to that member alone (`ownerId`), and their `/reload`
|
||||
* types into their own panes only.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -39,23 +51,97 @@ const { reloadOpenSessions } = require("../lib/cc-reload");
|
||||
const { startUpdateAll, readUpdateAll, isRunning } = require("../lib/cc-update-all");
|
||||
const { detectPluginInstall } = require("../lib/update-check");
|
||||
const scope = require("../lib/scope");
|
||||
const { resolveCcTarget, ccGate } = require("../lib/cc-target");
|
||||
|
||||
/** Mirrors cc-plugin-cli's own spec rule — ids from the CLI's JSON still reach
|
||||
* an argv in the detached batch worker, so they are filtered before they go. */
|
||||
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
||||
|
||||
const router = Router();
|
||||
// Server mode: a non-admin's ?cwd= only names one of their own workspaces.
|
||||
// Server mode: who may reach which endpoint at all (an allowlist for a
|
||||
// non-admin; see cc-target.js), then a non-admin's ?cwd= only names one of
|
||||
// their own workspaces.
|
||||
router.use(ccGate);
|
||||
router.use(scope.ownCwdQuery);
|
||||
|
||||
function emitChanged(payload) {
|
||||
/**
|
||||
* `cc_config_changed` for a change to `tgt`. A member's own config is news
|
||||
* for that member alone (`ownerId`: scope.exactAudience) — its file names are
|
||||
* nobody else's business; the host's goes to everyone as before.
|
||||
*/
|
||||
function emitChanged(payload, tgt, req) {
|
||||
try {
|
||||
broadcast("cc_config_changed", { source: "dashboard", ...payload });
|
||||
const owner = tgt && tgt.own && req && req.user ? { ownerId: req.user.id } : {};
|
||||
broadcast("cc_config_changed", { source: "dashboard", ...payload, ...owner });
|
||||
} catch {
|
||||
/* websocket may not be initialised in unit tests */
|
||||
}
|
||||
}
|
||||
|
||||
/** The request's target, or null once a 403 ENOACCOUNT has been answered. */
|
||||
function target(req, res) {
|
||||
try {
|
||||
return resolveCcTarget(req);
|
||||
} catch (err) {
|
||||
res.status(err.status || 403).json({
|
||||
error: { code: err.code || "ENOACCOUNT", message: err.message },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const askedCwd = (req) =>
|
||||
typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
|
||||
|
||||
/**
|
||||
* Reader options for `tgt`: the host's exactly as before (`?cwd=` or the
|
||||
* server's own folder); a member's own home, with a project layer only when
|
||||
* they named one of their workspaces (ownCwdQuery held it there) — never the
|
||||
* dashboard's own folder.
|
||||
*/
|
||||
function readOpts(req, tgt) {
|
||||
if (!tgt.own) return { cwd: cwdOf(req) };
|
||||
const asked = askedCwd(req);
|
||||
return {
|
||||
home: tgt.home,
|
||||
claudeJson: tgt.claudeJson,
|
||||
cwd: asked || undefined,
|
||||
noProject: !asked,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Writer options for `tgt`, or null once answered. The host's: the server's
|
||||
* own folder for the project layers, never `?cwd=` (as before). A member's:
|
||||
* their own home, and a project layer only in a workspace of their own whose
|
||||
* folder is their own account's (`?cwd=`) — anything else 403 EFOLDER.
|
||||
*/
|
||||
function writeOpts(req, res, tgt) {
|
||||
if (!tgt.own) return { cwd: process.cwd() };
|
||||
const asked = askedCwd(req);
|
||||
if (!asked) return { home: tgt.home, noProject: true };
|
||||
let owner = null;
|
||||
try {
|
||||
owner = path.isAbsolute(asked) ? require("../lib/run-as").accountForPath(asked) : null;
|
||||
} catch {
|
||||
owner = null;
|
||||
}
|
||||
if (
|
||||
!path.isAbsolute(asked) ||
|
||||
owner?.name !== tgt.acct.name ||
|
||||
!scope.insideOwnWorkspace(req, path.resolve(asked))
|
||||
) {
|
||||
res.status(403).json({
|
||||
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { home: tgt.home, cwd: path.resolve(asked) };
|
||||
}
|
||||
|
||||
/** The member a CLI call runs as (`acct`), or undefined for the host's own CLI. */
|
||||
const cliAcct = (tgt) => (tgt.own ? tgt.acct : undefined);
|
||||
|
||||
// Map mutate-error codes → HTTP status. Anything unmapped is a 500.
|
||||
const ERR_TO_STATUS = {
|
||||
EBADTYPE: 400,
|
||||
@@ -73,6 +159,8 @@ const ERR_TO_STATUS = {
|
||||
EREDACTED: 400,
|
||||
// The file on disk is unusable as-is; the caller must fix it by hand first.
|
||||
EBADJSON: 409,
|
||||
// S3: the folder's member has no usable account — nothing runs as the host instead.
|
||||
EMEMBERGONE: 409,
|
||||
};
|
||||
|
||||
function mutateError(res, err) {
|
||||
@@ -145,8 +233,24 @@ function listOpts(req, res) {
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /target — whose config this page shows: `own` (a member's, S3), `host`
|
||||
* (an admin's, local mode; an analyst's read-only view), or `none` (a
|
||||
* non-admin with no account: the page is not theirs to open).
|
||||
*/
|
||||
router.get("/target", (req, res) => {
|
||||
let tgt;
|
||||
try {
|
||||
tgt = resolveCcTarget(req);
|
||||
} catch {
|
||||
return res.json({ kind: "none", readOnly: true, claudeHome: null });
|
||||
}
|
||||
res.json({ kind: tgt.own ? "own" : "host", readOnly: tgt.readOnly, claudeHome: tgt.home });
|
||||
});
|
||||
|
||||
router.get("/overview", (req, res) => {
|
||||
res.json(cc.readOverview({ cwd: cwdOf(req) }));
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json(cc.readOverview(readOpts(req, tgt)));
|
||||
});
|
||||
|
||||
router.get("/skills", (req, res) => {
|
||||
@@ -155,7 +259,8 @@ router.get("/skills", (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/agents", (req, res) => {
|
||||
res.json({ items: cc.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json({ items: cc.readAgents({ scope: scopeOf(req), ...readOpts(req, tgt) }) });
|
||||
});
|
||||
|
||||
router.get("/commands", (req, res) => {
|
||||
@@ -164,7 +269,10 @@ router.get("/commands", (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/output-styles", (req, res) => {
|
||||
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
const tgt = target(req, res);
|
||||
if (tgt) {
|
||||
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), ...readOpts(req, tgt) }) });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/plugins", (req, res) => {
|
||||
@@ -174,27 +282,33 @@ router.get("/plugins", (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/mcp", (req, res) => {
|
||||
res.json(cc.readMcpServers({ cwd: cwdOf(req) }));
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json(cc.readMcpServers(readOpts(req, tgt)));
|
||||
});
|
||||
|
||||
router.get("/hooks", (req, res) => {
|
||||
res.json({ items: cc.readHooks({ cwd: cwdOf(req) }) });
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json({ items: cc.readHooks(readOpts(req, tgt)) });
|
||||
});
|
||||
|
||||
router.get("/settings", (req, res) => {
|
||||
res.json({ items: cc.readSettings({ cwd: cwdOf(req) }) });
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json({ items: cc.readSettings(readOpts(req, tgt)) });
|
||||
});
|
||||
|
||||
router.get("/memory", (req, res) => {
|
||||
res.json({ items: cc.readMemory({ cwd: cwdOf(req) }) });
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json({ items: cc.readMemory(readOpts(req, tgt)) });
|
||||
});
|
||||
|
||||
router.get("/marketplaces", (_req, res) => {
|
||||
res.json(cc.readMarketplaces());
|
||||
router.get("/marketplaces", (req, res) => {
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json(cc.readMarketplaces(readOpts(req, tgt)));
|
||||
});
|
||||
|
||||
router.get("/keybindings", (_req, res) => {
|
||||
res.json(cc.readKeybindings());
|
||||
router.get("/keybindings", (req, res) => {
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json(cc.readKeybindings(readOpts(req, tgt)));
|
||||
});
|
||||
|
||||
// PUT /api/cc-config/keybindings — structured overwrite of the user's
|
||||
@@ -207,31 +321,43 @@ router.put("/keybindings", sameOriginGuard, (req, res) => {
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "groups array is required" } });
|
||||
}
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
const result = ccMutate.writeKeybindings({ groups });
|
||||
emitChanged({ action: "write", type: "keybindings", name: null, project: null });
|
||||
const result = ccMutate.writeKeybindings({ groups, home: tgt.own ? tgt.home : undefined });
|
||||
emitChanged({ action: "write", type: "keybindings", name: null, project: null }, tgt, req);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/statusline", (_req, res) => {
|
||||
res.json(cc.readStatusline());
|
||||
router.get("/statusline", (req, res) => {
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json(cc.readStatusline(readOpts(req, tgt)));
|
||||
});
|
||||
|
||||
router.get("/hook-scripts", (_req, res) => {
|
||||
res.json(cc.readHookScripts());
|
||||
router.get("/hook-scripts", (req, res) => {
|
||||
const tgt = target(req, res);
|
||||
if (tgt) res.json(cc.readHookScripts(readOpts(req, tgt)));
|
||||
});
|
||||
|
||||
// GET /api/cc-config/file?path=/abs/path — return body of a single file.
|
||||
// Path must resolve under CLAUDE_HOME, project .claude/, or be project CLAUDE.md.
|
||||
// Path must resolve under the target's Claude home, project .claude/, or be
|
||||
// project CLAUDE.md. An analyst with an account of their own also keeps the
|
||||
// host's files they read before (read-only).
|
||||
router.get("/file", (req, res) => {
|
||||
const p = req.query.path;
|
||||
if (typeof p !== "string" || !p) {
|
||||
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
|
||||
}
|
||||
const result = cc.readFileSafe(p, { cwd: cwdOf(req) });
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
let result = cc.readFileSafe(p, readOpts(req, tgt));
|
||||
if (result.error && tgt.own && req.user?.role === "analyst") {
|
||||
const hostRead = cc.readFileSafe(p, { cwd: cwdOf(req) });
|
||||
if (!hostRead.error) result = hostRead;
|
||||
}
|
||||
if (result.error)
|
||||
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
|
||||
res.json(result);
|
||||
@@ -253,19 +379,26 @@ router.put("/file", sameOriginGuard, (req, res) => {
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
const tgt = target(req, res);
|
||||
const opts = tgt && writeOpts(req, res, tgt);
|
||||
if (!opts) return;
|
||||
try {
|
||||
// process.cwd(), never ?cwd= — the same rule PATCH /settings already
|
||||
// follows: a caller-chosen write target lets any request drop a file
|
||||
// anywhere on disk. Reads keep the override; writes do not.
|
||||
// The host's: process.cwd(), never ?cwd= — the same rule PATCH /settings
|
||||
// already follows: a caller-chosen write target lets any request drop a
|
||||
// file anywhere on disk. A member's: their own workspace only (writeOpts).
|
||||
const result = ccMutate.writeArtifact({
|
||||
scope,
|
||||
type,
|
||||
name,
|
||||
content,
|
||||
project,
|
||||
cwd: process.cwd(),
|
||||
...opts,
|
||||
});
|
||||
emitChanged({ action: "write", scope, type, name: name || null, project: project || null });
|
||||
emitChanged(
|
||||
{ action: "write", scope, type, name: name || null, project: project || null },
|
||||
tgt,
|
||||
req
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
@@ -279,10 +412,17 @@ router.delete("/file", sameOriginGuard, (req, res) => {
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
const tgt = target(req, res);
|
||||
const opts = tgt && writeOpts(req, res, tgt);
|
||||
if (!opts) return;
|
||||
try {
|
||||
// Pinned for the same reason as PUT above.
|
||||
const result = ccMutate.deleteArtifact({ scope, type, name, project, cwd: process.cwd() });
|
||||
emitChanged({ action: "delete", scope, type, name: name || null, project: project || null });
|
||||
const result = ccMutate.deleteArtifact({ scope, type, name, project, ...opts });
|
||||
emitChanged(
|
||||
{ action: "delete", scope, type, name: name || null, project: project || null },
|
||||
tgt,
|
||||
req
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
@@ -298,12 +438,33 @@ router.delete("/file", sameOriginGuard, (req, res) => {
|
||||
* pinned to the server's own cwd. It stays narrow — see project-memory.js —
|
||||
* and sits behind the same loopback-origin guard the process-spawning routes
|
||||
* use, so a random web page cannot plant instructions in a project directory.
|
||||
* A member names only a folder of their own workspaces, in their own account
|
||||
* (403 EFOLDER otherwise); under S3 the file is written as the folder's
|
||||
* account (project-memory.js), an admin's create in a member's folder too.
|
||||
*/
|
||||
router.post("/project-memory", sameOriginGuard, (req, res) => {
|
||||
const { dir, content } = req.body || {};
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
if (tgt.own) {
|
||||
let owner = null;
|
||||
try {
|
||||
owner =
|
||||
typeof dir === "string" && path.isAbsolute(dir)
|
||||
? require("../lib/run-as").accountForPath(dir)
|
||||
: null;
|
||||
} catch {
|
||||
owner = null;
|
||||
}
|
||||
if (owner?.name !== tgt.acct.name || !scope.insideOwnWorkspace(req, path.resolve(dir))) {
|
||||
return res.status(403).json({
|
||||
error: { code: "EFOLDER", message: "that folder is not one of your workspaces" },
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = projectMemory.createProjectMemory({ tool: "claude", dir, content });
|
||||
emitChanged({ action: "write", scope: "project", type: "memory", name: null });
|
||||
emitChanged({ action: "write", scope: "project", type: "memory", name: null }, tgt, req);
|
||||
res.json({ ...result, created: true });
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
@@ -320,6 +481,8 @@ router.post("/project-memory", sameOriginGuard, (req, res) => {
|
||||
const CLI_ERR_TO_STATUS = {
|
||||
EBADARG: 400,
|
||||
ECLINOTFOUND: 503,
|
||||
// S3: `claude` is not published to the shared tools, so no member can run it.
|
||||
ENOTSHARED: 503,
|
||||
ETIMEOUT: 504,
|
||||
ECLIFAILED: 502,
|
||||
};
|
||||
@@ -332,7 +495,7 @@ function cliError(res, err) {
|
||||
}
|
||||
|
||||
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
|
||||
function cliResult(res, result, changed, extra) {
|
||||
function cliResult(res, result, changed, extra, tgt, req) {
|
||||
if (!result.ok) {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
@@ -344,13 +507,15 @@ function cliResult(res, result, changed, extra) {
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
emitChanged(changed);
|
||||
emitChanged(changed, tgt, req);
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, ...extra });
|
||||
}
|
||||
|
||||
router.get("/plugins/available", async (_req, res) => {
|
||||
router.get("/plugins/available", async (req, res) => {
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
res.json(await ccCli.listAvailable());
|
||||
res.json(await ccCli.listAvailable({ acct: cliAcct(tgt) }));
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
@@ -358,9 +523,18 @@ router.get("/plugins/available", async (_req, res) => {
|
||||
|
||||
router.post("/plugins/install", sameOriginGuard, async (req, res) => {
|
||||
const { spec, scope } = req.body || {};
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
const result = await ccCli.pluginInstall({ spec, scope });
|
||||
return cliResult(res, result, { action: "install", type: "plugin", name: spec });
|
||||
const result = await ccCli.pluginInstall({ spec, scope, acct: cliAcct(tgt) });
|
||||
return cliResult(
|
||||
res,
|
||||
result,
|
||||
{ action: "install", type: "plugin", name: spec },
|
||||
undefined,
|
||||
tgt,
|
||||
req
|
||||
);
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
@@ -384,8 +558,10 @@ router.post("/plugins/action", sameOriginGuard, async (req, res) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
const result = await run({ spec: key });
|
||||
const result = await run({ spec: key, acct: cliAcct(tgt) });
|
||||
// "Update one, reload that one" — the counterpart to the batch, which
|
||||
// reloads once at the end. Only on success, and only when asked: an
|
||||
// enable/disable already needs a reload but the caller decides, and a
|
||||
@@ -393,12 +569,12 @@ router.post("/plugins/action", sameOriginGuard, async (req, res) => {
|
||||
let reloaded;
|
||||
if (reload && result.ok) {
|
||||
try {
|
||||
reloaded = await reloadOpenSessions();
|
||||
reloaded = await reloadOpenSessions({ account: cliAcct(tgt) });
|
||||
} catch {
|
||||
/* a reload that cannot be delivered must not fail the update itself */
|
||||
}
|
||||
}
|
||||
return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded });
|
||||
return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded }, tgt, req);
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
@@ -408,7 +584,9 @@ router.post("/plugins/action", sameOriginGuard, async (req, res) => {
|
||||
* Type `/reload-plugins` + `/reload-skills` into the live Claude Code lane
|
||||
* panes. Not a config mutation — it changes nothing on disk, it just tells
|
||||
* sessions to re-read what is already there — so it neither queues on the CLI
|
||||
* mutation chain nor emits `cc_config_changed`.
|
||||
* mutation chain nor emits `cc_config_changed`. A member's reload (S3) types
|
||||
* into the panes that run as them, and only those; an admin's into the
|
||||
* host's, as before.
|
||||
*/
|
||||
router.post("/reload", sameOriginGuard, async (req, res) => {
|
||||
const { kinds } = req.body || {};
|
||||
@@ -417,8 +595,10 @@ router.post("/reload", sameOriginGuard, async (req, res) => {
|
||||
error: { code: "EBADARG", message: "kinds must be an array of 'plugins' | 'skills'" },
|
||||
});
|
||||
}
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
|
||||
res.json({ ok: true, ...(await reloadOpenSessions({ kinds, account: cliAcct(tgt) })) });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
error: { code: "ERELOAD", message: err.message || String(err) },
|
||||
@@ -484,9 +664,18 @@ router.post("/plugins/update-all", sameOriginGuard, async (req, res) => {
|
||||
|
||||
router.post("/marketplaces/add", sameOriginGuard, async (req, res) => {
|
||||
const { source, scope } = req.body || {};
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
const result = await ccCli.marketplaceAdd({ source, scope });
|
||||
return cliResult(res, result, { action: "add", type: "marketplace", name: source });
|
||||
const result = await ccCli.marketplaceAdd({ source, scope, acct: cliAcct(tgt) });
|
||||
return cliResult(
|
||||
res,
|
||||
result,
|
||||
{ action: "add", type: "marketplace", name: source },
|
||||
undefined,
|
||||
tgt,
|
||||
req
|
||||
);
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
@@ -508,9 +697,11 @@ router.post("/marketplaces/action", sameOriginGuard, async (req, res) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
const result = await run({ name });
|
||||
return cliResult(res, result, { action, type: "marketplace", name });
|
||||
const result = await run({ name, acct: cliAcct(tgt) });
|
||||
return cliResult(res, result, { action, type: "marketplace", name }, undefined, tgt, req);
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
@@ -526,6 +717,8 @@ router.post("/marketplaces/action", sameOriginGuard, async (req, res) => {
|
||||
|
||||
router.post("/mcp/add", sameOriginGuard, async (req, res) => {
|
||||
const { name, scope, transport, command, args, url, headers, env } = req.body || {};
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
const result = await ccMcpCli.mcpAdd({
|
||||
name,
|
||||
@@ -536,8 +729,9 @@ router.post("/mcp/add", sameOriginGuard, async (req, res) => {
|
||||
url,
|
||||
headers,
|
||||
env,
|
||||
acct: cliAcct(tgt),
|
||||
});
|
||||
return cliResult(res, result, { action: "add", type: "mcp", name });
|
||||
return cliResult(res, result, { action: "add", type: "mcp", name }, undefined, tgt, req);
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
@@ -545,9 +739,11 @@ router.post("/mcp/add", sameOriginGuard, async (req, res) => {
|
||||
|
||||
router.post("/mcp/remove", sameOriginGuard, async (req, res) => {
|
||||
const { name, scope } = req.body || {};
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
try {
|
||||
const result = await ccMcpCli.mcpRemove({ name, scope });
|
||||
return cliResult(res, result, { action: "remove", type: "mcp", name });
|
||||
const result = await ccMcpCli.mcpRemove({ name, scope, acct: cliAcct(tgt) });
|
||||
return cliResult(res, result, { action: "remove", type: "mcp", name }, undefined, tgt, req);
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
@@ -560,12 +756,17 @@ router.post("/mcp/remove", sameOriginGuard, async (req, res) => {
|
||||
//
|
||||
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
|
||||
// target chosen by the caller would let any request create a settings.json
|
||||
// anywhere on disk. Reads keep the override; writes do not.
|
||||
// anywhere on disk. Reads keep the override; writes do not. A member's own
|
||||
// config is the exception that proves it: their home, and a project layer
|
||||
// only in one of their own workspaces (writeOpts).
|
||||
router.patch("/settings", sameOriginGuard, (req, res) => {
|
||||
const { scope, set, unset } = req.body || {};
|
||||
const tgt = target(req, res);
|
||||
const opts = tgt && writeOpts(req, res, tgt);
|
||||
if (!opts) return;
|
||||
try {
|
||||
const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset });
|
||||
emitChanged({ action: "patch", type: "settings", name: scope, project: null });
|
||||
const result = ccMutate.patchSettings({ scope, set, unset, ...opts });
|
||||
emitChanged({ action: "patch", type: "settings", name: scope, project: null }, tgt, req);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
@@ -685,10 +886,12 @@ router.delete("/profiles", sameOriginGuard, (req, res) => {
|
||||
});
|
||||
|
||||
router.get("/backups", (req, res) => {
|
||||
const tgt = target(req, res);
|
||||
if (!tgt) return;
|
||||
const scope =
|
||||
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
||||
const type = typeof req.query.type === "string" ? req.query.type : undefined;
|
||||
res.json({ items: ccMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
|
||||
res.json({ items: ccMutate.listBackups({ scope, type, ...readOpts(req, tgt) }) });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { randomUUID: uuidv4 } = require("crypto");
|
||||
const dbModule = require("../db");
|
||||
const { stmts, db } = dbModule;
|
||||
const { broadcast: sendNow } = require("../websocket");
|
||||
|
||||
@@ -1205,7 +1205,7 @@ router.get("/:id/proof/:slug/:group/:file", (req, res) => {
|
||||
const ext = path.extname(p).toLowerCase();
|
||||
const contentType =
|
||||
ext === ".html" ? "text/html; charset=utf-8" : ext === ".png" ? "image/png" : "image/jpeg";
|
||||
res.type(contentType).sendFile(p);
|
||||
require("../lib/send-file").sendFile(res.type(contentType), p);
|
||||
});
|
||||
|
||||
router.delete("/:id/proof/:slug", sameOriginGuard, s3Guard, (req, res) => {
|
||||
@@ -1262,7 +1262,7 @@ router.post("/:id/mcp/sync", sameOriginGuard, s3Guard, async (req, res) => {
|
||||
/**
|
||||
* Detect a Node.js project at this lane's OWN directory and scaffold
|
||||
* .agenthub/profile/ if one is found — the HTTP equivalent of
|
||||
* `agenthub lanes profile init`, always targeting lane.cwd (never an arbitrary
|
||||
* `agenthub workspace profile init`, always targeting lane.cwd (never an arbitrary
|
||||
* path; the CLI's <repo> argument has no meaning here, this lane's own
|
||||
* directory is the only sensible target). "No Node.js project detected" is
|
||||
* a normal 200 outcome, not an error — most lanes won't be auto-detectable
|
||||
|
||||
+1
-1
@@ -283,7 +283,7 @@ router.get("/avatar", (req, res) => {
|
||||
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
|
||||
// The client versions the URL with `avatarAt`, so a new picture is a new URL.
|
||||
res.set("Cache-Control", "private, max-age=86400");
|
||||
res.sendFile(file.path);
|
||||
require("../lib/send-file").sendFile(res, file.path);
|
||||
});
|
||||
|
||||
router.put(
|
||||
|
||||
@@ -48,7 +48,7 @@ router.get("/:id/sprite", (req, res) => {
|
||||
res.set("Content-Type", file.type);
|
||||
res.set("X-Content-Type-Options", "nosniff");
|
||||
res.set("Cache-Control", "private, max-age=3600");
|
||||
res.sendFile(file.path);
|
||||
require("../lib/send-file").sendFile(res, file.path);
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ router.get("/:id/runs/:runId/:format", (req, res) => {
|
||||
if (!file) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Report file not found" } });
|
||||
}
|
||||
res.download(file, `report-${id}-${runId}.${format}`);
|
||||
require("../lib/send-file").download(res, file, `report-${id}-${runId}.${format}`);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -232,7 +232,7 @@ router.get("/:id/avatar", (req, res) => {
|
||||
res.set("Content-Security-Policy", "sandbox; default-src 'none'");
|
||||
// Versioned by the client with the owner's `avatarAt`.
|
||||
res.set("Cache-Control", "private, max-age=86400");
|
||||
res.sendFile(file.path);
|
||||
require("../lib/send-file").sendFile(res, file.path);
|
||||
});
|
||||
|
||||
/** A list of workspace ids from the body, or [] when absent. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user