642 lines
23 KiB
JavaScript
642 lines
23 KiB
JavaScript
/**
|
|
* @file codex-config.test.js
|
|
* @description Tests for /api/codex-config — the Codex CLI configuration
|
|
* explorer. Builds a fake CODEX_HOME and project .codex/ in tmpdir, points
|
|
* the server at it, and exercises every read/write surface plus
|
|
* path-containment guards, backups, and the CLI-backed MCP/plugin/
|
|
* marketplace routes (via a stubbed `codex` binary on PATH).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const os = require("node:os");
|
|
const http = require("node:http");
|
|
const { WebSocket } = require("ws");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-test-"));
|
|
const FAKE_HOME = path.join(TMP, "home", ".codex");
|
|
const FAKE_PROJECT = path.join(TMP, "project");
|
|
const FAKE_PROJECT_CODEX = path.join(FAKE_PROJECT, ".codex");
|
|
|
|
fs.mkdirSync(path.join(FAKE_HOME, "skills", "demo-skill"), { recursive: true });
|
|
fs.mkdirSync(path.join(FAKE_HOME, "agents"), { recursive: true });
|
|
fs.mkdirSync(path.join(FAKE_HOME, "rules"), { recursive: true });
|
|
fs.mkdirSync(path.join(FAKE_PROJECT_CODEX, "skills", "proj-skill"), { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "skills", "demo-skill", "SKILL.md"),
|
|
`---\nname: demo-skill\ndescription: A demo skill for tests\n---\n\nBody text here.`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_PROJECT_CODEX, "skills", "proj-skill", "SKILL.md"),
|
|
`---\nname: proj-skill\n---\n\nProject skill.`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(FAKE_HOME, "agents", "demo-agent.toml"),
|
|
`name = "demo-agent"\nmodel = "gpt-5"\n`
|
|
);
|
|
fs.writeFileSync(path.join(FAKE_HOME, "rules", "default.rules"), "allow");
|
|
fs.writeFileSync(path.join(FAKE_HOME, "config.toml"), 'model = "gpt-5"\n');
|
|
fs.writeFileSync(path.join(FAKE_PROJECT, "AGENTS.md"), "# Project memory\nHello.");
|
|
|
|
process.env.CODEX_HOME = FAKE_HOME;
|
|
const TEST_DB = path.join(TMP, "dashboard-test.db");
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function fetchJson(p, opts = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(p, BASE);
|
|
const headers = { ...(opts.headers || {}) };
|
|
let bodyBuf;
|
|
if (opts.body !== undefined) {
|
|
bodyBuf = Buffer.from(JSON.stringify(opts.body));
|
|
headers["Content-Type"] = "application/json";
|
|
headers["Content-Length"] = bodyBuf.length;
|
|
}
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
method: opts.method || "GET",
|
|
headers,
|
|
},
|
|
(res) => {
|
|
const chunks = [];
|
|
res.on("data", (c) => chunks.push(c));
|
|
res.on("end", () => {
|
|
const body = Buffer.concat(chunks).toString("utf8");
|
|
let json;
|
|
try {
|
|
json = JSON.parse(body);
|
|
} catch {
|
|
json = body;
|
|
}
|
|
resolve({ status: res.statusCode, body: json });
|
|
});
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (bodyBuf) req.write(bodyBuf);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
function stubCodex(scriptBody) {
|
|
const bin = path.join(TMP, `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;
|
|
};
|
|
}
|
|
|
|
const ECHO_ARGV = 'for a in "$@"; do echo "$a"; done';
|
|
|
|
describe("/api/codex-config", () => {
|
|
before(async () => {
|
|
const app = createApp();
|
|
server = await startServer(app, 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
after(async () => {
|
|
await new Promise((r) => server.close(r));
|
|
try {
|
|
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
} catch {
|
|
/* best-effort temp cleanup */
|
|
}
|
|
});
|
|
|
|
it("overview reports counts and roots", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/overview?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.roots.codexHome, FAKE_HOME);
|
|
assert.equal(body.roots.projectCodexDir, FAKE_PROJECT_CODEX);
|
|
assert.equal(body.roots.projectRoot, FAKE_PROJECT);
|
|
assert.equal(body.counts.skills.user, 1);
|
|
assert.equal(body.counts.skills.project, 1);
|
|
assert.equal(body.counts.agents.user, 1);
|
|
assert.equal(body.counts.rules.user, 1);
|
|
});
|
|
|
|
it("lists skills for both scopes", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/skills?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.ok(Array.isArray(body.skills));
|
|
const demo = body.skills.find((s) => s.name === "demo-skill");
|
|
assert.equal(demo.scope, "user");
|
|
assert.equal(demo.frontmatter.name, "demo-skill");
|
|
const proj = body.skills.find((s) => s.name === "proj-skill");
|
|
assert.equal(proj.scope, "project");
|
|
});
|
|
|
|
it("scope=user filters out project items", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/codex-config/skills?scope=user&cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.ok(body.skills.every((s) => s.scope === "user"));
|
|
});
|
|
|
|
it("agents parses top-level TOML scalars", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/codex-config/agents?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const a = body.agents.find((x) => x.name === "demo-agent");
|
|
assert.equal(a.meta.model, "gpt-5");
|
|
});
|
|
|
|
it("rules returns preview text", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/codex-config/rules?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const r = body.rules.find((x) => x.name === "default");
|
|
assert.match(r.preview, /allow/);
|
|
});
|
|
|
|
it("memory returns project AGENTS.md", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/codex-config/memory?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const proj = body.memory.find((x) => x.scope === "project");
|
|
assert.equal(proj.exists, true);
|
|
assert.match(proj.preview, /Project memory/);
|
|
});
|
|
|
|
it("config returns user config.toml content", async () => {
|
|
const { body } = await fetchJson(
|
|
`/api/codex-config/config?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
const user = body.config.find((x) => x.scope === "user");
|
|
assert.equal(user.exists, true);
|
|
assert.match(user.content, /gpt-5/);
|
|
});
|
|
|
|
it("file endpoint reads inside CODEX_HOME", async () => {
|
|
const target = path.join(FAKE_HOME, "rules", "default.rules");
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.ok, true);
|
|
assert.equal(body.text, "allow");
|
|
});
|
|
|
|
it("file endpoint blocks paths outside allowed roots", async () => {
|
|
const outside = path.join(TMP, "evil.rules");
|
|
fs.writeFileSync(outside, "secret");
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(outside)}`
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "READ_DENIED");
|
|
});
|
|
|
|
it("file endpoint refuses a leaf symlink that escapes CODEX_HOME (no secret leaks)", async () => {
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
|
|
fs.writeFileSync(path.join(outside, "secret.rules"), "TOP SECRET LEAF");
|
|
const linkPath = path.join(FAKE_HOME, "rules", "leaf-link.rules");
|
|
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
|
|
try {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(linkPath)}`
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "READ_DENIED");
|
|
assert.doesNotMatch(JSON.stringify(body), /TOP SECRET LEAF/);
|
|
} finally {
|
|
fs.rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("file endpoint refuses an ancestor symlink that escapes CODEX_HOME (no secret leaks)", async () => {
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-outside-"));
|
|
fs.writeFileSync(path.join(outside, "secret.txt"), "TOP SECRET ANCESTOR");
|
|
const linkDir = path.join(FAKE_HOME, "evil-dir");
|
|
fs.symlinkSync(outside, linkDir, "dir");
|
|
try {
|
|
const target = path.join(linkDir, "secret.txt");
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "READ_DENIED");
|
|
assert.doesNotMatch(JSON.stringify(body), /TOP SECRET ANCESTOR/);
|
|
} finally {
|
|
fs.rmSync(linkDir, { force: true });
|
|
fs.rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("file endpoint 404s on a missing file inside an existing root", async () => {
|
|
const target = path.join(FAKE_HOME, "rules", "does-not-exist.rules");
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}&path=${encodeURIComponent(target)}`
|
|
);
|
|
assert.equal(status, 404);
|
|
assert.equal(body.error.code, "NOT_FOUND");
|
|
});
|
|
|
|
it("file endpoint 404s on a ?cwd= that doesn't exist yet, and creates nothing", async () => {
|
|
const missingCwd = path.join(TMP, `never-existed-${process.hrtime.bigint()}`);
|
|
const target = path.join(missingCwd, "AGENTS.md");
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(missingCwd)}&path=${encodeURIComponent(target)}`
|
|
);
|
|
assert.equal(status, 404);
|
|
assert.equal(body.error.code, "NOT_FOUND");
|
|
assert.equal(fs.existsSync(missingCwd), false, "GET /file must not create the missing root");
|
|
});
|
|
|
|
it("file endpoint requires a path", async () => {
|
|
const { status, body } = await fetchJson("/api/codex-config/file");
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "BAD_PATH");
|
|
});
|
|
|
|
it("PUT /file creates a new rule (no backup, file did not exist)", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "user", type: "rules", name: "fresh", content: "deny" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.created, true);
|
|
assert.equal(body.backup, null);
|
|
assert.equal(fs.readFileSync(body.file, "utf8"), "deny");
|
|
});
|
|
|
|
it("PUT /file overwrites an existing rule and creates a backup, and broadcasts codex_config_changed", async () => {
|
|
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
|
|
const ws = new WebSocket(wsUrl);
|
|
const messages = [];
|
|
await new Promise((resolve, reject) => {
|
|
ws.on("open", resolve);
|
|
ws.on("error", reject);
|
|
});
|
|
ws.on("message", (msg) => {
|
|
try {
|
|
messages.push(JSON.parse(msg));
|
|
} catch {
|
|
/* parse error */
|
|
}
|
|
});
|
|
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "user", type: "rules", name: "default", content: "deny-all" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(body.created, false);
|
|
assert.ok(body.backup, "backup metadata returned");
|
|
assert.match(fs.readFileSync(body.file, "utf8"), /deny-all/);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
ws.close();
|
|
assert.ok(
|
|
messages.some((m) => m.type === "codex_config_changed"),
|
|
"PUT /file should broadcast codex_config_changed"
|
|
);
|
|
});
|
|
|
|
it("PUT /file ignores ?cwd= for project scope — writes to the server's own cwd, not a caller-chosen dir", async () => {
|
|
const victim = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-victim-"));
|
|
const originalCwd = process.cwd();
|
|
const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-servercwd-"));
|
|
process.chdir(serverCwd);
|
|
try {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(victim)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "project", type: "memory", content: "PWNED" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(fs.existsSync(path.join(victim, "AGENTS.md")), false, "must not write to ?cwd=");
|
|
assert.equal(body.file, path.join(serverCwd, "AGENTS.md"));
|
|
assert.equal(fs.readFileSync(body.file, "utf8"), "PWNED");
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
fs.rmSync(victim, { recursive: true, force: true });
|
|
fs.rmSync(serverCwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("DELETE /file ignores ?cwd= for project scope", async () => {
|
|
const victim = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-victim-"));
|
|
fs.writeFileSync(path.join(victim, ".codex-marker"), "x"); // proves this dir wasn't touched
|
|
const originalCwd = process.cwd();
|
|
const serverCwd = fs.mkdtempSync(path.join(os.tmpdir(), "codex-config-servercwd-"));
|
|
fs.mkdirSync(path.join(serverCwd, ".codex", "rules"), { recursive: true });
|
|
fs.writeFileSync(path.join(serverCwd, ".codex", "rules", "victim.rules"), "real");
|
|
process.chdir(serverCwd);
|
|
try {
|
|
const { status } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(victim)}`,
|
|
{
|
|
method: "DELETE",
|
|
body: { scope: "project", type: "rules", name: "victim" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.equal(fs.existsSync(path.join(victim, ".codex-marker")), true, "?cwd= dir left alone");
|
|
assert.equal(fs.existsSync(path.join(serverCwd, ".codex", "rules", "victim.rules")), false);
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
fs.rmSync(victim, { recursive: true, force: true });
|
|
fs.rmSync(serverCwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("PUT /file rejects a write whose name escapes the root with 400, not 500", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "user", type: "agents", name: "../evil", content: "x" },
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADNAME");
|
|
});
|
|
|
|
it("PUT /file rejects unknown type", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "user", type: "plugins", name: "x", content: "y" },
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADTYPE");
|
|
});
|
|
|
|
it("PUT /file rejects a name on a fixed-filename type (memory)", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "project", type: "memory", name: "nope", content: "x" },
|
|
}
|
|
);
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADNAME");
|
|
});
|
|
|
|
it("PUT /file rejects oversize content", async () => {
|
|
const huge = "x".repeat(256 * 1024 + 1);
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "PUT",
|
|
body: { scope: "user", type: "rules", name: "huge", content: huge },
|
|
}
|
|
);
|
|
assert.equal(status, 413);
|
|
assert.equal(body.error.code, "ETOOLARGE");
|
|
});
|
|
|
|
it("DELETE /file backs up and removes a rule", async () => {
|
|
fs.writeFileSync(path.join(FAKE_HOME, "rules", "to-delete.rules"), "bye");
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "DELETE",
|
|
body: { scope: "user", type: "rules", name: "to-delete" },
|
|
}
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.ok(body.backup);
|
|
assert.equal(fs.existsSync(path.join(FAKE_HOME, "rules", "to-delete.rules")), false);
|
|
});
|
|
|
|
it("DELETE /file 404s on a missing item", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/file?cwd=${encodeURIComponent(FAKE_PROJECT)}`,
|
|
{
|
|
method: "DELETE",
|
|
body: { scope: "user", type: "rules", name: "never-existed" },
|
|
}
|
|
);
|
|
assert.equal(status, 404);
|
|
assert.equal(body.error.code, "ENOTFOUND");
|
|
});
|
|
|
|
it("backups endpoint lists everything we just created", async () => {
|
|
const { status, body } = await fetchJson(
|
|
`/api/codex-config/backups?cwd=${encodeURIComponent(FAKE_PROJECT)}`
|
|
);
|
|
assert.equal(status, 200);
|
|
assert.ok(Array.isArray(body.items));
|
|
assert.ok(body.items.length >= 2, `expected >=2 backups, got ${body.items.length}`);
|
|
});
|
|
|
|
// ── CLI-backed routes (fake `codex` on PATH) ────────────────────────
|
|
|
|
it("GET /mcp returns the CLI's JSON output", async () => {
|
|
const restore = stubCodex(`cat <<'JSON'\n{"servers":[{"name":"docs"}]}\nJSON`);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/mcp");
|
|
assert.equal(status, 200);
|
|
assert.deepEqual(body.mcp, { servers: [{ name: "docs" }] });
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("GET /mcp|/plugins|/marketplaces treat empty stdout as an empty list (fresh-install case), not a 502", async () => {
|
|
const restore = stubCodex(""); // exits 0, prints nothing — the normal fresh-install state
|
|
try {
|
|
for (const [route, key] of [
|
|
["/api/codex-config/mcp", "mcp"],
|
|
["/api/codex-config/plugins", "plugins"],
|
|
["/api/codex-config/marketplaces", "marketplaces"],
|
|
]) {
|
|
const { status, body } = await fetchJson(route);
|
|
assert.equal(status, 200, route);
|
|
assert.deepEqual(body[key], [], route);
|
|
}
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("GET /mcp reports unparseable CLI JSON as 502, not a type-confused 200", async () => {
|
|
const restore = stubCodex('echo "not json"');
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/mcp");
|
|
assert.equal(status, 502);
|
|
assert.equal(body.error.code, "ECLIBADOUTPUT");
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /mcp/add rejects a non-array args with 400, not a 500", async () => {
|
|
const restore = stubCodex(ECHO_ARGV);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/mcp/add", {
|
|
method: "POST",
|
|
body: { name: "docs", command: "node", args: "oops" },
|
|
});
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADARG");
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /mcp/add runs the CLI with the given argv", async () => {
|
|
const restore = stubCodex(ECHO_ARGV);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/mcp/add", {
|
|
method: "POST",
|
|
body: { name: "docs", url: "https://example.com/mcp" },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.equal(body.ok, true);
|
|
assert.deepEqual(body.stdout.trim().split("\n"), [
|
|
"mcp",
|
|
"add",
|
|
"docs",
|
|
"--url",
|
|
"https://example.com/mcp",
|
|
]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /mcp/remove runs the CLI", async () => {
|
|
const restore = stubCodex(ECHO_ARGV);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/mcp/remove", {
|
|
method: "POST",
|
|
body: { name: "docs" },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.deepEqual(body.stdout.trim().split("\n"), ["mcp", "remove", "docs"]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /plugins/action validates the action name", async () => {
|
|
const { status, body } = await fetchJson("/api/codex-config/plugins/action", {
|
|
method: "POST",
|
|
body: { spec: "demo", action: "purge" },
|
|
});
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADACTION");
|
|
});
|
|
|
|
it("POST /plugins/action adds through the CLI", async () => {
|
|
const restore = stubCodex(ECHO_ARGV);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/plugins/action", {
|
|
method: "POST",
|
|
body: { spec: "demo@market", action: "add" },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.deepEqual(body.stdout.trim().split("\n"), ["plugin", "add", "demo@market"]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /marketplaces/add runs the CLI with the pasted url", async () => {
|
|
const restore = stubCodex(ECHO_ARGV);
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
|
|
method: "POST",
|
|
body: { url: "https://example.com/mp.git" },
|
|
});
|
|
assert.equal(status, 200);
|
|
assert.deepEqual(body.stdout.trim().split("\n"), [
|
|
"plugin",
|
|
"marketplace",
|
|
"add",
|
|
"https://example.com/mp.git",
|
|
]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /marketplaces/add rejects a non-http(s) url", async () => {
|
|
const { status, body } = await fetchJson("/api/codex-config/marketplaces/add", {
|
|
method: "POST",
|
|
body: { url: "not a url" },
|
|
});
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADARG");
|
|
});
|
|
|
|
it("reports a failed CLI call as 422 and keeps the stderr", async () => {
|
|
const restore = stubCodex('echo "✘ marketplace \\"nope\\" is not registered" >&2\nexit 1');
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
|
|
method: "POST",
|
|
body: { name: "nope", action: "remove" },
|
|
});
|
|
assert.equal(status, 422);
|
|
assert.equal(body.error.code, "ECLIEXIT");
|
|
assert.match(body.error.message, /not registered/);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("POST /marketplaces/action validates the action name", async () => {
|
|
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
|
|
method: "POST",
|
|
body: { name: "demo-mp", action: "update" },
|
|
});
|
|
assert.equal(status, 400);
|
|
assert.equal(body.error.code, "EBADACTION");
|
|
});
|
|
|
|
it("reports a missing `codex` binary as 503 rather than a crash", async () => {
|
|
const empty = path.join(TMP, `empty-${process.hrtime.bigint()}`);
|
|
fs.mkdirSync(empty, { recursive: true });
|
|
const originalPath = process.env.PATH;
|
|
process.env.PATH = empty;
|
|
try {
|
|
const { status, body } = await fetchJson("/api/codex-config/marketplaces/action", {
|
|
method: "POST",
|
|
body: { name: "demo-mp", action: "remove" },
|
|
});
|
|
assert.equal(status, 503);
|
|
assert.equal(body.error.code, "ECLINOTFOUND");
|
|
} finally {
|
|
process.env.PATH = originalPath;
|
|
}
|
|
});
|
|
});
|