feat(codex): wrap the codex mcp/plugin CLI in argv-safe calls
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* @file Tests for server/lib/codex-cli.js — the `codex mcp`/`codex plugin`
|
||||
* wrapper that backs the Codex config panel's MCP-server and plugin
|
||||
* management. Uses a fake `codex` script on PATH (the fixture style
|
||||
* server/__tests__/cc-plugin-cli.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(), "agenthub-codexcli-"));
|
||||
const cli = require("../lib/codex-cli");
|
||||
|
||||
/**
|
||||
* Put a fake `codex` on PATH. The default body echoes the argv, one element
|
||||
* per line, so a test can assert the exact array that was spawned.
|
||||
*/
|
||||
function stubCodex(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, "codex");
|
||||
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 `codex` on it at all. */
|
||||
function stubMissingCodex() {
|
||||
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("codex-cli argument validation", () => {
|
||||
it("refuses a name that could be read as a flag or a path", async () => {
|
||||
await assert.rejects(() => cli.mcpRemove("--force"), /name/i);
|
||||
await assert.rejects(() => cli.mcpRemove("../../etc"), /name/i);
|
||||
await assert.rejects(() => cli.pluginRemove("--force"), /name/i);
|
||||
await assert.rejects(() => cli.marketplaceRemove("--force"), /name/i);
|
||||
await assert.rejects(() => cli.mcpAdd({ name: "--force", command: "node" }), /name/i);
|
||||
});
|
||||
|
||||
it("accepts a plain identifier as a name", () => {
|
||||
assert.match("docs", cli.NAME_RE);
|
||||
assert.doesNotMatch("--force", cli.NAME_RE);
|
||||
assert.doesNotMatch("../../etc", cli.NAME_RE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex-cli argv construction", () => {
|
||||
it("passes the server name as one argv entry, never through a shell", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
const result = await cli.mcpAdd({ name: "docs", command: "node", args: ["server.js"] });
|
||||
assert.deepEqual(argvOf(result), ["mcp", "add", "docs", "--", "node", "server.js"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds mcp list / remove argvs", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
assert.deepEqual(argvOf(await cli.mcpList()), ["mcp", "list", "--json"]);
|
||||
assert.deepEqual(argvOf(await cli.mcpRemove("docs")), ["mcp", "remove", "docs"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds plugin list / add / remove argvs", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
assert.deepEqual(argvOf(await cli.pluginList()), ["plugin", "list", "--json"]);
|
||||
assert.deepEqual(argvOf(await cli.pluginAdd("demo@sg")), ["plugin", "add", "demo@sg"]);
|
||||
assert.deepEqual(argvOf(await cli.pluginRemove("demo")), ["plugin", "remove", "demo"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds marketplace list / add / remove argvs, nested under plugin", async () => {
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
assert.deepEqual(argvOf(await cli.marketplaceList()), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"list",
|
||||
"--json",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceAdd("https://example.com/mp.git")), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://example.com/mp.git",
|
||||
]);
|
||||
assert.deepEqual(argvOf(await cli.marketplaceRemove("sg")), [
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"remove",
|
||||
"sg",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never lets a rejected name reach a shell", async () => {
|
||||
// If the value were interpolated into a shell string this would create
|
||||
// the file; the NAME_RE guard rejects it before any spawn happens.
|
||||
const canary = path.join(ROOT, "pwned");
|
||||
const restore = stubCodex();
|
||||
try {
|
||||
await assert.rejects(() => cli.mcpRemove("docs; touch " + canary), /name/i);
|
||||
assert.equal(fs.existsSync(canary), false);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("codex-cli run outcomes", () => {
|
||||
it("surfaces a non-zero exit as ok:false with stderr kept", async () => {
|
||||
const restore = stubCodex('echo "no such marketplace" >&2\nexit 2');
|
||||
try {
|
||||
const res = await cli.marketplaceRemove("nope");
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, 2);
|
||||
assert.match(res.stderr, /no such marketplace/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws a clear error when the codex CLI is not on PATH", async () => {
|
||||
const restore = stubMissingCodex();
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => cli.mcpList(),
|
||||
(err) => {
|
||||
assert.equal(err.code, "ECLINOTFOUND");
|
||||
assert.match(err.message, /not found on PATH/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses JSON output into `data`", async () => {
|
||||
const restore = stubCodex(`cat <<'JSON'\n{"servers":[{"name":"docs"}]}\nJSON`);
|
||||
try {
|
||||
const res = await cli.mcpList();
|
||||
assert.equal(res.ok, true);
|
||||
assert.deepEqual(res.data, { servers: [{ name: "docs" }] });
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves `data` undefined when stdout is not JSON", async () => {
|
||||
const restore = stubCodex('echo "removed docs"');
|
||||
try {
|
||||
const res = await cli.mcpRemove("docs");
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.data, undefined);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes mutating calls so two clicks cannot interleave", async () => {
|
||||
const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`);
|
||||
const restore = stubCodex(`echo "start $2" >> ${log}\nsleep 0.2\necho "end $2" >> ${log}`);
|
||||
try {
|
||||
await Promise.all([cli.mcpRemove("first"), cli.mcpRemove("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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user