Files
AgentHub/server/lib/codex-mutate.js
T

232 lines
7.6 KiB
JavaScript

/**
* @file codex-mutate.js
* @description Mutation helpers for the Codex Config panel. Handles
* create / overwrite / delete on Codex's text-file surfaces: skills, agents,
* rules, memory (AGENTS.md), and config (config.toml). Mirrors cc-mutate.js's
* structure (single `resolveTarget` chokepoint, regex-validated names,
* containment re-check, backup-before-mutation, atomic write) so the two
* panels share the same safety shape and error-code vocabulary.
*
* Hard constraints (do not relax without a follow-up review):
* - `memory` and `config` are fixed filenames (AGENTS.md / config.toml) —
* they take NO `name`. Passing one is a caller error (EBADNAME).
* - Every write/delete of an existing file creates a timestamped backup
* BEFORE the mutation. Backups land under
* <root>/codex-config-backups/<type>/, a directory Codex never scans, so
* a deleted skill can never come back as a discoverable skill named
* after its backup.
* - Writes are atomic via temp file + fs.renameSync (see config-write.js).
* - Names are validated against a strict allowlist regex; resolved paths
* are double-checked to live under the expected containment root before
* any I/O.
* - Codex has no `project-local` scope and no auto-memory: only "user" and
* "project" are valid scopes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home");
const { MAX_FILE_BYTES } = require("./cc-discovery");
const { createBackup, atomicWriteFile, assertUnder } = require("./config-write");
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const TYPES = {
skills: { kind: "dir", subdir: "skills", filename: "SKILL.md" },
agents: { kind: "file", subdir: "agents", ext: ".toml" },
rules: { kind: "file", subdir: "rules", ext: ".rules" },
memory: { kind: "fixed", file: "AGENTS.md" }, // AGENTS.md, no `name`
config: { kind: "fixed", file: "config.toml" }, // config.toml, no `name`
};
function makeError(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
function assertScope(scope) {
if (scope !== "user" && scope !== "project") {
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
}
}
/** The <root> a (scope) resolves under for skills/agents/rules subdirs. */
function rootForScope(scope, opts = {}) {
return scope === "user" ? getCodexHome() : getProjectCodexDir(opts.cwd);
}
/** Fixed-filename path for memory/config, per scope's own layout rule. */
function fixedPathFor(type, scope, opts = {}) {
if (type === "memory") {
return scope === "user"
? path.join(getCodexHome(), "AGENTS.md")
: path.join(getProjectRoot(opts.cwd), "AGENTS.md");
}
// config
return scope === "user"
? path.join(getCodexHome(), "config.toml")
: path.join(getProjectCodexDir(opts.cwd), "config.toml");
}
/**
* Resolve the on-disk target for a (scope, type, name) tuple AND the
* containment root used for path-traversal checks.
*
* Returns: { kind: "file" | "dir", target, filePath, containmentRoot }
*/
function resolveTarget(scope, type, name, opts = {}) {
const spec = TYPES[type];
if (!spec) throw makeError("EBADTYPE", `unknown type: ${type}`);
assertScope(scope);
if (spec.kind === "fixed") {
if (name !== undefined && name !== null) {
throw makeError("EBADNAME", `${type} is a fixed file and takes no name`);
}
const filePath = fixedPathFor(type, scope, opts);
return { kind: "file", target: filePath, filePath, containmentRoot: path.dirname(filePath) };
}
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw makeError("EBADNAME", `name must match ${NAME_RE}`);
}
const subdirAbs = path.join(rootForScope(scope, opts), spec.subdir);
if (spec.kind === "dir") {
const target = path.join(subdirAbs, name);
return {
kind: "dir",
target,
filePath: path.join(target, spec.filename),
containmentRoot: subdirAbs,
};
}
const target = path.join(subdirAbs, name + spec.ext);
return { kind: "file", target, filePath: target, containmentRoot: subdirAbs };
}
/** <root>/codex-config-backups/<type>/, always rooted the same way as the read side's <root>. */
function backupRootFor(scope, type, opts = {}) {
return path.join(rootForScope(scope, opts), "codex-config-backups", type);
}
function rmTreeSync(p) {
fs.rmSync(p, { recursive: true, force: true });
}
// ── Public API ─────────────────────────────────────────────────────────
/**
* Create or overwrite a single text artifact. `name` is required for
* skills/agents/rules and forbidden for memory/config.
*
* @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args
*/
function writeArtifact(args) {
const { scope, type, name, content, cwd } = args;
if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string");
if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`);
}
const r = resolveTarget(scope, type, name, { cwd });
assertUnder(r.containmentRoot, 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;
if (r.kind === "dir") {
fs.mkdirSync(r.target, { recursive: true });
}
atomicWriteFile(r.filePath, content);
return { file: r.filePath, created: !existedBefore, backup };
}
/**
* Delete a single text artifact. Backup is mandatory and runs first; if
* the target does not exist, no backup is attempted.
*/
function deleteArtifact(args) {
const { scope, type, name, cwd } = args;
const r = resolveTarget(scope, type, name, { cwd });
assertUnder(r.containmentRoot, r.target);
if (!fs.existsSync(r.target)) {
throw makeError("ENOTFOUND", `${type}/${name || TYPES[type].file} does not exist`);
}
const backup = createBackup({
source: r.target,
backupRoot: backupRootFor(scope, type, { cwd }),
type,
name: path.basename(r.target),
});
if (r.kind === "dir") {
rmTreeSync(r.target);
} else {
fs.unlinkSync(r.target);
}
return { file: r.filePath, backup };
}
/**
* List backups for either all types or a specific (scope, type) bucket.
* Returns [{ type, name, file, mtime, size }].
*/
function listBackups(opts = {}) {
const out = [];
const scopes = opts.scope ? [opts.scope] : ["user", "project"];
const types = opts.type ? [opts.type] : Object.keys(TYPES);
for (const scope of scopes) {
for (const type of types) {
const root = backupRootFor(scope, type, opts);
let entries = [];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
continue;
}
for (const ent of entries) {
const full = path.join(root, ent.name);
let stat;
try {
stat = fs.statSync(full);
} catch {
continue;
}
out.push({
type,
name: ent.name,
file: full,
mtime: stat.mtimeMs,
size: ent.isDirectory() ? null : stat.size,
});
}
}
}
return out.sort((a, b) => b.mtime - a.mtime);
}
module.exports = {
writeArtifact,
deleteArtifact,
listBackups,
resolveTarget, // exported for tests
TYPES,
NAME_RE,
};