Files
nntrivi2001 84e2a9f0ac feat(server-s3): one Linux user per account (S3) — experimental
Server mode can now run each account as its own Linux user (ah-<name>)
when `agenthub server install-system` has set S3 up. The dashboard
reaches a member only through sudo -u / write-as / pane-launch, reads a
member's files only through owner- and type-checked opens, and hooks
are authenticated by peer uid. Local mode and server mode without S3
are unchanged.

EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not
yet been verified on a real install with real member accounts, sudo or
a real Codex login. Do not offer it to real users yet.

- S3 core: provisioning, per-member tmux servers, the proxy key
  gateway, shared tools in /opt, dropped capabilities for children
  (P0–P5)
- P4: plan and quota per member home
- P6: ~/.claude.json seed; opt-in plugin baseline installed as the
  member; managed-settings spike script (not run)
- P7: Codex account pool (`agenthub accounts add --tool codex`)
- Roles merged with main's admin / analyst / developer: analysts write
  only their own rows, under S3 as well
- Per-account prefs: one implementation (Chat UI + onboarding)
- Security reviews F1–F49 (five rounds), each fix with a regression
  test: symlink/TOCTOU-safe member reads, no host writes in a member
  home, credentials and the data dir never served, owner and folder
  account must agree, pane tokens bound to the pane's uid, bounded
  sudo/tmux/git calls and member reads (no hang or crash from a member),
  async route rejections answer 500 instead of exiting
- Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator
  runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules

Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box);
client 1162/1162; tsc clean; headers check passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-09 14:57:10 +07:00

663 lines
23 KiB
JavaScript

