Files
AgentHub/server/__tests__/cc-plugin-cli.test.js
T
nntrivi2001 008ebf1c5c feat(cc-config): manage plugins, marketplaces, hooks and settings from the dashboard
The Claude Config page was read-only for these four surfaces. It can now:

- register a marketplace from a pasted GitHub `owner/repo`, http(s) URL or
  absolute path, and install / enable / disable / update / uninstall plugins,
  all delegated to the `claude plugin …` CLI so its registry stays consistent;
- edit hooks and settings.json keys in the user, project and project-local
  layers.

Server:

- `server/lib/cc-plugin-cli.js` wraps the CLI with `execFile` and an explicit
  argv array — never a shell string. Every argument is validated before the
  spawn (shape allowlist per kind, no control characters, no leading `-`, no
  `..`), `CLAUDE_CONFIG_DIR` is pinned to the dashboard's CLAUDE_HOME, stdin is
  closed so a prompt errors instead of hanging, and mutating calls are
  serialized in-process.
- `cc-mutate.patchSettings` merges individual dotted keys into one settings
  layer. It re-reads the file unredacted, so keys the UI never displayed —
  including secret-valued ones — survive byte-for-byte. A payload carrying the
  `<redacted>` placeholder is refused (EREDACTED), as are `__proto__` /
  `prototype` / `constructor` segments; a settings file that does not parse is
  reported (EBADJSON) rather than clobbered. Backup first, then atomic write.
- Six additive routes on /api/cc-config; a non-zero CLI exit is a 422 carrying
  the CLI's own message, a missing binary a 503.

Client: install/add bars, per-card plugin and marketplace actions behind a
two-click confirm, and edit modes for hooks and settings that send only the
keys that changed. New i18n keys in en + vi; the explainer banners that said
these surfaces were read-only now describe what the writes actually do.

Tests: 61 new server tests (CLI argv construction and validation, the settings
key-merge, and the six routes against a fake `claude` on PATH).
2026-08-25 15:58:03 +07:00

258 lines
8.7 KiB
JavaScript

