Files
AgentHub/server/lib/codex-discovery.js
T
nntrivi2001 236e94760f fix(codex): escape-aware closing-quote scan, honest symlink coverage
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.
2026-09-08 16:48:45 +07:00

297 lines
9.8 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
} else if (value.startsWith('"') || value.startsWith("'")) {
// A quoted value with a trailing `# comment` outside the string —
// e.g. model = "gpt-5.4" # default. Find the real closing quote
// (skipping a `\"` escaped one) and strip everything after it.
const quote = value[0];
let closeIdx = -1;
for (let i = 1; i < value.length; i++) {
if (value[i] === quote && value[i - 1] !== "\\") {
closeIdx = i;
break;
}
}
if (closeIdx > 0) value = value.slice(1, closeIdx);
else continue;
} 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)),
},
};
}
module.exports = {
readSkills,
readAgents,
readRules,
readMemory,
readConfig,
readOverview,
readTomlMeta,
};