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
+52 -1
View File
@@ -72,7 +72,7 @@ describe("codex-cli argument validation", () => {
}
});
it("accepts only http(s) URLs", () => {
it("accepts only http(s) URLs (mcpAdd's --url form)", () => {
assert.equal(cli.assertUrl("https://example.com/mp.git", "url"), "https://example.com/mp.git");
for (const bad of ["ssh://example.com/mp.git", "not a url", "file:///tmp/x"]) {
assert.throws(() => cli.assertUrl(bad, "url"), /http\(s\)/, `accepted ${bad}`);
@@ -81,6 +81,32 @@ describe("codex-cli argument validation", () => {
assert.throws(() => cli.assertUrl("--force", "url"), /may not start with/);
});
it("marketplaceAdd accepts the source forms `codex plugin marketplace add` actually takes", async () => {
// Not assertUrl: `codex plugin marketplace add <SOURCE>` also takes
// owner/repo and a local path, which a bare http(s)-only check rejected.
const restore = stubCodex();
try {
assert.deepEqual(await cli.marketplaceAdd("openai/codex").then(argvOf), [
"plugin",
"marketplace",
"add",
"openai/codex",
]);
assert.deepEqual(await cli.marketplaceAdd("/abs/local/path").then(argvOf), [
"plugin",
"marketplace",
"add",
"/abs/local/path",
]);
} finally {
restore();
}
// ssh remotes are deliberately out of scope, same as cc-plugin-cli's
// marketplace source guard — no credential/host-key story for them yet.
await assert.rejects(() => cli.marketplaceAdd("git@github.com:owner/repo.git"), /source/);
await assert.rejects(() => cli.marketplaceAdd("../escape"), /source/);
});
it("rejects mcpAdd with neither or both of url/command", async () => {
await assert.rejects(() => cli.mcpAdd({ name: "docs" }), /requires either/);
await assert.rejects(
@@ -267,6 +293,31 @@ describe("codex-cli run outcomes", () => {
}
});
it("unwraps `codex plugin list --json`'s {installed, available} object into a bare array", async () => {
// Real codex-cli 0.149.0 shape — an object, unlike `mcp list`'s array.
const restore = stubCodex(
`cat <<'JSON'\n{"installed":[{"name":"demo"}],"available":[{"name":"other"}]}\nJSON`
);
try {
const res = await cli.pluginList();
assert.equal(res.ok, true);
assert.deepEqual(res.data, [{ name: "demo" }]);
} finally {
restore();
}
});
it("unwraps `codex plugin marketplace list --json`'s {marketplaces} object into a bare array", async () => {
const restore = stubCodex(`cat <<'JSON'\n{"marketplaces":[{"name":"sg"}]}\nJSON`);
try {
const res = await cli.marketplaceList();
assert.equal(res.ok, true);
assert.deepEqual(res.data, [{ name: "sg" }]);
} finally {
restore();
}
});
it("leaves `data` undefined when stdout is not JSON", async () => {
const restore = stubCodex('echo "removed docs"');
try {