/**
* @file Tests for server/lib/cc-plugin-cli.js — the `claude plugin …` wrapper
* that backs plugin/marketplace management from the dashboard. Uses a real
* fake `claude` script on PATH (the fixture style server/__tests__/gh.test.js
* uses) rather than mocking child_process, and pins the exact argv each
* operation builds — that argv IS the security boundary.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ccplugin-"));
const cli = require("../lib/cc-plugin-cli");
/**
* Put a fake `claude` on PATH. The default body echoes the argv, one element
* per line, so a test can assert the exact array that was spawned.
*/
function stubClaude(scriptBody = 'for a in "$@"; do echo "$a"; done') {
const bin = path.join(ROOT, `bin-${process.hrtime.bigint()}`);
fs.mkdirSync(bin, { recursive: true });
const file = path.join(bin, "claude");
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
fs.chmodSync(file, 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
return () => {
process.env.PATH = originalPath;
};
}
/** PATH with no `claude` on it at all. */
function stubMissingClaude() {
const empty = path.join(ROOT, `empty-${process.hrtime.bigint()}`);
fs.mkdirSync(empty, { recursive: true });
const originalPath = process.env.PATH;
process.env.PATH = empty;
return () => {
process.env.PATH = originalPath;
};
}
const argvOf = (result) => result.stdout.trim().split("\n");
after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 }));
describe("cc-plugin-cli argument validation", () => {
it("accepts owner/repo, http(s) URLs and absolute paths as a source", () => {
assert.equal(cli.assertSource("anthropics/claude-plugins"), "anthropics/claude-plugins");
assert.equal(cli.assertSource("https://github.com/a/b"), "https://github.com/a/b");
assert.equal(cli.assertSource("/opt/marketplace"), "/opt/marketplace");
});
it("rejects sources that are neither repo, URL nor absolute path", () => {
for (const bad of ["./relative", "git@github.com:a/b.git", "file:///tmp/x", "a/b/c/d e"]) {
assert.throws(() => cli.assertSource(bad), /source must be/, `accepted ${bad}`);
}
});
it("rejects a source that could become a flag or escape upward", () => {
assert.throws(() => cli.assertSource("--scope"), /may not start with/);
assert.throws(() => cli.assertSource("/opt/../etc"), /'\.\.'/);
});
it("rejects control characters and empty values", () => {
assert.throws(() => cli.assertSource("a/b\nrm -rf /"), /control characters/);
assert.throws(() => cli.assertSource(""), /required/);
assert.throws(() => cli.assertSource(null), /required/);
});
it("accepts <name> and <name>@<marketplace> plugin specs only", () => {
assert.equal(cli.assertPluginSpec("ccam"), "ccam");
assert.equal(cli.assertPluginSpec("ccam@sg"), "ccam@sg");
for (const bad of ["ccam@bad/repo", "../ccam", "ccam ccam", "@sg", "-ccam"]) {
assert.throws(() => cli.assertPluginSpec(bad), undefined, `accepted ${bad}`);
}
});
it("rejects marketplace names that are not plain identifiers", () => {
assert.equal(cli.assertMarketplaceName("sg"), "sg");
assert.throws(() => cli.assertMarketplaceName("sg/other"), /valid identifier/);
});
it("defaults the CLI scope to user and rejects unknown scopes", () => {
assert.equal(cli.assertCliScope(undefined), "user");
assert.equal(cli.assertCliScope("local"), "local");
// `project-local` is the dashboard's settings vocabulary, not the CLI's.
assert.throws(() => cli.assertCliScope("project-local"), /scope must be one of/);
});
});
describe("cc-plugin-cli argv construction", () => {
it("builds the marketplace add argv with an explicit scope", async () => {
const restore = stubClaude();
try {
const result = await cli.marketplaceAdd({ source: "owner/repo" });
assert.deepEqual(argvOf(result), [
"plugin",
"marketplace",
"add",
"owner/repo",
"--scope",
"user",
]);
} finally {
restore();
}
});
it("passes -y on install so a non-TTY run never waits for a prompt", async () => {
const restore = stubClaude();
try {
const result = await cli.pluginInstall({ spec: "demo@tmpmk", scope: "project" });
assert.deepEqual(argvOf(result), [
"plugin",
"install",
"demo@tmpmk",
"--scope",
"project",
"-y",
]);
} finally {
restore();
}
});
it("builds uninstall / enable / disable / update argvs", async () => {
const restore = stubClaude();
try {
assert.deepEqual(argvOf(await cli.pluginUninstall({ spec: "demo@m" })), [
"plugin",
"uninstall",
"demo@m",
]);
assert.deepEqual(argvOf(await cli.pluginEnable({ spec: "demo@m" })), [
"plugin",
"enable",
"demo@m",
]);
assert.deepEqual(argvOf(await cli.pluginDisable({ spec: "demo@m" })), [
"plugin",
"disable",
"demo@m",
]);
assert.deepEqual(argvOf(await cli.pluginUpdate({ spec: "demo@m" })), [
"plugin",
"update",
"demo@m",
]);
assert.deepEqual(argvOf(await cli.marketplaceRemove({ name: "m" })), [
"plugin",
"marketplace",
"remove",
"m",
]);
assert.deepEqual(argvOf(await cli.marketplaceUpdate({ name: "m" })), [
"plugin",
"marketplace",
"update",
"m",
]);
} finally {
restore();
}
});
it("never lets a source reach a shell", async () => {
// If the value were interpolated into a shell string this would create the
// file; through execFile's argv array it stays one literal argument.
const canary = path.join(ROOT, "pwned");
const restore = stubClaude();
try {
await assert.rejects(
() => cli.marketplaceAdd({ source: `owner/repo; touch ${canary}` }),
/source must be/
);
assert.equal(fs.existsSync(canary), false);
} finally {
restore();
}
});
});
describe("cc-plugin-cli run outcomes", () => {
it("reports a non-zero exit as ok:false instead of throwing", async () => {
const restore = stubClaude('echo "✘ Failed to install plugin \\"nope\\"" >&2\nexit 1');
try {
const result = await cli.pluginInstall({ spec: "nope@m" });
assert.equal(result.ok, false);
assert.equal(result.code, 1);
assert.match(cli.cliMessage(result), /Failed to install plugin/);
} finally {
restore();
}
});
it("throws a clear error when the claude CLI is not on PATH", async () => {
const restore = stubMissingClaude();
try {
await assert.rejects(
() => cli.marketplaceUpdate({ name: "m" }),
(err) => {
assert.equal(err.code, "ECLINOTFOUND");
assert.match(err.message, /not found on PATH/);
return true;
}
);
} finally {
restore();
}
});
it("parses `plugin list --available --json`", async () => {
const payload = JSON.stringify({
installed: [{ id: "demo@m" }],
available: [{ pluginId: "other@m", name: "other" }],
});
const restore = stubClaude(`cat <<'JSON'\n${payload}\nJSON`);
try {
const listed = await cli.listAvailable();
assert.equal(listed.installed.length, 1);
assert.equal(listed.available[0].pluginId, "other@m");
} finally {
restore();
}
});
it("surfaces unparseable list output as an error", async () => {
const restore = stubClaude("echo 'not json'");
try {
await assert.rejects(() => cli.listAvailable(), /could not parse/);
} finally {
restore();
}
});
it("serializes mutating calls so two clicks cannot interleave", async () => {
// Each run appends its marker around a sleep; interleaved runs would
// produce "a1 b1 a2 b2" rather than two complete pairs.
const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`);
const restore = stubClaude(`echo "start $3" >> ${log}\nsleep 0.2\necho "end $3" >> ${log}`);
try {
await Promise.all([
cli.marketplaceUpdate({ name: "first" }),
cli.marketplaceUpdate({ name: "second" }),
]);
const lines = fs.readFileSync(log, "utf8").trim().split("\n");
assert.equal(lines[0].replace("start ", ""), lines[1].replace("end ", ""));
assert.equal(lines[2].replace("start ", ""), lines[3].replace("end ", ""));
} finally {
restore();
}
});
});