test(eval): add interview behavior eval harness
This commit is contained in:
parent
6805fb2be7
commit
c4aade92fc
16 changed files with 1773 additions and 0 deletions
384
build/eval/interview/run-codex.mjs
Normal file
384
build/eval/interview/run-codex.mjs
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue