Files
AgentHub/server/__tests__/codex-cli.test.js
T
nntrivi2001 acff059e92 fix(codex-config): close whole-branch review defects in the Codex panel
Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.

Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).

Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
2026-09-09 01:17:06 +07:00

345 lines
12 KiB
JavaScript

/**
* @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"), /spec/i);
await assert.rejects(() => cli.pluginRemove("../../etc"), /spec/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);
});
it("accepts <name> and <name>@<marketplace> plugin specs only", () => {
assert.equal(cli.assertPluginSpec("agenthub"), "agenthub");
assert.equal(cli.assertPluginSpec("agenthub@sg"), "agenthub@sg");
for (const bad of ["../agenthub", "agenthub agenthub", "@sg", "-agenthub", "a/b"]) {
assert.throws(() => cli.assertPluginSpec(bad), undefined, `accepted ${bad}`);
}
});
it("accepts only http(s) URLs (mcpAdd's --url form)", () => {
assert.equal(cli.assertUrl("https://example.com/mp.git", "url"), "https://example.com/mp.git");
for (const bad of ["ssh://example.com/mp.git", "not a url", "file:///tmp/x"]) {
assert.throws(() => cli.assertUrl(bad, "url"), /http\(s\)/, `accepted ${bad}`);
}
// rejected earlier, by the shared leading-dash guard, not the URL check
assert.throws(() => cli.assertUrl("--force", "url"), /may not start with/);
});
it("marketplaceAdd accepts the source forms `codex plugin marketplace add` actually takes", async () => {
// Not assertUrl: `codex plugin marketplace add <SOURCE>` also takes
// owner/repo and a local path, which a bare http(s)-only check rejected.
const restore = stubCodex();
try {
assert.deepEqual(await cli.marketplaceAdd("openai/codex").then(argvOf), [
"plugin",
"marketplace",
"add",
"openai/codex",
]);
assert.deepEqual(await cli.marketplaceAdd("/abs/local/path").then(argvOf), [
"plugin",
"marketplace",
"add",
"/abs/local/path",
]);
} finally {
restore();
}
// ssh remotes are deliberately out of scope, same as cc-plugin-cli's
// marketplace source guard — no credential/host-key story for them yet.
await assert.rejects(() => cli.marketplaceAdd("git@github.com:owner/repo.git"), /source/);
await assert.rejects(() => cli.marketplaceAdd("../escape"), /source/);
});
it("rejects mcpAdd with neither or both of url/command", async () => {
await assert.rejects(() => cli.mcpAdd({ name: "docs" }), /requires either/);
await assert.rejects(
() => cli.mcpAdd({ name: "docs", url: "https://x", command: "node" }),
/either url or command, not both/
);
});
it("rejects env together with url — --env is invalid on the url form", async () => {
await assert.rejects(
() => cli.mcpAdd({ name: "docs", url: "https://x", env: { A: "1" } }),
/stdio-only/
);
});
it("rejects an env key that is not a valid identifier", async () => {
await assert.rejects(
() => cli.mcpAdd({ name: "docs", command: "node", env: { "1bad": "1" } }),
/identifier/
);
});
});
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("puts --env flags between NAME and -- for a stdio server", async () => {
const restore = stubCodex();
try {
const result = await cli.mcpAdd({
name: "docs",
command: "node",
args: ["server.js"],
env: { API_KEY: "secret", MODE: "prod" },
});
assert.deepEqual(argvOf(result), [
"mcp",
"add",
"docs",
"--env",
"API_KEY=secret",
"--env",
"MODE=prod",
"--",
"node",
"server.js",
]);
} finally {
restore();
}
});
it("builds the --url form with an optional --bearer-token-env-var", async () => {
const restore = stubCodex();
try {
const result = await cli.mcpAdd({
name: "docs",
url: "https://example.com/mcp",
bearerTokenEnvVar: "DOCS_TOKEN",
});
assert.deepEqual(argvOf(result), [
"mcp",
"add",
"docs",
"--bearer-token-env-var",
"DOCS_TOKEN",
"--url",
"https://example.com/mcp",
]);
} 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"]);
// `plugin list` reports marketplace-scoped plugins as `<name>@<marketplace>`,
// and `plugin add` takes that form — so remove has to accept it back.
assert.deepEqual(argvOf(await cli.pluginRemove("demo@sg")), ["plugin", "remove", "demo@sg"]);
} 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("unwraps `codex plugin list --json`'s {installed, available} object into a bare array", async () => {
// Real codex-cli 0.149.0 shape — an object, unlike `mcp list`'s array.
const restore = stubCodex(
`cat <<'JSON'\n{"installed":[{"name":"demo"}],"available":[{"name":"other"}]}\nJSON`
);
try {
const res = await cli.pluginList();
assert.equal(res.ok, true);
assert.deepEqual(res.data, [{ name: "demo" }]);
} finally {
restore();
}
});
it("unwraps `codex plugin marketplace list --json`'s {marketplaces} object into a bare array", async () => {
const restore = stubCodex(`cat <<'JSON'\n{"marketplaces":[{"name":"sg"}]}\nJSON`);
try {
const res = await cli.marketplaceList();
assert.equal(res.ok, true);
assert.deepEqual(res.data, [{ name: "sg" }]);
} 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();
}
});
});