84e2a9f0ac
Server mode can now run each account as its own Linux user (ah-<name>) when `agenthub server install-system` has set S3 up. The dashboard reaches a member only through sudo -u / write-as / pane-launch, reads a member's files only through owner- and type-checked opens, and hooks are authenticated by peer uid. Local mode and server mode without S3 are unchanged. EXPERIMENTAL: tested with fake sudo and fake accounts only. It has not yet been verified on a real install with real member accounts, sudo or a real Codex login. Do not offer it to real users yet. - S3 core: provisioning, per-member tmux servers, the proxy key gateway, shared tools in /opt, dropped capabilities for children (P0–P5) - P4: plan and quota per member home - P6: ~/.claude.json seed; opt-in plugin baseline installed as the member; managed-settings spike script (not run) - P7: Codex account pool (`agenthub accounts add --tool codex`) - Roles merged with main's admin / analyst / developer: analysts write only their own rows, under S3 as well - Per-account prefs: one implementation (Chat UI + onboarding) - Security reviews F1–F49 (five rounds), each fix with a regression test: symlink/TOCTOU-safe member reads, no host writes in a member home, credentials and the data dir never served, owner and folder account must agree, pane tokens bound to the pane's uid, bounded sudo/tmux/git calls and member reads (no hang or crash from a member), async route rejections answer 500 instead of exiting - Docs: docs/SERVER-MODE.md (S3 model, accepted ceilings, operator runbook), docs/plans/server-mode/S3-PLAN.md, CLAUDE.md S3 rules Tests: server 2838/2841 (3 need 127.0.0.1:3000, held on this box); client 1162/1162; tsc clean; headers check passes. Co-Authored-By: Claude <noreply@anthropic.com>
351 lines
12 KiB
JavaScript
351 lines
12 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.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" };
|
|
}
|
|
if (require("./cc-discovery").isSecretConfigPath(resolved)) {
|
|
return { error: "a credential file is never served" };
|
|
}
|
|
|
|
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,
|
|
};
|