feat(codex): expose the Codex config API
This commit is contained in:
+1276
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* @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 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 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("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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,7 @@ const pushRouter = require("./routes/push");
|
||||
const importRouter = require("./routes/import");
|
||||
const updatesRouter = require("./routes/updates");
|
||||
const ccConfigRouter = require("./routes/cc-config");
|
||||
const codexConfigRouter = require("./routes/codex-config");
|
||||
const runRouter = require("./routes/run");
|
||||
const alertsRouter = require("./routes/alerts");
|
||||
const webhooksRouter = require("./routes/webhooks");
|
||||
@@ -113,6 +114,7 @@ function createApp() {
|
||||
app.use("/api/import", importRouter);
|
||||
app.use("/api/updates", updatesRouter);
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
app.use("/api/run", runRouter);
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
// New endpoint groups (previously-undocumented gaps in the base spec).
|
||||
const ccConfig = require("./openapi-extra/cc-config");
|
||||
const codexConfig = require("./openapi-extra/codex-config");
|
||||
const run = require("./openapi-extra/run");
|
||||
const lanes = require("./openapi-extra/lanes");
|
||||
const push = require("./openapi-extra/push");
|
||||
@@ -44,6 +45,7 @@ function combine(...fragments) {
|
||||
|
||||
module.exports = combine(
|
||||
ccConfig,
|
||||
codexConfig,
|
||||
run,
|
||||
lanes,
|
||||
push,
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* @file OpenAPI 3.0 fragment for the Codex Config router
|
||||
* (`server/routes/codex-config.js`, mounted at `/api/codex-config`).
|
||||
* Documents the read surfaces (skills, agents, rules, memory, config, plus
|
||||
* MCP servers/plugins/marketplaces read live from the `codex` CLI), the
|
||||
* low-risk mutation surface (PUT/DELETE /file), the backups listing, and the
|
||||
* CLI-backed MCP/plugin/marketplace mutation endpoints.
|
||||
*
|
||||
* Mirrors `server/openapi-extra/cc-config.js`'s structure and level of
|
||||
* detail is intentionally lighter — one shared response shape per surface
|
||||
* kind rather than a bespoke schema per field — since this panel has far
|
||||
* fewer fields per record. All schema names are prefixed with `CodexConfig`.
|
||||
* The shared `{ error: { code, message } }` envelope reuses the base
|
||||
* `ErrorResponse` schema (defined in server/openapi.js).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const tags = [
|
||||
{
|
||||
name: "CodexConfig",
|
||||
description:
|
||||
"Codex CLI configuration explorer: discover and (for low-risk artifacts) edit skills, agents, rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and marketplaces.",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Shared sub-schemas ─────────────────────────────────────────────────
|
||||
|
||||
const schemas = {
|
||||
CodexConfigScope: {
|
||||
type: "string",
|
||||
enum: ["user", "project", "all"],
|
||||
description:
|
||||
"Discovery scope. `user` = $CODEX_HOME (or ~/.codex); `project` = <cwd>/.codex; `all` = both, merged. Defaults to `all` when omitted or unrecognized.",
|
||||
example: "all",
|
||||
},
|
||||
|
||||
CodexConfigSkill: {
|
||||
type: "object",
|
||||
description:
|
||||
"A discovered skill: a directory containing a SKILL.md under <root>/skills/<name>/.",
|
||||
required: [
|
||||
"scope",
|
||||
"name",
|
||||
"path",
|
||||
"file",
|
||||
"size",
|
||||
"mtime",
|
||||
"truncated",
|
||||
"frontmatter",
|
||||
"preview",
|
||||
],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
name: { type: "string", example: "code-reviewer" },
|
||||
path: { type: "string", example: "/home/user/.codex/skills/code-reviewer" },
|
||||
file: { type: "string", example: "/home/user/.codex/skills/code-reviewer/SKILL.md" },
|
||||
size: { type: "integer", example: 2048 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
truncated: { type: "boolean", example: false },
|
||||
frontmatter: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
example: { name: "code-reviewer", description: "Reviews diffs" },
|
||||
},
|
||||
preview: { type: "string", example: "Use this skill to review pull requests..." },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigAgent: {
|
||||
type: "object",
|
||||
description: "A discovered TOML agent definition (<root>/agents/<name>.toml).",
|
||||
required: ["scope", "name", "file", "size", "mtime", "meta", "preview"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
name: { type: "string", example: "reviewer" },
|
||||
file: { type: "string", example: "/home/user/.codex/agents/reviewer.toml" },
|
||||
size: { type: "integer", example: 512 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
meta: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
"Best-effort top-level scalar fields (name, description, model, sandbox_mode).",
|
||||
example: { name: "reviewer", model: "gpt-5" },
|
||||
},
|
||||
preview: { type: "string", example: 'name = "reviewer"\nmodel = "gpt-5"' },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigRule: {
|
||||
type: "object",
|
||||
description: "A discovered rules file (<root>/rules/<name>.rules).",
|
||||
required: ["scope", "name", "file", "size", "mtime", "preview"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
name: { type: "string", example: "default" },
|
||||
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
|
||||
size: { type: "integer", example: 128 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
preview: { type: "string", example: "allow" },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigMemoryEntry: {
|
||||
type: "object",
|
||||
description: "AGENTS.md at either the Codex home or the project root.",
|
||||
required: ["scope", "file", "exists"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "project" },
|
||||
file: { type: "string", example: "/repo/AGENTS.md" },
|
||||
exists: { type: "boolean", example: true },
|
||||
size: { type: "integer", example: 256 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
preview: { type: "string", example: "# Project memory\nHello." },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigConfigEntry: {
|
||||
type: "object",
|
||||
description: "config.toml at either the Codex home or the project .codex dir.",
|
||||
required: ["scope", "file", "exists"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
file: { type: "string", example: "/home/user/.codex/config.toml" },
|
||||
exists: { type: "boolean", example: true },
|
||||
size: { type: "integer", example: 640 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
content: { type: "string", example: 'model = "gpt-5"\n' },
|
||||
truncated: { type: "boolean", example: false },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigOverviewResponse: {
|
||||
type: "object",
|
||||
required: ["roots", "counts"],
|
||||
properties: {
|
||||
roots: {
|
||||
type: "object",
|
||||
properties: {
|
||||
codexHome: { type: "string", example: "/home/user/.codex" },
|
||||
projectCodexDir: { type: "string", example: "/repo/.codex" },
|
||||
projectRoot: { type: "string", example: "/repo" },
|
||||
},
|
||||
},
|
||||
counts: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: { user: { type: "integer" }, project: { type: "integer" } },
|
||||
},
|
||||
example: {
|
||||
skills: { user: 3, project: 1 },
|
||||
agents: { user: 2, project: 0 },
|
||||
rules: { user: 1, project: 0 },
|
||||
memory: { user: 1, project: 1 },
|
||||
config: { user: 1, project: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigFileReadResponse: {
|
||||
type: "object",
|
||||
required: ["ok", "file", "size", "mtime", "truncated", "text"],
|
||||
properties: {
|
||||
ok: { type: "boolean", example: true },
|
||||
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
|
||||
size: { type: "integer", example: 128 },
|
||||
mtime: { type: "number", example: 1718900000000 },
|
||||
truncated: { type: "boolean", example: false },
|
||||
text: { type: "string", example: "allow" },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigFileWriteResponse: {
|
||||
type: "object",
|
||||
required: ["file", "created", "backup"],
|
||||
properties: {
|
||||
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
|
||||
created: { type: "boolean", example: true },
|
||||
backup: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
additionalProperties: true,
|
||||
description: "Backup metadata when an existing file was overwritten; null on first create.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigFileDeleteResponse: {
|
||||
type: "object",
|
||||
required: ["file", "backup"],
|
||||
properties: {
|
||||
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
|
||||
backup: { type: "object", additionalProperties: true },
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigBackupsResponse: {
|
||||
type: "object",
|
||||
required: ["items"],
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", example: "rules" },
|
||||
name: { type: "string", example: "default.rules.2026-06-25T12-00-00.000Z.bak" },
|
||||
file: { type: "string" },
|
||||
mtime: { type: "number" },
|
||||
size: { type: "integer", nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
CodexConfigCliActionResponse: {
|
||||
type: "object",
|
||||
description: "Result of a successful mutating `codex` CLI call.",
|
||||
required: ["ok", "stdout", "stderr"],
|
||||
properties: {
|
||||
ok: { type: "boolean", example: true },
|
||||
stdout: { type: "string" },
|
||||
stderr: { type: "string" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── Reusable parameters ──────────────────────────────────────────────────
|
||||
|
||||
const scopeParam = {
|
||||
name: "scope",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["user", "project", "all"], default: "all" },
|
||||
description: "Discovery scope: `user`, `project`, or `all` (default).",
|
||||
example: "all",
|
||||
};
|
||||
|
||||
const cwdParam = {
|
||||
name: "cwd",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Override the working directory used to resolve the project root and project .codex dir. Defaults to the dashboard server's own cwd.",
|
||||
example: "/repo",
|
||||
};
|
||||
|
||||
const errorResponse = (code, message) => ({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
example: { error: { code, message } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cliExitResponse = {
|
||||
description: "`ECLIEXIT` — the `codex` CLI exited non-zero. stdout/stderr are preserved.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: {
|
||||
type: "object",
|
||||
properties: { code: { type: "string" }, message: { type: "string" } },
|
||||
},
|
||||
code: { type: "integer", nullable: true },
|
||||
stdout: { type: "string" },
|
||||
stderr: { type: "string" },
|
||||
},
|
||||
},
|
||||
example: {
|
||||
error: { code: "ECLIEXIT", message: 'marketplace "nope" is not registered' },
|
||||
code: 1,
|
||||
stdout: "",
|
||||
stderr: '✘ marketplace "nope" is not registered\n',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function readSurface(name, summary, schemaRef, exampleItems) {
|
||||
return {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary,
|
||||
operationId: `codexConfigGet${name[0].toUpperCase()}${name.slice(1)}`,
|
||||
parameters: name === "memory" || name === "config" ? [cwdParam] : [scopeParam, cwdParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: `${summary}.`,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { [name]: { type: "array", items: { $ref: schemaRef } } },
|
||||
},
|
||||
example: { [name]: exampleItems },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────
|
||||
|
||||
const paths = {
|
||||
"/api/codex-config/overview": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Codex config explorer overview",
|
||||
description:
|
||||
"Read-only. Returns the three resolved filesystem roots plus per-scope counts across skills, agents, rules, memory, and config.",
|
||||
operationId: "codexConfigGetOverview",
|
||||
parameters: [cwdParam],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Overview of roots and counts.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigOverviewResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/skills": readSurface(
|
||||
"skills",
|
||||
"List discovered skills",
|
||||
"#/components/schemas/CodexConfigSkill",
|
||||
[{ scope: "user", name: "code-reviewer", frontmatter: {}, preview: "..." }]
|
||||
),
|
||||
"/api/codex-config/agents": readSurface(
|
||||
"agents",
|
||||
"List discovered agent definitions",
|
||||
"#/components/schemas/CodexConfigAgent",
|
||||
[{ scope: "user", name: "reviewer", meta: { model: "gpt-5" } }]
|
||||
),
|
||||
"/api/codex-config/rules": readSurface(
|
||||
"rules",
|
||||
"List discovered rules files",
|
||||
"#/components/schemas/CodexConfigRule",
|
||||
[{ scope: "user", name: "default", preview: "allow" }]
|
||||
),
|
||||
"/api/codex-config/memory": readSurface(
|
||||
"memory",
|
||||
"Read AGENTS.md at both scopes",
|
||||
"#/components/schemas/CodexConfigMemoryEntry",
|
||||
[{ scope: "project", file: "/repo/AGENTS.md", exists: true }]
|
||||
),
|
||||
"/api/codex-config/config": readSurface(
|
||||
"config",
|
||||
"Read config.toml at both scopes",
|
||||
"#/components/schemas/CodexConfigConfigEntry",
|
||||
[{ scope: "user", file: "/home/user/.codex/config.toml", exists: true }]
|
||||
),
|
||||
|
||||
"/api/codex-config/file": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Read a single file body",
|
||||
description:
|
||||
"Read-only. Returns the body of one file. Strict path containment: the resolved absolute path must live under CODEX_HOME, the project .codex dir, or (only for a file literally named AGENTS.md) the project root.",
|
||||
operationId: "codexConfigGetFile",
|
||||
parameters: [
|
||||
{
|
||||
name: "path",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
example: "/home/user/.codex/rules/default.rules",
|
||||
},
|
||||
cwdParam,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "File body with metadata.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigFileReadResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("READ_DENIED", "path is outside allowed roots"),
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Create or overwrite a text artifact",
|
||||
description:
|
||||
"Writes skills/agents/rules/memory/config. `name` is required for skills/agents/rules and forbidden for memory/config (fixed filenames). Backs up any existing file first; broadcasts `codex_config_changed` on success.",
|
||||
operationId: "codexConfigPutFile",
|
||||
parameters: [cwdParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["scope", "type", "content"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"] },
|
||||
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
|
||||
name: { type: "string", nullable: true, example: "default" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
},
|
||||
example: { scope: "user", type: "rules", name: "default", content: "allow" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "File written.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigFileWriteResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADNAME", "name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"),
|
||||
413: errorResponse("ETOOLARGE", "content exceeds 262144 bytes"),
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Delete a text artifact",
|
||||
description:
|
||||
"Deletes skills/agents/rules/memory/config. Backup is mandatory and runs first. Broadcasts `codex_config_changed` on success.",
|
||||
operationId: "codexConfigDeleteFile",
|
||||
parameters: [cwdParam],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["scope", "type"],
|
||||
properties: {
|
||||
scope: { type: "string", enum: ["user", "project"] },
|
||||
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
|
||||
name: { type: "string", nullable: true },
|
||||
},
|
||||
},
|
||||
example: { scope: "user", type: "agents", name: "reviewer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "File deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigFileDeleteResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: errorResponse("ENOTFOUND", "agents/reviewer does not exist"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/backups": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "List backups",
|
||||
description:
|
||||
"Read-only. Lists timestamped backups, optionally filtered by scope and/or type.",
|
||||
operationId: "codexConfigGetBackups",
|
||||
parameters: [
|
||||
{
|
||||
name: "scope",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["user", "project"] },
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
|
||||
},
|
||||
cwdParam,
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backups list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigBackupsResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/mcp": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "List configured MCP servers",
|
||||
description:
|
||||
"Reads live from `codex mcp list --json` — there is no on-disk reader for this surface.",
|
||||
operationId: "codexConfigGetMcp",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP servers as reported by the CLI.",
|
||||
content: { "application/json": { schema: { type: "object", properties: { mcp: {} } } } },
|
||||
},
|
||||
422: cliExitResponse,
|
||||
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/plugins": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "List installed plugins",
|
||||
description: "Reads live from `codex plugin list --json`.",
|
||||
operationId: "codexConfigGetPlugins",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Plugins as reported by the CLI.",
|
||||
content: {
|
||||
"application/json": { schema: { type: "object", properties: { plugins: {} } } },
|
||||
},
|
||||
},
|
||||
422: cliExitResponse,
|
||||
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/marketplaces": {
|
||||
get: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "List registered marketplaces",
|
||||
description: "Reads live from `codex plugin marketplace list --json`.",
|
||||
operationId: "codexConfigGetMarketplaces",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Marketplaces as reported by the CLI.",
|
||||
content: {
|
||||
"application/json": { schema: { type: "object", properties: { marketplaces: {} } } },
|
||||
},
|
||||
},
|
||||
422: cliExitResponse,
|
||||
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/mcp/add": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Add an MCP server",
|
||||
description:
|
||||
"Runs `codex mcp add`. Accepts either a streamable-HTTP server (`url`, optional `bearerTokenEnvVar`) or a stdio server (`command`, `args`, optional `env`) — never both.",
|
||||
operationId: "codexConfigPostMcpAdd",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
url: { type: "string" },
|
||||
bearerTokenEnvVar: { type: "string" },
|
||||
command: { type: "string" },
|
||||
args: { type: "array", items: { type: "string" } },
|
||||
env: { type: "object", additionalProperties: { type: "string" } },
|
||||
},
|
||||
},
|
||||
example: { name: "github", url: "https://mcp.example.com/github" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server added.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADARG", "url must be a valid http(s) URL"),
|
||||
422: cliExitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/mcp/remove": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Remove an MCP server",
|
||||
operationId: "codexConfigPostMcpRemove",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: { name: { type: "string" } },
|
||||
},
|
||||
example: { name: "github" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "MCP server removed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
422: cliExitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/plugins/action": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Add or remove a plugin",
|
||||
description: "Runs `codex plugin add <spec>` or `codex plugin remove <spec>`.",
|
||||
operationId: "codexConfigPostPluginsAction",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["spec", "action"],
|
||||
properties: {
|
||||
spec: { type: "string" },
|
||||
action: { type: "string", enum: ["add", "remove"] },
|
||||
},
|
||||
},
|
||||
example: { spec: "demo@market", action: "add" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Plugin action applied.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADACTION", "action must be one of add, remove"),
|
||||
422: cliExitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/marketplaces/add": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Register a marketplace",
|
||||
description: "Runs `codex plugin marketplace add <url>`.",
|
||||
operationId: "codexConfigPostMarketplacesAdd",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", required: ["url"], properties: { url: { type: "string" } } },
|
||||
example: { url: "https://example.com/marketplace.json" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Marketplace added.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADARG", "marketplace url must be a valid http(s) URL"),
|
||||
422: cliExitResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/codex-config/marketplaces/action": {
|
||||
post: {
|
||||
tags: ["CodexConfig"],
|
||||
summary: "Remove a marketplace",
|
||||
description: "Runs `codex plugin marketplace remove <name>`.",
|
||||
operationId: "codexConfigPostMarketplacesAction",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name", "action"],
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
action: { type: "string", enum: ["remove"] },
|
||||
},
|
||||
},
|
||||
example: { name: "demo-mp", action: "remove" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Marketplace action applied.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: errorResponse("EBADACTION", "action must be one of remove"),
|
||||
422: cliExitResponse,
|
||||
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { tags, schemas, paths };
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* @file codex-config.js
|
||||
* @description HTTP surface for inspecting and (carefully) mutating Codex
|
||||
* CLI configuration: skills, agents, rules, memory (AGENTS.md),
|
||||
* config.toml, MCP servers, plugins, and marketplaces. Powers the Codex
|
||||
* Config panel — the Codex counterpart of cc-config.js.
|
||||
*
|
||||
* Read paths (skills/agents/rules/memory/config) come from codex-discovery.js
|
||||
* (pure file reads). MCP servers, plugins, and marketplaces have no on-disk
|
||||
* reader — codex-discovery.js deliberately leaves them to the `codex` CLI —
|
||||
* so those three GET routes, plus every mutation, shell out through
|
||||
* codex-cli.js. A non-zero CLI exit is reported as 422 with stdout/stderr
|
||||
* preserved, never a 500.
|
||||
*
|
||||
* Write paths (skills/agents/rules/memory/config) go through codex-mutate.js:
|
||||
* every write/delete is preceded by a timestamped backup, and every mutation
|
||||
* error code is mapped explicitly below so an unmapped one can never fall
|
||||
* through to a bare 500.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Router } = require("express");
|
||||
const codexDiscovery = require("../lib/codex-discovery");
|
||||
const codexMutate = require("../lib/codex-mutate");
|
||||
const codexCli = require("../lib/codex-cli");
|
||||
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("../lib/codex-home");
|
||||
const { isUnder, MAX_FILE_BYTES } = require("../lib/cc-discovery");
|
||||
const { broadcast } = require("../websocket");
|
||||
|
||||
const router = Router();
|
||||
|
||||
function emitChanged(payload) {
|
||||
try {
|
||||
broadcast("codex_config_changed", { source: "dashboard", ...payload });
|
||||
} catch {
|
||||
/* websocket may not be initialised in unit tests */
|
||||
}
|
||||
}
|
||||
|
||||
function scopeOf(req) {
|
||||
const s = String(req.query.scope || "all");
|
||||
return s === "user" || s === "project" ? s : "all";
|
||||
}
|
||||
|
||||
function cwdOf(req) {
|
||||
// Mirrors cc-config's cwdOf: the server's own cwd is the natural
|
||||
// "project", but ?cwd= lets the caller inspect another working dir.
|
||||
const c = typeof req.query.cwd === "string" && req.query.cwd ? req.query.cwd : null;
|
||||
return c || process.cwd();
|
||||
}
|
||||
|
||||
// ── Mutation errors (codex-mutate.js) → HTTP status ────────────────────
|
||||
// Anything unmapped is a 500 — this map must cover every code codex-mutate
|
||||
// can throw.
|
||||
const ERR_TO_STATUS = {
|
||||
EBADTYPE: 400,
|
||||
EBADSCOPE: 400,
|
||||
EBADNAME: 400,
|
||||
EBADCONTENT: 400,
|
||||
EOUTOFROOT: 400,
|
||||
ETOOLARGE: 413,
|
||||
ENOTFOUND: 404,
|
||||
};
|
||||
|
||||
function mutateError(res, err) {
|
||||
const status = ERR_TO_STATUS[err.code] || 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
||||
}
|
||||
|
||||
// ── Discovery reads ─────────────────────────────────────────────────────
|
||||
|
||||
router.get("/overview", (req, res) => {
|
||||
res.json(codexDiscovery.readOverview({ cwd: cwdOf(req) }));
|
||||
});
|
||||
|
||||
router.get("/skills", (req, res) => {
|
||||
res.json({ skills: codexDiscovery.readSkills({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/agents", (req, res) => {
|
||||
res.json({ agents: codexDiscovery.readAgents({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/rules", (req, res) => {
|
||||
res.json({ rules: codexDiscovery.readRules({ scope: scopeOf(req), cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/memory", (req, res) => {
|
||||
res.json({ memory: codexDiscovery.readMemory({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
router.get("/config", (req, res) => {
|
||||
res.json({ config: codexDiscovery.readConfig({ cwd: cwdOf(req) }) });
|
||||
});
|
||||
|
||||
// GET /api/codex-config/file?path=/abs/path — return body of a single file.
|
||||
// Path must resolve under CODEX_HOME, the project .codex dir, or (only for a
|
||||
// file literally named AGENTS.md) the project root. No dedicated reader for
|
||||
// this exists in codex-discovery.js (it only reads whole surfaces), so this
|
||||
// mirrors cc-discovery's readFileSafe against the Codex roots instead.
|
||||
function readCodexFileSafe(absPath, opts = {}) {
|
||||
const codexHome = getCodexHome();
|
||||
const projectCodexDir = getProjectCodexDir(opts.cwd);
|
||||
const projectRoot = getProjectRoot(opts.cwd);
|
||||
const resolved = path.resolve(absPath);
|
||||
const inside = [codexHome, projectCodexDir, projectRoot].some((root) => isUnder(root, resolved));
|
||||
if (!inside) return { error: "path is outside allowed roots" };
|
||||
if (
|
||||
isUnder(projectRoot, resolved) &&
|
||||
!isUnder(projectCodexDir, resolved) &&
|
||||
path.basename(resolved) !== "AGENTS.md"
|
||||
) {
|
||||
return { error: "only AGENTS.md is readable from project root" };
|
||||
}
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
return { error: "file not readable" };
|
||||
}
|
||||
if (!stat.isFile()) return { error: "file not readable" };
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(resolved, "utf8");
|
||||
} catch {
|
||||
return { error: "file not readable" };
|
||||
}
|
||||
const truncated = stat.size > MAX_FILE_BYTES;
|
||||
return {
|
||||
ok: true,
|
||||
file: resolved,
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
truncated,
|
||||
text: truncated ? text.slice(0, MAX_FILE_BYTES) : text,
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/file", (req, res) => {
|
||||
const p = req.query.path;
|
||||
if (typeof p !== "string" || !p) {
|
||||
return res.status(400).json({ error: { code: "BAD_PATH", message: "path is required" } });
|
||||
}
|
||||
const result = readCodexFileSafe(p, { cwd: cwdOf(req) });
|
||||
if (result.error)
|
||||
return res.status(400).json({ error: { code: "READ_DENIED", message: result.error } });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Mutation endpoints (codex-mutate.js) ────────────────────────────────
|
||||
//
|
||||
// PUT /api/codex-config/file — create or overwrite. Body: { scope, type, name?, content }
|
||||
// DELETE /api/codex-config/file — delete. Body: { scope, type, name? }
|
||||
// GET /api/codex-config/backups[?scope=&type=] — list backups
|
||||
|
||||
router.put("/file", (req, res) => {
|
||||
const { scope, type, name, content } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = codexMutate.writeArtifact({ scope, type, name, content, cwd: cwdOf(req) });
|
||||
emitChanged({ action: "write", scope, type, name: name || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/file", (req, res) => {
|
||||
const { scope, type, name } = req.body || {};
|
||||
if (typeof scope !== "string" || typeof type !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADREQ", message: "scope and type are required" } });
|
||||
}
|
||||
try {
|
||||
const result = codexMutate.deleteArtifact({ scope, type, name, cwd: cwdOf(req) });
|
||||
emitChanged({ action: "delete", scope, type, name: name || null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/backups", (req, res) => {
|
||||
const scope =
|
||||
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
||||
const type = typeof req.query.type === "string" ? req.query.type : undefined;
|
||||
try {
|
||||
res.json({ items: codexMutate.listBackups({ scope, type, cwd: cwdOf(req) }) });
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── MCP servers / plugins / marketplaces (via the `codex` CLI) ─────────
|
||||
//
|
||||
// Same reasoning as cc-config.js's plugin/marketplace routes: these shell
|
||||
// out to `codex …` with an argv array — never a shell string — because the
|
||||
// CLI owns config.toml's schema. Identifiers travel in the body, not the
|
||||
// URL, for the same reason DELETE /file does.
|
||||
|
||||
const CLI_ERR_TO_STATUS = {
|
||||
EBADARG: 400,
|
||||
ECLINOTFOUND: 503,
|
||||
ETIMEOUT: 504,
|
||||
};
|
||||
|
||||
function cliError(res, err) {
|
||||
const status = CLI_ERR_TO_STATUS[err.code] || 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort human message from a failed run. codex-cli.js does not export
|
||||
* one (unlike cc-plugin-cli's cliMessage), so this route-local helper does
|
||||
* the same "last non-empty line, prefer a marked one" heuristic.
|
||||
*/
|
||||
function cliMessage(result) {
|
||||
const text = `${result.stderr || ""}\n${result.stdout || ""}`;
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
const marked = lines.find((l) => l.includes("✘") || l.toLowerCase().includes("error"));
|
||||
const picked = marked || lines[lines.length - 1] || "";
|
||||
return picked.replace(/^✘\s*/, "").slice(0, 1000);
|
||||
}
|
||||
|
||||
/** A non-zero CLI exit is a 422, not a 500 — the caller can inspect stderr. */
|
||||
function cliFailure(res, result) {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: "ECLIEXIT",
|
||||
message: cliMessage(result) || `codex exited with code ${result.code}`,
|
||||
},
|
||||
code: result.code,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read-through result for a GET route backed by the CLI. */
|
||||
function cliRead(res, result, key) {
|
||||
if (!result.ok) return cliFailure(res, result);
|
||||
res.json({ [key]: result.data !== undefined ? result.data : result.stdout });
|
||||
}
|
||||
|
||||
/** Result for a mutating route: broadcast on success, 422 on CLI failure. */
|
||||
function cliResult(res, result, changed) {
|
||||
if (!result.ok) return cliFailure(res, result);
|
||||
emitChanged(changed);
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
|
||||
}
|
||||
|
||||
router.get("/mcp", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.mcpList(), "mcp");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/plugins", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.pluginList(), "plugins");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/marketplaces", async (_req, res) => {
|
||||
try {
|
||||
return cliRead(res, await codexCli.marketplaceList(), "marketplaces");
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/add", async (req, res) => {
|
||||
try {
|
||||
const result = await codexCli.mcpAdd(req.body || {});
|
||||
return cliResult(res, result, { action: "add", type: "mcp", name: (req.body || {}).name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/remove", async (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
try {
|
||||
const result = await codexCli.mcpRemove(name);
|
||||
return cliResult(res, result, { action: "remove", type: "mcp", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const PLUGIN_ACTIONS = {
|
||||
add: codexCli.pluginAdd,
|
||||
remove: codexCli.pluginRemove,
|
||||
};
|
||||
|
||||
router.post("/plugins/action", async (req, res) => {
|
||||
const { spec, action } = req.body || {};
|
||||
const run = PLUGIN_ACTIONS[action];
|
||||
if (!run) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EBADACTION",
|
||||
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await run(spec);
|
||||
return cliResult(res, result, { action, type: "plugin", name: spec });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/marketplaces/add", async (req, res) => {
|
||||
const { url } = req.body || {};
|
||||
try {
|
||||
const result = await codexCli.marketplaceAdd(url);
|
||||
return cliResult(res, result, { action: "add", type: "marketplace", name: url });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const MARKETPLACE_ACTIONS = {
|
||||
remove: codexCli.marketplaceRemove,
|
||||
};
|
||||
|
||||
router.post("/marketplaces/action", async (req, res) => {
|
||||
const { name, action } = req.body || {};
|
||||
const run = MARKETPLACE_ACTIONS[action];
|
||||
if (!run) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EBADACTION",
|
||||
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await run(name);
|
||||
return cliResult(res, result, { action, type: "marketplace", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user