297 lines
11 KiB
JavaScript
297 lines
11 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");
|
|
|
|
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 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");
|
|
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]));
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|