Files
AgentHub/server/routes/codex-config.js
T
nntrivi2001 85187dfc78 feat(config): create a new project's memory file from Settings
The Memory tab could only show this machine's user memory and the
dashboard's own project file. Setting up a project you had not run
through the dashboard meant opening a terminal — `PUT /file`'s
"project" scope always resolves against the server's cwd, on purpose.

Add one narrow route per tool that writes into the directory you name:
CLAUDE.md for Claude Code, AGENTS.md for Codex, both through the same
`project-memory.js`. Being the only write path that honours a
caller-supplied directory, it pays for that:

  - the directory must be absolute and already exist — nothing is
    created for you;
  - the filename comes from the route, never from the body;
  - an existing file, symlink included, is a 409 — this can only
    create, never overwrite;
  - it sits behind the loopback-Origin guard /api/run uses, so a page
    in the user's browser cannot plant agent instructions in a project
    directory. That guard moves to lib/same-origin.js to be shared.

The modal takes the dashboard's known project dirs as suggestions on a
free-text input rather than a closed list: a project it has never seen
is exactly the case this exists for.

Mutation-checked: dropping the origin guard, and swapping lstat for
stat, each fail a test.
2026-09-09 01:45:00 +07:00

359 lines
12 KiB
JavaScript

/**
* @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 { 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 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,
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;