/** * @file codex-cli.js * @description Drives the official `codex mcp …` / `codex plugin …` CLI so * the Codex config panel can add/remove MCP servers, marketplaces and * plugins without the dashboard touching Codex's TOML config directly. * * Why the CLI and not direct TOML edits: the CLI owns config.toml's schema * (MCP server table, plugin/marketplace registration) and rewrites it in * place. Reproducing that here would drift the moment the CLI's format * moves — the same reasoning `cc-plugin-cli.js` follows for `claude plugin`. * * Hard constraints (do not relax without a follow-up review): * - `execFile` with an explicit argv array. NEVER a shell string — a * server/plugin/marketplace name comes straight from a user-typed field. * - A name is validated against `NAME_RE` BEFORE spawning: no leading `-` * (never a flag), no `/` (never a path). This is the same guard the * HTTP routes re-check. Plugin specs and URLs get their own purpose-built * guards (`assertPluginSpec`, `assertUrl`) — same idea, looser shape. * - Mutating calls are serialized in-process; two dashboard clicks must * not interleave writes to the same config.toml. * - `CODEX_HOME` is pinned to the same home `codex-discovery` reads, so * the CLI writes where the dashboard looks. * * A non-zero CLI exit resolves as `{ok: false}` (data, not an error) so a * route can map it to HTTP 422. Only a rejected name or a missing binary * throws — those are programming/environment errors, not CLI outcomes. * * @author Nguyễn Ngọc Trí Vĩ */ const { execFile } = require("node:child_process"); const { getCodexHome } = require("./codex-home"); const DEFAULT_TIMEOUT_MS = 120_000; const MAX_OUTPUT_BYTES = 1024 * 1024; /** An MCP server / plugin / marketplace name. No slashes: never a path. */ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; /** `name` or `name@marketplace` — same shape as cc-plugin-cli's PLUGIN_SPEC_RE. */ const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/; /** An environment variable name — used for both `--env K=V` and `--bearer-token-env-var`. */ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; function makeError(code, message) { const err = new Error(message); err.code = code; return err; } /** * Baseline check applied to every argv element we interpolate. A value that * starts with `-` would be parsed as a flag by the CLI even inside an argv * array, so it is rejected rather than escaped. */ function assertArg(value, label) { if (typeof value !== "string" || !value.trim()) { throw makeError("EBADARG", `${label} is required`); } if (value.length > 512) throw makeError("EBADARG", `${label} is too long`); // eslint-disable-next-line no-control-regex if (/[\u0000-\u001f\u007f]/.test(value)) { throw makeError("EBADARG", `${label} contains control characters`); } if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`); return value; } /** An MCP server / plugin / marketplace identifier — the routes' shared guard. */ function assertName(value, label = "name") { assertArg(value, label); if (!NAME_RE.test(value)) { throw makeError("EBADARG", `${label} is not a valid identifier`); } return value; } /** `` or `@`, as `codex plugin add` accepts. */ function assertPluginSpec(value) { assertArg(value, "plugin spec"); if (!PLUGIN_SPEC_RE.test(value)) { throw makeError("EBADARG", "plugin spec must be or @"); } return value; } /** An environment variable NAME (never a literal secret). */ function assertEnvKey(value, label) { assertArg(value, label); if (!ENV_KEY_RE.test(value)) { throw makeError("EBADARG", `${label} is not a valid identifier`); } return value; } /** An http(s) URL — `new URL()` both validates and normalizes the parse. */ function assertUrl(value, label) { assertArg(value, label); let parsed; try { parsed = new URL(value); } catch { throw makeError("EBADARG", `${label} must be a valid http(s) URL`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw makeError("EBADARG", `${label} must be a valid http(s) URL`); } return value; } // One in-process queue for every mutating CLI call — the same reasoning as // cc-plugin-cli.js's `mutationQueue`: this dashboard is the only Node writer, // so a promise chain is the whole locking story. It does NOT protect against // a human running `codex …` in a terminal at the same moment. let mutationQueue = Promise.resolve(); function serialize(fn) { const run = mutationQueue.then(fn, fn); mutationQueue = run.then( () => undefined, () => undefined ); return run; } /** * Run `codex ` and resolve with the outcome. A non-zero exit is a * normal result (`ok:false`), not a rejection. When stdout parses as JSON * the parsed value is attached as `data`. * * @param {string[]} args argv AFTER the binary name; every element pre-validated. * @param {{timeoutMs?:number}} [opts] * @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, data?:object}>} */ function runCodex(args, opts = {}) { const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS; return new Promise((resolve, reject) => { const child = execFile( "codex", args, { timeout, maxBuffer: MAX_OUTPUT_BYTES, // Pin the CLI to the same home codex-discovery reads, so a write // made here is a write the dashboard can see. env: { ...process.env, CODEX_HOME: getCodexHome() }, }, (err, stdout, stderr) => { const out = String(stdout || ""); const errOut = String(stderr || ""); if (err && (err.code === "ENOENT" || err.code === "EACCES")) { return reject( makeError( "ECLINOTFOUND", "the `codex` CLI was not found on PATH — install Codex or add it to the dashboard's PATH" ) ); } if (err && err.killed) { return reject(makeError("ETIMEOUT", `codex ${args[0]} timed out after ${timeout}ms`)); } const code = err ? (typeof err.code === "number" ? err.code : 1) : 0; const result = { ok: !err, code, stdout: out, stderr: errOut }; try { result.data = JSON.parse(out.trim()); } catch { /* not every command emits JSON */ } resolve(result); } ); // Every command here is non-interactive. Closing stdin turns a would-be // prompt into an immediate CLI error instead of a hang until `timeout`. try { child.stdin?.end(); } catch { /* the child may already have exited */ } }); } // ── MCP servers ──────────────────────────────────────────────────────── async function mcpList() { return runCodex(["mcp", "list", "--json"]); } /** * `codex mcp add` has two mutually exclusive forms: a streamable HTTP server * (`--url`, optionally `--bearer-token-env-var`) or a stdio server * (`-- `, optionally `--env K=V`). `--env` is invalid on * the `--url` form per the CLI's own `--help`, so `{url, env}` together is * rejected rather than silently dropping one of them. */ async function mcpAdd({ name, url, bearerTokenEnvVar, command, args = [], env }) { const n = assertName(name); if (url && command) { throw makeError("EBADARG", "mcpAdd accepts either url or command, not both"); } if (!url && !command) { throw makeError("EBADARG", "mcpAdd requires either url or command"); } if (url) { if (env) throw makeError("EBADARG", "env is not valid together with url (stdio-only)"); const u = assertUrl(url, "url"); const flags = bearerTokenEnvVar ? ["--bearer-token-env-var", assertEnvKey(bearerTokenEnvVar, "bearerTokenEnvVar")] : []; return serialize(() => runCodex(["mcp", "add", n, ...flags, "--url", u])); } const cmd = assertArg(command, "command"); const cmdArgs = args.map((a, i) => assertArg(a, `args[${i}]`)); const envFlags = Object.entries(env || {}).flatMap(([k, v]) => { const key = assertEnvKey(k, "env key"); assertArg(String(v), `env.${k}`); return ["--env", `${key}=${v}`]; }); return serialize(() => runCodex(["mcp", "add", n, ...envFlags, "--", cmd, ...cmdArgs])); } async function mcpRemove(name) { const n = assertName(name); return serialize(() => runCodex(["mcp", "remove", n])); } // ── Plugins ──────────────────────────────────────────────────────────── async function pluginList() { return runCodex(["plugin", "list", "--json"]); } async function pluginAdd(spec) { const s = assertPluginSpec(spec); return serialize(() => runCodex(["plugin", "add", s])); } async function pluginRemove(name) { const n = assertName(name); return serialize(() => runCodex(["plugin", "remove", n])); } // ── Marketplaces ─────────────────────────────────────────────────────── async function marketplaceList() { return runCodex(["plugin", "marketplace", "list", "--json"]); } async function marketplaceAdd(url) { const u = assertUrl(url, "marketplace url"); return serialize(() => runCodex(["plugin", "marketplace", "add", u])); } async function marketplaceRemove(name) { const n = assertName(name); return serialize(() => runCodex(["plugin", "marketplace", "remove", n])); } module.exports = { runCodex, mcpList, mcpAdd, mcpRemove, pluginList, pluginAdd, pluginRemove, marketplaceList, marketplaceAdd, marketplaceRemove, NAME_RE, PLUGIN_SPEC_RE, assertPluginSpec, assertUrl, };