/** * @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"); // Reused, not reimplemented — see marketplaceAdd's doc comment. const { assertSource } = require("./cc-plugin-cli"); 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 environment variable VALUE. Unlike `assertArg`, a leading "-" is fine: * the value is the right-hand side of one `KEY=VALUE` argv element (built * by envFlags below), never its own argv position, so it can never be * parsed as a flag. Rejecting it anyway (as `assertArg` would) refused * ordinary values like "-1" or a base64 string that happens to start with * "-" for no real reason. */ function assertEnvValue(value, label) { if (typeof value !== "string") 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`); } 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 (!Array.isArray(args)) throw makeError("EBADARG", "args must be an array"); if (env !== undefined && (typeof env !== "object" || env === null || Array.isArray(env))) { throw makeError("EBADARG", "env must be an object"); } 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"); assertEnvValue(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 ──────────────────────────────────────────────────────────── /** * `codex plugin list --json` returns `{installed: [...], available: [...]}`, * not a bare array — unlike `mcp list`. The installed list is what the * Plugins tab shows; unwrap it here so `cliRead` (codex-config.js) can keep * treating every list route the same way: `result.data` is the list. */ async function pluginList() { const result = await runCodex(["plugin", "list", "--json"]); // `data` is null when stdout was the literal `null` — JSON that parses to // nothing. Guard it: a CLI's output must never reach the route as a 500. if (result.data !== undefined && !Array.isArray(result.data)) { result.data = Array.isArray(result.data?.installed) ? result.data.installed : []; } return result; } async function pluginAdd(spec) { const s = assertPluginSpec(spec); return serialize(() => runCodex(["plugin", "add", s])); } async function pluginRemove(spec) { // A spec, not a bare name: `plugin add` accepts `@`, so // `plugin remove` has to accept back what `plugin list` hands out. // cc-plugin-cli.js validates every plugin verb the same way. const s = assertPluginSpec(spec); return serialize(() => runCodex(["plugin", "remove", s])); } // ── Marketplaces ─────────────────────────────────────────────────────── /** Same object-not-array shape as `pluginList` — unwrap `marketplaces`. */ async function marketplaceList() { const result = await runCodex(["plugin", "marketplace", "list", "--json"]); if (result.data !== undefined && !Array.isArray(result.data)) { result.data = Array.isArray(result.data?.marketplaces) ? result.data.marketplaces : []; } return result; } /** * `codex plugin marketplace add ` accepts a local path, `owner/repo`, * an https git URL, or an ssh git URL — not just http(s), so `assertUrl` * rejected the common `owner/repo` form. Reuse cc-plugin-cli's `assertSource` * (same shape `claude plugin marketplace add` accepts) instead of writing a * third validator. That guard also excludes ssh remotes and `..` — kept as * is here too: ssh needs its own credential/host-key handling this dashboard * has no story for yet, and every other CLI-facing source guard in this repo * already draws that same line. */ async function marketplaceAdd(source) { const s = assertSource(source); return serialize(() => runCodex(["plugin", "marketplace", "add", s])); } async function marketplaceRemove(name) { const n = assertName(name); return serialize(() => runCodex(["plugin", "marketplace", "remove", n])); } /** * Best-effort human message from a failed run. The CLI prints its errors with * a `✘` marker on either stream; fall back to the last non-empty line. * Unchanged copy of cc-plugin-cli.js's cliMessage — same CLI-output shape, * same heuristic. */ function cliMessage(res) { const text = `${res.stderr || ""}\n${res.stdout || ""}`; const lines = text .split("\n") .map((l) => l.trim()) .filter(Boolean); const marked = lines.find((l) => l.includes("✘")); const picked = marked || lines[lines.length - 1] || ""; return picked.replace(/^✘\s*/, "").slice(0, 1000); } module.exports = { runCodex, cliMessage, mcpList, mcpAdd, mcpRemove, pluginList, pluginAdd, pluginRemove, marketplaceList, marketplaceAdd, marketplaceRemove, NAME_RE, PLUGIN_SPEC_RE, assertPluginSpec, assertUrl, };