384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
#!/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);
|
|
});
|