Files
AgentHub/server/lib/cc-plugin-cli.js
T
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

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

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

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00

374 lines
14 KiB
JavaScript

/**
* @file cc-plugin-cli.js
* @description Drives the official `claude plugin …` CLI so the dashboard can
* add/remove marketplaces and install/uninstall/enable/disable/update plugins
* without the user opening a terminal.
*
* Why the CLI and not direct JSON edits: `installed_plugins.json`,
* `known_marketplaces.json` and the plugin cache tree are written together by
* the CLI (it clones the marketplace repo, resolves versions, prunes the
* cache). Reproducing that here would drift the moment the CLI's schema moves.
*
* Hard constraints (do not relax without a follow-up review):
* - `execFile` with an explicit argv array. NEVER a shell string — the
* `source`/`spec` values come straight from a user-typed field.
* - Every argument is validated BEFORE the spawn: no leading `-` (a value
* can never become a flag), no control characters, and a shape allowlist
* per argument kind.
* - Mutating calls are serialized in-process; two dashboard clicks must not
* interleave writes to the same manifest.
* - `CLAUDE_CONFIG_DIR` is pinned ONLY when the operator set `CLAUDE_HOME`.
* 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: 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");
const DEFAULT_TIMEOUT_MS = 120_000;
const MAX_OUTPUT_BYTES = 1024 * 1024;
/** `owner/repo` — the GitHub shorthand `claude plugin marketplace add` accepts. */
// Both halves must START with an alphanumeric, so `./relative` is not mistaken
// for `owner/repo`.
const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** `name` or `name@marketplace`. No slashes: a plugin spec is never a path. */
const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
/** A marketplace's registered name (what `known_marketplaces.json` is keyed by). */
const MARKETPLACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
/** Install scopes the CLI understands — note `local`, not `project-local`. */
const CLI_SCOPES = ["user", "project", "local"];
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/**
* Baseline check applied to every argv element we interpolate. A value that
* starts with `-` would be parsed as a flag by the CLI even inside an argv
* array, so it is rejected rather than escaped.
*/
function assertArg(value, label) {
if (typeof value !== "string" || !value.trim()) {
throw makeError("EBADARG", `${label} is required`);
}
if (value.length > 512) throw makeError("EBADARG", `${label} is too long`);
// eslint-disable-next-line no-control-regex
if (/[\u0000-\u001f\u007f]/.test(value)) {
throw makeError("EBADARG", `${label} contains control characters`);
}
if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`);
return value;
}
/**
* A marketplace source is one of: `owner/repo`, an http(s) URL, or an absolute
* path on this machine. Anything else (an ssh remote, a `file://` URL, a
* relative path) is refused — the set the UI advertises is the set we accept.
*/
function assertSource(value) {
assertArg(value, "source");
const v = value.trim();
if (v.includes("..")) throw makeError("EBADARG", "source may not contain '..'");
if (/^https?:\/\/\S+$/i.test(v)) return v;
if (path.isAbsolute(v)) return v;
if (REPO_RE.test(v)) return v;
throw makeError("EBADARG", "source must be owner/repo, an http(s) URL, or an absolute path");
}
function assertPluginSpec(value) {
assertArg(value, "plugin");
if (!PLUGIN_SPEC_RE.test(value)) {
throw makeError("EBADARG", "plugin must be <name> or <name>@<marketplace>");
}
return value;
}
function assertMarketplaceName(value) {
assertArg(value, "marketplace");
if (!MARKETPLACE_NAME_RE.test(value)) {
throw makeError("EBADARG", "marketplace name is not a valid identifier");
}
return 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 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, 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;
}
/**
* `[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.
*/
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`)
// — the exact file cc-discovery.getClaudeJsonPath() reads (see that
// function's own comment). Pinning CLAUDE_CONFIG_DIR unconditionally to
// getClaudeHome() made the CLI write `~/.claude/.claude.json` instead —
// a file nothing reads — so every default-install add/remove was inert.
// When CLAUDE_HOME IS set, this pins it and the CLI writes
// <CLAUDE_HOME>/.claude.json — which is what getClaudeJsonPath() reads in
// 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(
cmd,
argv,
{
timeout,
maxBuffer: MAX_OUTPUT_BYTES,
...spawnOpts,
},
(err, stdout, stderr) => {
const out = String(stdout || "");
const errOut = String(stderr || "");
if (err && (err.code === "ENOENT" || err.code === "EACCES")) {
return reject(
makeError(
"ECLINOTFOUND",
"the `claude` CLI was not found on PATH — install Claude Code or add it to the dashboard's PATH"
)
);
}
if (err && err.killed) {
return reject(makeError("ETIMEOUT", `claude ${args[0]} timed out after ${timeout}ms`));
}
const code = err ? (typeof err.code === "number" ? err.code : 1) : 0;
resolve({ ok: !err, code, stdout: out, stderr: errOut, args });
}
);
// Every command here is non-interactive. Closing stdin turns a would-be
// prompt into an immediate CLI error instead of a hang until `timeout`.
try {
child.stdin?.end();
} catch {
/* the child may already have exited */
}
});
}
// ── Operations ─────────────────────────────────────────────────────────
// Each validates first, then queues. Validation errors throw synchronously
// from the returned promise, which is what the routes turn into a 400.
// `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, acct);
return queued(["plugin", "marketplace", "add", src, "--scope", sc], acct);
}
async function marketplaceRemove({ name, acct }) {
const n = assertMarketplaceName(name);
return queued(["plugin", "marketplace", "remove", n], acct);
}
async function marketplaceUpdate({ name, acct }) {
const n = assertMarketplaceName(name);
return queued(["plugin", "marketplace", "update", n], acct);
}
async function pluginInstall({ spec, scope, acct }) {
const s = assertPluginSpec(spec);
const sc = assertCliScope(scope, acct);
// `-y` accepts the marketplace-declared install command without a prompt;
// 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, acct }) {
const s = assertPluginSpec(spec);
return queued(["plugin", "uninstall", s], acct);
}
async function pluginEnable({ spec, acct }) {
const s = assertPluginSpec(spec);
return queued(["plugin", "enable", s], acct);
}
async function pluginDisable({ spec, acct }) {
const s = assertPluginSpec(spec);
return queued(["plugin", "disable", s], acct);
}
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 queued(["plugin", "update", s, "-y"], acct);
}
/**
* `claude plugin list --available --json` — every plugin offered by a
* 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({ 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");
}
let parsed;
try {
parsed = JSON.parse(res.stdout);
} catch {
throw makeError("ECLIFAILED", "could not parse `claude plugin list --json` output");
}
return {
installed: Array.isArray(parsed.installed) ? parsed.installed : [],
available: Array.isArray(parsed.available) ? parsed.available : [],
};
}
/**
* Best-effort human message from a failed run. The CLI prints its errors with
* a `✘` marker on either stream; fall back to the last non-empty line.
*/
function cliMessage(res) {
const text = `${res.stderr || ""}\n${res.stdout || ""}`;
const lines = text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const marked = lines.find((l) => l.includes("✘"));
const picked = marked || lines[lines.length - 1] || "";
return picked.replace(/^✘\s*/, "").slice(0, 1000);
}
module.exports = {
runClaude,
cliMessage,
// Exported so cc-mcp-cli.js queues onto this SAME chokepoint — one CLI
// process against CLAUDE_CONFIG_DIR at a time, whether the click was a
// plugin install or an MCP add.
serialize,
listAvailable,
marketplaceAdd,
marketplaceRemove,
marketplaceUpdate,
pluginInstall,
pluginUninstall,
pluginEnable,
pluginDisable,
pluginUpdate,
// exported for tests
assertSource,
assertPluginSpec,
assertMarketplaceName,
assertCliScope,
PLUGIN_SPEC_RE,
__setExecFileImpl,
};