#!/usr/bin/env node // Codex CLI 러너 — designpaca 인터뷰 게이트 평가. // // `codex exec --json` 비대화형 모드에서는 request_user_input 이 // "not supported in exec mode" 로 거부되므로, 질문은 항상 평문 // (agent_message 아이템)으로만 나온다. 구조화 질문 채널은 없다. // // 격리: // - CODEX_HOME 을 이 실행 전용 임시 디렉터리로 돌린다. 인증은 사용자의 // 실제 ~/.codex/auth.json 을 그 임시 CODEX_HOME 에 복사해서만 쓰고(원본은 // 읽기만), 실행이 끝나면 임시 복사본을 지운다. // - HOME/USERPROFILE 도 같이 돌린다. 단, 이 Codex 빌드(0.153.4, Windows)는 // `~/.agents/skills` 사용자 스코프 스킬 루트를 홈 디렉터리 크레이트로 // 해석하면서 HOME/USERPROFILE 환경변수 재정의를 무시하는 것을 실측으로 // 확인했다(같은 이름의 실제 설치 스킬이 격리 실행에서도 그대로 보였다). // CODEX_HOME 은 정상적으로 재정의를 따른다(codex doctor 로 확인). // 이 스크립트는 실행 전 "실제" 사용자 홈의 ~/.agents/skills, ~/.codex/skills // 를 스캔해 그 안의 스킬 이름을 전부 격리된 CODEX_HOME/config.toml의 // [[skills.config]] 규칙으로 끄고, 우리가 작업 디렉터리에 넣은 SKILL.md // 경로만 다시 켜는 방식으로 이름 충돌(예: 실제 설치된 "designpaca" v0.12.0이 // 같이 잡히는 것)을 막는다. // 그 밖의 무관한 스킬 노출 자체는 이 방식으로도 완전히 막지 못한다 // (README 의 "알려진 한계" 참고). // - fresh CODEX_HOME에서는 workspace-write 샌드박스의 exec_command가 전부 // "blocked by policy"로 거부되던 문제가 있었다(pwd조차 실패). 실측으로 // 원인을 찾았다: 사용자의 실제 ~/.codex/config.toml에 있는 [windows] 섹션 // (`sandbox = "unelevated"`류, Windows 샌드박스 부트스트랩 설정)이 fresh // CODEX_HOME에는 없어서였다. 이 섹션은 인증·토큰·계정 값이 없어 읽기 전용으로 // 그대로 복제해도 안전하므로, 실행마다 실제 ~/.codex/config.toml에서 이 // 섹션만 추출해 격리된 CODEX_HOME/config.toml에 덧붙인다 // (extractWindowsSectionFromRealConfig). 원본은 절대 쓰지 않는다. // // 단독 실행: // node build/eval/interview/run-codex.mjs \ // --workdir <절대경로> --scenario S1 --prompt "..." \ // --skill-path <워크dir 안의 SKILL.md 절대경로> \ // --out result.json --log raw.jsonl import { spawn, execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import readline from "node:readline"; import { snapshotDir, diffSnapshots } from "./lib/fsutil.mjs"; import { scoreQuestionText } from "./lib/text-heuristic.mjs"; function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = argv[i + 1]; if (next === undefined || next.startsWith("--")) { out[key] = true; } else { out[key] = next; i++; } } } return out; } function tomlString(s) { return `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } /** 실제 사용자 홈의 .agents/skills, .codex/skills 안 스킬 이름을 전부 모은다(SKILL.md frontmatter의 name:). */ function collectRealSkillNames(realHome) { const names = new Set(); for (const sub of [path.join(realHome, ".agents", "skills"), path.join(realHome, ".codex", "skills")]) { let entries; try { entries = fs.readdirSync(sub, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory()) continue; const skillMd = path.join(sub, entry.name, "SKILL.md"); try { const text = fs.readFileSync(skillMd, "utf8"); const m = text.match(/^name:\s*(.+?)\s*$/m); if (m) names.add(m[1].trim()); } catch { // SKILL.md 없으면 무시 } } } return [...names]; } /** * 격리 가드용 skills.config TOML 조각을 만든다. 우리 skillPath 는 이름이 겹쳐도 다시 켠다. * -c CLI 플래그로 넘기면 Windows shell 인용 문제(중첩 따옴표가 깨짐)가 있어 * CODEX_HOME/config.toml 파일에 직접 쓴다([[skills.config]] 배열-테이블 형식). */ function buildSkillIsolationToml(realHome, ourSkillPath) { const names = collectRealSkillNames(realHome); const blocks = names.map((n) => `[[skills.config]]\nname = ${tomlString(n)}\nenabled = false\n`); blocks.push(`[[skills.config]]\npath = ${tomlString(ourSkillPath)}\nenabled = true\n`); return blocks.join("\n"); } /** * 사용자의 실제 ~/.codex/config.toml 에서 [windows] 섹션만 읽기 전용으로 추출한다. * fresh(격리된) CODEX_HOME 에서 workspace-write 샌드박스의 모든 exec_command가 * "blocked by policy"로 거부되던 문제가, 이 섹션(`sandbox = "unelevated"`류 Windows * 샌드박스 부트스트랩 설정)이 없어서였음을 실측으로 확인했다(재현: fresh CODEX_HOME + * 이 섹션 추가 -> pwd/Get-Content 정상 동작). 이 섹션에는 인증·토큰·계정 값이 없다 * (실제 값은 `sandbox = "unelevated"` 한 줄 정도). 원본 ~/.codex/config.toml 은 * 절대 쓰지 않는다 — 읽기만 한다. */ function extractWindowsSectionFromRealConfig(realHome) { const cfgPath = path.join(realHome, ".codex", "config.toml"); let text; try { text = fs.readFileSync(cfgPath, "utf8"); } catch { return null; } const lines = text.split(/\r?\n/); let start = -1; for (let i = 0; i < lines.length; i++) { if (/^\[windows\]\s*$/.test(lines[i])) { start = i; break; } } if (start === -1) return null; let end = lines.length; for (let i = start + 1; i < lines.length; i++) { if (/^\[/.test(lines[i])) { end = i; break; } } const section = lines.slice(start, end).join("\n").trim(); return section ? section + "\n" : null; } /** * Windows 에서 npm이 깐 `codex` 는 codex.cmd -> powershell.exe -File codex.ps1 로 이어지는 * 배치/파워셸 릴레이라, 공백과 한글이 섞인 프롬프트가 인자·stdin 양쪽에서 깨지는 것을 * 실측으로 확인했다. 가능하면 그 뒤에 있는 진짜 네이티브 codex.exe를 직접 찾아 * 셸을 거치지 않고 그대로 실행한다(찾지 못하면 codex.cmd 경로로 폴백한다). */ function resolveCodexBinaryWindows() { try { const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8", shell: true }).trim(); const scopeDir = path.join(globalRoot, "@openai", "codex", "node_modules", "@openai"); const entries = fs.readdirSync(scopeDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name.startsWith("codex-")); for (const e of entries) { const vendorDir = path.join(scopeDir, e.name, "vendor"); if (!fs.existsSync(vendorDir)) continue; for (const triple of fs.readdirSync(vendorDir)) { const exe = path.join(vendorDir, triple, "bin", "codex.exe"); if (fs.existsSync(exe)) return exe; } } } catch { return null; } return null; } async function main() { const args = parseArgs(process.argv.slice(2)); const workdir = args.workdir; const scenario = args.scenario || "unknown"; const prompt = args.prompt; const model = args.model || null; const timeoutMs = Number(args["timeout-ms"] || 300000); const outPath = args.out; const logPath = args.log; const skillPath = args["skill-path"]; // workdir 안 .agents/skills/designpaca/SKILL.md if (!workdir || !prompt || !outPath || !skillPath) { console.error("사용법: run-codex.mjs --workdir --scenario --prompt --skill-path --out [--log ] [--model ] [--timeout-ms ]"); process.exit(2); } const realHome = os.homedir(); const isolationRoot = path.join(path.dirname(workdir), "_codex-isolation"); const isolatedHome = path.join(isolationRoot, "home"); const isolatedCodexHome = path.join(isolationRoot, "codexhome"); fs.mkdirSync(isolatedHome, { recursive: true }); fs.mkdirSync(isolatedCodexHome, { recursive: true }); const realAuthPath = path.join(realHome, ".codex", "auth.json"); const isolatedAuthPath = path.join(isolatedCodexHome, "auth.json"); let authCopied = false; if (fs.existsSync(realAuthPath)) { fs.copyFileSync(realAuthPath, isolatedAuthPath); authCopied = true; } const windowsSection = extractWindowsSectionFromRealConfig(realHome); const isolationToml = buildSkillIsolationToml(realHome, skillPath) + (windowsSection ? "\n" + windowsSection : ""); fs.writeFileSync(path.join(isolatedCodexHome, "config.toml"), isolationToml); const codexArgs = [ "exec", "--json", "--skip-git-repo-check", "-s", "workspace-write", "-C", workdir, ]; if (model) { codexArgs.push("-m", model); } // 프롬프트는 인자로 넘기지 않고 stdin으로 넣는다. codex.cmd -> powershell.exe -> // codex.ps1 로 이어지는 Windows 셸 릴레이가 공백·한글이 섞인 긴 인자를 깨뜨리는 // 것을 실측으로 확인했다(`codex exec` 는 프롬프트 인자가 없으면 stdin으로 읽는다). const snapshotBefore = snapshotDir(workdir); const result = { scenario, runner: "codex", model: model || "", asked: false, askChannel: null, questionText: null, wroteBeforeAnswer: null, firstVisibleAction: null, finalMessageText: null, toolsUsed: [], durationMs: null, killedReason: null, exitCode: null, error: null, sandboxPolicyRejections: [], windowsSandboxSectionApplied: !!windowsSection, isolationNote: "HOME/USERPROFILE/CODEX_HOME 재정의 + skills.config 이름 차단·재활성화로 격리를 시도했다. " + "이 Windows 빌드에서는 사용자 스코프 스킬 루트(home_dir 기반)가 env override를 우회하는 것을 확인했으므로 " + "무관한 스킬 노출 자체는 완전히 막지 못했을 수 있다. sandboxPolicyRejections 가 비어 있지 않으면 " + "샌드박스 자체가 이 실행에서 막혀 결과가 무효일 가능성이 높다(README 참고).", }; const logStream = logPath ? fs.createWriteStream(logPath, { flags: "a" }) : null; const startedAt = Date.now(); const isWin = process.platform === "win32"; let codexBin = "codex"; let useShell = isWin; if (isWin) { const resolved = resolveCodexBinaryWindows(); if (resolved) { codexBin = resolved; useShell = false; } } result.codexBinaryUsed = codexBin; const child = spawn(codexBin, codexArgs, { cwd: workdir, stdio: ["pipe", "pipe", "pipe"], shell: useShell, env: { ...process.env, HOME: isolatedHome, USERPROFILE: isolatedHome, CODEX_HOME: isolatedCodexHome, }, }); child.stdin.write(prompt, "utf8"); child.stdin.end(); let killedByUs = false; function killChild(reason) { if (killedByUs || child.killed) return; killedByUs = true; result.killedReason = reason; try { child.kill("SIGTERM"); } catch { // 무시 } setTimeout(() => { try { if (!child.killed) child.kill("SIGKILL"); } catch { // 무시 } }, 3000); } const overallTimeout = setTimeout(() => killChild("timeout"), timeoutMs); function markAskedIfNeeded(channel, text) { if (result.asked) return; result.asked = true; result.askChannel = channel; result.questionText = text; const after = snapshotDir(workdir); result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count; killChild("asked"); } child.stderr.on("data", (chunk) => { const text = chunk.toString(); if (logStream) logStream.write(`[stderr] ${text}`); if (/rejected: blocked by policy|Rejected\(/.test(text)) { result.sandboxPolicyRejections.push(text.trim().slice(0, 500)); } }); const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); rl.on("line", (line) => { if (logStream) logStream.write(line + "\n"); if (!line.trim()) return; let evt; try { evt = JSON.parse(line); } catch { return; } if (evt.type === "item.completed" && evt.item) { const item = evt.item; if (item.type === "agent_message" && item.text) { if (!result.firstVisibleAction) { result.firstVisibleAction = `text:${item.text.slice(0, 120)}`; } const score = scoreQuestionText(item.text); if (score.isQuestion) { markAskedIfNeeded("text", item.text); } result.finalMessageText = item.text; } else if (item.type === "command_execution") { const label = `command_execution:${(item.command || "").slice(0, 80)}`; result.toolsUsed.push(label); if (!result.firstVisibleAction) { result.firstVisibleAction = `tool:${label}`; } } else if (item.type === "error") { result.toolsUsed.push(`error:${(item.message || "").slice(0, 80)}`); } return; } }); await new Promise((resolve) => { child.on("close", (code) => { result.exitCode = code; resolve(); }); child.on("error", (err) => { result.error = String(err && err.message ? err.message : err); resolve(); }); }); clearTimeout(overallTimeout); if (logStream) logStream.end(); if (result.wroteBeforeAnswer === null) { const after = snapshotDir(workdir); result.wroteBeforeAnswer = diffSnapshots(snapshotBefore, after).count; } result.durationMs = Date.now() - startedAt; if (authCopied) { try { fs.unlinkSync(isolatedAuthPath); } catch { // 무시 — 그래도 최선을 다해 지운다 } } fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.writeFileSync(outPath, JSON.stringify(result, null, 2)); console.log(JSON.stringify(result)); } main().catch((err) => { console.error("run-codex.mjs 실패:", err); process.exit(1); });