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:
2026-10-26 14:22:41 +07:00
parent cc2ece94c3
commit 8546748759
102 changed files with 4165 additions and 2004 deletions
+3 -2
View File
@@ -441,7 +441,7 @@ All endpoints return JSON unless noted. Error responses use:
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It now documents every backend route (75 path entries). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It documents every `/api` route (332 paths, 399 operations); `server/__tests__/openapi-coverage.test.js` fails when a registered route has no operation in the spec, or an operation has no route (the `/api/workspaces` and `/api/ai-routers` alias mounts are documented once, under `/api/lanes` and `/api/ai-proxies`). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
### Core Endpoints
@@ -747,10 +747,11 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
### Claude Config Explorer (`/api/cc-config`)
Reads — and carefully gated mutations for low-risk text-file artifacts — for every Claude Code configuration surface. Mutations always create timestamped backups under `<root>/cc-config-backups/<type>/` before writing.
Reads — and carefully gated mutations for low-risk text-file artifacts — for every Claude Code configuration surface. Mutations always create timestamped backups under `<root>/cc-config-backups/<type>/` before writing. In server mode the router gates itself (`lib/cc-target.js`): an admin works on the host's Claude home, a non-admin with their own Linux account (S3) on THEIR `~/.claude` (writes as them through `write-as` / sudo `claude`), anyone else gets `403 ENOACCOUNT`; `/profiles*` and `/plugins/update-all` stay an admin's. See `docs/SERVER-MODE.md` → "Your own Claude Code config".
| Method | Path | Description |
| -------- | ------------------------------------- | ----------- |
| `GET` | `/api/cc-config/target` | Whose config this login manages: `own` / `host` / `none` |
| `GET` | `/api/cc-config/overview` | Roots + counts for every surface (used by the Overview tab) |
| `GET` | `/api/cc-config/skills` | Skills with parsed frontmatter, `?scope=user\|project\|all` |
| `GET` | `/api/cc-config/agents` | Subagents under `<scope>/.claude/agents/*.md` |
+11
View File
@@ -98,6 +98,17 @@ describe("reloadableLanes", () => {
stubTmux([]);
assert.deepEqual(reloadableLanes(), []);
});
it("keeps the host's panes and one member's apart (S3)", (t) => {
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"]);
const owners = { "agenthub-lane-2": { name: "mia" }, "agenthub-lane-3": { name: "bob" } };
t.mock.method(require("../lib/run-as"), "accountForSession", (id) => owners[id] || null);
const ids = (list) => list.map((l) => l.id);
// The host's reload never types into a member's pane…
assert.deepEqual(ids(reloadableLanes()), ["agenthub-lane-1"]);
// …and a member's reaches their own panes only.
assert.deepEqual(ids(reloadableLanes({ name: "mia" })), ["agenthub-lane-2"]);
});
});
describe("reloadOpenSessions", () => {
@@ -77,7 +77,7 @@
"label": "CI gates + preflight",
"icon": "🧪",
"gate": true,
"command": "agenthub lanes hook ci-gate",
"command": "agenthub workspace hook ci-gate",
"aliases": ["pre-push-gate", "tests", "migration-collision", "sync-conflict"],
"detect": [
{
@@ -95,7 +95,7 @@
"label": "E2e on feature branch",
"icon": "🧪",
"gate": false,
"command": "agenthub lanes up --qc",
"command": "agenthub workspace up --qc",
"aliases": ["e2e", "e2e-scoped", "booting", "live"],
"detect": [
{
@@ -143,7 +143,7 @@
"label": "Browser QC",
"icon": "🔍",
"gate": true,
"command": "agenthub lanes proof-link",
"command": "agenthub workspace proof-link",
"aliases": [],
"detect": [
{
@@ -152,7 +152,7 @@
},
{
"tool": "Bash",
"match": "\\bagenthub\\b[^;&|]*\\blanes proof-link\\b"
"match": "\\bagenthub\\b[^;&|]*\\b(?:lanes|workspace) proof-link\\b"
}
]
},
@@ -219,7 +219,7 @@
"label": "Watching PR",
"icon": "👁",
"gate": false,
"command": "agenthub lanes ci status",
"command": "agenthub workspace ci status",
"aliases": ["pr-comment-fix"],
"detect": [
{
+68 -62
View File
@@ -1,5 +1,5 @@
/**
* @file Tests for the `agenthub stage` / `agenthub lanes` CLI subcommands: lane
* @file Tests for the `agenthub stage` / `agenthub workspace` CLI subcommands: lane
* resolution from the current directory, the stage round-trip through the HTTP
* API, and the non-zero exit when the cwd belongs to no lane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -194,20 +194,26 @@ after(() => {
});
describe("agenthub workspaces", () => {
it("is the lanes command under the UI's word", async () => {
const [lanes, workspaces, workspace] = await Promise.all([
cli(["lanes"], LANE_DIR),
it("is the workspace command under its plural", async () => {
const [workspaces, workspace] = await Promise.all([
cli(["workspaces"], LANE_DIR),
cli(["workspace"], LANE_DIR),
]);
assert.equal(workspaces.status, 0, workspaces.stderr);
assert.match(workspaces.stdout, /CLI lane/);
assert.equal(workspaces.stdout, lanes.stdout);
assert.equal(workspace.stdout, lanes.stdout);
// The old name still works but says so — on stderr, never in stdout.
assert.match(lanes.stderr, /old name.*agenthub workspace/);
assert.doesNotMatch(workspaces.stderr, /old name/);
assert.doesNotMatch(lanes.stdout, /old name/);
assert.equal(workspace.stdout, workspaces.stdout);
});
it("refuses the removed old names, naming the new spelling", async () => {
const [lanes, flag] = await Promise.all([
cli(["lanes"], LANE_DIR),
cli(["stage", "review", "--lane", "1"], LANE_DIR),
]);
assert.equal(lanes.status, 1);
assert.match(lanes.stderr, /renamed: use agenthub workspace/);
assert.equal(lanes.stdout, "");
assert.equal(flag.status, 1);
assert.match(flag.stderr, /renamed: use --workspace <id>/);
});
});
@@ -216,7 +222,7 @@ describe("agenthub stage", () => {
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /review/);
const list = await cli(["lanes"], LANE_DIR);
const list = await cli(["workspace"], LANE_DIR);
assert.match(list.stdout, /review/);
});
@@ -298,7 +304,7 @@ describe("agenthub stage in a directory shared by two lanes", () => {
assert.notEqual(await stageOf(codexLane.id), "implement");
});
it("--workspace names the lane like --lane", async () => {
it("--workspace names the lane", async () => {
const r = await cli(
["stage", "review", "--workspace", String(codexLane.id)],
DUAL_DIR,
@@ -328,18 +334,18 @@ describe("agenthub stage in a directory shared by two lanes", () => {
assert.match(`${r.stdout}${r.stderr}`, /no codex workspace/i);
});
it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
// Even with two lanes at the same cwd, --lane <id> bypasses all resolution
const r = await cli(["stage", "review", "--lane", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
it("--workspace flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
// Even with two lanes at the same cwd, --workspace <id> bypasses all resolution
const r = await cli(["stage", "review", "--workspace", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.equal(await stageOf(codexLane.id), "review");
assert.notEqual(await stageOf(claudeLane.id), "review");
});
it("positional numeric ID acts like --lane for commands that support it (e.g. agenthub lanes pipeline)", async () => {
// Commands like `agenthub lanes pipeline` treat a leading numeric arg as the lane ID.
it("positional numeric ID acts like --workspace for commands that support it (e.g. agenthub workspace pipeline)", async () => {
// Commands like `agenthub workspace pipeline` treat a leading numeric arg as the lane ID.
// Other commands like `agenthub stage` have their own positional argument and don't support it.
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
const r = await cli(["workspace", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`workspace #${codexLane.id}`));
});
@@ -386,15 +392,15 @@ describe("agenthub stage in a directory shared by two lanes", () => {
});
});
describe("agenthub lanes add", () => {
describe("agenthub workspace add", () => {
it("creates a lane and it appears in lanes list", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-add-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
const r = await cli(["workspace", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created workspace/i);
const list = await cli(["lanes"], addDir);
const list = await cli(["workspace"], addDir);
assert.equal(list.status, 0, list.stderr);
assert.match(list.stdout, /CLI added/);
} finally {
@@ -405,7 +411,7 @@ describe("agenthub lanes add", () => {
it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-no-pipeline-"));
try {
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
const r = await cli(["workspace", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Created workspace/i);
@@ -423,7 +429,7 @@ describe("agenthub lanes add", () => {
try {
const r = await cli(
[
"lanes",
"workspace",
"add",
"--cwd",
addDir,
@@ -449,17 +455,17 @@ describe("agenthub lanes add", () => {
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
// `--pipeline` was creation-only, so every lane added from the dashboard
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
const show = await cli(["lanes", "pipeline"], LANE_DIR);
const show = await cli(["workspace", "pipeline"], LANE_DIR);
assert.equal(show.status, 0, show.stderr);
assert.match(show.stdout, /default/);
assert.match(show.stdout, /available:.*ship-feature/);
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const set = await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
assert.equal(set.status, 0, set.stderr);
assert.match(set.stdout, /ship-feature/);
assert.match(set.stdout, /16 nodes/);
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
const back = await cli(["workspace", "pipeline", "default"], LANE_DIR);
assert.equal(back.status, 0, back.stderr);
assert.match(back.stdout, /8 nodes/);
});
@@ -467,17 +473,17 @@ describe("agenthub lanes add", () => {
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
// getPipeline() returns the default template for an unknown id, so without
// a write-side check a typo would store, render `default`, and look fine.
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
const r = await cli(["workspace", "pipeline", "no-such-pipeline"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
const after = await cli(["lanes", "pipeline"], LANE_DIR);
const after = await cli(["workspace", "pipeline"], LANE_DIR);
assert.match(after.stdout, /default/);
});
it("provisions a managed worktree lane and reports it ready", async () => {
const r = await cli(
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
["workspace", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
SOURCE_REPO
);
assert.equal(r.status, 0, r.stderr);
@@ -504,7 +510,7 @@ describe("agenthub lanes add", () => {
);
await git(["branch", "feat"], emptyRepo);
const r = await cli(
["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
["workspace", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
emptyRepo
);
assert.notEqual(r.status, 0);
@@ -517,7 +523,7 @@ describe("agenthub lanes add", () => {
});
});
describe("agenthub lanes destructive lifecycle", () => {
describe("agenthub workspace destructive lifecycle", () => {
// Every test provisions its OWN lane. These tests used to share one mutable
// `managedLane` across describe blocks, which meant they only ever exercised
// whatever state the previous test happened to leave behind — the same
@@ -526,7 +532,7 @@ describe("agenthub lanes destructive lifecycle", () => {
async function ownLane(title) {
const slug = `cli-destructive-${(laneSeq += 1)}`;
const added = await cli(
["lanes", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
["workspace", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
SOURCE_REPO
);
assert.equal(added.status, 0, added.stderr);
@@ -542,7 +548,7 @@ describe("agenthub lanes destructive lifecycle", () => {
const dirtyFile = path.join(lane.cwd, "dirty.txt");
fs.writeFileSync(dirtyFile, "must survive\n");
const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
const r = await cli(["workspace", "reset", String(lane.id)], SOURCE_REPO);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /Preflight for reset workspace #\d+:/);
assert.match(r.stdout, /dirty\s+0/);
@@ -555,7 +561,7 @@ describe("agenthub lanes destructive lifecycle", () => {
const lane = await ownLane("CLI reset applied");
fs.writeFileSync(path.join(lane.cwd, "dirty.txt"), "to be cleaned\n");
const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
const r = await cli(["workspace", "reset", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Reset workspace #${lane.id}`));
assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
@@ -577,8 +583,8 @@ describe("agenthub lanes destructive lifecycle", () => {
const lane = await ownLane("CLI reset race");
fs.writeFileSync(path.join(lane.cwd, "race.txt"), "one more untracked file\n");
const [a, b] = await Promise.all([
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
cli(["workspace", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
cli(["workspace", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
]);
const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
assert.equal(winner.status, 0, winner.stderr);
@@ -591,7 +597,7 @@ describe("agenthub lanes destructive lifecycle", () => {
it("removes a managed lane's record with --yes, leaving its directory on disk", async () => {
const lane = await ownLane("CLI remove managed");
assert.equal(fs.existsSync(lane.cwd), true);
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
const r = await cli(["workspace", "remove", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`Removed workspace #${lane.id}`));
assert.match(r.stdout, /left for you to delete yourself/);
@@ -610,14 +616,14 @@ describe("agenthub lanes destructive lifecycle", () => {
});
const id = created.body.lane.id;
const reset = await cli(["lanes", "reset", String(id), "--yes"], adoptedDir);
const reset = await cli(["workspace", "reset", String(id), "--yes"], adoptedDir);
assert.notEqual(reset.status, 0);
assert.match(`${reset.stdout}${reset.stderr}`, /points at a directory you own/i);
// The server permits `remove` for an adopted lane — it drops the dashboard
// record only — so the CLI must not refuse it, or the capability is
// unreachable from the terminal.
const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
const removed = await cli(["workspace", "remove", String(id), "--yes"], adoptedDir);
assert.equal(removed.status, 0, removed.stderr);
assert.match(removed.stdout, new RegExp(`Removed workspace #${id}`));
assert.equal((await get(`/api/lanes/${id}`)).status, 404);
@@ -634,7 +640,7 @@ describe("agenthub lanes destructive lifecycle", () => {
assert.equal(preflight.status, 200);
const { sessions, events, tokenRows } = preflight.body;
const r = await cli(["lanes", "purge", String(lane.id), "--yes"], SOURCE_REPO);
const r = await cli(["workspace", "purge", String(lane.id), "--yes"], SOURCE_REPO);
assert.equal(r.status, 0, r.stderr);
assert.match(
r.stdout,
@@ -645,7 +651,7 @@ describe("agenthub lanes destructive lifecycle", () => {
});
});
describe("agenthub lanes — inferred stage detection", () => {
describe("agenthub workspace — inferred stage detection", () => {
it("prints the inferred stage when detection leads the declared stage", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-lead-"));
try {
@@ -666,10 +672,10 @@ describe("agenthub lanes — inferred stage detection", () => {
},
});
const r = await cli(["lanes"], dir);
const r = await cli(["workspace"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in agenthub lanes output");
assert.ok(line, "lane row not found in agenthub workspaces output");
assert.match(line, /⇢ detected:tests/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
@@ -685,7 +691,7 @@ describe("agenthub lanes — inferred stage detection", () => {
// Declare 'ship' (past 'tests' in the default pipeline) first, so the
// detection below is behind the declaration and recordDetection's
// declared-wins guard refuses to write it.
const staged = await cli(["stage", "ship", "--lane", String(id)], dir);
const staged = await cli(["stage", "ship", "--workspace", String(id)], dir);
assert.equal(staged.status, 0, staged.stderr);
await post("/api/hooks/event", {
@@ -698,10 +704,10 @@ describe("agenthub lanes — inferred stage detection", () => {
},
});
const r = await cli(["lanes"], dir);
const r = await cli(["workspace"], dir);
assert.equal(r.status, 0, r.stderr);
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
assert.ok(line, "lane row not found in agenthub lanes output");
assert.ok(line, "lane row not found in agenthub workspaces output");
assert.doesNotMatch(line, /detected:/);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
@@ -709,9 +715,9 @@ describe("agenthub lanes — inferred stage detection", () => {
});
});
describe("agenthub lanes node", () => {
describe("agenthub workspace node", () => {
it("with no node id, lists every node and its command, and exits non-zero with usage", async () => {
const r = await cli(["lanes", "node"], LANE_DIR);
const r = await cli(["workspace", "node"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /\bintake\b/);
assert.match(r.stdout, /\bdone\b/);
@@ -719,17 +725,17 @@ describe("agenthub lanes node", () => {
});
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
await cli(["lanes", "pipeline", "default"], LANE_DIR);
const r = await cli(["lanes", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
await cli(["workspace", "pipeline", "default"], LANE_DIR);
const r = await cli(["workspace", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /"claude" "\/superpowers:writing-plans the export button"/);
});
it("--print shows a shell node split into argv, never a shell string", async () => {
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const r = await cli(["lanes", "node", "gates", "--print"], LANE_DIR);
await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
const r = await cli(["workspace", "node", "gates", "--print"], LANE_DIR);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /"agenthub" "lanes" "hook" "ci-gate"/);
assert.match(r.stdout, /"agenthub" "workspace" "hook" "ci-gate"/);
});
it("every node of both shipped templates declares a runnable command", async () => {
@@ -744,26 +750,26 @@ describe("agenthub lanes node", () => {
it("matches a node by alias, so one slash command works on either template", async () => {
// `default` calls the step `implement`, `ship-feature` calls it
// `implementing` — /agenthub:implement must not depend on which one is active.
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
const ship = await cli(["lanes", "node", "implement", "--print"], LANE_DIR);
await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
const ship = await cli(["workspace", "node", "implement", "--print"], LANE_DIR);
assert.equal(ship.status, 0, ship.stderr);
assert.match(ship.stdout, /test-driven-development/);
await cli(["lanes", "pipeline", "default"], LANE_DIR);
const def = await cli(["lanes", "node", "implementing", "--print"], LANE_DIR);
await cli(["workspace", "pipeline", "default"], LANE_DIR);
const def = await cli(["workspace", "node", "implementing", "--print"], LANE_DIR);
assert.equal(def.status, 0, def.stderr);
assert.match(def.stdout, /test-driven-development/);
});
it("names the pipeline's nodes when asked for one that is not in it", async () => {
const r = await cli(["lanes", "node", "not-a-node", "--print"], LANE_DIR);
const r = await cli(["workspace", "node", "not-a-node", "--print"], LANE_DIR);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /no node "not-a-node"/);
assert.match(r.stdout, /\bintake\b/);
});
});
describe("agenthub lanes ci status", () => {
describe("agenthub workspace ci status", () => {
let restorePath;
let bin;
@@ -807,7 +813,7 @@ exit 1
});
it("exits 0 and prints success state", async () => {
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR);
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR);
assert.equal(r.status, 0);
assert.match(r.stdout, /"state": "success"/);
});
@@ -816,7 +822,7 @@ exit 1
const orig = process.env.CI_STATUS_FIXTURE;
try {
process.env.CI_STATUS_FIXTURE = "failure";
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR, {
CI_STATUS_FIXTURE: "failure",
});
assert.equal(r.status, 1);
@@ -830,7 +836,7 @@ exit 1
const orig = process.env.CI_STATUS_FIXTURE;
try {
process.env.CI_STATUS_FIXTURE = "pending";
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR, {
CI_STATUS_FIXTURE: "pending",
});
assert.equal(r.status, 3);
+1 -1
View File
@@ -151,7 +151,7 @@ describe("per-node driver command", () => {
assert.equal(byId.intake, "/superpowers:brainstorming");
assert.equal(byId.implementing, "/superpowers:test-driven-development");
assert.equal(byId.gates, "agenthub lanes hook ci-gate");
assert.equal(byId.gates, "agenthub workspace hook ci-gate");
// A terminal or gate node has no work of its own to run: reaching it IS
// the declaration, so its command is the declaration - which keeps
// `agenthub lanes node <id>` runnable for every node in the template.
+1 -1
View File
@@ -74,7 +74,7 @@ describe("mcp/server.js", () => {
}
if (p === "/api/pricing/cost") return send(200, { total_cost: 1.5, daily_costs: [] });
if (p === "/api/analytics") return send(500, { error: { message: "boom" } });
if (p === "/api/lanes") return send(200, "<!doctype html><html></html>", "text/html");
if (p === "/api/workspaces") return send(200, "<!doctype html><html></html>", "text/html");
return send(404, "Cannot GET " + p, "text/html");
});
await new Promise((r) => api.listen(0, "127.0.0.1", r));
+125
View File
@@ -0,0 +1,125 @@
/**
* @file Fails when an Express route under /api has no operation in the OpenAPI
* document. Builds the app with `createApp()`, walks every router layer with
* its mount prefix (recorded by wrapping `express.application.use` before the
* server loads), and checks each method + path against `createOpenApiSpec()`.
* Path parameters are compared by position only (`:token` and `{ref}` match),
* and the two alias mounts that the spec documents by their tag description
* (`/api/workspaces` = `/api/lanes`, `/api/ai-routers` = `/api/ai-proxies`)
* are allowlisted. It also fails on a spec operation no route answers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const os = require("os");
const fs = require("fs");
const TEST_DB = path.join(os.tmpdir(), `dashboard-openapi-cov-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
// Record each app.use mount path on the layer it creates — Express keeps only
// a compiled matcher, which cannot be turned back into the prefix.
const express = require("express");
const originalUse = express.application.use;
express.application.use = function (mount, ...rest) {
const out = originalUse.call(this, mount, ...rest);
if (typeof mount === "string" || Array.isArray(mount)) {
const stack = (this.router || this._router).stack;
stack[stack.length - 1].__mount = mount;
}
return out;
};
const { createApp } = require("../index");
const { createOpenApiSpec } = require("../openapi");
/** Mounts that repeat another, documented mount by design (see the tags). */
const ALIAS_PREFIXES = ["/api/workspaces", "/api/ai-routers"];
/** Spec paths served by middleware rather than a route (Swagger UI). */
const MIDDLEWARE_PATHS = ["get /api/docs"];
const METHODS = new Set(["get", "post", "put", "patch", "delete"]);
/** `:id`, `:id(\\d+)`, `{id}` all become `{}`; a trailing slash is dropped. */
function normalize(p) {
return (
p
.replace(/:[A-Za-z_]\w*(\([^)]*\))?\??/g, "{}")
.replace(/\{[^}]+\}/g, "{}")
.replace(/\/$/, "") || "/"
);
}
function collectRoutes(app) {
const out = new Set();
const walk = (stack, prefixes) => {
for (const layer of stack) {
if (layer.route) {
const routePaths = [].concat(layer.route.path);
for (const prefix of prefixes) {
for (const rp of routePaths) {
for (const m of Object.keys(layer.route.methods)) {
if (METHODS.has(m)) out.add(`${m} ${normalize(prefix + rp)}`);
}
}
}
} else if (layer.handle && Array.isArray(layer.handle.stack)) {
const mounts = layer.__mount === undefined ? [""] : [].concat(layer.__mount);
walk(
layer.handle.stack,
prefixes.flatMap((a) => mounts.map((b) => a + b))
);
}
}
};
walk((app.router || app._router).stack, [""]);
return [...out].filter((k) => k.split(" ")[1].startsWith("/api"));
}
describe("OpenAPI coverage", () => {
let routes;
let documented;
before(() => {
routes = collectRoutes(createApp());
documented = new Set();
for (const [p, ops] of Object.entries(createOpenApiSpec().paths)) {
for (const m of Object.keys(ops)) if (METHODS.has(m)) documented.add(`${m} ${normalize(p)}`);
}
});
it("finds the routes it is meant to check", () => {
assert.ok(routes.length > 300, `only ${routes.length} routes found — the walk is broken`);
assert.ok(routes.includes("get /api/lanes/{}"));
});
it("documents every registered /api route", () => {
const missing = routes.filter(
(k) =>
!documented.has(k) &&
!ALIAS_PREFIXES.some((a) => {
const p = k.split(" ")[1];
return p === a || p.startsWith(a + "/");
})
);
assert.deepEqual(missing, [], `routes missing from the OpenAPI spec:\n${missing.join("\n")}`);
});
it("documents no operation that no route answers", () => {
const have = new Set(routes);
const stale = [...documented].filter((k) => !have.has(k) && !MIDDLEWARE_PATHS.includes(k));
assert.deepEqual(stale, [], `spec operations with no route:\n${stale.join("\n")}`);
});
});
process.on("exit", () => {
for (const f of [TEST_DB, `${TEST_DB}-wal`, `${TEST_DB}-shm`]) {
try {
fs.unlinkSync(f);
} catch {
/* already gone */
}
}
});
+8
View File
@@ -20,7 +20,14 @@ process.env.DASHBOARD_DB_PATH = TEST_DB;
const { db } = require("../db");
const { runRead } = require("../lib/read-pool");
// The pool unrefs its worker (a server must be able to exit while one idles),
// so nothing else would keep this file's event loop alive while a job runs:
// node:test then cancels the awaiting test ("Promise resolution is still
// pending but the event loop has already resolved"). A server never sees this.
let keepAlive;
before(() => {
keepAlive = setInterval(() => {}, 1000);
db.prepare("INSERT INTO sessions (id, status) VALUES ('rp-1', 'completed')").run();
const ins = db.prepare(
"INSERT INTO events (session_id, event_type, tool_name) VALUES ('rp-1', 'PreToolUse', ?)"
@@ -34,6 +41,7 @@ afterEach(() => {
});
after(() => {
clearInterval(keepAlive);
try {
db.close();
} catch {}
+57
View File
@@ -0,0 +1,57 @@
/**
* @file Tests for server/lib/send-file.js: under Express 5 a server-chosen
* absolute path inside a dotted directory (`~/.claude/…`) is still served, a
* dotfile itself is still refused (Express 4's rule), and a download keeps its
* attachment name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const os = require("os");
const path = require("path");
const express = require("express");
const { sendFile, download } = require("../lib/send-file");
describe("send-file", () => {
let dir;
let server;
let base;
before(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "send-file-"));
fs.mkdirSync(path.join(dir, ".claude"));
fs.writeFileSync(path.join(dir, ".claude", "a.txt"), "hi");
fs.writeFileSync(path.join(dir, ".claude", ".secret"), "no");
const app = express();
app.get("/file", (_req, res) => sendFile(res, path.join(dir, ".claude", "a.txt")));
app.get("/dot", (_req, res) => sendFile(res, path.join(dir, ".claude", ".secret")));
app.get("/dl", (_req, res) => download(res, path.join(dir, ".claude", "a.txt"), "r.txt"));
await new Promise((r) => (server = app.listen(0, "127.0.0.1", r)));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server.close();
fs.rmSync(dir, { recursive: true, force: true });
});
it("serves a file whose parent directory is a dot-directory", async () => {
const res = await fetch(`${base}/file`);
assert.equal(res.status, 200);
assert.equal(await res.text(), "hi");
});
it("still refuses a dotfile", async () => {
const res = await fetch(`${base}/dot`);
assert.equal(res.status, 404);
});
it("downloads under the given name", async () => {
const res = await fetch(`${base}/dl`);
assert.equal(res.status, 200);
assert.match(res.headers.get("content-disposition"), /^attachment; filename="?r\.txt"?$/);
assert.equal(await res.text(), "hi");
});
});
@@ -0,0 +1,469 @@
/**
* @file server-s3-cc-config.test.js
* @description Settings for members (S3 plan, P6): a non-admin manages THEIR
* OWN Claude Code configuration through `/api/cc-config`. Covers the router's
* own allowlist gate (`cc-target.js` — no `/profiles*`, no
* `/plugins/update-all`, nothing unlisted), the one target resolver (a
* member's `~/.claude`, an admin's host home unchanged, 403 ENOACCOUNT for a
* developer with no account — never the host's), every file write running AS
* the member through `write-as` with the content on stdin (skills, settings,
* keybindings, backups), `claude plugin|mcp …` run as the member's sudo argv
* (no `-y`, user scope only, no env/headers across sudo), project writes only
* in the member's own workspaces, an admin's project-memory create in a
* member's folder written as that member, and a member's change broadcast to
* that member alone. A fake passwd (every member = this test's uid),
* server.conf, fake sudo (run-as) and a fake `claude` spawn make it run
* without root.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-cc-config-")));
const USERS = path.join(TMP, "users");
const MIA = path.join(USERS, "mia");
const BOB = path.join(USERS, "bob");
const ANA = path.join(USERS, "ana");
const HOST = path.join(TMP, "host-claude");
const TOOLS = path.join(TMP, "tools");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
process.env.DASHBOARD_DB_PATH = path.join(TMP, "data", "dashboard.db");
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.LANES_ROOT = path.join(TMP, "lanes");
process.env.CLAUDE_HOME = HOST;
process.env.CODEX_HOME = path.join(TMP, "host-codex");
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
process.env.AGENTHUB_USERS_ROOT = USERS;
process.env.AGENTHUB_TOOLS_ROOT = TOOLS;
const { createApp, startServer } = require("../index");
const { db } = require("../db");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const tmux = require("../lib/tmux");
const ccCli = require("../lib/cc-plugin-cli");
const scope = require("../lib/scope");
const { fakeSudo } = require("./fixtures/fake-sudo");
const WRITE_AS = "/opt/agenthub/libexec/write-as";
const CLAUDE = path.join(TOOLS, "bin", "claude");
const UID = process.getuid();
let server;
let BASE;
const cookies = {};
let sudo;
let cliCalls = [];
const call = (who, method, p, body) =>
fetch(BASE + p, {
method,
headers: { "Content-Type": "application/json", cookie: cookies[who] },
body: body ? JSON.stringify(body) : undefined,
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
const idOf = (name) => db.prepare("SELECT id FROM users WHERE username = ?").get(name).id;
const addLane = (cwd, owner) =>
db
.prepare("INSERT INTO lanes (cwd, tool, owner_id, status) VALUES (?, 'claude', ?, 'idle')")
.run(cwd, idOf(owner));
/** Every sudo argv recorded since the last reset, as one string each. */
const sudoLines = () => sudo.calls.map((c) => c.join(" "));
before(async () => {
for (const d of [path.join(MIA, "proj"), path.join(MIA, "proj2"), path.join(BOB, "proj"), ANA])
fs.mkdirSync(d, { recursive: true });
fs.mkdirSync(HOST, { recursive: true });
fs.mkdirSync(path.dirname(CLAUDE), { recursive: true });
fs.writeFileSync(CLAUDE, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
fs.writeFileSync(
process.env.AGENTHUB_PASSWD,
["mia", "bob", "ana", "boss"].map((n) => `ah-${n}:x:${UID}:${UID}::/:/bin/sh`).join("\n") + "\n"
);
runAs.__resetCache();
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
);
linux.__resetConfCache();
linux.__setRunImpl(() => "ok");
sudo = fakeSudo();
runAs.__setExecImpl(sudo.exec);
tmux.__setExecImpl((args) => {
if (args[0] === "-V") return "tmux 3.4";
if (args.includes("has-session")) throw Object.assign(new Error("no"), { status: 1 });
return "";
});
// The `claude` CLI never really runs: every spawn is recorded and answers ok.
ccCli.__setExecFileImpl((cmd, argv, opts, cb) => {
cliCalls.push({ cmd, argv, opts });
const listing = argv.includes("list")
? JSON.stringify({ installed: [], available: [{ pluginId: "p@m" }] })
: "done\n";
setImmediate(() => cb(null, listing, ""));
return { stdin: { end() {} } };
});
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
process.env.AGENTHUB_MODE = "server";
for (const [n, role] of [
["mia", "developer"],
["bob", "developer"],
["eve", "developer"], // no Linux account
["ana", "analyst"],
["boss", "admin"],
]) {
await auth.createUser(n, `${n}-pass-123`, role);
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: n, password: `${n}-pass-123` }),
});
cookies[n] = login.headers.get("set-cookie").split(";")[0];
}
addLane(path.join(MIA, "proj"), "mia");
addLane(path.join(BOB, "proj"), "bob");
});
beforeEach(() => {
sudo.calls.length = 0;
cliCalls = [];
});
after(() => {
ccCli.__setExecFileImpl(null);
tmux.__reset();
delete process.env.AGENTHUB_MODE;
delete process.env.AGENTHUB_USERS_ROOT;
delete process.env.AGENTHUB_TOOLS_ROOT;
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("the gate: what a non-admin may reach under /api/cc-config", () => {
it("keeps the host's login profiles and Update all an admin's", async () => {
for (const [method, p] of [
["GET", "/api/cc-config/profiles"],
["POST", "/api/cc-config/profiles"],
["POST", "/api/cc-config/profiles/activate"],
["POST", "/api/cc-config/profiles/usage"],
["DELETE", "/api/cc-config/profiles"],
["GET", "/api/cc-config/Profiles"],
["GET", "/api/cc-config/%70rofiles"],
["GET", "/api/cc-config/plugins/update-all"],
["POST", "/api/cc-config/plugins/update-all"],
["GET", "/api/cc-config/no-such-route"],
["POST", "/api/cc-config/no-such-route"],
]) {
const r = await call("mia", method, p, method === "GET" ? undefined : {});
assert.equal(r.status, 403, `${method} ${p}`);
assert.equal(r.body.error.code, "EADMIN", `${method} ${p}`);
}
assert.equal(cliCalls.length, 0);
});
it("lets an analyst still read the host-wide views, never change them", async () => {
assert.equal((await call("ana", "GET", "/api/cc-config/profiles")).status, 200);
const w = await call("ana", "POST", "/api/cc-config/profiles", { name: "x" });
assert.equal(w.status, 403);
assert.equal(w.body.error.code, "EADMIN");
});
it("leaves an admin every endpoint, the host's as before", async () => {
assert.equal((await call("boss", "GET", "/api/cc-config/profiles")).status, 200);
const t = await call("boss", "GET", "/api/cc-config/target");
assert.deepEqual(t.body, { kind: "host", readOnly: false, claudeHome: HOST });
});
});
describe("a developer with no Linux account", () => {
it("is refused — never served or written the host's config", async () => {
const t = await call("eve", "GET", "/api/cc-config/target");
assert.equal(t.body.kind, "none");
for (const [method, p, body] of [
["GET", "/api/cc-config/overview"],
["GET", "/api/cc-config/settings"],
["PUT", "/api/cc-config/file", { scope: "user", type: "skills", name: "x", content: "y" }],
["PATCH", "/api/cc-config/settings", { scope: "user", set: { theme: "dark" } }],
["POST", "/api/cc-config/plugins/install", { spec: "p@m" }],
]) {
const r = await call("eve", method, p, body);
assert.equal(r.status, 403, `${method} ${p}`);
assert.equal(r.body.error.code, "ENOACCOUNT", `${method} ${p}`);
}
assert.ok(!fs.existsSync(path.join(HOST, "skills", "x")));
assert.ok(!fs.existsSync(path.join(HOST, "settings.json")));
assert.equal(cliCalls.length, 0);
assert.equal(sudo.calls.length, 0);
// The run form's own lists still answer (empty), as before.
const skills = await call("eve", "GET", "/api/cc-config/skills");
assert.equal(skills.status, 200);
assert.deepEqual(skills.body.items, []);
});
});
describe("a member's own config", () => {
const own = path.join(MIA, ".claude");
it("is the one the page shows", async () => {
const t = await call("mia", "GET", "/api/cc-config/target");
assert.deepEqual(t.body, { kind: "own", readOnly: false, claudeHome: own });
const o = await call("mia", "GET", "/api/cc-config/overview");
assert.equal(o.status, 200);
assert.equal(o.body.roots.claudeHome, own);
assert.equal(o.body.roots.claudeJson, path.join(MIA, ".claude.json"));
// No workspace named: no project layer — never the dashboard's own folder.
assert.equal(o.body.roots.projectRoot, null);
});
it("writes a skill AS the member through write-as, content on stdin, with a backup", async () => {
const body = {
scope: "user",
type: "skills",
name: "mine",
content: "---\nname: mine\n---\nv1",
};
const r1 = await call("mia", "PUT", "/api/cc-config/file", body);
assert.equal(r1.status, 200, JSON.stringify(r1.body));
const file = path.join(own, "skills", "mine", "SKILL.md");
assert.equal(fs.readFileSync(file, "utf8"), body.content);
assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`)));
assert.ok(!sudoLines().some((l) => l.includes("v1")), "content crossed sudo's argv");
assert.ok(!fs.existsSync(path.join(HOST, "skills", "mine")));
sudo.calls.length = 0;
const r2 = await call("mia", "PUT", "/api/cc-config/file", { ...body, content: "v2" });
assert.equal(r2.status, 200);
assert.ok(r2.body.backupPath.startsWith(path.join(own, "cc-config-backups", "skills")));
assert.equal(fs.readFileSync(path.join(r2.body.backupPath, "SKILL.md"), "utf8"), body.content);
assert.ok(sudoLines().some((l) => l.includes(` copy ${r2.body.backupPath}`)));
const list = await call("mia", "GET", "/api/cc-config/skills?scope=user");
assert.deepEqual(
list.body.items.map((i) => i.name),
["mine"]
);
sudo.calls.length = 0;
const del = await call("mia", "DELETE", "/api/cc-config/file", {
scope: "user",
type: "skills",
name: "mine",
});
assert.equal(del.status, 200);
assert.ok(!fs.existsSync(path.join(own, "skills", "mine")));
assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "skills", "mine")} remove`)));
});
it("merges settings.json and writes keybindings.json as the member", async () => {
const r = await call("mia", "PATCH", "/api/cc-config/settings", {
scope: "user",
set: { "env.SECRETISH": "s3cr3t-value", theme: "dark" },
});
assert.equal(r.status, 200, JSON.stringify(r.body));
const settings = JSON.parse(fs.readFileSync(path.join(own, "settings.json"), "utf8"));
assert.equal(settings.theme, "dark");
assert.ok(!sudoLines().some((l) => l.includes("s3cr3t-value")));
assert.ok(sudoLines().some((l) => l.includes(`${path.join(own, "settings.json")} replace`)));
const again = await call("mia", "PATCH", "/api/cc-config/settings", {
scope: "user",
set: { theme: "light" },
});
assert.ok(again.body.backupPath.startsWith(path.join(own, ".cc-config-backups")));
const kb = await call("mia", "PUT", "/api/cc-config/keybindings", {
groups: [{ context: "Chat", bindings: [{ key: "ctrl+t", action: "toggleTodos" }] }],
});
assert.equal(kb.status, 200, JSON.stringify(kb.body));
assert.equal(kb.body.file, path.join(own, "keybindings.json"));
const read = await call("mia", "GET", "/api/cc-config/keybindings");
assert.equal(read.body.groups[0].bindings[0].action, "toggleTodos");
// Project layers need a workspace named.
const proj = await call("mia", "PATCH", "/api/cc-config/settings", {
scope: "project",
set: { theme: "dark" },
});
assert.equal(proj.status, 400);
assert.equal(proj.body.error.code, "EBADSCOPE");
assert.ok(!fs.existsSync(path.join(HOST, "settings.json")));
});
it("writes into a project only in a workspace of the member's own", async () => {
const q = (cwd) => `/api/cc-config/file?cwd=${encodeURIComponent(cwd)}`;
const body = { scope: "project", type: "agents", name: "helper", content: "agent" };
const mine = await call("mia", "PUT", q(path.join(MIA, "proj")), body);
assert.equal(mine.status, 200, JSON.stringify(mine.body));
const file = path.join(MIA, "proj", ".claude", "agents", "helper.md");
assert.equal(fs.readFileSync(file, "utf8"), "agent");
assert.ok(sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${file} replace`)));
for (const cwd of [path.join(BOB, "proj"), path.join(MIA, "proj2"), MIA]) {
const r = await call("mia", "PUT", q(cwd), body);
assert.equal(r.status, 403, cwd);
assert.equal(r.body.error.code, "EFOLDER", cwd);
}
assert.ok(!fs.existsSync(path.join(BOB, "proj", ".claude")));
const pm = await call("mia", "POST", "/api/cc-config/project-memory", {
dir: path.join(BOB, "proj"),
content: "x",
});
assert.equal(pm.status, 403);
assert.ok(!fs.existsSync(path.join(BOB, "proj", "CLAUDE.md")));
});
it("runs `claude plugin` and `claude mcp` as the member — no -y, user scope only", async () => {
const inst = await call("mia", "POST", "/api/cc-config/plugins/install", { spec: "p@m" });
assert.equal(inst.status, 200, JSON.stringify(inst.body));
const add = await call("mia", "POST", "/api/cc-config/marketplaces/add", {
source: "owner/repo",
});
assert.equal(add.status, 200);
const mcp = await call("mia", "POST", "/api/cc-config/mcp/add", {
name: "srv",
scope: "user",
command: "npx",
args: ["srv"],
});
assert.equal(mcp.status, 200, JSON.stringify(mcp.body));
const avail = await call("mia", "GET", "/api/cc-config/plugins/available");
assert.equal(avail.status, 200);
const prefix = ["-n", "-u", "ah-mia", `HOME=${MIA}`, "DISABLE_AUTOUPDATER=1", CLAUDE];
assert.deepEqual(
cliCalls.map((c) => [c.cmd, ...c.argv]),
[
["sudo", ...prefix, "plugin", "install", "p@m", "--scope", "user"],
["sudo", ...prefix, "plugin", "marketplace", "add", "owner/repo", "--scope", "user"],
["sudo", ...prefix, "mcp", "add", "--scope", "user", "srv", "--", "npx", "srv"],
["sudo", ...prefix, "plugin", "list", "--available", "--json"],
]
);
assert.ok(cliCalls.every((c) => c.opts.cwd === MIA));
assert.ok(!cliCalls.some((c) => c.argv.some((a) => a === "-y" || a.startsWith("--danger"))));
cliCalls = [];
for (const [p, body] of [
["/api/cc-config/plugins/install", { spec: "p@m", scope: "project" }],
["/api/cc-config/marketplaces/add", { source: "owner/repo", scope: "local" }],
["/api/cc-config/mcp/add", { name: "s", scope: "local", command: "x" }],
["/api/cc-config/mcp/add", { name: "s", scope: "user", command: "x", env: { K: "v" } }],
[
"/api/cc-config/mcp/add",
{ name: "s", scope: "user", url: "https://x", headers: { a: "b" } },
],
]) {
const r = await call("mia", "POST", p, body);
assert.equal(r.status, 400, `${p} ${JSON.stringify(body)}`);
}
assert.equal(cliCalls.length, 0, "a refused call never spawned");
});
it("answers ENOTSHARED while claude is not in the shared tools", async () => {
fs.renameSync(CLAUDE, `${CLAUDE}.off`);
try {
const r = await call("mia", "POST", "/api/cc-config/plugins/action", {
key: "p@m",
action: "enable",
});
assert.equal(r.status, 503);
assert.equal(r.body.error.code, "ENOTSHARED");
assert.equal(cliCalls.length, 0);
} finally {
fs.renameSync(`${CLAUDE}.off`, CLAUDE);
}
});
});
describe("an admin", () => {
it("still writes the host's own config, as the host", async () => {
const r = await call("boss", "PUT", "/api/cc-config/file", {
scope: "user",
type: "commands",
name: "hosty",
content: "host cmd",
});
assert.equal(r.status, 200);
assert.equal(fs.readFileSync(path.join(HOST, "commands", "hosty.md"), "utf8"), "host cmd");
assert.equal(sudo.calls.length, 0);
const inst = await call("boss", "POST", "/api/cc-config/plugins/install", { spec: "p@m" });
assert.equal(inst.status, 200);
assert.deepEqual(
cliCalls.map((c) => [c.cmd, ...c.argv]),
[["claude", "plugin", "install", "p@m", "--scope", "user", "-y"]]
);
});
it("creates CLAUDE.md in a member's folder as that member, never as the host", async () => {
const dir = path.join(MIA, "proj2");
const r = await call("boss", "POST", "/api/cc-config/project-memory", {
dir,
content: "# memo",
});
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.equal(fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"), "# memo");
assert.ok(
sudoLines().some((l) => l.includes(`-u ah-mia ${WRITE_AS} ${path.join(dir, "CLAUDE.md")}`))
);
});
});
describe("a member's config change on the WebSocket", () => {
it("names the owner, so it reaches that member alone", () => {
assert.deepEqual([...scope.exactAudience("cc_config_changed", { ownerId: 7, name: "x" })], [7]);
assert.equal(scope.exactAudience("cc_config_changed", { name: "x" }), null);
assert.equal(scope.exactAudience("lane_update", { ownerId: 7 }), null);
});
it("is delivered to the member and not to an admin; the host's reaches both", async () => {
const WebSocket = require("ws");
const open = (who) => {
const ws = new WebSocket(BASE.replace("http", "ws") + "/ws", {
headers: { cookie: cookies[who] },
});
const seen = [];
ws.on("message", (m) => {
const msg = JSON.parse(String(m));
if (msg.type === "cc_config_changed") seen.push(msg.data);
});
return new Promise((resolve, reject) =>
ws.on("open", () => resolve({ ws, seen })).on("error", reject)
);
};
const mia = await open("mia");
const boss = await open("boss");
try {
await call("mia", "PUT", "/api/cc-config/file", {
scope: "user",
type: "commands",
name: "secret-plan",
content: "x",
});
await call("boss", "PUT", "/api/cc-config/file", {
scope: "user",
type: "commands",
name: "host-cmd",
content: "x",
});
await new Promise((r) => setTimeout(r, 300));
assert.ok(
mia.seen.some((d) => d.name === "secret-plan"),
JSON.stringify(mia.seen)
);
assert.ok(!JSON.stringify(boss.seen).includes("secret-plan"), JSON.stringify(boss.seen));
assert.ok(boss.seen.some((d) => d.name === "host-cmd"));
// The host's change reaches the member as the bare type (as before).
assert.ok(!JSON.stringify(mia.seen).includes("host-cmd"));
assert.ok(mia.seen.some((d) => d && Object.keys(d).length === 0));
} finally {
mia.ws.close();
boss.ws.close();
}
});
});
+2
View File
@@ -571,6 +571,8 @@ test.describe("ship-feature pipeline template", () => {
["review", "Skill", { skill: "superpowers:requesting-code-review" }],
["qc", "Agent", { subagent_type: "qc-local" }],
["qc", "Bash", { command: "agenthub lanes proof-link" }],
["qc", "Bash", { command: "agenthub workspace proof-link" }],
["gates", "Bash", { command: "agenthub workspace hook ci-gate" }],
["gate", "Agent", { subagent_type: "senior-gate-reviewer" }],
["gate", "Skill", { skill: "superpowers:verification-before-completion" }],
["publishing", "Skill", { skill: "superpowers:finishing-a-development-branch" }],
+13 -3
View File
@@ -159,6 +159,10 @@ const APP_VERSION = (() => {
function createApp() {
const app = express();
// Express 5 parses a query string with node's `querystring` by default;
// keep Express 4's `qs` parsing, which every route and guard (`namedIds`
// refusing `?lane[]=`/`?lane[x]=`) was written against.
app.set("query parser", "extended");
const openApiSpec = createOpenApiSpec();
// Security hardening (GHSA-gr74-4xfh-6jw9): loopback-only CORS, a Host-header
@@ -181,6 +185,12 @@ function createApp() {
);
app.use("/api/proxy-gw", require("./lib/proxy-gateway").proxyGateway);
app.use(express.json({ limit: "1mb" }));
// Express 4's parser left `req.body` as `{}` when there was no JSON body;
// Express 5's leaves it undefined, and handlers destructure it.
app.use((req, _res, next) => {
if (req.body === undefined) req.body = {};
next();
});
app.use("/api", tokenGuard);
// Server mode's login gate (server/lib/auth.js); a no-op in local mode.
app.use("/api", authGuard);
@@ -251,7 +261,7 @@ function createApp() {
// (complements Swagger UI's interactive console at /api/docs). The bundle is
// served from node_modules, never a CDN, so the reference works offline.
app.get("/api/redoc/redoc.standalone.js", (_req, res) => {
res.sendFile(redocBundlePath(), (err) => {
require("./lib/send-file").sendFile(res, redocBundlePath(), (err) => {
if (err && !res.headersSent) res.status(500).end();
});
});
@@ -341,9 +351,9 @@ function startServer(app, port) {
},
})
);
app.get("*", (_req, res) => {
app.get("/{*splat}", (_req, res) => {
res.setHeader("Cache-Control", "no-cache, must-revalidate");
res.sendFile(path.join(clientDist, "index.html"));
require("./lib/send-file").sendFile(res, path.join(clientDist, "index.html"));
});
}
+5 -8
View File
@@ -56,12 +56,13 @@ const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
/**
* API prefixes whose writes change the host account's own setup. (AI Proxy
* profiles are not here: a member writes their own personal ones, and
* `routes/ai-proxies.js` keeps the shared ones an admin's.)
* `routes/ai-proxies.js` keeps the shared ones an admin's. Nor is
* `/cc-config`: its router gates itself — `cc-target.js`'s allowlist — since a
* member there manages their OWN Claude Code config.)
*/
const ADMIN_WRITE_PREFIXES = [
"/settings",
"/updates",
"/cc-config",
"/codex-config",
// The AI tools' login profiles: switching one changes the host's own login.
"/tool-profiles",
@@ -85,10 +86,9 @@ const ADMIN_WRITE_PREFIXES = [
* API prefixes a developer may not reach at all, reads included: the host's own
* configuration and logs, and every view across all users (export, metrics,
* reports, webhook deliveries, remote machines, imports). Admin and analyst
* pass. `MEMBER_READ_ALLOW` re-opens the few reads a developer's own work needs.
* pass. (`/cc-config` gates itself: see ADMIN_WRITE_PREFIXES.)
*/
const STAFF_ONLY_PREFIXES = [
"/cc-config",
"/codex-config",
"/tool-profiles",
"/git-credentials",
@@ -104,8 +104,6 @@ const STAFF_ONLY_PREFIXES = [
// The machine's hardware and every user's CPU/memory share.
"/host",
];
// The run form's `/` autocomplete lists commands and skills.
const MEMBER_READ_ALLOW = ["/cc-config/commands", "/cc-config/skills"];
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
// lower-cased form: the host's own AI-tool config files (they may hold keys).
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
@@ -723,8 +721,7 @@ function authGuard(req, res, next) {
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "developer" &&
((STAFF_ONLY_PREFIXES.some(under) && !(isRead && MEMBER_READ_ALLOW.some(under))) ||
STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
(STAFF_ONLY_PREFIXES.some(under) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
) {
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
}
+173 -80
View File
@@ -6,10 +6,20 @@
* scripts). Powers the Claude Config Explorer page. All operations are pure
* file reads — never writes.
*
* Path containment: every read resolves under getClaudeHome(),
* getProjectClaudeDir(), or getProjectRoot() (for CLAUDE.md). Reads outside
* those roots return null. Settings are redacted of secret-like keys before
* returning.
* Path containment: every read resolves under the Claude home being read
* (`opts.home`, else getClaudeHome()), getProjectClaudeDir(), or
* getProjectRoot() (for CLAUDE.md). Reads outside those roots return null.
* Settings are redacted of secret-like keys before returning.
*
* Whose config: every reader takes `opts.home` (a Claude dir) and
* `opts.claudeJson` — a member's own (`<home>/.claude`, `<home>/.claude.json`)
* when the Config Explorer serves a member under S3 — and `opts.noProject`,
* which reads no project layer at all (a member who named no workspace: never
* the dashboard's own folder). A path inside a member's home is read only
* through run-as's guarded reader (a regular file that member owns, size
* capped) and listed only when it really lies in that home and is theirs
* (`memberPathOk`), and a plugin's or marketplace's install path that the
* member's own JSON names is followed only inside their home.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
@@ -28,6 +38,9 @@ function getProjectClaudeDir(cwd) {
return path.join(getProjectRoot(cwd), ".claude");
}
/** The Claude dir a read is about: the caller's `opts.home`, else the host's. */
const homeOf = (opts) => (opts && opts.home) || getClaudeHome();
function getClaudeJsonPath() {
// NOT path.join(getClaudeHome(), ".claude.json") — verified against the
// real `claude` binary, the two CLAUDE_HOME branches behave differently:
@@ -57,6 +70,52 @@ function isUnder(root, target) {
return t.startsWith(r + path.sep);
}
/**
* S3: the member whose home `p` lies in (`run-as.ownerForRead`), or null for a
* host path / outside S3.
*/
function memberOf(p) {
try {
return require("./run-as").ownerForRead(p);
} catch {
return null;
}
}
/**
* Whether a path may be listed or stat'ed for what it holds: always outside a
* member's home; inside one only when its real path stays in that home and
* the member owns it. The dashboard reads with CAP_DAC_READ_SEARCH, so a link
* a member planted (`~/.claude/hooks -> /root`) would otherwise list another
* account's file names.
*/
function memberPathOk(p) {
const acct = memberOf(p);
if (!acct) return true;
try {
const real = fs.realpathSync(p);
if (!isUnder(fs.realpathSync(acct.home), real)) return false;
return fs.statSync(real).uid === acct.uid;
} catch {
return false;
}
}
/**
* A path a member's OWN JSON names (a plugin's `installPath`, a marketplace's
* `installLocation`) is followed only inside that member's home; any path is
* followed for the host, as before. `claudeDir` is the home being read.
*/
function followable(claudeDir, p) {
const acct = memberOf(claudeDir);
if (!acct) return true;
try {
return isUnder(fs.realpathSync(acct.home), fs.realpathSync(p)) && memberPathOk(p);
} catch {
return false;
}
}
function readJson(absPath) {
try {
const raw = require("./run-as").readFileSafe(absPath, "utf8");
@@ -122,6 +181,8 @@ function parseFrontmatter(text) {
}
function safeReadText(absPath) {
const runAs = require("./run-as");
if (runAs.isMemberFile(absPath)) return memberReadText(runAs, absPath);
try {
const stat = fs.statSync(absPath);
if (!stat.isFile()) return null;
@@ -144,7 +205,36 @@ function safeReadText(absPath) {
}
}
/**
* `safeReadText` for a member's file: opened through run-as's guard (a regular
* file that member owns, never a FIFO or another account's file through a
* link), and only the first MAX_FILE_BYTES read — never the whole of a file
* the member made huge.
*/
function memberReadText(runAs, absPath) {
let fd;
try {
fd = runAs.openSafe(absPath);
const st = fs.fstatSync(fd);
const want = Math.min(st.size, MAX_FILE_BYTES);
const buf = Buffer.alloc(want);
let off = 0;
for (let n; off < want && (n = fs.readSync(fd, buf, off, want - off, off)) > 0;) off += n;
return {
truncated: st.size > MAX_FILE_BYTES,
size: st.size,
text: buf.toString("utf8", 0, off),
mtime: st.mtimeMs,
};
} catch {
return null;
} finally {
if (fd != null) fs.closeSync(fd);
}
}
function listDir(absPath) {
if (!memberPathOk(absPath)) return [];
try {
return fs.readdirSync(absPath, { withFileTypes: true });
} catch {
@@ -223,9 +313,9 @@ function readSkillsAt(scope, claudeDir) {
function readSkills(opts = {}) {
const out = [];
if (opts.scope !== "project") {
out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome()));
out.push(...readSkillsAt("user", opts.userClaudeDir || homeOf(opts)));
}
if (opts.scope !== "user") {
if (opts.scope !== "user" && !opts.noProject) {
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
}
return out;
@@ -262,9 +352,9 @@ function readSimpleMdSurface(subdir) {
return (opts = {}) => {
const out = [];
if (opts.scope !== "project") {
out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir));
out.push(...readMdFilesAt("user", opts.userClaudeDir || homeOf(opts), subdir));
}
if (opts.scope !== "user") {
if (opts.scope !== "user" && !opts.noProject) {
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
}
return out;
@@ -396,7 +486,11 @@ function readPluginContributions(installPath) {
if (!installPath) return null;
let pluginJson = null;
try {
const raw = fs.readFileSync(path.join(installPath, ".claude-plugin", "plugin.json"), "utf8");
const raw = require("./run-as").readFileSafe(
path.join(installPath, ".claude-plugin", "plugin.json"),
"utf8",
{ max: MAX_FILE_BYTES }
);
pluginJson = JSON.parse(raw);
} catch {
pluginJson = null;
@@ -416,13 +510,9 @@ function readPluginContributions(installPath) {
outputStyles: items.outputStyles.length,
items,
hooks: (() => {
try {
return fs
.readdirSync(path.join(installPath, "hooks"), { withFileTypes: true })
.filter((e) => isFileLike(e, path.join(installPath, "hooks", e.name))).length;
} catch {
return 0;
}
return listDir(path.join(installPath, "hooks")).filter((e) =>
isFileLike(e, path.join(installPath, "hooks", e.name))
).length;
})(),
pluginJson,
};
@@ -448,7 +538,10 @@ function readPlugins(home = getClaudeHome()) {
const installPath = inst.installPath;
let exists = false;
try {
exists = installPath ? fs.statSync(installPath).isDirectory() : false;
exists =
installPath && followable(home, installPath)
? fs.statSync(installPath).isDirectory()
: false;
} catch {
exists = false;
}
@@ -492,7 +585,7 @@ function readMcpServers(opts = {}) {
const out = { user: [], projectScoped: [] };
// ~/.claude.json is the primary CLI state file; mcpServers can live at the
// top level (legacy) or inside projects[<cwd>].mcpServers (per-project).
const claudeJson = readJson(getClaudeJsonPath());
const claudeJson = readJson(opts.claudeJson || getClaudeJsonPath());
if (claudeJson.ok && claudeJson.data) {
const top = claudeJson.data.mcpServers;
if (top && typeof top === "object") {
@@ -505,7 +598,7 @@ function readMcpServers(opts = {}) {
}
}
const projects = claudeJson.data.projects;
if (projects && typeof projects === "object") {
if (projects && typeof projects === "object" && !opts.noProject) {
const projectRoot = getProjectRoot(opts.cwd);
const projectEntry = projects[projectRoot];
if (projectEntry && projectEntry.mcpServers) {
@@ -520,7 +613,7 @@ function readMcpServers(opts = {}) {
}
}
// Also sniff settings.json for an mcpServers key (rare but supported).
const userSettings = readJson(path.join(getClaudeHome(), "settings.json"));
const userSettings = readJson(path.join(homeOf(opts), "settings.json"));
if (userSettings.ok && userSettings.data && userSettings.data.mcpServers) {
for (const [name, def] of Object.entries(userSettings.data.mcpServers)) {
out.user.push({
@@ -567,9 +660,12 @@ const HOOK_EVENT_TYPES = [
"PreCompact",
];
function readHooks(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
/** The settings layers a read covers: user, plus the project's two unless `noProject`. */
function settingsSources(opts = {}) {
const user = [{ scope: "user", file: path.join(homeOf(opts), "settings.json") }];
if (opts.noProject) return user;
return [
...user,
{
scope: "project",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
@@ -579,6 +675,10 @@ function readHooks(opts = {}) {
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
},
];
}
function readHooks(opts = {}) {
const sources = settingsSources(opts);
const result = [];
for (const { scope, file } of sources) {
const j = readJson(file);
@@ -617,17 +717,7 @@ function readHooks(opts = {}) {
// ── Settings ───────────────────────────────────────────────────────────
function readSettings(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "settings.json") },
{
scope: "project",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
},
{
scope: "project-local",
file: path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
},
];
const sources = settingsSources(opts);
return sources.map(({ scope, file }) => {
const j = readJson(file);
if (!j.ok) return { scope, file, exists: false };
@@ -643,8 +733,8 @@ function readSettings(opts = {}) {
// ── Marketplaces ───────────────────────────────────────────────────────
function readMarketplaces() {
const home = getClaudeHome();
function readMarketplaces(opts = {}) {
const home = homeOf(opts);
const knownPath = path.join(home, "plugins", "known_marketplaces.json");
const known = readJson(knownPath);
const out = [];
@@ -654,10 +744,10 @@ function readMarketplaces() {
const sourceDef = def && def.source;
let pluginCount = null;
let marketplaceJson = null;
if (installLocation) {
if (installLocation && followable(home, installLocation)) {
try {
const mfPath = path.join(installLocation, ".claude-plugin", "marketplace.json");
const raw = fs.readFileSync(mfPath, "utf8");
const raw = require("./run-as").readFileSafe(mfPath, "utf8", { max: MAX_FILE_BYTES });
marketplaceJson = JSON.parse(raw);
pluginCount = Array.isArray(marketplaceJson.plugins)
? marketplaceJson.plugins.length
@@ -687,8 +777,8 @@ function readMarketplaces() {
// ── Keybindings ────────────────────────────────────────────────────────
function readKeybindings() {
const file = path.join(getClaudeHome(), "keybindings.json");
function readKeybindings(opts = {}) {
const file = path.join(homeOf(opts), "keybindings.json");
const j = readJson(file);
if (!j.ok) return { file, exists: false };
const data = j.data && typeof j.data === "object" ? j.data : {};
@@ -710,13 +800,13 @@ function readKeybindings() {
// ── Statusline (config + script content) ──────────────────────────────
function readStatusline() {
const userSettingsPath = path.join(getClaudeHome(), "settings.json");
function readStatusline(opts = {}) {
const userSettingsPath = path.join(homeOf(opts), "settings.json");
const j = readJson(userSettingsPath);
const config = j.ok && j.data && j.data.statusLine ? j.data.statusLine : null;
const candidates = [
path.join(getClaudeHome(), "statusline.py"),
path.join(getClaudeHome(), "statusline-command.sh"),
path.join(homeOf(opts), "statusline.py"),
path.join(homeOf(opts), "statusline-command.sh"),
];
const scripts = [];
for (const file of candidates) {
@@ -736,8 +826,8 @@ function readStatusline() {
// ── Hook handler scripts dir (~/.claude/hooks/) ───────────────────────
function readHookScripts() {
const dir = path.join(getClaudeHome(), "hooks");
function readHookScripts(opts = {}) {
const dir = path.join(homeOf(opts), "hooks");
const entries = listDir(dir);
return {
dir,
@@ -747,6 +837,7 @@ function readHookScripts() {
const file = path.join(dir, e.name);
let stat;
try {
if (!memberPathOk(file)) return null;
stat = fs.statSync(file);
} catch {
return null;
@@ -774,10 +865,10 @@ const MEMORY_INDEX_RE = /^(MEMORY|INDEX)\b/i;
* mutable via cc-mutate's "auto-memory" type (create/edit/delete + backup).
*/
function readMemory(opts = {}) {
const sources = [
{ scope: "user", file: path.join(getClaudeHome(), "CLAUDE.md") },
{ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") },
];
const sources = [{ scope: "user", file: path.join(homeOf(opts), "CLAUDE.md") }];
if (!opts.noProject) {
sources.push({ scope: "project", file: path.join(getProjectRoot(opts.cwd), "CLAUDE.md") });
}
const result = [];
for (const { scope, file } of sources) {
const r = safeReadText(file);
@@ -796,16 +887,11 @@ function readMemory(opts = {}) {
// root, an unreadable memory dir, or a single bad file must never break
// the memory tab — every layer is wrapped so we degrade to "fewer files".
try {
const projectsRoot = path.join(getClaudeHome(), "projects");
for (const proj of fs.readdirSync(projectsRoot)) {
const projectsRoot = path.join(homeOf(opts), "projects");
for (const { name: proj } of listDir(projectsRoot)) {
const memDir = path.join(projectsRoot, proj, "memory");
let files;
try {
files = fs.readdirSync(memDir);
} catch {
continue;
}
files = files
const files = listDir(memDir)
.map((e) => e.name)
.filter((f) => f.endsWith(".md"))
.sort((a, b) => {
const rank = (f) => (MEMORY_INDEX_RE.test(f) ? 0 : 1);
@@ -848,16 +934,18 @@ function readMemory(opts = {}) {
* secret, and AgentHub's own stores under CLAUDE_HOME (`agenthub-proxies`,
* `agenthub-git-credentials`, the pool accounts…) — no route returns a key.
*/
function isSecretConfigPath(resolved) {
function isSecretConfigPath(resolved, home) {
const base = path.basename(resolved);
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
const rel = path.relative(getClaudeHome(), resolved);
if (
!rel.startsWith("..") &&
!path.isAbsolute(rel) &&
rel.split(path.sep)[0].startsWith("agenthub-")
)
return true;
for (const h of new Set([getClaudeHome(), home].filter(Boolean))) {
const rel = path.relative(h, resolved);
if (
!rel.startsWith("..") &&
!path.isAbsolute(rel) &&
rel.split(path.sep)[0].startsWith("agenthub-")
)
return true;
}
// AgentHub's data dir is no Claude config: it holds the database, the push
// keys and every gateway pane's settings file WITH its live pane token.
const { getDataDir, dataDirCandidates } = require("./claude-home");
@@ -872,24 +960,28 @@ function isSecretConfigPath(resolved) {
}
function readFileSafe(absPath, opts = {}) {
const allowedRoots = [
getClaudeHome(),
getProjectClaudeDir(opts.cwd),
getProjectRoot(opts.cwd), // for CLAUDE.md only — caller must pass exact name
];
const allowedRoots = [homeOf(opts)];
if (!opts.noProject) {
allowedRoots.push(
getProjectClaudeDir(opts.cwd),
getProjectRoot(opts.cwd) // for CLAUDE.md only — caller must pass exact name
);
}
const resolved = path.resolve(absPath);
const inside = allowedRoots.some((root) => isUnder(root, resolved));
if (!inside) return { error: "path is outside allowed roots" };
// Extra guard: under project root we only allow CLAUDE.md (avoid leaking
// arbitrary repo files via this endpoint).
if (
!opts.noProject &&
isUnder(getProjectRoot(opts.cwd), resolved) &&
!isUnder(getProjectClaudeDir(opts.cwd), resolved) &&
path.basename(resolved) !== "CLAUDE.md"
) {
return { error: "only CLAUDE.md is readable from project root" };
}
if (isSecretConfigPath(resolved)) return { error: "a credential file is never served" };
if (isSecretConfigPath(resolved, homeOf(opts)))
return { error: "a credential file is never served" };
const r = safeReadText(resolved);
if (!r) return { error: "file not readable" };
return { ok: true, file: resolved, ...r };
@@ -902,13 +994,13 @@ function readOverview(opts = {}) {
const agents = readAgents(opts);
const commands = readCommands(opts);
const outputStyles = readOutputStyles(opts);
const plugins = readPlugins();
const plugins = readPlugins(homeOf(opts));
const mcp = readMcpServers(opts);
const hooks = readHooks(opts);
const settings = readSettings(opts);
const memory = readMemory(opts);
const marketplaces = readMarketplaces();
const keybindings = readKeybindings();
const marketplaces = readMarketplaces(opts);
const keybindings = readKeybindings(opts);
const countByScope = (arr) => ({
user: arr.filter((x) => x.scope === "user").length,
@@ -923,10 +1015,10 @@ function readOverview(opts = {}) {
return {
roots: {
claudeHome: getClaudeHome(),
projectClaudeDir: getProjectClaudeDir(opts.cwd),
projectRoot: getProjectRoot(opts.cwd),
claudeJson: getClaudeJsonPath(),
claudeHome: homeOf(opts),
projectClaudeDir: opts.noProject ? null : getProjectClaudeDir(opts.cwd),
projectRoot: opts.noProject ? null : getProjectRoot(opts.cwd),
claudeJson: opts.claudeJson || getClaudeJsonPath(),
},
counts: {
skills: countByScope(skills),
@@ -983,6 +1075,7 @@ module.exports = {
isUnder,
isDirLike,
isFileLike,
memberPathOk,
MAX_FILE_BYTES,
HOOK_EVENT_TYPES,
};
+35 -7
View File
@@ -44,6 +44,12 @@
* route can map it to HTTP 422. Only a bad argument or a missing binary
* throws — those are programming/environment errors, not CLI outcomes.
*
* S3, a member's own config (`acct`): the CLI runs AS the member through
* sudo (cc-plugin-cli's `runClaude`), `user` scope only (their CLI runs in
* their home, no project), and with no `-e`/`--header` at all — those carry
* keys and tokens, and sudo logs its whole argv. Such a server is added from
* the member's own terminal instead.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -174,15 +180,34 @@ function headerFlags(headers) {
});
}
/**
* S3, a member's own config: `user` scope only, and no env values or headers
* — they would cross sudo's argv, which sudo logs.
*/
function assertMemberMcp({ scope, headers, env, acct }) {
if (!acct) return;
if (scope && scope !== "user") {
throw makeError("EBADARG", "only scope user can be changed for your own config");
}
const has = (o) => o && typeof o === "object" && Object.keys(o).length > 0;
if (has(headers) || has(env)) {
throw makeError(
"EBADARG",
"env values and headers would be logged by sudo: add this server with `claude mcp add` in your own terminal"
);
}
}
/**
* `claude mcp add` has two mutually exclusive forms, matching the real CLI
* (not a `--url` flag — see the file header): an http/sse server (`name`,
* positional `url`, optional `--header`) or a stdio server (`name`, optional
* `-e`, `-- command args…`).
*/
async function mcpAdd({ name, scope, transport, command, args = [], url, headers, env }) {
async function mcpAdd({ name, scope, transport, command, args = [], url, headers, env, acct }) {
const n = assertName(name);
const sc = assertMcpScope(scope, { required: true });
assertMemberMcp({ scope: sc, headers, env, acct });
if (url && command) {
throw makeError("EBADARG", "mcpAdd accepts either url or command, not both");
}
@@ -201,8 +226,9 @@ async function mcpAdd({ name, scope, transport, command, args = [], url, headers
}
const t = transport === "sse" ? "sse" : "http";
const hFlags = headerFlags(headers);
return serialize(() =>
runClaude(["mcp", "add", "--scope", sc, "--transport", t, n, u, ...hFlags])
return serialize(
() => runClaude(["mcp", "add", "--scope", sc, "--transport", t, n, u, ...hFlags], { acct }),
acct
);
}
@@ -216,16 +242,18 @@ async function mcpAdd({ name, scope, transport, command, args = [], url, headers
// env/header values, applied because of position rather than KEY=VALUE.
const cmdArgs = args.map((a, i) => assertEmbeddedValue(a, `args[${i}]`));
const eFlags = envFlags(env);
return serialize(() =>
runClaude(["mcp", "add", "--scope", sc, n, ...eFlags, "--", cmd, ...cmdArgs])
return serialize(
() => runClaude(["mcp", "add", "--scope", sc, n, ...eFlags, "--", cmd, ...cmdArgs], { acct }),
acct
);
}
async function mcpRemove({ name, scope }) {
async function mcpRemove({ name, scope, acct }) {
const n = assertName(name);
const sc = assertMcpScope(scope);
assertMemberMcp({ scope: sc, acct });
const scopeFlags = sc ? ["--scope", sc] : [];
return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags]));
return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags], { acct }), acct);
}
module.exports = {
+188 -57
View File
@@ -24,13 +24,23 @@
* any failure path.
* - Names are validated against a strict allowlist regex; resolved paths
* are double-checked to live under the expected root before any I/O.
*
* Whose config: every entry point takes `home` (a Claude dir; the host's
* getClaudeHome() when absent) and `noProject` (refuse the project layers —
* a member who named no workspace of theirs; never the dashboard's own
* folder). Every file operation goes through `ioFor(root)`: under S3 a root
* inside a member's home — their own config, or an admin's write into a
* member's workspace — is written, backed up, created and deleted AS that
* member through `write-as` (content on stdin), and read through run-as's
* guarded reader; the host never writes inside a member's home itself. Any
* other root is plain `fs`, exactly as before.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
const { isUnder, MAX_FILE_BYTES } = require("./cc-discovery");
const { isUnder, MAX_FILE_BYTES, memberPathOk } = require("./cc-discovery");
const {
timestamp,
createBackup,
@@ -66,28 +76,135 @@ function getProjectClaudeDir(cwd) {
return path.join(getProjectRoot(cwd), ".claude");
}
/** The Claude dir a write is about: the caller's `home`, else the host's. */
const homeOf = (opts) => (opts && opts.home) || getClaudeHome();
/** No project layer for this caller (a member who named no workspace of theirs). */
function assertProject(opts) {
if (opts && opts.noProject) {
throw makeError("EBADSCOPE", "a project write needs ?cwd= naming one of your workspaces");
}
}
function rootForScope(scope, opts = {}) {
if (scope === "user") return getClaudeHome();
if (scope === "project") return getProjectClaudeDir(opts.cwd);
if (scope === "user") return homeOf(opts);
if (scope === "project") {
assertProject(opts);
return getProjectClaudeDir(opts.cwd);
}
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
function memoryPathForScope(scope, opts = {}) {
if (scope === "user") return path.join(getClaudeHome(), "CLAUDE.md");
if (scope === "project") return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
if (scope === "user") return path.join(homeOf(opts), "CLAUDE.md");
if (scope === "project") {
assertProject(opts);
return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
}
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
// ── File operations: as the host, or AS the member whose home it is ──────
/** Plain `fs`: the host's own config, as it always was. */
const HOST_IO = {
acct: null,
exists: (p) => fs.existsSync(p),
readText: (file) => fs.readFileSync(file, "utf8"),
mkdir: (dir) => fs.mkdirSync(dir, { recursive: true }),
write: (file, content) => atomicWriteFile(file, content),
backup: (args) => createBackup(args),
copyFile: (src, dst) => {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
},
remove: (p) => fs.rmSync(p, { recursive: true, force: true }),
trustedRootReady: () => {},
};
/**
* S3: every operation AS `acct`, through `write-as` (content on stdin, never
* sudo's argv). The member can swap any name in their own home, and a write
* by the host would follow the swap — as the member, it can only do what the
* member could. Reads go through run-as's guarded reader (their own regular
* file, size capped).
*/
function memberIo(acct) {
const runAs = require("./run-as");
const lexists = (p) => {
try {
fs.lstatSync(p);
return true;
} catch {
return false;
}
};
const copy = (src, dst) => {
runAs.mkdirAs(acct, path.dirname(dst));
runAs.copyAs(acct, src, dst);
};
return {
acct,
exists: (p) => fs.existsSync(p),
readText: (file) => {
try {
return runAs.readFileSafe(file, "utf8", { max: MAX_FILE_BYTES });
} catch (err) {
if (err.code === "ETOOBIG") throw makeError("ETOOLARGE", `${file} is too large`);
if (err.code === "EFOREIGNFILE") {
throw makeError("EOUTOFROOT", `${file} is not a file of this account's`);
}
throw err;
}
},
mkdir: (dir) => runAs.mkdirAs(acct, dir),
write: (file, content) => {
runAs.mkdirAs(acct, path.dirname(file));
runAs.writeAs(acct, file, "replace", content);
},
backup: ({ source, backupRoot, name }) => {
if (!lexists(source)) return null;
const dst = path.join(backupRoot, `${name}.${timestamp()}.bak`);
copy(source, dst);
return dst;
},
copyFile: copy,
remove: (p) => runAs.removeAs(acct, p),
// The host may not create a member's directory itself (assertNoSymlinkEscape
// would): the member does, and the check runs with `create: false`.
trustedRootReady: (root) => runAs.mkdirAs(acct, root),
};
}
/**
* How to touch files under `root`: AS the member whose home it is (S3), else
* as the host. Throws EMEMBERGONE for a member's folder with no usable
* account — never "the host then".
*/
function ioFor(root) {
const acct = require("./run-as").accountForPath(path.resolve(root));
return acct ? memberIo(acct) : HOST_IO;
}
/** `assertNoSymlinkEscape` without the host ever creating a member's directory. */
function guardTarget(io, trustedRoot, target) {
io.trustedRootReady(trustedRoot);
assertNoSymlinkEscape(trustedRoot, target, {
label: "Claude",
create: io.acct ? false : undefined,
});
}
/**
* Resolve (and validate) the memory dir for a per-project file-based memory
* store: ~/.claude/projects/<project>/memory/. Rejects slugs that could
* traverse out of the projects root.
*/
function autoMemoryDir(project) {
function autoMemoryDir(project, opts = {}) {
if (typeof project !== "string" || !PROJECT_SLUG_RE.test(project) || project.includes("..")) {
throw makeError("EBADPROJECT", `invalid project slug: ${project}`);
}
const projectsRoot = path.join(getClaudeHome(), "projects");
const projectsRoot = path.join(homeOf(opts), "projects");
const dir = path.join(projectsRoot, project, "memory");
if (!isUnder(projectsRoot, dir)) {
throw makeError("EOUTOFROOT", "project escapes the projects root");
@@ -128,7 +245,7 @@ function resolveTarget(scope, type, name, opts = {}) {
}
if (spec.kind === "auto-memory") {
const memDir = autoMemoryDir(opts.project);
const memDir = autoMemoryDir(opts.project, opts);
if (typeof name !== "string" || !MEMORY_FILE_RE.test(name) || name.includes("..")) {
throw makeError("EBADNAME", `auto-memory name must be a flat *.md filename`);
}
@@ -140,7 +257,7 @@ function resolveTarget(scope, type, name, opts = {}) {
target,
filePath: target,
containmentRoot: memDir,
trustedRoot: path.join(getClaudeHome(), "projects"),
trustedRoot: path.join(homeOf(opts), "projects"),
};
}
@@ -190,25 +307,21 @@ function autoMemoryBackupRoot(memDir) {
return path.join(memDir, ".cc-config-backups", "auto-memory");
}
function rmTreeSync(p) {
fs.rmSync(p, { recursive: true, force: true });
}
/**
* Always-on backup, routed to the bucket the (scope, type) pair backs up
* into. For files, copies to <bucket>/<name>.<ts>.bak. For dirs (skills),
* copies the whole tree. Returns the backup path (or null if there was
* nothing to back up — e.g. brand-new file).
*/
function backupTarget({ scope, type, target, opts, trustedRoot }) {
function backupTarget({ scope, type, target, opts, trustedRoot, io = HOST_IO }) {
let root;
if (type === "memory") root = memoryBackupRoot(scope, opts);
else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target));
else root = backupRoot(scope, type, opts);
// The backup DESTINATION needs the same guard as the artifact: a link at
// `<root>/cc-config-backups` sends the copied contents outside the root.
if (trustedRoot) assertNoSymlinkEscape(trustedRoot, root, { label: "Claude" });
return createBackup({ source: target, backupRoot: root, type, name: path.basename(target) });
if (trustedRoot) guardTarget(io, trustedRoot, root);
return io.backup({ source: target, backupRoot: root, type, name: path.basename(target) });
}
// ── Public API ─────────────────────────────────────────────────────────
@@ -220,12 +333,14 @@ function backupTarget({ scope, type, target, opts, trustedRoot }) {
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
*/
function writeArtifact(args) {
const { scope, type, name, content, cwd, project } = args;
const { scope, type, name, content, cwd, project, home, noProject } = args;
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const r = resolveTarget(scope, type, name, { cwd, project });
const opts = { cwd, home, noProject };
const r = resolveTarget(scope, type, name, { ...opts, project });
const io = ioFor(r.trustedRoot);
// Containment guard: even after our regex, double-check that the resolved
// path actually lives under the expected root. Defends against quirks like
@@ -235,23 +350,24 @@ function writeArtifact(args) {
}
// The lexical check above is defeated by a symlink at the target or at any
// ancestor of it — see config-write.js. Resolve before writing.
assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" });
guardTarget(io, r.trustedRoot, r.target);
const existedBefore = fs.existsSync(r.filePath);
const existedBefore = io.exists(r.filePath);
const backupPath = existedBefore
? backupTarget({
scope,
type,
target: r.kind === "dir" ? r.target : r.filePath,
opts: { cwd },
opts,
trustedRoot: r.trustedRoot,
io,
})
: null;
if (r.kind === "dir") {
fs.mkdirSync(r.target, { recursive: true });
io.mkdir(r.target);
}
atomicWriteFile(r.filePath, content);
io.write(r.filePath, content);
return {
ok: true,
@@ -267,17 +383,19 @@ function writeArtifact(args) {
* the backup fails, the original is left intact.
*/
function deleteArtifact(args) {
const { scope, type, name, cwd, project } = args;
const r = resolveTarget(scope, type, name, { cwd, project });
const { scope, type, name, cwd, project, home, noProject } = args;
const opts = { cwd, home, noProject };
const r = resolveTarget(scope, type, name, { ...opts, project });
const io = ioFor(r.trustedRoot);
if (!isUnder(r.containmentRoot, r.target)) {
throw makeError("EOUTOFROOT", "resolved path is outside containment root");
}
// rmSync/unlinkSync only unlink the link itself, but the backup that runs
// first copies THROUGH it — so the same guard has to cover delete.
assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" });
guardTarget(io, r.trustedRoot, r.target);
if (!fs.existsSync(r.target)) {
if (!io.exists(r.target)) {
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
}
@@ -285,15 +403,14 @@ function deleteArtifact(args) {
scope,
type,
target: r.target,
opts: { cwd },
opts,
trustedRoot: r.trustedRoot,
io,
});
if (r.kind === "dir") {
rmTreeSync(r.target);
} else {
fs.unlinkSync(r.target);
}
if (io.acct) io.remove(r.target);
else if (r.kind === "dir") HOST_IO.remove(r.target);
else fs.unlinkSync(r.target);
return { ok: true, file: r.filePath, target: r.target, backupPath };
}
@@ -307,12 +424,12 @@ function deleteArtifact(args) {
// preserved and only the `bindings` array is replaced, so metadata is never
// dropped. Backups land under CLAUDE_HOME/cc-config-backups/keybindings/.
function keybindingsFile() {
return path.join(getClaudeHome(), "keybindings.json");
function keybindingsFile(opts) {
return path.join(homeOf(opts), "keybindings.json");
}
function keybindingsBackupRoot() {
return path.join(getClaudeHome(), "cc-config-backups", "keybindings");
function keybindingsBackupRoot(opts) {
return path.join(homeOf(opts), "cc-config-backups", "keybindings");
}
// A keybinding key ("ctrl+t", "escape", "shift+ctrl+f") or action id
@@ -331,7 +448,7 @@ function validKbString(s, max) {
* @param {{ groups: Array<{context:string, bindings:Array<{key:string,action:string}>}> }} args
*/
function writeKeybindings(args = {}) {
const { groups } = args;
const { groups, home } = args;
if (!Array.isArray(groups)) {
throw makeError("EBADCONTENT", "groups must be an array");
}
@@ -379,12 +496,13 @@ function writeKeybindings(args = {}) {
outBindings.push({ context, bindings: map });
}
const file = keybindingsFile();
const file = keybindingsFile({ home });
const io = ioFor(homeOf({ home }));
// Preserve any existing top-level metadata ($schema, $docs, unknown keys).
let base = {};
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
const parsed = JSON.parse(io.readText(file));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed;
} catch {
base = {};
@@ -396,16 +514,15 @@ function writeKeybindings(args = {}) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const existedBefore = fs.existsSync(file);
const existedBefore = io.exists(file);
let backupPath = null;
if (existedBefore) {
const root = keybindingsBackupRoot();
fs.mkdirSync(root, { recursive: true });
const root = keybindingsBackupRoot({ home });
backupPath = path.join(root, `keybindings.json.${timestamp()}.bak`);
fs.copyFileSync(file, backupPath);
io.copyFile(file, backupPath);
}
atomicWriteFile(file, content);
io.write(file, content);
return { ok: true, file, target: file, backupPath, created: !existedBefore };
}
@@ -417,9 +534,15 @@ function writeKeybindings(args = {}) {
// layer `local` (see cc-plugin-cli.js) — the two vocabularies are separate on
// purpose and never share a validator.
const SETTINGS_SCOPES = {
user: (opts) => path.join(getClaudeHome(), "settings.json"),
project: (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
"project-local": (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
user: (opts) => path.join(homeOf(opts), "settings.json"),
project: (opts) => {
assertProject(opts);
return path.join(getProjectClaudeDir(opts.cwd), "settings.json");
},
"project-local": (opts) => {
assertProject(opts);
return path.join(getProjectClaudeDir(opts.cwd), "settings.local.json");
},
};
/** A single dotted-path segment. `$` is allowed for `$schema`-style metadata. */
@@ -512,8 +635,8 @@ function unsetDeep(obj, parts) {
* @returns {{path:string, backupPath:string|null, created:boolean, changed:string[]}}
*/
function patchSettings(args) {
const { scope, cwd, set, unset } = args;
const opts = { cwd };
const { scope, cwd, set, unset, home, noProject } = args;
const opts = { cwd, home, noProject };
const file = settingsFileFor(scope, opts);
const setEntries =
@@ -531,13 +654,17 @@ function patchSettings(args) {
});
const parsedUnset = unsetPaths.map((key) => ({ parts: parseSettingsPath(key), key }));
const io = ioFor(path.dirname(file));
let existed = false;
let data = {};
let raw = null;
try {
raw = fs.readFileSync(file, "utf8");
raw = io.readText(file);
existed = true;
} catch {
} catch (err) {
// Too big, or not this account's own file: never "fresh" — that would
// overwrite it.
if (err.code === "ETOOLARGE" || err.code === "EOUTOFROOT") throw err;
/* fresh file */
}
if (existed) {
@@ -585,12 +712,11 @@ function patchSettings(args) {
let backupPath = null;
if (existed) {
const root = settingsBackupRoot(scope, opts);
fs.mkdirSync(root, { recursive: true });
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
fs.copyFileSync(file, backupPath);
io.copyFile(file, backupPath);
}
atomicWriteFile(file, output);
io.write(file, output);
return { path: file, backupPath, created: !existed, changed };
}
@@ -600,7 +726,9 @@ function patchSettings(args) {
*/
function listBackups(opts = {}) {
const out = [];
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
const scopes = (opts.scope ? [opts.scope] : ["user", "project"]).filter(
(s) => !(opts.noProject && s === "project")
);
// auto-memory backups live per-project, not under a user/project root — they
// are scanned separately below.
const types = (opts.type ? [opts.type] : Object.keys(TYPES)).filter((t) => t !== "auto-memory");
@@ -611,6 +739,7 @@ function listBackups(opts = {}) {
type === "memory" ? memoryBackupRoot(scope, opts) : backupRoot(scope, type, opts);
let entries = [];
try {
if (!memberPathOk(root)) continue;
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
@@ -642,11 +771,13 @@ function listBackups(opts = {}) {
(!opts.type || opts.type === "auto-memory") && (!opts.scope || opts.scope === "auto-memory");
if (wantAuto) {
try {
const projectsRoot = path.join(getClaudeHome(), "projects");
const projectsRoot = path.join(homeOf(opts), "projects");
if (!memberPathOk(projectsRoot)) throw new Error("not listable");
for (const proj of fs.readdirSync(projectsRoot)) {
const root = autoMemoryBackupRoot(path.join(projectsRoot, proj, "memory"));
let entries;
try {
if (!memberPathOk(root)) continue;
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
+123 -62
View File
@@ -21,11 +21,23 @@
* Left unset it stays unset, because the CLI's own defaults already write
* where `cc-discovery` looks — pinning it unconditionally is what once
* made every MCP add/remove write a file nothing reads.
* - S3, a member's own config (`acct` on any operation): the shared `claude`
* (`shared-tools.sharedPath`, ENOTSHARED when it is not published) runs
* AS the member — `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
* <claude> …` in their home, with their own default config dir. Only
* `--scope user`, and never `-y`: a plugin whose marketplace declares an
* install command fails instead of running unseen as them. Each account
* has its own queue, so one member's slow clone never holds up another's.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile } = require("node:child_process");
const { execFile: realExecFile } = require("node:child_process");
// Test seam: how `claude` (or `sudo … claude`) is spawned.
let execFile = realExecFile;
function __setExecFileImpl(fn) {
execFile = fn || realExecFile;
}
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
@@ -98,57 +110,48 @@ function assertMarketplaceName(value) {
return value;
}
function assertCliScope(value) {
function assertCliScope(value, acct) {
if (value === undefined || value === null || value === "") return "user";
if (!CLI_SCOPES.includes(value)) {
throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`);
}
// A member's CLI runs in their home, which is no project: only their user
// layer is theirs to change from here.
if (acct && value !== "user") {
throw makeError("EBADARG", "only scope user can be changed for your own config");
}
return value;
}
// One in-process queue for every mutating CLI call. The dashboard is a single
// Node process, so a promise chain is the whole locking story — no file lock,
// no cross-process coordination. It does NOT protect against a human running
// `claude plugin …` in a terminal at the same moment; the CLI owns that race.
let mutationQueue = Promise.resolve();
// One in-process queue per account (the host's is "host") for every mutating
// CLI call. The dashboard is a single Node process, so a promise chain is the
// whole locking story — no file lock, no cross-process coordination. It does
// NOT protect against a human running `claude plugin …` in a terminal at the
// same moment; the CLI owns that race.
const mutationQueues = new Map();
function serialize(fn) {
const run = mutationQueue.then(fn, fn);
mutationQueue = run.then(
() => undefined,
() => undefined
function serialize(fn, acct) {
const key = acct ? acct.login : "host";
const queue = mutationQueues.get(key) || Promise.resolve();
const run = queue.then(fn, fn);
mutationQueues.set(
key,
run.then(
() => undefined,
() => undefined
)
);
return run;
}
/**
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
* normal result (`ok:false`), not a rejection — callers map it to a response
* body. Only a spawn failure (binary missing) rejects.
*
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
* @param {{timeoutMs?:number}} [opts]
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
* `[command, argv, options]` for `claude <args>`: the host's own `claude` on
* PATH, or — for `acct` (S3) — the shared one AS the member through sudo,
* with only HOME and DISABLE_AUTOUPDATER set (sudo resets the rest; their
* default config dir is their own `~/.claude`), in their home.
*/
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
// It also predates the confirmation prompt the flag answers, so the same
// command without it is the correct retry, not a weaker one.
const NO_YES_FLAG = /unknown option '-y'/;
async function runClaude(args, opts = {}) {
const result = await runClaudeOnce(args, opts);
if (!result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
return runClaudeOnce(
args.filter((a) => a !== "-y"),
opts
);
}
return result;
}
function runClaudeOnce(args, opts = {}) {
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
function claudeCommand(args, acct) {
if (!acct) {
// Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME
// (a non-default install). Left unset, the CLI's own default config dir
// is `~/.claude` and it writes `.claude.json` NEXT TO it (`~/.claude.json`)
@@ -161,13 +164,62 @@ function runClaudeOnce(args, opts = {}) {
// that branch, so both cases line up. Keep the two in step.
const env = { ...process.env };
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
return ["claude", args, { env }];
}
const claude = require("./shared-tools").sharedPath("claude");
if (!claude) throw makeError("ENOTSHARED", "claude is not in the shared tools yet");
// Never -y for a member, and never a --dangerously-* flag (none is built
// here; the validators refuse any value starting with "-").
const safe = args.filter((a) => a !== "-y");
const [cmd, argv] = require("./run-as").sudoArgv(acct, claude, safe, {
HOME: acct.home,
DISABLE_AUTOUPDATER: "1",
});
return [cmd, argv, { env: process.env, cwd: acct.home }];
}
/**
* Run `claude <args…>` and resolve with the outcome. A non-zero exit is a
* normal result (`ok:false`), not a rejection — callers map it to a response
* body. Only a spawn failure (binary missing) rejects.
*
* @param {string[]} args argv AFTER the binary name; every element pre-validated.
* @param {{timeoutMs?:number, acct?:object}} [opts] `acct`: run AS that member (S3).
* @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>}
*/
// A Claude Code older than `plugin install|update -y` rejects the flag outright.
// It also predates the confirmation prompt the flag answers, so the same
// command without it is the correct retry, not a weaker one.
const NO_YES_FLAG = /unknown option '-y'/;
async function runClaude(args, opts = {}) {
const result = await runClaudeOnce(args, opts);
if (!opts.acct && !result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) {
return runClaudeOnce(
args.filter((a) => a !== "-y"),
opts
);
}
return result;
}
function runClaudeOnce(args, opts = {}) {
const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
return new Promise((resolve, reject) => {
let cmd, argv, spawnOpts;
try {
[cmd, argv, spawnOpts] = claudeCommand(args, opts.acct);
} catch (err) {
reject(err);
return;
}
const child = execFile(
"claude",
args,
cmd,
argv,
{
timeout,
maxBuffer: MAX_OUTPUT_BYTES,
env,
...spawnOpts,
},
(err, stdout, stderr) => {
const out = String(stdout || "");
@@ -201,51 +253,55 @@ function runClaudeOnce(args, opts = {}) {
// Each validates first, then queues. Validation errors throw synchronously
// from the returned promise, which is what the routes turn into a 400.
async function marketplaceAdd({ source, scope }) {
// `acct` (S3): run AS that member, on their own config — see the file header.
const queued = (args, acct) => serialize(() => runClaude(args, { acct }), acct);
async function marketplaceAdd({ source, scope, acct }) {
const src = assertSource(source);
const sc = assertCliScope(scope);
return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc]));
const sc = assertCliScope(scope, acct);
return queued(["plugin", "marketplace", "add", src, "--scope", sc], acct);
}
async function marketplaceRemove({ name }) {
async function marketplaceRemove({ name, acct }) {
const n = assertMarketplaceName(name);
return serialize(() => runClaude(["plugin", "marketplace", "remove", n]));
return queued(["plugin", "marketplace", "remove", n], acct);
}
async function marketplaceUpdate({ name }) {
async function marketplaceUpdate({ name, acct }) {
const n = assertMarketplaceName(name);
return serialize(() => runClaude(["plugin", "marketplace", "update", n]));
return queued(["plugin", "marketplace", "update", n], acct);
}
async function pluginInstall({ spec, scope }) {
async function pluginInstall({ spec, scope, acct }) {
const s = assertPluginSpec(spec);
const sc = assertCliScope(scope);
const sc = assertCliScope(scope, acct);
// `-y` accepts the marketplace-declared install command without a prompt;
// required because stdin is not a TTY here.
return serialize(() => runClaude(["plugin", "install", s, "--scope", sc, "-y"]));
// required because stdin is not a TTY here. Never for a member (dropped by
// claudeCommand): there such a plugin fails instead.
return queued(["plugin", "install", s, "--scope", sc, "-y"], acct);
}
async function pluginUninstall({ spec }) {
async function pluginUninstall({ spec, acct }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "uninstall", s]));
return queued(["plugin", "uninstall", s], acct);
}
async function pluginEnable({ spec }) {
async function pluginEnable({ spec, acct }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "enable", s]));
return queued(["plugin", "enable", s], acct);
}
async function pluginDisable({ spec }) {
async function pluginDisable({ spec, acct }) {
const s = assertPluginSpec(spec);
return serialize(() => runClaude(["plugin", "disable", s]));
return queued(["plugin", "disable", s], acct);
}
async function pluginUpdate({ spec }) {
async function pluginUpdate({ spec, acct }) {
const s = assertPluginSpec(spec);
// `-y` for the same reason as pluginInstall: the CLI requires it whenever
// stdin is not a TTY (runClaude closes stdin), and without it an update whose
// marketplace declares an install command fails on a prompt nobody can answer.
return serialize(() => runClaude(["plugin", "update", s, "-y"]));
return queued(["plugin", "update", s, "-y"], acct);
}
/**
@@ -253,10 +309,14 @@ async function pluginUpdate({ spec }) {
* registered marketplace, plus what is already installed. Read-only, so it
* skips the mutation queue.
*
* @param {{acct?: object}} [opts] `acct`: the member's own list (S3).
* @returns {Promise<{installed:object[], available:object[]}>}
*/
async function listAvailable() {
const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 });
async function listAvailable({ acct } = {}) {
const res = await runClaude(["plugin", "list", "--available", "--json"], {
timeoutMs: 30_000,
acct,
});
if (!res.ok) {
throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed");
}
@@ -309,4 +369,5 @@ module.exports = {
assertMarketplaceName,
assertCliScope,
PLUGIN_SPEC_RE,
__setExecFileImpl,
};
+11 -5
View File
@@ -20,6 +20,11 @@
*
* Only `claude` lanes are addressed: Codex has no such command, and typing a
* slash command into a Codex pane would land as literal prompt text.
*
* Whose panes: by default the host's own (a member's pane runs on their own
* `~/.claude`, which a change to the host's config does not touch). With
* `account` (S3: a member reloading after changing their OWN config) only the
* panes that run as that member, and nobody else's.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -60,7 +65,7 @@ const RELOAD_COMMANDS = {
*
* @returns {{id: string, laneId: number|null, paneCommand: string|null}[]}
*/
function reloadableLanes() {
function reloadableLanes(account = null) {
const out = [];
for (const run of listRuns()) {
if (run.status !== "running") continue;
@@ -70,7 +75,8 @@ function reloadableLanes() {
// EMEMBERGONE (a disabled member's pane) is a member's too: skip it,
// never let one throw end the reload for every other pane.
try {
if (require("./run-as").accountForSession(run.id)) continue;
const acct = require("./run-as").accountForSession(run.id);
if (account ? acct?.name !== account.name : acct) continue;
} catch {
continue;
}
@@ -99,10 +105,10 @@ function reloadableLanes() {
* separate submissions, and waiting for that without blocking the event loop is
* the only way a live dashboard keeps broadcasting while it types.
*
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number}} [options] which
* @param {{kinds?: ("plugins"|"skills")[], settleMs?: number, account?: object}} [options] which
* commands to send; defaults to both, plugins first (a skill shipped BY a
* plugin only exists on disk once the plugin has been reloaded). `settleMs`
* is a test seam — no route passes it.
* is a test seam — no route passes it. `account`: only that member's panes.
* @returns {Promise<{lanes: {id: string, laneId: number|null, sent: string[], error?: string}[],
* commands: string[], lanes_total: number}>}
*/
@@ -112,7 +118,7 @@ async function reloadOpenSessions(options = {}) {
const settleMs = options.settleMs ?? SETTLE_MS;
const lanes = [];
for (const lane of reloadableLanes()) {
for (const lane of reloadableLanes(options.account || null)) {
const sent = [];
let error;
for (const command of commands) {
+135
View File
@@ -0,0 +1,135 @@
/**
* @file cc-target.js
* @description Whose Claude Code configuration a `/api/cc-config` request reads
* and writes — the ONE resolver every handler of `routes/cc-config.js` uses,
* and the allowlist of the endpoints a non-admin may reach there at all.
*
* - Local mode, or an admin: the host's Claude home (`getClaudeHome()`), with
* every handler behaving exactly as it did before members had a config page.
* - Server mode, a non-admin with an enabled, provisioned Linux account (S3):
* their OWN `<home>/.claude` and `<home>/.claude.json`. Their writes run as
* them (cc-mutate's `ioFor`, cc-plugin-cli's sudo), never as the host.
* - An analyst with no such account, reading: the host's, read-only — what an
* analyst read before. `?view=host` gives an analyst WITH an account that
* same read-only view.
* - Anyone else (a developer with no account, any non-admin write without
* one): 403 ENOACCOUNT — never "the host's then".
*
* Nothing here takes a home or a path from the request.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("node:path");
const { getClaudeHome } = require("./claude-home");
/**
* What a non-admin may call under `/api/cc-config` ("METHOD /path", lower
* case, HEAD counted as GET): reads of their own config and the writes that
* run as them. Everything else — `/profiles*` (the host's login switching),
* `/plugins/update-all` (the host's Update all, AgentHub's own plugin
* included), and any endpoint added later — stays an admin's until it is
* listed here.
*/
const MEMBER_ROUTES = new Set([
"GET /target",
"GET /overview",
"GET /skills",
"GET /agents",
"GET /commands",
"GET /output-styles",
"GET /plugins",
"GET /plugins/available",
"GET /mcp",
"GET /hooks",
"GET /settings",
"GET /memory",
"GET /marketplaces",
"GET /keybindings",
"GET /statusline",
"GET /hook-scripts",
"GET /file",
"GET /backups",
"PUT /keybindings",
"PUT /file",
"DELETE /file",
"PATCH /settings",
"POST /project-memory",
"POST /plugins/install",
"POST /plugins/action",
"POST /marketplaces/add",
"POST /marketplaces/action",
"POST /mcp/add",
"POST /mcp/remove",
"POST /reload",
]);
/** The host-wide reads an analyst had before (staff read everything): still read-only. */
const ANALYST_READS = new Set(["GET /profiles", "GET /plugins/update-all"]);
/** `"METHOD /path"` as the allowlists spell it (decoded, lower case, no trailing slash). */
function routeKey(req) {
const p = require("./auth").apiPath(req).toLowerCase().replace(/\/+$/, "") || "/";
const method = req.method === "HEAD" ? "GET" : String(req.method || "").toUpperCase();
return `${method} ${p}`;
}
/** Whether `req.user` may reach this cc-config endpoint at all (local mode and admins: yes). */
function mayReach(req) {
const u = req.user;
if (!u || u.role === "admin") return true;
const key = routeKey(req);
return MEMBER_ROUTES.has(key) || (u.role === "analyst" && ANALYST_READS.has(key));
}
/** Express middleware: the router's own gate (403 EADMIN), the single rule for `/cc-config`. */
function ccGate(req, res, next) {
if (mayReach(req)) return next();
res.status(403).json({ error: { code: "EADMIN", message: "only an admin may open this" } });
}
const isRead = (req) => req.method === "GET" || req.method === "HEAD";
function host(readOnly = false) {
return {
acct: null,
own: false,
readOnly,
home: getClaudeHome(),
claudeJson: require("./cc-discovery").getClaudeJsonPath(),
};
}
/**
* The target of `req`: `{acct, own, readOnly, home, claudeJson}` — `acct` and
* `own` set for a member's own config.
* @throws ENOACCOUNT (status 403) for a non-admin with no account to act on
*/
function resolveCcTarget(req) {
const u = req.user;
if (!u || u.role === "admin") return host();
if (u.role === "analyst" && isRead(req) && req.query?.view === "host") return host(true);
let acct = null;
try {
acct = require("./run-as").accountForUser(u.username);
} catch {
acct = null; // a disabled / unprovisioned account is no account: refuse below
}
if (acct) {
return {
acct,
own: true,
readOnly: false,
home: path.join(acct.home, ".claude"),
claudeJson: path.join(acct.home, ".claude.json"),
};
}
if (u.role === "analyst" && isRead(req)) return host(true);
throw Object.assign(
new Error(
"your Claude Code config lives in your own account, and this server has none for you yet"
),
{ code: "ENOACCOUNT", status: 403 }
);
}
module.exports = { resolveCcTarget, ccGate, mayReach, routeKey, MEMBER_ROUTES, ANALYST_READS };
+1 -1
View File
@@ -11,7 +11,7 @@
const fs = require("node:fs");
const path = require("node:path");
const yaml = require("js-yaml");
const yaml = require("./yaml");
const { parseEnvFile } = require("./lane-profile");
const { isListening } = require("./ports");
const { SECRETS_PATH } = require("./secrets");
+2 -2
View File
@@ -190,7 +190,7 @@ function providerEntry() {
* member's, and is never overwritten with something it did not say.
*/
function mergedModels(text) {
const yaml = require("js-yaml");
const yaml = require("./yaml");
const doc = text && text.trim() ? yaml.load(text) : {};
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
throw new Error("models.yml is not a YAML mapping");
@@ -227,7 +227,7 @@ function writeMemberModels(acct) {
/** The host's own default omp model (`modelRoles.default` in `~/.omp/agent/config.yml`), or null. */
function hostDefaultModel() {
try {
const doc = require("js-yaml").load(
const doc = require("./yaml").load(
fs.readFileSync(path.join(ompDir(), "agent", "config.yml"), "utf8")
);
const m = doc?.modelRoles?.default;
+1 -1
View File
@@ -51,7 +51,7 @@ function loadAll() {
icon: n.icon || "",
gate: !!n.gate,
// The one command that drives THIS node — a slash command
// (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook
// (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook
// ci-gate`), mirroring whatever the node's own `detect` rules watch
// for. `doc.skill` above names the driver for the whole pipeline;
// this narrows it to the step, so a reader of the pipeline map can
+11 -1
View File
@@ -15,6 +15,9 @@
* clobber an existing memory file;
* - a symlink at `<dir>/CLAUDE.md` pointing elsewhere is refused.
*
* Under S3 the file is written AS the account whose home the folder is in
* (`run-as.accountForPath`), never by the host.
*
* The capability is not new in kind: the Run page already spawns `claude` in
* any existing directory the same way. What it must not become is a general
* "write arbitrary content to an arbitrary path" endpoint — hence the fixed
@@ -91,7 +94,14 @@ function createProjectMemory({ tool, dir, content }) {
}
if (taken) throw makeError("EEXISTS", `${filename} already exists in ${realDir}`);
atomicWriteFile(file, content);
// S3: a folder in a member's home is written AS that member (`write-as`,
// content on stdin) — an admin's create there included; the host never
// writes inside a member's home itself. EMEMBERGONE (no usable account)
// refuses, never "the host then".
const runAs = require("./run-as");
const acct = runAs.accountForPath(file);
if (acct) runAs.writeAs(acct, file, "replace", content);
else atomicWriteFile(file, content);
return { file, size: Buffer.byteLength(content, "utf8") };
}
+1 -1
View File
@@ -249,7 +249,7 @@ function deleteProof(laneId, { slug, group, images } = {}) {
* already points at the right place.
*
* Never auto-invoked by this codebase; exposed only as the explicit
* `agenthub lanes proof-link` primitive.
* `agenthub workspace proof-link` primitive.
*
* @param {object} lane - A hydrated lane row (must have `cwd`).
* @returns {{linked: boolean}} `linked: false` when nothing changed.
+2 -2
View File
@@ -15,7 +15,7 @@
*
* Every session is named `agenthub-lane-<laneId>` so a real terminal can attach
* to the exact same session (`tmux attach -t agenthub-lane-<id>`, or
* `agenthub lanes shell`) — that's the whole point: the dashboard both creates
* `agenthub workspace shell`) — that's the whole point: the dashboard both creates
* the session (one-click Start/Resume) and is just one of possibly several
* attached clients tmux already keeps in sync.
*
@@ -719,7 +719,7 @@ function spawnRun(args) {
schedulePaste();
} else if (SHELL_COMMANDS.has(tmux.paneCommand(id) || "")) {
// The session exists but its pane is sitting at a bare shell prompt — a
// `agenthub lanes shell`, or a `claude` that already exited. Adopting it
// `agenthub workspace shell`, or a `claude` that already exited. Adopting it
// silently here would swallow the whole request: a Resume would spawn no
// `--resume` and an initial prompt would never be typed, while the API
// still answered 200. Run the argv in the pane the user already sees
+11
View File
@@ -603,6 +603,16 @@ function makeMemberFolder(dir) {
}
const removeAs = (acct, p) => writeAs(acct, p, "remove");
/**
* `write-as <src> copy <dest>` as `acct` (sync, bounded like every write-as):
* a recursive copy that never follows a link and never lands on an existing
* name — a backup of a member's own file, made by the member.
*/
function copyAs(acct, src, dest) {
const [cmd, argv] = sudoArgv(acct, WRITE_AS, [src, "copy", dest]);
execImpl(cmd, argv, { ...MEMBER_EXEC, input: "", stdio: ["pipe", "ignore", "pipe"] });
}
/** `[cmd, argv]` for `command args` in `cwd`: as its member, else as is. */
function commandIn(cwd, command, args, env = {}) {
const acct = accountForPath(cwd);
@@ -648,6 +658,7 @@ module.exports = {
mkdirAs,
makeMemberFolder,
removeAs,
copyAs,
launchArgv,
sameOwner,
openSafe,
+15
View File
@@ -320,6 +320,7 @@ function broadcastAudience(type, data) {
return null;
// A member's prompt box refetches its command list on these, but the
// payload names the host's profiles and files: members get the type only.
// (A member's OWN config change never gets here: `exactAudience`.)
case "cc_config_changed":
case "codex_config_changed":
return { strip: true };
@@ -328,6 +329,19 @@ function broadcastAudience(type, data) {
}
}
/**
* A broadcast meant for exactly these users, whatever their role, or null for
* the role-based rules above. Pure (no DB): asked for every client. A change
* to a member's OWN Claude Code config (`cc_config_changed` with `ownerId`,
* routes/cc-config.js) names their files: it reaches that member and nobody
* else, an admin or analyst included.
*/
function exactAudience(type, data) {
const d = data && typeof data === "object" ? data : {};
if (type === "cc_config_changed" && d.ownerId != null) return new Set([d.ownerId]);
return null;
}
/** Whether a client logged in as `user` (undefined in local mode) gets it. */
function audienceIncludes(audience, user) {
if (!user || user.role !== "developer") return true;
@@ -448,6 +462,7 @@ module.exports = {
folderTakenByOther,
scopeGuard,
broadcastAudience,
exactAudience,
audienceIncludes,
sessionOwners,
PATH_RULES,
+26
View File
@@ -0,0 +1,26 @@
/**
* @file send-file.js
* @description `res.sendFile` / `res.download` for an absolute path the server
* chose itself, with Express 4's dotfile rule. Express 5 (send 1.x) refuses
* with 404 any absolute path that has a dot-segment ANYWHERE — `~/.claude/…`
* (the plugin cache, the default data dir), `~/.local/…` — where Express 4
* looked only at the file's own name. Sending the basename against its
* directory as `root` makes send check that name alone again, so a dotfile is
* still refused and a dotted parent directory is not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const path = require("path");
/** `res.sendFile(abs, opts, cb)` with only the basename subject to `dotfiles`. */
function sendFile(res, abs, opts, cb) {
if (typeof opts === "function") [cb, opts] = [opts, undefined];
return res.sendFile(path.basename(abs), { ...opts, root: path.dirname(abs) }, cb);
}
/** `res.download(abs, name)` with only the basename subject to `dotfiles`. */
function download(res, abs, name, cb) {
return res.download(path.basename(abs), name, { root: path.dirname(abs) }, cb);
}
module.exports = { sendFile, download };
+1 -1
View File
@@ -3,7 +3,7 @@
* @description Thin wrapper around the `tmux` CLI for the terminal-run
* feature. Every dashboard-managed session is named `agenthub-lane-<id>` (see
* `pty-run.js`) so a real terminal can attach to the exact same session with
* `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes shell`). Never builds a
* `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace shell`). Never builds a
* shell string — every call is `execFileSync("tmux", [...argv])` (a member's
* listing: `execFile`) with an explicit argument array (matches this repo's
* rule for git in worktree.js).
+35
View File
@@ -0,0 +1,35 @@
/**
* @file yaml.js
* @description js-yaml 5 with js-yaml 4's loading rules, for the YAML files
* AgentHub reads from a user (a repo's docker-compose file, omp's
* `models.yml`/`config.yml`). js-yaml 5's `load` uses the bare YAML 1.2 core
* schema — no `<<` merge keys, no timestamps, `!!binary`/`!!set` refused — and
* throws on an empty document; 4's default schema was core plus exactly those
* tags, and an empty document loaded as `undefined`. A member's file that
* loaded before must load the same now (and `mergedModels` rewrites it).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const yaml = require("js-yaml");
/** js-yaml 4's DEFAULT_SCHEMA. */
const V4_SCHEMA = yaml.CORE_SCHEMA.withTags([
yaml.mergeTag,
yaml.timestampTag,
yaml.binaryTag,
yaml.omapTag,
yaml.pairsTag,
yaml.setTag,
]);
/** `yaml.load` as js-yaml 4 did it: undefined for a document-less text. */
function load(text) {
try {
return yaml.load(text, { schema: V4_SCHEMA });
} catch (err) {
if (err && /input is empty/.test(err.reason || "")) return undefined;
throw err;
}
}
module.exports = { load, dump: yaml.dump, V4_SCHEMA };
+14
View File
@@ -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: {
+30 -1
View File
@@ -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"],
+10 -2
View File
@@ -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" } },
},
},
+16
View File
@@ -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
? {
+5 -2
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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");
+2 -2
View File
@@ -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
View File
@@ -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(
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -10,7 +10,7 @@
*/
const { Router } = require("express");
const { v4: uuidv4 } = require("uuid");
const { randomUUID: uuidv4 } = require("crypto");
const { stmts } = require("../db");
const { invalidateWebhookCache, normalizeTarget, sendTest } = require("../lib/webhooks");
const {
+11 -2
View File
@@ -6,7 +6,7 @@
const { WebSocketServer } = require("ws");
const { isHostAllowed, isWebSocketAuthorized } = require("./lib/security");
const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/auth");
const { broadcastAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
let wss = null;
// How often an open terminal's login is re-checked in server mode.
@@ -229,7 +229,16 @@ function broadcast(type, data) {
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
// Server mode: worked out once, and only when a member is connected.
let audience;
// A message for exactly these users (a member's own config change): no
// one else, whatever their role. Pure, so asked once for every broadcast.
let only = null;
try {
only = exactAudience(type, data);
} catch {
only = new Set();
}
const reaches = (client) => {
if (only) return Boolean(client.user && only.has(client.user.id));
if (!client.user || client.user.role !== "developer")
return !isServerMode() || Boolean(client.user);
if (audience === undefined) {
@@ -246,7 +255,7 @@ function broadcast(type, data) {
if (client.readyState === 1 && reaches(client)) {
try {
const member = client.user && client.user.role === "developer";
if (member && audience && audience.strip) {
if (!only && member && audience && audience.strip) {
stripped ??= JSON.stringify({ type, data: {}, timestamp: new Date().toISOString() });
client.send(stripped);
return;