Files
AgentHub/server/lib/codex-cli.js
T

347 lines
13 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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;
}
/** `<name>` or `<name>@<marketplace>`, 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 <name> or <name>@<marketplace>");
}
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 <args…>` 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
* (`-- <command> <args…>`, 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 `<name>@<marketplace>`, 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 <SOURCE>` 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,
};