/** * @file codex-discovery.js * @description Read-only discovery of Codex CLI configuration surfaces * (skills, agents, rules, memory/AGENTS.md, config.toml). Powers the Codex * Config panel. All operations are pure file reads — never writes. MCP * servers and plugins are not covered here — they come from the `codex` CLI * itself, read elsewhere. * * Path containment: every read resolves under getCodexHome(), * getProjectCodexDir(), or getProjectRoot() (for AGENTS.md). Mirrors * cc-discovery.js's scope handling and record shapes so the two panels feel * like one system. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getCodexHome, getProjectCodexDir, getProjectRoot } = require("./codex-home"); const { parseFrontmatter, isUnder, isDirLike, isFileLike, MAX_FILE_BYTES, } = require("./cc-discovery"); function safeReadText(absPath) { try { const stat = fs.statSync(absPath); if (!stat.isFile()) return null; if (stat.size > MAX_FILE_BYTES) { return { truncated: true, size: stat.size, text: fs.readFileSync(absPath, "utf8").slice(0, MAX_FILE_BYTES), mtime: stat.mtimeMs, }; } return { truncated: false, size: stat.size, text: fs.readFileSync(absPath, "utf8"), mtime: stat.mtimeMs, }; } catch { return null; } } function listDir(absPath) { try { return fs.readdirSync(absPath, { withFileTypes: true }); } catch { return []; } } // ── Skills ────────────────────────────────────────────────────────────── function readSkillsAt(scope, codexDir) { const dir = path.join(codexDir, "skills"); const skills = []; for (const ent of listDir(dir)) { const skillDir = path.join(dir, ent.name); // isUnder here is defensive only — ent.name comes from readdirSync and // can't carry a traversal segment, so this can never actually trip. if (!isDirLike(ent, skillDir) || !isUnder(codexDir, skillDir)) continue; const skillFile = path.join(skillDir, "SKILL.md"); const read = safeReadText(skillFile); if (!read) continue; const { frontmatter, body } = parseFrontmatter(read.text); skills.push({ scope, name: ent.name, path: skillDir, file: skillFile, size: read.size, mtime: read.mtime, truncated: read.truncated, frontmatter: frontmatter || {}, preview: body.slice(0, 320), }); } return skills.sort((a, b) => a.name.localeCompare(b.name)); } function readSkills(opts = {}) { const out = []; if (opts.scope !== "project") out.push(...readSkillsAt("user", getCodexHome())); if (opts.scope !== "user") out.push(...readSkillsAt("project", getProjectCodexDir(opts.cwd))); return out; } // ── Agents (TOML) ────────────────────────────────────────────────────── /** * Best-effort reader for the handful of top-level scalar fields the UI * shows (`name`, `description`, `model`, `sandbox_mode`). Matches * `key = "value"` / `key = 123` / `key = true` lines appearing before the * first `[section]`, skipping over `"""..."""` blocks so a field name * mentioned inside one (e.g. in `developer_instructions`) is never mistaken * for a real top-level key. Anything richer than these scalars is the * raw-text editor's job, not this reader's — no TOML parser here. */ function readTomlMeta(text) { const meta = {}; const lines = text.split("\n"); let inTripleQuote = false; for (const rawLine of lines) { const line = rawLine.trim(); if (inTripleQuote) { if (line.includes('"""')) inTripleQuote = false; continue; } if (/^\[/.test(line)) break; // first section ends the top-level scalars const m = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/); if (!m) continue; let value = m[2]; if (value.startsWith('"""')) { if (!value.slice(3).includes('"""')) inTripleQuote = true; continue; } if (value.startsWith('"') || value.startsWith("'")) { // Find the real closing quote — one not preceded by an ODD run of // backslashes (an even run, including zero, means it isn't escaped) — // so a trailing `# comment` outside the string is stripped without // being fooled by an escaped quote or an escaped backslash inside it. const quote = value[0]; let closeIdx = -1; for (let i = 1; i < value.length; i++) { if (value[i] !== quote) continue; let backslashes = 0; while (value[i - 1 - backslashes] === "\\") backslashes++; if (backslashes % 2 === 0) { closeIdx = i; break; } } if (closeIdx < 0) continue; // \\ decodes to one backslash; other escapes (e.g. \") are left // literal — this is a best-effort reader, not a full TOML unescaper. value = value.slice(1, closeIdx).replace(/\\\\/g, "\\"); } else if (/^(true|false|-?\d+(\.\d+)?)\s*(#.*)?$/.test(value)) { value = value.replace(/\s*#.*$/, ""); value = value === "true" ? true : value === "false" ? false : Number(value); } else { continue; // arrays, inline tables, etc. — not one of the shown fields } meta[m[1]] = value; } return meta; } function readAgentsAt(scope, codexDir) { const dir = path.join(codexDir, "agents"); const agents = []; for (const ent of listDir(dir)) { if (!ent.name.endsWith(".toml")) continue; const file = path.join(dir, ent.name); // isUnder here is defensive only — ent.name comes from readdirSync and // can't carry a traversal segment, so this can never actually trip. if (!isFileLike(ent, file) || !isUnder(codexDir, file)) continue; const read = safeReadText(file); if (!read) continue; agents.push({ scope, name: ent.name.replace(/\.toml$/, ""), file, size: read.size, mtime: read.mtime, meta: readTomlMeta(read.text), preview: read.text.slice(0, 320), }); } return agents.sort((a, b) => a.name.localeCompare(b.name)); } function readAgents(opts = {}) { const out = []; if (opts.scope !== "project") out.push(...readAgentsAt("user", getCodexHome())); if (opts.scope !== "user") out.push(...readAgentsAt("project", getProjectCodexDir(opts.cwd))); return out; } // ── Rules (plain text) ───────────────────────────────────────────────── function readRulesAt(scope, codexDir) { const dir = path.join(codexDir, "rules"); const rules = []; for (const ent of listDir(dir)) { if (!ent.name.endsWith(".rules")) continue; const file = path.join(dir, ent.name); // isUnder here is defensive only — ent.name comes from readdirSync and // can't carry a traversal segment, so this can never actually trip. if (!isFileLike(ent, file) || !isUnder(codexDir, file)) continue; const read = safeReadText(file); if (!read) continue; rules.push({ scope, name: ent.name.replace(/\.rules$/, ""), file, size: read.size, mtime: read.mtime, preview: read.text.slice(0, 320), }); } return rules.sort((a, b) => a.name.localeCompare(b.name)); } function readRules(opts = {}) { const out = []; if (opts.scope !== "project") out.push(...readRulesAt("user", getCodexHome())); if (opts.scope !== "user") out.push(...readRulesAt("project", getProjectCodexDir(opts.cwd))); return out; } // ── Memory (AGENTS.md) ───────────────────────────────────────────────── function readMemory(opts = {}) { const sources = [ { scope: "user", file: path.join(getCodexHome(), "AGENTS.md") }, { scope: "project", file: path.join(getProjectRoot(opts.cwd), "AGENTS.md") }, ]; return sources.map(({ scope, file }) => { const r = safeReadText(file); if (!r) return { scope, file, exists: false }; return { scope, file, exists: true, size: r.size, mtime: r.mtime, preview: r.text.slice(0, 480), }; }); } // ── Config (config.toml) ─────────────────────────────────────────────── function readConfig(opts = {}) { const sources = [ { scope: "user", file: path.join(getCodexHome(), "config.toml") }, { scope: "project", file: path.join(getProjectCodexDir(opts.cwd), "config.toml") }, ]; return sources.map(({ scope, file }) => { const r = safeReadText(file); if (!r) return { scope, file, exists: false }; return { scope, file, exists: true, size: r.size, mtime: r.mtime, content: r.text, truncated: r.truncated, }; }); } // ── Overview (counts + roots) ────────────────────────────────────────── function readOverview(opts = {}) { const skills = readSkills(opts); const agents = readAgents(opts); const rules = readRules(opts); const memory = readMemory(opts); const config = readConfig(opts); const countByScope = (arr) => ({ user: arr.filter((x) => x.scope === "user").length, project: arr.filter((x) => x.scope === "project").length, }); return { roots: { codexHome: getCodexHome(), projectCodexDir: getProjectCodexDir(opts.cwd), projectRoot: getProjectRoot(opts.cwd), }, counts: { skills: countByScope(skills), agents: countByScope(agents), rules: countByScope(rules), memory: countByScope(memory.filter((m) => m.exists)), config: countByScope(config.filter((c) => c.exists)), }, }; } // ── Single-file body endpoint (with strict path containment) ─────────── /** * 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. * * 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}} */ function readFileSafe(absPath, opts = {}) { const codexHome = getCodexHome(); const projectCodexDir = getProjectCodexDir(opts.cwd); const projectRoot = getProjectRoot(opts.cwd); const resolved = path.resolve(absPath); const inside = [codexHome, projectCodexDir, projectRoot].some((root) => isUnder(root, resolved)); if (!inside) return { error: "path is outside allowed roots" }; if ( isUnder(projectRoot, resolved) && !isUnder(projectCodexDir, resolved) && path.basename(resolved) !== "AGENTS.md" ) { return { error: "only AGENTS.md is readable from project root" }; } const r = safeReadText(resolved); if (!r) return { notFound: true }; return { ok: true, file: resolved, ...r }; } module.exports = { readSkills, readAgents, readRules, readMemory, readConfig, readOverview, readFileSafe, readTomlMeta, };