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

213 lines
7.5 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.
* - 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");
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._-]*$/;
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;
}
// 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"]);
}
async function mcpAdd({ name, command, args = [], env }) {
const n = assertName(name);
const cmd = assertArg(command, "command");
const cmdArgs = args.map((a, i) => assertArg(a, `args[${i}]`));
const envFlags = Object.entries(env || {}).flatMap(([k, v]) => {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
throw makeError("EBADARG", `env key "${k}" is not a valid identifier`);
}
assertArg(String(v), `env.${k}`);
return ["--env", `${k}=${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 = assertArg(spec, "plugin 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 = assertArg(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,
};