#!/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