test(eval): add interview behavior eval harness

This commit is contained in:
Yun Chan 2026-09-24 13:26:04 +09:00
parent 6805fb2be7
commit c4aade92fc
16 changed files with 1773 additions and 0 deletions

View file

@ -0,0 +1,384 @@
#!/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 <dir> --scenario <id> --prompt <text> --skill-path <SKILL.md 경로> --out <result.json> [--log <raw.jsonl>] [--model <m>] [--timeout-ms <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 || "<default>",
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);
});