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.
Round 2's escape check rejected a quote whenever a single backslash
preceded it, so an escaped backslash before a real closing quote (e.g.
path = "path\\" # c) was misread as an escaped quote and the field was
dropped. Now counts the run of backslashes and only treats an ODD run as
escaping the quote. Folded the old two-branch quote handling into one
scan (it already finds the terminator correctly with or without a
trailing comment) and decode \\ -> \ in the extracted value, since the
scan already knows which backslash run is real.
readTomlMeta's trailing-comment stripper found the first quote character
regardless of escaping, so a description containing \"escaped quotes\"
before a real comment got silently truncated instead of dropped. The scan
now skips a quote preceded by a backslash.
The "never walks outside" containment test passed by construction (row.file
is always built off a fixed root) and would not fail if the isUnder guard
were removed. Replaced it with two tests that actually pin the regression
risk: a skill dir and an agent .toml symlinked in from outside CODEX_HOME,
asserting the row is discovered and its content read through the link.
isUnder stays as a defensive check (matches cc-discovery.js), now commented
as such.
readTomlMeta was dropping any field with a trailing `# comment` (common in
real agents/*.toml). readSkills/readAgents/readRules used bare
isDirectory()/isFile(), which miss symlinked entries per cc-discovery.js's
own documented Dirent quirk — reused its isDirLike/isFileLike instead
(exporting isDirLike from cc-discovery.js, which wasn't previously public).
Also swapped the "never walks outside" test for one that actually exercises
a symlink escape, and dropped the unnecessary isUnder re-export.