UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

234 lines (233 loc) • 11.4 kB
import { lstat, mkdir, readFile, realpath, rmdir, stat, utimes } from "node:fs/promises"; import path from "node:path"; import { claudeConfigDir, codexHome, copilotHome, homeDir } from "../home-paths.js"; import { logger } from "../logger.js"; import { atomic } from "./protocol.js"; export function pretrustEnabled(env = process.env) { return !/^(off|0|false|no)$/i.test(env.PHREN_PRETRUST?.trim() ?? ""); } /** Claude Code's global config file, resolved the way Claude resolves it. */ export async function claudeGlobalConfigFile(env = process.env) { const legacy = path.join(claudeConfigDir(env), ".config.json"); if (await stat(legacy).then(info => info.isFile(), () => false)) return legacy; const configured = env.CLAUDE_CONFIG_DIR?.trim(); return path.join(configured ? path.resolve(configured) : homeDir(env), ".claude.json"); } /** * The `projects` key Claude reads for `dir`: the normalized path, with * forward slashes on Windows (`C:/Users/me/repo`). Claude 2.1.280 builds * every lookup and write key this way (`normalize`, then * `replaceAll("\\", "/")` when the platform is Windows) and looks the key up * exactly, so a backslash key is never read there. */ export function claudeProjectKey(dir, platform = process.platform) { return platform === "win32" ? path.win32.normalize(dir).replaceAll("\\", "/") : path.posix.normalize(dir); } /** Claude takes `<file>.lock` (a proper-lockfile directory) around each config write; so does this. */ const LOCK_WAIT_MS = 3_000, LOCK_STALE_MS = 10_000; async function withClaudeLock(file, work) { const lock = `${file}.lock`, deadline = Date.now() + LOCK_WAIT_MS; for (;;) { try { await mkdir(lock); break; } catch (error) { if (error.code !== "EEXIST") throw error; const held = await stat(lock).catch(() => undefined); // A lock nobody refreshed for LOCK_STALE_MS belongs to a process that // died holding it; proper-lockfile takes it over the same way. if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS) { await rmdir(lock).catch(() => undefined); continue; } if (Date.now() >= deadline) throw new Error(`${path.basename(lock)} is held by another process`); await new Promise(resolve => setTimeout(resolve, 50)); } } try { return await work(); } finally { await utimes(lock, new Date(), new Date()).catch(() => undefined); await rmdir(lock).catch(() => undefined); } } /** * Sets `projects[dir].hasTrustDialogAccepted` and nothing else. Claude * rewrites this file all the time, so the write happens under Claude's own * lock, from a fresh read, and is retried when the file changed between the * read and the rename. A missing or unparsable file is left alone: that is * a Claude that has not finished its own first run, which needs the owner. */ export async function ensureClaudeFolderTrusted(dir, env = process.env) { const configured = await claudeGlobalConfigFile(env); // Write through a symlinked config to its target, never over the link. const file = await realpath(configured).catch(() => configured); const key = claudeProjectKey(dir); return withClaudeLock(file, async () => { for (let attempt = 0; attempt < 3; attempt++) { const before = await stat(file).catch(error => { if (error.code === "ENOENT") return undefined; throw error; }); if (!before) return "skipped"; const text = await readFile(file, "utf8"); const config = JSON.parse(text); if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error(`${path.basename(file)} is not a JSON object`); const record = config; const projects = record.projects && typeof record.projects === "object" && !Array.isArray(record.projects) ? record.projects : {}; const current = projects[key]; const entry = current && typeof current === "object" && !Array.isArray(current) ? current : {}; if (entry.hasTrustDialogAccepted === true) return "already"; const next = { ...record, projects: { ...projects, [key]: { ...entry, hasTrustDialogAccepted: true } } }; const now = await stat(file); if (now.mtimeMs !== before.mtimeMs || now.size !== before.size) continue; await atomic(file, JSON.stringify(next, null, 2), before.mode & 0o777); return "trusted"; } throw new Error(`${path.basename(file)} kept changing while it was being updated`); }); } function tomlQuote(value) { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } /** The config text with `[projects."<dir>"]` trusted, or undefined when it already is. */ export function codexTrustedText(text, dir) { const header = `[projects.${tomlQuote(dir)}]`; const trustLine = 'trust_level = "trusted"'; const at = text.indexOf(header); if (at < 0) { const separator = text && !text.endsWith("\n") ? "\n\n" : text ? "\n" : ""; return text + `${separator}${header}\n${trustLine}\n`; } const bodyStart = at + header.length; const rest = text.slice(bodyStart); const nextTable = rest.search(/^\s*\[/m); const section = nextTable >= 0 ? rest.slice(0, nextTable) : rest; if (/^[ \t]*trust_level\s*=\s*"trusted"/m.test(section)) return undefined; const existing = /^[ \t]*trust_level\s*=.*$/m.exec(section); if (existing) { const replaced = section.replace(existing[0], existing[0].match(/^[ \t]*/)[0] + trustLine); return text.slice(0, bodyStart) + replaced + rest.slice(section.length); } const startsWithNewline = section.startsWith("\n") || section.startsWith("\r\n"); // The table exists without the key: it goes on its own line under the header. return text.slice(0, bodyStart) + "\n" + trustLine + (startsWithNewline ? section : "\n" + section) + rest.slice(section.length); } /** * Adds or flips the one `trust_level` line for `dir` in Codex's * `config.toml`, appending the table when it is absent. The rest of the * file is kept byte for byte. An unreadable config is left untouched. */ export async function ensureCodexDirTrusted(dir, env = process.env) { const directory = codexHome(env); const configured = path.join(directory, "config.toml"); const file = await realpath(configured).catch(() => configured); for (let attempt = 0; attempt < 3; attempt++) { let text = ""; try { text = await readFile(file, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; } const next = codexTrustedText(text, dir); if (next === undefined) return "already"; await mkdir(directory, { recursive: true, mode: 0o700 }); const metadata = await lstat(file).catch(() => undefined); // Codex may have rewritten the file meanwhile; start over from its text. if (await readFile(file, "utf8").catch(() => "") !== text) continue; await atomic(file, next, metadata ? metadata.mode & 0o777 : 0o600); return "trusted"; } throw new Error("config.toml kept changing while it was being updated"); } /** * Adds `dir` to `trustedFolders` in Copilot's `settings.json`, creating the * file when Copilot has none yet. Other settings are kept; a file that is not * a JSON object is left alone (the launch then meets the trust screen). */ export async function ensureCopilotFolderTrusted(dir, env = process.env) { const directory = copilotHome(env); const configured = path.join(directory, "settings.json"); const file = await realpath(configured).catch(() => configured); for (let attempt = 0; attempt < 3; attempt++) { let text = ""; try { text = await readFile(file, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; } const settings = text.trim() ? JSON.parse(text) : {}; if (!settings || typeof settings !== "object" || Array.isArray(settings)) throw new Error("settings.json is not a JSON object"); const record = settings; if (record.trustedFolders !== undefined && !Array.isArray(record.trustedFolders)) throw new Error("trustedFolders in settings.json is not a list"); const trusted = (record.trustedFolders ?? []); if (trusted.includes(dir)) return "already"; await mkdir(directory, { recursive: true, mode: 0o700 }); const metadata = await lstat(file).catch(() => undefined); // Copilot may have rewritten the file meanwhile; start over from its text. if (await readFile(file, "utf8").catch(() => "") !== text) continue; await atomic(file, JSON.stringify({ ...record, trustedFolders: [...trusted, dir] }, null, 2) + "\n", metadata ? metadata.mode & 0o777 : 0o600); return "trusted"; } throw new Error("settings.json kept changing while it was being updated"); } /** * Trusts `dir` for `harness` before a launch, when the harness has a trust * screen and pre-trust is on. Never throws: a failure is logged and the * launch goes on to meet the screen, which dispatch then reports. */ export async function pretrustFolder(harness, dir, why, env = process.env) { if (harness !== "claude" && harness !== "codex" && harness !== "copilot") return "skipped"; if (!pretrustEnabled(env)) return "skipped"; if (!path.isAbsolute(dir)) return "skipped"; const name = harness === "claude" ? "Claude" : harness === "codex" ? "Codex" : "Copilot"; // The same folder by the path the Hook was given and by its real path: the // harness keys trust by whichever one its working directory reports. const real = await realpath(dir).catch(() => undefined); if (!real || !(await stat(real).then(info => info.isDirectory(), () => false))) return "skipped"; let outcome = "already"; for (const folder of [...new Set([dir, real])]) { try { const result = harness === "claude" ? await ensureClaudeFolderTrusted(folder, env) : harness === "codex" ? await ensureCodexDirTrusted(folder, env) : await ensureCopilotFolderTrusted(folder, env); if (result === "trusted") logger.info("launch", `Marked ${folder} trusted for ${name} (${why}).`); else if (result === "skipped") logger.warn("launch", `Did not mark ${folder} trusted for ${name}: its config file does not exist yet.`); if (result === "trusted" || (result === "skipped" && outcome === "already")) outcome = result; } catch (error) { logger.warn("launch", `Could not mark ${folder} trusted for ${name} (${why}): ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`); if (outcome === "already") outcome = "skipped"; } } return outcome; }