347 lines
13 KiB
JavaScript
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,
|
|
};
|