Files
AgentHub/server/openapi-extra/codex-config.js
T
nntrivi2001 acff059e92 fix(codex-config): close whole-branch review defects in the Codex panel
Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.

Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).

Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
2026-09-09 01:17:06 +07:00

764 lines
25 KiB
JavaScript

/**
* @file OpenAPI 3.0 fragment for the Codex Config router
* (`server/routes/codex-config.js`, mounted at `/api/codex-config`).
* Documents the read surfaces (skills, agents, rules, memory, config, plus
* MCP servers/plugins/marketplaces read live from the `codex` CLI), the
* low-risk mutation surface (PUT/DELETE /file), the backups listing, and the
* CLI-backed MCP/plugin/marketplace mutation endpoints.
*
* Mirrors `server/openapi-extra/cc-config.js`'s structure and level of
* detail is intentionally lighter — one shared response shape per surface
* kind rather than a bespoke schema per field — since this panel has far
* fewer fields per record. All schema names are prefixed with `CodexConfig`.
* The shared `{ error: { code, message } }` envelope reuses the base
* `ErrorResponse` schema (defined in server/openapi.js).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "CodexConfig",
description:
"Codex CLI configuration explorer: discover and (for low-risk artifacts) edit skills, agents, rules, memory (AGENTS.md), config.toml, MCP servers, plugins, and marketplaces.",
},
];
// ── Shared sub-schemas ─────────────────────────────────────────────────
const schemas = {
CodexConfigScope: {
type: "string",
enum: ["user", "project", "all"],
description:
"Discovery scope. `user` = $CODEX_HOME (or ~/.codex); `project` = <cwd>/.codex; `all` = both, merged. Defaults to `all` when omitted or unrecognized.",
example: "all",
},
CodexConfigSkill: {
type: "object",
description:
"A discovered skill: a directory containing a SKILL.md under <root>/skills/<name>/.",
required: [
"scope",
"name",
"path",
"file",
"size",
"mtime",
"truncated",
"frontmatter",
"preview",
],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "code-reviewer" },
path: { type: "string", example: "/home/user/.codex/skills/code-reviewer" },
file: { type: "string", example: "/home/user/.codex/skills/code-reviewer/SKILL.md" },
size: { type: "integer", example: 2048 },
mtime: { type: "number", example: 1718900000000 },
truncated: { type: "boolean", example: false },
frontmatter: {
type: "object",
additionalProperties: { type: "string" },
example: { name: "code-reviewer", description: "Reviews diffs" },
},
preview: { type: "string", example: "Use this skill to review pull requests..." },
},
},
CodexConfigAgent: {
type: "object",
description: "A discovered TOML agent definition (<root>/agents/<name>.toml).",
required: ["scope", "name", "file", "size", "mtime", "meta", "preview"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "reviewer" },
file: { type: "string", example: "/home/user/.codex/agents/reviewer.toml" },
size: { type: "integer", example: 512 },
mtime: { type: "number", example: 1718900000000 },
meta: {
type: "object",
additionalProperties: true,
description:
"Best-effort top-level scalar fields (name, description, model, sandbox_mode).",
example: { name: "reviewer", model: "gpt-5" },
},
preview: { type: "string", example: 'name = "reviewer"\nmodel = "gpt-5"' },
},
},
CodexConfigRule: {
type: "object",
description: "A discovered rules file (<root>/rules/<name>.rules).",
required: ["scope", "name", "file", "size", "mtime", "preview"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
name: { type: "string", example: "default" },
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
size: { type: "integer", example: 128 },
mtime: { type: "number", example: 1718900000000 },
preview: { type: "string", example: "allow" },
},
},
CodexConfigMemoryEntry: {
type: "object",
description: "AGENTS.md at either the Codex home or the project root.",
required: ["scope", "file", "exists"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "project" },
file: { type: "string", example: "/repo/AGENTS.md" },
exists: { type: "boolean", example: true },
size: { type: "integer", example: 256 },
mtime: { type: "number", example: 1718900000000 },
preview: { type: "string", example: "# Project memory\nHello." },
},
},
CodexConfigConfigEntry: {
type: "object",
description: "config.toml at either the Codex home or the project .codex dir.",
required: ["scope", "file", "exists"],
properties: {
scope: { type: "string", enum: ["user", "project"], example: "user" },
file: { type: "string", example: "/home/user/.codex/config.toml" },
exists: { type: "boolean", example: true },
size: { type: "integer", example: 640 },
mtime: { type: "number", example: 1718900000000 },
content: { type: "string", example: 'model = "gpt-5"\n' },
truncated: { type: "boolean", example: false },
},
},
CodexConfigOverviewResponse: {
type: "object",
required: ["roots", "counts"],
properties: {
roots: {
type: "object",
properties: {
codexHome: { type: "string", example: "/home/user/.codex" },
projectCodexDir: { type: "string", example: "/repo/.codex" },
projectRoot: { type: "string", example: "/repo" },
},
},
counts: {
type: "object",
additionalProperties: {
type: "object",
properties: { user: { type: "integer" }, project: { type: "integer" } },
},
example: {
skills: { user: 3, project: 1 },
agents: { user: 2, project: 0 },
rules: { user: 1, project: 0 },
memory: { user: 1, project: 1 },
config: { user: 1, project: 0 },
},
},
},
},
CodexConfigFileReadResponse: {
type: "object",
required: ["ok", "file", "size", "mtime", "truncated", "text"],
properties: {
ok: { type: "boolean", example: true },
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
size: { type: "integer", example: 128 },
mtime: { type: "number", example: 1718900000000 },
truncated: { type: "boolean", example: false },
text: { type: "string", example: "allow" },
},
},
CodexConfigFileWriteResponse: {
type: "object",
required: ["file", "created", "backup"],
properties: {
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
created: { type: "boolean", example: true },
backup: {
type: "object",
nullable: true,
additionalProperties: true,
description: "Backup metadata when an existing file was overwritten; null on first create.",
},
},
},
CodexConfigFileDeleteResponse: {
type: "object",
required: ["file", "backup"],
properties: {
file: { type: "string", example: "/home/user/.codex/rules/default.rules" },
backup: { type: "object", additionalProperties: true },
},
},
CodexConfigBackupsResponse: {
type: "object",
required: ["items"],
properties: {
items: {
type: "array",
items: {
type: "object",
properties: {
type: { type: "string", example: "rules" },
name: { type: "string", example: "default.rules.2026-06-25T12-00-00.000Z.bak" },
file: { type: "string" },
mtime: { type: "number" },
size: { type: "integer", nullable: true },
},
},
},
},
},
CodexConfigCliActionResponse: {
type: "object",
description: "Result of a successful mutating `codex` CLI call.",
required: ["ok", "stdout", "stderr"],
properties: {
ok: { type: "boolean", example: true },
stdout: { type: "string" },
stderr: { type: "string" },
},
},
};
// ── Reusable parameters ──────────────────────────────────────────────────
const scopeParam = {
name: "scope",
in: "query",
required: false,
schema: { type: "string", enum: ["user", "project", "all"], default: "all" },
description: "Discovery scope: `user`, `project`, or `all` (default).",
example: "all",
};
const cwdParam = {
name: "cwd",
in: "query",
required: false,
schema: { type: "string" },
description:
"Override the working directory used to resolve the project root and project .codex dir. Defaults to the dashboard server's own cwd.",
example: "/repo",
};
const errorResponse = (code, message) => ({
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
example: { error: { code, message } },
},
},
});
const cliExitResponse = {
description: "`ECLIEXIT` — the `codex` CLI exited non-zero. stdout/stderr are preserved.",
content: {
"application/json": {
schema: {
type: "object",
properties: {
error: {
type: "object",
properties: { code: { type: "string" }, message: { type: "string" } },
},
code: { type: "integer", nullable: true },
stdout: { type: "string" },
stderr: { type: "string" },
},
},
example: {
error: { code: "ECLIEXIT", message: 'marketplace "nope" is not registered' },
code: 1,
stdout: "",
stderr: '✘ marketplace "nope" is not registered\n',
},
},
},
};
function readSurface(name, summary, schemaRef, exampleItems) {
return {
get: {
tags: ["CodexConfig"],
summary,
operationId: `codexConfigGet${name[0].toUpperCase()}${name.slice(1)}`,
parameters: name === "memory" || name === "config" ? [cwdParam] : [scopeParam, cwdParam],
responses: {
200: {
description: `${summary}.`,
content: {
"application/json": {
schema: {
type: "object",
properties: { [name]: { type: "array", items: { $ref: schemaRef } } },
},
example: { [name]: exampleItems },
},
},
},
},
},
};
}
// ── Paths ─────────────────────────────────────────────────────────────
const paths = {
"/api/codex-config/overview": {
get: {
tags: ["CodexConfig"],
summary: "Codex config explorer overview",
description:
"Read-only. Returns the three resolved filesystem roots plus per-scope counts across skills, agents, rules, memory, and config.",
operationId: "codexConfigGetOverview",
parameters: [cwdParam],
responses: {
200: {
description: "Overview of roots and counts.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigOverviewResponse" },
},
},
},
},
},
},
"/api/codex-config/skills": readSurface(
"skills",
"List discovered skills",
"#/components/schemas/CodexConfigSkill",
[{ scope: "user", name: "code-reviewer", frontmatter: {}, preview: "..." }]
),
"/api/codex-config/agents": readSurface(
"agents",
"List discovered agent definitions",
"#/components/schemas/CodexConfigAgent",
[{ scope: "user", name: "reviewer", meta: { model: "gpt-5" } }]
),
"/api/codex-config/rules": readSurface(
"rules",
"List discovered rules files",
"#/components/schemas/CodexConfigRule",
[{ scope: "user", name: "default", preview: "allow" }]
),
"/api/codex-config/memory": readSurface(
"memory",
"Read AGENTS.md at both scopes",
"#/components/schemas/CodexConfigMemoryEntry",
[{ scope: "project", file: "/repo/AGENTS.md", exists: true }]
),
"/api/codex-config/config": readSurface(
"config",
"Read config.toml at both scopes",
"#/components/schemas/CodexConfigConfigEntry",
[{ scope: "user", file: "/home/user/.codex/config.toml", exists: true }]
),
"/api/codex-config/file": {
get: {
tags: ["CodexConfig"],
summary: "Read a single file body",
description:
"Read-only, no filesystem side effect. Returns the body of one file. Strict path containment: the resolved absolute path must live under CODEX_HOME, the project .codex dir, or (only for a file literally named AGENTS.md) the project root, and must not escape that root through a symlink. Error messages never reveal what does or does not exist outside the root.",
operationId: "codexConfigGetFile",
parameters: [
{
name: "path",
in: "query",
required: true,
schema: { type: "string" },
example: "/home/user/.codex/rules/default.rules",
},
cwdParam,
],
responses: {
200: {
description: "File body with metadata.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileReadResponse" },
},
},
},
400: errorResponse("READ_DENIED", "path escapes the allowed root"),
404: errorResponse("NOT_FOUND", "file not found"),
},
},
put: {
tags: ["CodexConfig"],
summary: "Create or overwrite a text artifact",
description:
"Writes skills/agents/rules/memory/config. `name` is required for skills/agents/rules and forbidden for memory/config (fixed filenames). `project` scope always resolves against the dashboard server's own cwd — there is no `?cwd=` override on writes, unlike the read routes, so a caller cannot point a write at an arbitrary directory. Backs up any existing file first; broadcasts `codex_config_changed` on success.",
operationId: "codexConfigPutFile",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["scope", "type", "content"],
properties: {
scope: { type: "string", enum: ["user", "project"] },
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
name: { type: "string", nullable: true, example: "default" },
content: { type: "string" },
},
},
example: { scope: "user", type: "rules", name: "default", content: "allow" },
},
},
},
responses: {
200: {
description: "File written.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileWriteResponse" },
},
},
},
400: errorResponse("EBADNAME", "name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"),
413: errorResponse("ETOOLARGE", "content exceeds 262144 bytes"),
},
},
delete: {
tags: ["CodexConfig"],
summary: "Delete a text artifact",
description:
"Deletes skills/agents/rules/memory/config. `project` scope always resolves against the dashboard server's own cwd — no `?cwd=` override, same as PUT. Backup is mandatory and runs first. Broadcasts `codex_config_changed` on success.",
operationId: "codexConfigDeleteFile",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["scope", "type"],
properties: {
scope: { type: "string", enum: ["user", "project"] },
type: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
name: { type: "string", nullable: true },
},
},
example: { scope: "user", type: "agents", name: "reviewer" },
},
},
},
responses: {
200: {
description: "File deleted.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigFileDeleteResponse" },
},
},
},
404: errorResponse("ENOTFOUND", "agents/reviewer does not exist"),
},
},
},
"/api/codex-config/backups": {
get: {
tags: ["CodexConfig"],
summary: "List backups",
description:
"Read-only. Lists timestamped backups, optionally filtered by scope and/or type.",
operationId: "codexConfigGetBackups",
parameters: [
{
name: "scope",
in: "query",
required: false,
schema: { type: "string", enum: ["user", "project"] },
},
{
name: "type",
in: "query",
required: false,
schema: { type: "string", enum: ["skills", "agents", "rules", "memory", "config"] },
},
cwdParam,
],
responses: {
200: {
description: "Backups list.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigBackupsResponse" },
},
},
},
},
},
},
"/api/codex-config/mcp": {
get: {
tags: ["CodexConfig"],
summary: "List configured MCP servers",
description:
"Reads live from `codex mcp list --json` — there is no on-disk reader for this surface.",
operationId: "codexConfigGetMcp",
responses: {
200: {
description: "MCP servers as reported by the CLI.",
content: { "application/json": { schema: { type: "object", properties: { mcp: {} } } } },
},
422: cliExitResponse,
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/plugins": {
get: {
tags: ["CodexConfig"],
summary: "List installed plugins",
description:
"Reads live from `codex plugin list --json` and unwraps its `{installed, available}` " +
"object down to the `installed` array — the route's `plugins` field is always a list.",
operationId: "codexConfigGetPlugins",
responses: {
200: {
description: "Installed plugins as reported by the CLI.",
content: {
"application/json": {
schema: { type: "object", properties: { plugins: { type: "array", items: {} } } },
},
},
},
422: cliExitResponse,
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/marketplaces": {
get: {
tags: ["CodexConfig"],
summary: "List registered marketplaces",
description:
"Reads live from `codex plugin marketplace list --json` and unwraps its " +
"`{marketplaces}` object down to a bare array.",
operationId: "codexConfigGetMarketplaces",
responses: {
200: {
description: "Marketplaces as reported by the CLI.",
content: {
"application/json": {
schema: {
type: "object",
properties: { marketplaces: { type: "array", items: {} } },
},
},
},
},
422: cliExitResponse,
502: errorResponse("ECLIBADOUTPUT", "could not parse `codex` JSON output"),
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
"/api/codex-config/mcp/add": {
post: {
tags: ["CodexConfig"],
summary: "Add an MCP server",
description:
"Runs `codex mcp add`. Accepts either a streamable-HTTP server (`url`, optional `bearerTokenEnvVar`) or a stdio server (`command`, `args`, optional `env`) — never both.",
operationId: "codexConfigPostMcpAdd",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name"],
properties: {
name: { type: "string" },
url: { type: "string" },
bearerTokenEnvVar: { type: "string" },
command: { type: "string" },
args: { type: "array", items: { type: "string" } },
env: { type: "object", additionalProperties: { type: "string" } },
},
},
example: { name: "github", url: "https://mcp.example.com/github" },
},
},
},
responses: {
200: {
description: "MCP server added.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADARG", "url must be a valid http(s) URL"),
422: cliExitResponse,
},
},
},
"/api/codex-config/mcp/remove": {
post: {
tags: ["CodexConfig"],
summary: "Remove an MCP server",
operationId: "codexConfigPostMcpRemove",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name"],
properties: { name: { type: "string" } },
},
example: { name: "github" },
},
},
},
responses: {
200: {
description: "MCP server removed.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
422: cliExitResponse,
},
},
},
"/api/codex-config/plugins/action": {
post: {
tags: ["CodexConfig"],
summary: "Add or remove a plugin",
description: "Runs `codex plugin add <spec>` or `codex plugin remove <spec>`.",
operationId: "codexConfigPostPluginsAction",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["spec", "action"],
properties: {
spec: { type: "string" },
action: { type: "string", enum: ["add", "remove"] },
},
},
example: { spec: "demo@market", action: "add" },
},
},
},
responses: {
200: {
description: "Plugin action applied.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADACTION", "action must be one of add, remove"),
422: cliExitResponse,
},
},
},
"/api/codex-config/marketplaces/add": {
post: {
tags: ["CodexConfig"],
summary: "Register a marketplace",
description:
"Runs `codex plugin marketplace add <source>`. `source` is an owner/repo shorthand, " +
"an http(s) git URL, or an absolute local path — the same set `claude plugin " +
"marketplace add` accepts; ssh remotes are not.",
operationId: "codexConfigPostMarketplacesAdd",
requestBody: {
required: true,
content: {
"application/json": {
schema: { type: "object", required: ["url"], properties: { url: { type: "string" } } },
example: { url: "openai/codex" },
},
},
},
responses: {
200: {
description: "Marketplace added.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse(
"EBADARG",
"source must be owner/repo, an http(s) URL, or an absolute path"
),
422: cliExitResponse,
},
},
},
"/api/codex-config/marketplaces/action": {
post: {
tags: ["CodexConfig"],
summary: "Remove a marketplace",
description: "Runs `codex plugin marketplace remove <name>`.",
operationId: "codexConfigPostMarketplacesAction",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["name", "action"],
properties: {
name: { type: "string" },
action: { type: "string", enum: ["remove"] },
},
},
example: { name: "demo-mp", action: "remove" },
},
},
},
responses: {
200: {
description: "Marketplace action applied.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CodexConfigCliActionResponse" },
},
},
},
400: errorResponse("EBADACTION", "action must be one of remove"),
422: cliExitResponse,
503: errorResponse("ECLINOTFOUND", "the `codex` CLI was not found on PATH"),
},
},
},
};
module.exports = { tags, schemas, paths };