/**
* @file cc-config.js
* @description HTTP surface for inspecting and (carefully) mutating Claude
* Code configuration: skills, subagents, slash commands, output styles,
* plugins, marketplaces, MCP servers, hooks, settings, memory, keybindings,
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
* page.
*
* Read paths cover every surface. Write paths cover: low-risk text-file
* artifacts (skills, agents, commands, output styles, memory, per-project
* auto-memory), the structured keybindings.json editor, settings.json layers
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
* plugin/marketplace management delegated to the `claude plugin …` CLI, and
* MCP server add/remove delegated to the `claude mcp …` CLI. Every file
* write is preceded by a timestamped backup; the CLI-delegated writes are
* not files this server touches directly, so there is nothing to back up —
* the CLI owns `~/.claude.json`.
*
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
* account the local CLI runs as; those carry identity metadata only, never
* token material.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const path = require("path");
const cc = require("../lib/cc-discovery");
const ccMutate = require("../lib/cc-mutate");
const ccCli = require("../lib/cc-plugin-cli");
const ccMcpCli = require("../lib/cc-mcp-cli");
const ccProfiles = require("../lib/cc-profiles");
const ccUsage = require("../lib/cc-usage");
const projectMemory = require("../lib/project-memory");
const { sameOriginGuard } = require("../lib/same-origin");
const { broadcast } = require("../websocket");
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");
/** 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.
router.use(scope.ownCwdQuery);
function emitChanged(payload) {
try {
broadcast("cc_config_changed", { source: "dashboard", ...payload });
} catch {
/* websocket may not be initialised in unit tests */
}
}
// Map mutate-error codes → HTTP status. Anything unmapped is a 500.
const ERR_TO_STATUS = {
EBADTYPE: 400,
EBADSCOPE: 400,
EBADNAME: 400,
EBADPROJECT: 400,
EBADCONTENT: 400,
EBADDIR: 400,
EBADTOOL: 400,
// The project already has a memory file; creating would clobber it.
EEXISTS: 409,
ETOOLARGE: 413,
EOUTOFROOT: 400,
ENOTFOUND: 404,
EREDACTED: 400,
// The file on disk is unusable as-is; the caller must fix it by hand first.
EBADJSON: 409,
};
function mutateError(res, err) {
const status = ERR_TO_STATUS[err.code] || 500;
return res
.status(status)
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
function scopeOf(req) {
const s = String(req.query.scope || "all");
return s === "user" || s === "project" ? s : "all";
}
function cwdOf(req) {
// The dashboard server's own cwd is the natural "project" — but allow
// override via ?cwd= so the user can inspect another working dir without
// restarting the server.
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
return c || process.cwd();
}
/**
* `{scope, cwd}` for the two lists a member may read (the run form's `/`
* autocomplete). Server mode keeps a member's project items to their own
* workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is
* no project at all (never the dashboard's own working directory). Returns null
* once it has answered; `none` = nothing to list.
*/
function listOpts(req, res) {
const opts = { scope: scopeOf(req), cwd: cwdOf(req) };
if (scope.memberId(req) == null) return opts;
const asked = typeof req.query.cwd === "string" && req.query.cwd;
if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" };
if (!path.isAbsolute(asked) || !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 opts;
}
router.get("/overview", (req, res) => {
res.json(cc.readOverview({ cwd: cwdOf(req) }));
});
router.get("/skills", (req, res) => {
const opts = listOpts(req, res);
if (opts) res.json({ items: opts.none ? [] : cc.readSkills(opts) });
});
router.get("/agents", (req, res) => {
res.json({ items: cc.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/commands", (req, res) => {
const opts = listOpts(req, res);
if (opts) res.json({ items: opts.none ? [] : cc.readCommands(opts) });
});
router.get("/output-styles", (req, res) => {
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
});
router.get("/plugins", (_req, res) => {
res.json(cc.readPlugins());
});
router.get("/mcp", (req, res) => {
res.json(cc.readMcpServers({ cwd: cwdOf(req) }));
});
router.get("/hooks", (req, res) => {
res.json({ items: cc.readHooks({ cwd: cwdOf(req) }) });
});
router.get("/settings", (req, res) => {
res.json({ items: cc.readSettings({ cwd: cwdOf(req) }) });
});
router.get("/memory", (req, res) => {
res.json({ items: cc.readMemory({ cwd: cwdOf(req) }) });
});
router.get("/marketplaces", (_req, res) => {
res.json(cc.readMarketplaces());
});
router.get("/keybindings", (_req, res) => {
res.json(cc.readKeybindings());
});
// PUT /api/cc-config/keybindings — structured overwrite of the user's
// ~/.claude/keybindings.json. Body: { groups: [{ context, bindings: [{ key,
// action }] }] }. Backs the file up first and preserves top-level metadata.
router.put("/keybindings", (req, res) => {
const { groups } = req.body || {};
if (!Array.isArray(groups)) {
return res
.status(400)
.json({ error: { code: "EBADREQ", message: "groups array is required" } });
}
try {
const result = ccMutate.writeKeybindings({ groups });
emitChanged({ action: "write", type: "keybindings", name: null, project: null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
router.get("/statusline", (_req, res) => {
res.json(cc.readStatusline());
});
router.get("/hook-scripts", (_req, res) => {
res.json(cc.readHookScripts());
});
// 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.
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) });
if (result.error)
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
res.json(result);
});
// ── Phase-2 mutation endpoints ─────────────────────────────────────────
//
// PUT /api/cc-config/file — create or overwrite. Body: { scope, type, name?, content }
// DELETE /api/cc-config/file — delete. Body: { scope, type, name? }
// GET /api/cc-config/backups[?scope=&type=] — list backups
//
// Plugins, MCP, hooks-in-settings, and settings.json files are intentionally
// not mutable here. See cc-mutate.js for the rationale.
router.put("/file", (req, res) => {
const { scope, type, name, content, project } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
.status(400)
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
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.
const result = ccMutate.writeArtifact({
scope,
type,
name,
content,
project,
cwd: process.cwd(),
});
emitChanged({ action: "write", scope, type, name: name || null, project: project || null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
router.delete("/file", (req, res) => {
const { scope, type, name, project } = req.body || {};
if (typeof scope !== "string" || typeof type !== "string") {
return res
.status(400)
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
}
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 });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
/**
* POST /api/cc-config/project-memory — create `CLAUDE.md` in a project the
* dashboard has never run in. Body: `{ dir, content }`.
*
* The one write here that honours a caller-supplied directory, which is the
* whole point: you cannot bootstrap a NEW project's memory from a writer
* 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.
*/
router.post("/project-memory", sameOriginGuard, (req, res) => {
const { dir, content } = req.body || {};
try {
const result = projectMemory.createProjectMemory({ tool: "claude", dir, content });
emitChanged({ action: "write", scope: "project", type: "memory", name: null });
res.json({ ...result, created: true });
} catch (err) {
return mutateError(res, err);
}
});
// ── Plugins / marketplaces (via the `claude` CLI) ──────────────────────
//
// These shell out to `claude plugin …` with an argv array — never a shell
// string — because the CLI owns the manifest + cache tree that plugin state
// lives in. Identifiers travel in the body, not the URL, so a key like
// `agenthub@sg` needs no encoding (same reason `DELETE /file` takes a body).
const CLI_ERR_TO_STATUS = {
EBADARG: 400,
ECLINOTFOUND: 503,
ETIMEOUT: 504,
ECLIFAILED: 502,
};
function cliError(res, err) {
const status = CLI_ERR_TO_STATUS[err.code] || 500;
return res
.status(status)
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
function cliResult(res, result, changed, extra) {
if (!result.ok) {
return res.status(422).json({
error: {
code: "ECLIEXIT",
message: ccCli.cliMessage(result) || `claude exited with code ${result.code}`,
},
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
});
}
emitChanged(changed);
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr, ...extra });
}
router.get("/plugins/available", async (_req, res) => {
try {
res.json(await ccCli.listAvailable());
} catch (err) {
return cliError(res, err);
}
});
router.post("/plugins/install", async (req, res) => {
const { spec, scope } = req.body || {};
try {
const result = await ccCli.pluginInstall({ spec, scope });
return cliResult(res, result, { action: "install", type: "plugin", name: spec });
} catch (err) {
return cliError(res, err);
}
});
const PLUGIN_ACTIONS = {
enable: ccCli.pluginEnable,
disable: ccCli.pluginDisable,
update: ccCli.pluginUpdate,
uninstall: ccCli.pluginUninstall,
};
router.post("/plugins/action", async (req, res) => {
const { key, action, reload } = req.body || {};
const run = PLUGIN_ACTIONS[action];
if (!run) {
return res.status(400).json({
error: {
code: "EBADACTION",
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
},
});
}
try {
const result = await run({ spec: key });
// "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
// reload after a FAILED update would tell the session to activate nothing.
let reloaded;
if (reload && result.ok) {
try {
reloaded = await reloadOpenSessions();
} catch {
/* a reload that cannot be delivered must not fail the update itself */
}
}
return cliResult(res, result, { action, type: "plugin", name: key }, { reloaded });
} catch (err) {
return cliError(res, err);
}
});
/**
* 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`.
*/
router.post("/reload", async (req, res) => {
const { kinds } = req.body || {};
if (kinds !== undefined && !Array.isArray(kinds)) {
return res.status(400).json({
error: { code: "EBADARG", message: "kinds must be an array of 'plugins' | 'skills'" },
});
}
try {
res.json({ ok: true, ...(await reloadOpenSessions({ kinds })) });
} catch (err) {
res.status(500).json({
error: { code: "ERELOAD", message: err.message || String(err) },
});
}
});
// ── Update all ─────────────────────────────────────────────────────────
//
// Runs detached (see server/lib/cc-update-all.js): the batch ends with AgentHub
// itself when this server runs from the plugin cache, and that step stops the
// process. The response is therefore only an acknowledgement — progress is
// polled from GET below, which reads the worker's on-disk journal.
router.get("/plugins/update-all", (_req, res) => {
res.json({ batch: readUpdateAll() });
});
router.post("/plugins/update-all", async (req, res) => {
if (isRunning()) {
return res.status(409).json({
error: { code: "EBUSY", message: "an update-all batch is already running" },
batch: readUpdateAll(),
});
}
let installed;
try {
installed = (await ccCli.listAvailable()).installed || [];
} catch (err) {
return cliError(res, err);
}
// Ids come from the CLI's own JSON, never from the request body, but they
// reach an argv in a detached process — so they are filtered here too.
const plugins = installed.map((p) => String(p.id || "")).filter((id) => PLUGIN_SPEC_RE.test(id));
// Which plugin, if any, is the one this dashboard is running from. Derived
// from our own path — never from the request — so no caller can nominate a
// different plugin as "self" and get a bootstrap run against it.
const selfRoot = path.resolve(__dirname, "..", "..");
const detected = detectPluginInstall(selfRoot);
const selfPlugin = detected
? { marketplace: detected.marketplace, plugin: detected.plugin }
: null;
const plan = {
plugins,
selfPlugin,
selfRoot: selfPlugin ? selfRoot : null,
reload: req.body?.reload !== false,
};
try {
const started = startUpdateAll(plan);
res.json({ ok: true, ...started, planned: plugins.length, self_plugin: selfPlugin });
} catch (err) {
res.status(500).json({
error: { code: "EUPDATEALL", message: err.message || String(err) },
});
}
});
router.post("/marketplaces/add", async (req, res) => {
const { source, scope } = req.body || {};
try {
const result = await ccCli.marketplaceAdd({ source, scope });
return cliResult(res, result, { action: "add", type: "marketplace", name: source });
} catch (err) {
return cliError(res, err);
}
});
const MARKETPLACE_ACTIONS = {
remove: ccCli.marketplaceRemove,
update: ccCli.marketplaceUpdate,
};
router.post("/marketplaces/action", async (req, res) => {
const { name, action } = req.body || {};
const run = MARKETPLACE_ACTIONS[action];
if (!run) {
return res.status(400).json({
error: {
code: "EBADACTION",
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
},
});
}
try {
const result = await run({ name });
return cliResult(res, result, { action, type: "marketplace", name });
} catch (err) {
return cliError(res, err);
}
});
// ── MCP servers (via the `claude` CLI) ─────────────────────────────────
//
// Same reasoning as the plugin/marketplace routes above: `~/.claude.json` is
// rewritten concurrently by the running CLI, so add/remove shell out to
// `claude mcp …` rather than editing the file. Reads are unaffected — GET
// /mcp above stays on `readMcpServers`; the client refetches it after either
// call below succeeds.
router.post("/mcp/add", async (req, res) => {
const { name, scope, transport, command, args, url, headers, env } = req.body || {};
try {
const result = await ccMcpCli.mcpAdd({
name,
scope,
transport,
command,
args,
url,
headers,
env,
});
return cliResult(res, result, { action: "add", type: "mcp", name });
} catch (err) {
return cliError(res, err);
}
});
router.post("/mcp/remove", async (req, res) => {
const { name, scope } = req.body || {};
try {
const result = await ccMcpCli.mcpRemove({ name, scope });
return cliResult(res, result, { action: "remove", type: "mcp", name });
} catch (err) {
return cliError(res, err);
}
});
// PATCH /api/cc-config/settings — merge individual keys into one settings
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
// unset?: ["dotted"] }. Hooks are edited through this same route as
// `set: { "hooks.PreToolUse": [...] }` in settings.json's own nested shape.
//
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
// target chosen by the caller would let any request create a settings.json
// anywhere on disk. Reads keep the override; writes do not.
router.patch("/settings", (req, res) => {
const { scope, set, unset } = req.body || {};
try {
const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset });
emitChanged({ action: "patch", type: "settings", name: scope, project: null });
res.json(result);
} catch (err) {
return mutateError(res, err);
}
});
// ── Auth profiles ──────────────────────────────────────────────────────
//
// Switch which Anthropic account (= which quota) the local `claude` CLI runs
// as, keeping every other config surface in place. Responses carry identity
// metadata only — never token material. See cc-profiles.js.
const PROFILE_ERR_TO_STATUS = {
EBADNAME: 400,
EBADLABEL: 400,
EBADREQ: 400,
ENOTFOUND: 404,
EBADJSON: 409,
EACTIVE: 409,
EUNSAVED: 409,
ENOAUTH: 409,
EDUPLICATE: 409,
ELABELTAKEN: 409,
EMISMATCH: 409,
};
function profileError(res, e) {
const status = PROFILE_ERR_TO_STATUS[e.code] || 500;
return res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
// The live token's owner (asked of Anthropic, cached per token) decides who is
// logged in — `.claude.json` can name another account. Null offline: then the
// config is all there is, as before.
router.get("/profiles", async (_req, res) => {
try {
res.json(ccProfiles.listProfiles({ owner: await ccUsage.liveOwner() }));
} catch (e) {
return profileError(res, e);
}
});
router.post("/profiles", async (req, res) => {
try {
const owner = await ccUsage.liveOwner();
const result = ccProfiles.saveProfile((req.body || {}).name, { owner });
emitChanged({ action: "save", type: "profile", name: result.name, project: null });
res.json(result);
} catch (e) {
return profileError(res, e);
}
});
// Rename is a label edit; the profile's `name` identifier never moves.
router.patch("/profiles", (req, res) => {
try {
const body = req.body || {};
const result = ccProfiles.renameProfile(body.name, body.label);
emitChanged({ action: "rename", type: "profile", name: result.name, project: null });
res.json(result);
} catch (e) {
return profileError(res, e);
}
});
router.post("/profiles/activate", async (req, res) => {
try {
const owner = await ccUsage.liveOwner();
const result = ccProfiles.activateProfile((req.body || {}).name, { owner });
emitChanged({ action: "activate", type: "profile", name: result.name, project: null });
res.json(result);
} catch (e) {
return profileError(res, e);
}
});
/**
* POST /profiles/usage — how much of this profile's account quota is spent.
*
* A POST, and behind the same-origin guard, because it is not a read: a saved
* profile's access token has almost always expired, so answering means
* refreshing it, and the refresh rotates the stored credentials on disk (see
* `cc-usage.js`). That is also why nothing calls this on a timer — the UI asks
* per profile, when the user asks.
*
* The response is two percentages and two reset timestamps. No token material
* crosses this boundary, in either direction: the name is all the caller sends.
*/
router.post("/profiles/usage", sameOriginGuard, async (req, res) => {
const name = (req.body || {}).name;
try {
res.json(await ccUsage.profileUsage(name));
} catch (e) {
const status =
{
ENOTFOUND: 404,
EBADNAME: 400,
ENOREFRESH: 409,
ENOAUTH: 409,
ESTALE: 409,
ELIVEEXPIRED: 409,
EUNAUTHORIZED: 401,
}[e.code] || 502;
res.status(status).json({ error: { code: e.code || "EUPSTREAM", message: e.message } });
}
});
// Identifier travels in the body, not the URL — same reason as DELETE /file.
router.delete("/profiles", (req, res) => {
try {
const result = ccProfiles.deleteProfile((req.body || {}).name);
emitChanged({ action: "delete", type: "profile", name: result.name, project: null });
res.json(result);
} catch (e) {
return profileError(res, e);
}
});
router.get("/backups", (req, res) => {
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) }) });
});
module.exports = router;