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.
This commit is contained in:
2026-09-09 01:17:06 +07:00
parent fef3e640d0
commit acff059e92
16 changed files with 359 additions and 108 deletions
+32 -5
View File
@@ -30,6 +30,8 @@
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;
@@ -230,8 +232,18 @@ async function mcpRemove(name) {
// ── 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() {
return runCodex(["plugin", "list", "--json"]);
const result = await runCodex(["plugin", "list", "--json"]);
if (result.data !== undefined && !Array.isArray(result.data)) {
result.data = Array.isArray(result.data.installed) ? result.data.installed : [];
}
return result;
}
async function pluginAdd(spec) {
@@ -249,13 +261,28 @@ async function pluginRemove(spec) {
// ── Marketplaces ───────────────────────────────────────────────────────
/** Same object-not-array shape as `pluginList` — unwrap `marketplaces`. */
async function marketplaceList() {
return runCodex(["plugin", "marketplace", "list", "--json"]);
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;
}
async function marketplaceAdd(url) {
const u = assertUrl(url, "marketplace url");
return serialize(() => runCodex(["plugin", "marketplace", "add", u]));
/**
* `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) {
+18 -29
View File
@@ -22,11 +22,6 @@ const {
isFileLike,
MAX_FILE_BYTES,
} = require("./cc-discovery");
// Shared symlink-escape guard — the same check writeArtifact/deleteArtifact
// use, so a read and a write of the same path agree on what "inside the
// root" means. See codex-mutate.js's assertNotSymlinkEscape for the leaf +
// ancestor symlink reasoning.
const { assertNotSymlinkEscape } = require("./codex-mutate");
function safeReadText(absPath) {
try {
@@ -296,17 +291,25 @@ function readOverview(opts = {}) {
/**
* Read the body of one file living under CODEX_HOME, the project .codex
* dir, or (only for a file literally named AGENTS.md) the project root.
* Mirrors cc-discovery's readFileSafe, plus the same leaf/ancestor
* symlink-escape guard the write side (codex-mutate.js) uses — a string-only
* `isUnder` check alone would let a symlink dropped anywhere along the path
* read arbitrary files outside the root.
* Mirrors cc-discovery's readFileSafe.
*
* A GET has no filesystem side effect: `assertNotSymlinkEscape` is called
* with `{create: false}` so it never mkdirs a caller-chosen root, and a
* missing root or file both come back as `{notFound: true}` rather than the
* write side's `EOUTOFROOT`. Failure messages never include the resolved
* path — a `GET /file` response is not the place to reveal what does or
* does not exist outside the root.
* Containment here is lexical — `isUnder` after `path.resolve`, never a
* realpath check — and deliberately so: a path whose LOCATION lies inside a
* root is readable even when it is a symlink resolving somewhere else. The
* other four Codex readers (readSkills/readAgents/readRules/readMemory/
* readConfig) already follow such symlinks — a symlinked dotfiles skill or
* config is the normal setup — so this endpoint refusing the same path
* would show a file in the list, preview its body, and then bare-`READ_DENIED`
* it on click. The write side (codex-mutate.js) keeps its stricter
* realpath-based `assertNotSymlinkEscape`: reading through a link shows you
* your own file, writing through one clobbers something unrelated — that
* asymmetry is intentional, not an inconsistency to "fix" here.
*
* A `path` that resolves outside the roots (e.g. `../../etc/passwd`) or a
* `?cwd=`-driven escape are still refused by the lexical `isUnder` checks
* below — only the symlink-target check is relaxed. Failure messages never
* include the resolved path — a `GET /file` response is not the place to
* reveal what does or does not exist outside the root.
*
* @returns {{ok:true,file:string,size:number,mtime:number,truncated:boolean,text:string}
* | {notFound:true} | {error:string}}
@@ -327,20 +330,6 @@ function readFileSafe(absPath, opts = {}) {
return { error: "only AGENTS.md is readable from project root" };
}
const trustedRoot = isUnder(codexHome, resolved)
? codexHome
: isUnder(projectCodexDir, resolved)
? projectCodexDir
: projectRoot;
try {
assertNotSymlinkEscape(trustedRoot, resolved, { create: false });
} catch (err) {
// A root that doesn't exist yet (ENOENT from realpathSync, since this
// call never mkdirs it) means there is nothing to find, not a refusal.
if (err.code === "ENOENT") return { notFound: true };
return { error: "path escapes the allowed root" };
}
const r = safeReadText(resolved);
if (!r) return { notFound: true };
return { ok: true, file: resolved, ...r };
+17 -9
View File
@@ -209,14 +209,20 @@ function writeArtifact(args) {
assertNotSymlinkEscape(r.trustedRoot, r.target);
const existedBefore = fs.existsSync(r.filePath);
const backup = existedBefore
? createBackup({
source: r.kind === "dir" ? r.target : r.filePath,
backupRoot: backupRootFor(scope, type, { cwd }),
type,
name: path.basename(r.target),
})
: null;
let backup = null;
if (existedBefore) {
const backupRoot = backupRootFor(scope, type, { cwd });
// The backup write never went through the symlink-escape guard: a
// `<root>/codex-config-backups` symlink would send the previous file's
// body wherever it points. Same check as the target above, same reason.
assertNotSymlinkEscape(r.trustedRoot, backupRoot);
backup = createBackup({
source: r.kind === "dir" ? r.target : r.filePath,
backupRoot,
type,
name: path.basename(r.target),
});
}
if (r.kind === "dir") {
fs.mkdirSync(r.target, { recursive: true });
@@ -240,9 +246,11 @@ function deleteArtifact(args) {
}
assertNotSymlinkEscape(r.trustedRoot, r.target);
const backupRoot = backupRootFor(scope, type, { cwd });
assertNotSymlinkEscape(r.trustedRoot, backupRoot);
const backup = createBackup({
source: r.target,
backupRoot: backupRootFor(scope, type, { cwd }),
backupRoot,
type,
name: path.basename(r.target),
});