/** * @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ĩ */ const { Router } = require("express"); const codexDiscovery = require("../lib/codex-discovery"); const codexMutate = require("../lib/codex-mutate"); const codexCli = require("../lib/codex-cli"); const projectMemory = require("../lib/project-memory"); const { sameOriginGuard } = require("../lib/same-origin"); const { broadcast } = require("../websocket"); const scope = require("../lib/scope"); const router = Router(); // Server mode: a non-admin's ?cwd= only names one of their own workspaces. router.use(scope.ownCwdQuery); 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, EBADDIR: 400, EBADTOOL: 400, EOUTOFROOT: 400, ETOOLARGE: 413, ENOTFOUND: 404, // The project already has an AGENTS.md; creating would clobber it. EEXISTS: 409, }; 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. codexDiscovery.readFileSafe // checks that containment lexically — a symlink whose LOCATION is inside a // root is readable even when it resolves outside, matching the other four // discovery readers. The write side (codex-mutate.js) keeps the stricter // realpath-based assertNotSymlinkEscape — reads and writes intentionally // disagree on symlinks; see readFileSafe's doc comment for why. 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 = codexDiscovery.readFileSafe(p, { cwd: cwdOf(req) }); if (result.notFound) return res.status(404).json({ error: { code: "NOT_FOUND", message: "file not found" } }); 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 // // Writes resolve "project" scope against the SERVER's cwd, never `?cwd=`: a // write target chosen by the caller would let any request create a file // anywhere on disk (same reasoning cc-config.js's PATCH /settings gives for // refusing `?cwd=` there). Reads keep the override. 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: process.cwd() }); 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: process.cwd() }); emitChanged({ action: "delete", scope, type, name: name || null }); res.json(result); } catch (err) { return mutateError(res, err); } }); /** * POST /api/codex-config/project-memory — create `AGENTS.md` in a project the * dashboard has never run in. Body: `{ dir, content }`. The Codex twin of * cc-config.js's route of the same name; both go through project-memory.js, * which is where the narrowing rules for this one caller-chosen write path * are documented. */ router.post("/project-memory", sameOriginGuard, (req, res) => { const { dir, content } = req.body || {}; try { const result = projectMemory.createProjectMemory({ tool: "codex", dir, content }); emitChanged({ action: "write", scope: "project", type: "memory", name: null }); res.json({ ...result, created: true }); } 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 } }); } /** 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: codexCli.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 `codex … --json`. `result.data` * is only set when stdout parsed as JSON (see runCodex). A fresh install with * zero MCP servers/plugins/marketplaces is the normal case and prints nothing * — that's an empty list, not a parse failure. Only stdout that has content * and still doesn't parse (the CLI's output shape changed under us) is a 502, * never a 200 with a string where the OpenAPI fragment promises an array/object. */ function cliRead(res, result, key) { if (!result.ok) return cliFailure(res, result); if (result.data === undefined) { if (!result.stdout.trim()) return res.json({ [key]: [] }); return res.status(502).json({ error: { code: "ECLIBADOUTPUT", message: `could not parse \`codex\` JSON output for ${key}`, }, }); } res.json({ [key]: result.data }); } /** 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;