Files
AgentHub/server/__tests__/codex-mutate.test.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

429 lines
15 KiB
JavaScript

/**
* @file Tests for codex-mutate.js: create/edit/delete on Codex config
* surfaces (skills, agents, rules, memory, config) across the user
* (CODEX_HOME) and project (<cwd>/.codex or <cwd> for AGENTS.md) scopes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
describe("codex-mutate", () => {
let TMP;
let REPO;
let prevCodexHome;
let cm;
before(() => {
prevCodexHome = process.env.CODEX_HOME;
TMP = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-"));
REPO = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-repo-"));
process.env.CODEX_HOME = TMP;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-mutate")];
cm = require("../lib/codex-mutate");
});
after(() => {
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
fs.rmSync(TMP, { recursive: true, force: true });
fs.rmSync(REPO, { recursive: true, force: true });
});
it("creates a project skill as a directory with SKILL.md inside", () => {
const { file, created } = cm.writeArtifact({
scope: "project",
type: "skills",
name: "release-guard",
content: "---\nname: x\n---\n",
cwd: REPO,
});
assert.equal(created, true);
assert.equal(file, path.join(REPO, ".codex", "skills", "release-guard", "SKILL.md"));
});
it("backs the old body up before overwriting it", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "default", content: "old", cwd: REPO });
const { backup } = cm.writeArtifact({
scope: "user",
type: "rules",
name: "default",
content: "new",
cwd: REPO,
});
assert.equal(fs.readFileSync(backup, "utf8"), "old");
assert.equal(fs.readFileSync(path.join(TMP, "rules", "default.rules"), "utf8"), "new");
});
it("refuses a name that climbs out of the root", () => {
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "agents",
name: "../../evil",
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADNAME"
);
});
it("writes AGENTS.md at the repo root, not inside .codex", () => {
const { file } = cm.writeArtifact({
scope: "project",
type: "memory",
content: "# rules",
cwd: REPO,
});
assert.equal(file, path.join(REPO, "AGENTS.md"));
});
it("keeps a deleted skill's backup out of the skills tree", () => {
cm.writeArtifact({ scope: "user", type: "skills", name: "gone", content: "x", cwd: REPO });
const { backup } = cm.deleteArtifact({
scope: "user",
type: "skills",
name: "gone",
cwd: REPO,
});
assert.ok(
!backup.includes(path.join(TMP, "skills", "gone")),
"a backup must never be discoverable as a skill"
);
assert.equal(fs.existsSync(path.join(TMP, "skills", "gone")), false);
});
it("writes and reads back a project agent under .codex/agents/<name>.toml", () => {
const { file } = cm.writeArtifact({
scope: "project",
type: "agents",
name: "reviewer",
content: 'name = "reviewer"\n',
cwd: REPO,
});
assert.equal(file, path.join(REPO, ".codex", "agents", "reviewer.toml"));
});
it("writes user config.toml under CODEX_HOME and project config.toml under <cwd>/.codex", () => {
const u = cm.writeArtifact({
scope: "user",
type: "config",
content: 'model = "x"\n',
cwd: REPO,
});
assert.equal(u.file, path.join(TMP, "config.toml"));
const p = cm.writeArtifact({
scope: "project",
type: "config",
content: 'model = "y"\n',
cwd: REPO,
});
assert.equal(p.file, path.join(REPO, ".codex", "config.toml"));
});
it("rejects a scope that is neither user nor project, and writes nothing", () => {
const target = path.join(TMP, "rules", "x.rules");
assert.throws(
() =>
cm.writeArtifact({
scope: "project-local",
type: "rules",
name: "x",
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADSCOPE"
);
assert.equal(fs.existsSync(target), false);
});
it("rejects a type outside TYPES, and writes nothing", () => {
const target = path.join(TMP, "commands", "x.md");
assert.throws(
() =>
cm.writeArtifact({ scope: "user", type: "commands", name: "x", content: "x", cwd: REPO }),
(e) => e.code === "EBADTYPE"
);
assert.equal(fs.existsSync(target), false);
});
it("refuses a symlinked skill dir that escapes both Codex roots, and writes nothing outside", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const linkPath = path.join(TMP, "skills", "escape-link");
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(outside, linkPath, "dir");
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "skills",
name: "escape-link",
content: "pwned",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "SKILL.md")), false);
fs.rmSync(outside, { recursive: true, force: true });
});
it("follows a symlinked skill dir that points back inside the same root", () => {
const realDir = path.join(TMP, "skills", "real-dir");
fs.mkdirSync(realDir, { recursive: true });
const linkPath = path.join(TMP, "skills", "inside-link");
fs.symlinkSync(realDir, linkPath, "dir");
const { file, created } = cm.writeArtifact({
scope: "user",
type: "skills",
name: "inside-link",
content: "fine",
cwd: REPO,
});
assert.equal(created, true);
assert.equal(fs.readFileSync(file, "utf8"), "fine");
assert.equal(fs.readFileSync(path.join(realDir, "SKILL.md"), "utf8"), "fine");
});
it("refuses to back up a symlinked artifact that escapes the roots when deleting it", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
fs.writeFileSync(path.join(outside, "secret.rules"), "top secret");
const linkPath = path.join(TMP, "rules", "escape-del.rules");
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
fs.symlinkSync(path.join(outside, "secret.rules"), linkPath, "file");
assert.throws(
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "escape-del", cwd: REPO }),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "secret.rules")), true);
fs.rmSync(outside, { recursive: true, force: true });
});
it("refuses a brand-new artifact whose subdir (not the leaf) is a symlink escaping the root", () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const linkPath = path.join(TMP, "agents");
// "agents" doesn't exist yet in this fresh suite of tests for this scope —
// remove it first if an earlier test already created it as a real dir.
fs.rmSync(linkPath, { recursive: true, force: true });
fs.symlinkSync(outside, linkPath, "dir");
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "agents",
name: "brand-new",
content: "pwned",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.existsSync(path.join(outside, "brand-new.toml")), false);
fs.rmSync(linkPath, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
});
it("still writes correctly when $CODEX_HOME itself is a symlink (a dotfiles checkout)", () => {
const realHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-realhome-"));
const linkHome = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-linkhome-"));
fs.rmdirSync(linkHome); // symlinkSync needs the link path itself to not exist
fs.symlinkSync(realHome, linkHome, "dir");
const prev = process.env.CODEX_HOME;
process.env.CODEX_HOME = linkHome;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-mutate")];
const linkedCm = require("../lib/codex-mutate");
try {
const { file, created } = linkedCm.writeArtifact({
scope: "user",
type: "rules",
name: "via-link",
content: "ok",
cwd: REPO,
});
assert.equal(created, true);
assert.equal(fs.readFileSync(file, "utf8"), "ok");
assert.equal(
fs.readFileSync(path.join(realHome, "rules", "via-link.rules"), "utf8"),
"ok",
"the write must land in the real directory the symlinked CODEX_HOME points to"
);
} finally {
process.env.CODEX_HOME = prev;
delete require.cache[require.resolve("../lib/codex-home")];
delete require.cache[require.resolve("../lib/codex-mutate")];
fs.rmSync(linkHome, { force: true });
fs.rmSync(realHome, { recursive: true, force: true });
}
});
it("refuses to write (and back up) when <root>/codex-config-backups is a symlink escaping the root", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "toclobber", content: "v1", cwd: REPO });
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const backupsLink = path.join(TMP, "codex-config-backups");
fs.rmSync(backupsLink, { recursive: true, force: true });
fs.symlinkSync(outside, backupsLink, "dir");
try {
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "rules",
name: "toclobber",
content: "v2 — attacker wants this in `outside`",
cwd: REPO,
}),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.readdirSync(outside).length, 0, "backup must not land outside the root");
} finally {
fs.rmSync(backupsLink, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
it("refuses to delete (and back up) when <root>/codex-config-backups is a symlink escaping the root", () => {
cm.writeArtifact({
scope: "user",
type: "rules",
name: "todelete2",
content: "keep-me",
cwd: REPO,
});
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "codex-mut-outside-"));
const backupsLink = path.join(TMP, "codex-config-backups");
fs.rmSync(backupsLink, { recursive: true, force: true });
fs.symlinkSync(outside, backupsLink, "dir");
try {
assert.throws(
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "todelete2", cwd: REPO }),
(e) => e.code === "EOUTOFROOT"
);
assert.equal(fs.readdirSync(outside).length, 0, "backup must not land outside the root");
assert.equal(
fs.readFileSync(path.join(TMP, "rules", "todelete2.rules"), "utf8"),
"keep-me",
"the original file must survive a refused backup"
);
} finally {
fs.rmSync(backupsLink, { force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
for (const badName of ["..", ".", "/etc/passwd", "a/b", "a\0b"]) {
it(`rejects the bad name ${JSON.stringify(badName)} with EBADNAME and writes nothing`, () => {
const target = path.join(TMP, "agents", `${badName.replace(/[/\0]/g, "_")}.toml`);
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "agents",
name: badName,
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADNAME"
);
assert.equal(fs.existsSync(target), false);
});
}
it("rejects a name passed to memory, and writes nothing", () => {
const target = path.join(TMP, "AGENTS.md");
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "memory",
name: "AGENTS",
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADNAME"
);
assert.equal(fs.existsSync(target), false);
});
it("rejects a name passed to config, and leaves any existing config.toml untouched", () => {
const target = path.join(TMP, "config.toml");
const before = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "config",
name: "config",
content: "x",
cwd: REPO,
}),
(e) => e.code === "EBADNAME"
);
const after = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
assert.equal(after, before);
});
it("rejects content over the size limit and leaves the existing file untouched", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "toobig", content: "small", cwd: REPO });
const huge = "x".repeat(256 * 1024 + 1);
assert.throws(
() =>
cm.writeArtifact({
scope: "user",
type: "rules",
name: "toobig",
content: huge,
cwd: REPO,
}),
(e) => e.code === "ETOOLARGE"
);
assert.equal(fs.readFileSync(path.join(TMP, "rules", "toobig.rules"), "utf8"), "small");
});
it("refuses to delete something that does not exist, and creates no backup", () => {
const before = fs.existsSync(path.join(TMP, "codex-config-backups", "rules"))
? fs.readdirSync(path.join(TMP, "codex-config-backups", "rules")).length
: 0;
assert.throws(
() => cm.deleteArtifact({ scope: "user", type: "rules", name: "never-existed", cwd: REPO }),
(e) => e.code === "ENOTFOUND"
);
const after = fs.existsSync(path.join(TMP, "codex-config-backups", "rules"))
? fs.readdirSync(path.join(TMP, "codex-config-backups", "rules")).length
: 0;
assert.equal(after, before, "no backup should be created for a missing target");
});
it("lists backups for a given scope and type", () => {
cm.writeArtifact({ scope: "user", type: "rules", name: "listed", content: "v1", cwd: REPO });
cm.writeArtifact({ scope: "user", type: "rules", name: "listed", content: "v2", cwd: REPO });
const rows = cm.listBackups({ scope: "user", type: "rules" });
assert.ok(rows.length >= 1);
assert.equal(rows[0].type, "rules");
assert.equal(rows[0].name.startsWith("listed.rules."), true);
});
it("refuses a listBackups scope that is neither user nor project", () => {
assert.throws(
() => cm.listBackups({ scope: "../../../../tmp" }),
(e) => e.code === "EBADSCOPE"
);
});
it("refuses a listBackups type that tries to traverse outside the backups root", () => {
assert.throws(
() => cm.listBackups({ scope: "user", type: "../../../../tmp" }),
(e) => e.code === "EBADTYPE"
);
});
});