Files
AriWeb-Suite/modules/webmaker/lib/engine-codex.js
T

185 lines
21 KiB
JavaScript
Raw Normal View History

'use strict';
// OpenAI Codex CLI(서버 로그인: ChatGPT 계정) 헤드리스 실행 엔진. `codex exec --json` 이벤트를 파싱해 runClaude 와 같은 결과를 돌려준다.
// 서버에서 관리자가 `codex login`(또는 `codex login --device-auth`) 으로 로그인해 두면 API 키 없이 사용할 수 있다.
const { spawn, execFileSync } = require('child_process');
const readline = require('readline');
const fs = require('fs');
const os = require('os');
const path = require('path');
const HOME = os.homedir();
const KNOWN = [process.env.CODEX_BIN, path.join(HOME, '.npm-global', 'bin', 'codex'), path.join(HOME, '.local', 'bin', 'codex'), '/usr/local/bin/codex', '/usr/bin/codex'].filter(Boolean);
function isExec(p) { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch (_) { return false; } }
function resolveBin() { for (const d of (process.env.PATH || '').split(path.delimiter)) { const p = path.join(d, 'codex'); if (d && isExec(p)) return p; } return KNOWN.find(isExec) || null; }
/**
* 실행 환경 변수: v1.3.17 (S07) 서비스 프로세스의 환경을 통째로 넘기지 않고 codex 실행에 필요한 것만 넘긴다
* (DATA_ROOT·키 파일 경로·서비스 설정이 사용자 지시로 움직이는 명령에 보이지 않게).
*/
function cleanEnv() { const env = {}; for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'CODEX_HOME', 'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY']) if (process.env[k]) env[k] = process.env[k]; if (!env.HOME) env.HOME = HOME; return env; }
/**
* v1.5.7 (오류 수정): 쓰기 작업의 샌드박스 옵션.
* 1.4.x 까지는 `--full-auto`(작업 폴더 쓰기 + 승인 없음)를 썼는데, codex CLI 0.159 부터 이 옵션이 없어져
* `error: unexpected argument '--full-auto' found` 로 모든 OpenAI 서버 로그인 작업이 13ms 만에 실패했다(2026-10-14 운영 로그 2건).
* 지금 버전은 `-s workspace-write`(작업 폴더에만 쓰기, 네트워크 없음)가 같은 뜻이고 exec 는 원래 승인을 묻지 않는다.
* 설치된 codex 의 `exec --help` 에 --full-auto 가 있으면 옛 옵션을, 없으면 새 옵션을 쓴다(실행 파일별로 한 번만 확인).
*/
const helpCache = new Map();
function execHelp(bin) {
let hit = helpCache.get(bin); let mtime = 0; try { mtime = fs.statSync(bin).mtimeMs; } catch (_) {}
if (hit && hit.mtime === mtime) return hit.help;
let help = ''; try { help = execFileSync(bin, ['exec', '--help'], { timeout: 15000, env: cleanEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).toString(); } catch (e) { help = String((e.stdout || '') + (e.stderr || '')); }
helpCache.set(bin, { mtime, help }); return help;
}
/**
* 샌드박스 사용 여부 (v1.5.7).
* codex 의 Linux 샌드박스는 비특권 user namespace 로 만든다. Ubuntu 24.04 는 기본으로 이를 막아
* (kernel.apparmor_restrict_unprivileged_userns=1) 샌드박스 안의 모든 파일 쓰기·셸 명령이 "Operation not permitted" 로 실패한다
* (2026-10-14 두 운영 서버 모두 확인 — 값을 0 으로 바꾸면 정상). 그래서
* - 설정 파일 ai.codexSandbox = auto(기본) : 커널 제한이 있으면 샌드박스 없이(--dangerously-bypass-approvals-and-sandbox) 실행하고 기동 로그에 한 번 안내,
* 제한이 없으면 workspace-write 샌드박스(작업 폴더에만 쓰기, 네트워크 없음)
* - workspace-write : 항상 샌드박스 (제한된 서버에서는 작업이 실패한다)
* - none : 항상 샌드박스 없이 (1.3.16 이전 방식 — 작업 폴더 밖 쓰기는 프롬프트 규칙에만 의존)
* 샌드박스를 쓰려면 운영자가 `sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0` (영구: /etc/sysctl.d/60-codex-sandbox.conf) 뒤 재시작.
* @returns {{mode:'workspace-write'|'none', reason:string, restricted:boolean, setting:string}}
*/
let warned = false;
function sandboxMode() {
const setting = String(process.env.CODEX_SANDBOX || 'auto').trim().toLowerCase();
let restricted = false; try { restricted = fs.readFileSync('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').trim() === '1' && (process.getuid ? process.getuid() !== 0 : true); } catch (_) { restricted = false; }
const fix = 'sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (영구: /etc/sysctl.d/60-codex-sandbox.conf 에 같은 줄) 뒤 서비스 재시작';
let mode, reason;
// v1.6.31 (요구 3, 검토 S2): 샌드박스를 쓸 수 없으면 "제한 없이 실행" 이 아니라 **실행하지 않는다**(fail-closed). 샌드박스 없는 실행은 운영자가 ai.codexUnsafe=true 를
// 명시했을 때만 허용하고(진단용), 그래도 기동 로그에 경고한다. mode 'blocked' 는 runCodex 가 작업을 시작하지 않고 이유를 돌려준다.
const unsafeOk = (() => { try { return require('./config').get('ai.codexUnsafe') === true; } catch (_) { return false; } })();
if (setting === 'none') { if (unsafeOk) { mode = 'none'; reason = '설정 ai.codexSandbox=none + ai.codexUnsafe=true — 샌드박스 없이 실행 (운영자 명시, 권장하지 않음)'; } else { mode = 'blocked'; reason = '설정 ai.codexSandbox=none 이지만 ai.codexUnsafe 가 켜져 있지 않아 OpenAI 서버 로그인 작업을 실행하지 않습니다 (샌드박스 없는 실행은 다른 사이트·서버 설정에 접근할 수 있음). 커널 제한을 풀어 샌드박스를 쓰거나(아래 명령) ai.codexSandbox=auto 로 두세요'; } }
else if (setting === 'workspace-write') { mode = 'workspace-write'; reason = restricted ? `설정 파일 ai.codexSandbox=workspace-write — 그러나 커널이 비특권 user namespace 를 막고 있어(kernel.apparmor_restrict_unprivileged_userns=1) 작업이 실패할 수 있습니다. ${fix}` : '워크스페이스 샌드박스 (작업 폴더에만 쓰기, 네트워크 없음)'; }
else if (restricted) { if (unsafeOk) { mode = 'none'; reason = `커널이 비특권 user namespace 를 막고 있어 codex 샌드박스를 쓸 수 없고, 운영자가 ai.codexUnsafe=true 로 샌드박스 없는 실행을 허용했습니다 (권장하지 않음). 샌드박스를 쓰려면 ${fix}`; } else { mode = 'blocked'; reason = `커널이 비특권 user namespace 를 막고 있어(kernel.apparmor_restrict_unprivileged_userns=1, Ubuntu 24.04 기본) codex 샌드박스를 쓸 수 없습니다. 안전한 실행 환경이 없으므로 OpenAI 서버 로그인 작업을 실행하지 않습니다 — 운영자가 ${fix} 하면 샌드박스로 실행됩니다`; } }
else { mode = 'workspace-write'; reason = '워크스페이스 샌드박스 (작업 폴더에만 쓰기, 네트워크 없음)'; }
if ((mode === 'none' || mode === 'blocked') && !warned) { warned = true; console.log(`[codex] ${reason}`); }
return { mode, reason, restricted, setting };
}
/**
* 실행 인수 중 샌드박스·승인 부분.
* 1.4.x 까지는 `--full-auto` 였는데 codex CLI 0.159 부터 이 옵션이 없어져 `error: unexpected argument '--full-auto' found` 로 모든
* OpenAI 서버 로그인 작업이 13ms 만에 실패했다(2026-10-14 운영 로그 2건). 설치된 codex 의 `exec --help` 를 보고 옵션을 고른다.
* @param {string} bin codex 실행 파일
* @param {boolean} readOnly 읽기 전용 작업(기획·분석)인지
*/
function sandboxArgs(bin, readOnly) {
const sb = sandboxMode(); const help = execHelp(bin);
if (sb.mode === 'none') return /--dangerously-bypass-approvals-and-sandbox/.test(help) ? ['--dangerously-bypass-approvals-and-sandbox'] : ['-s', 'danger-full-access', '-c', 'approval_policy="never"'];
if (readOnly) return ['-s', 'read-only'];
if (/--full-auto/.test(help)) return ['--full-auto'];
return ['-s', 'workspace-write', '-c', 'approval_policy="never"'];
}
/** codex 가 ChatGPT 백엔드에서 받아 둔 모델 목록 파일 (codex 실행 때마다 갱신됨) */
const MODELS_CACHE = path.join(HOME, '.codex', 'models_cache.json');
/** 설치·로그인 상태 */
function codexInfo() {
const bin = resolveBin(); let version = null; if (bin) { try { version = execFileSync(bin, ['--version'], { timeout: 15000, env: cleanEnv() }).toString().trim(); } catch (e) { version = null; } }
let login = { loggedIn: false, mode: null };
try { const a = JSON.parse(fs.readFileSync(path.join(HOME, '.codex', 'auth.json'), 'utf8')); login = { loggedIn: !!(a.tokens || a.OPENAI_API_KEY), mode: a.OPENAI_API_KEY ? 'api-key' : (a.tokens ? 'chatgpt' : null), lastRefresh: a.last_refresh || null }; } catch (_) {}
let defaultModel = null; try { const c = fs.readFileSync(path.join(HOME, '.codex', 'config.toml'), 'utf8'); defaultModel = (c.match(/^\s*model\s*=\s*"([^"]+)"/m) || [])[1] || null; } catch (_) {}
let modelsCachedAt = null; try { modelsCachedAt = JSON.parse(fs.readFileSync(MODELS_CACHE, 'utf8')).fetched_at || null; } catch (_) {}
return { bin, version, ...login, defaultModel, modelsCachedAt, searched: KNOWN, sandbox: sandboxMode() }; // v1.5.7 sandbox: 샌드박스 사용 여부와 이유(설정 페이지에 표시)
}
/**
* v1.4.14 (요구 5) 서버 로그인(ChatGPT 계정)으로 쓸 수 있는 모델 전체.
*
* 1.4.5 까지는 미리 적어 둔 후보(gpt-5.x)를 하나씩 짧게 실행해 통과한 것만 돌려줬다. 후보 이름이 백엔드의 실제 모델과
* 달라져(gpt-6.x 세대) config.toml 의 기본 모델 하나만 조회되는 문제가 있었다("모델 1개 조회").
* codex CLI 는 로그인 상태로 실행될 때 백엔드의 모델 목록을 ~/.codex/models_cache.json 에 저장한다
* ({ fetched_at, models: [{ slug, display_name, description, visibility: 'list'|'hide', upgrade: {retirement_at…} … }] }).
* 이 파일을 읽으면 그 계정에 보이는 모델을 모두 한 번에 알 수 있다 — 아래 cachedModels().
* 파일이 없을 때(설치 직후·한 번도 실행 안 함)만 기본 모델로 한 번 실행해 파일을 만들게 하고, 그래도 없으면 후보 목록으로 돌아간다.
*/
function cachedModels() {
let d; try { d = JSON.parse(fs.readFileSync(MODELS_CACHE, 'utf8')); } catch (_) { return null; }
const list = Array.isArray(d && d.models) ? d.models : []; if (!list.length) return null;
return list.filter((m) => m && m.slug).map((m) => {
const retire = m.upgrade && m.upgrade.retirement_at ? m.upgrade.retirement_at : null;
return { id: m.slug, name: m.display_name || m.slug, description: m.description || '', hidden: m.visibility === 'hide', priority: Number.isFinite(m.priority) ? m.priority : 999, retireAt: retire, upgradeTo: m.upgrade && m.upgrade.model ? m.upgrade.model : null, reasoning: Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels.map((x) => x.effort) : [] };
}).sort((a, b) => a.priority - b.priority);
}
/** 모델 목록 파일이 없을 때 후보로 쓰는 이름 (2026-10 ChatGPT/Codex 세대) — 실제 목록은 cachedModels() 가 우선 */
const CANDIDATES = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5'];
/** 참고 단가(USD/1M, 입력·출력). ChatGPT 구독 로그인은 토큰 과금이 아니라 관리자가 화면에서 고쳐 쓰는 기준값이다. 모르는 모델은 0 */
const PRICE = { 'gpt-6-astra': [2, 16], 'gpt-6.1-sol': [1.25, 10], 'gpt-6-sol': [1.25, 10], 'gpt-6-luna': [0.25, 2], 'gpt-5.6-sol': [1.25, 10], 'gpt-5.6-terra': [0.5, 4], 'gpt-5.6-luna': [0.25, 2], 'gpt-5.5': [1.25, 10], 'gpt-5.2-codex': [1.75, 14], 'gpt-5.2': [1.75, 14], 'gpt-5.1-codex-max': [1.25, 10], 'gpt-5.1': [1.25, 10], 'gpt-5-codex': [1.25, 10], 'gpt-5': [1.25, 10], 'gpt-5-mini': [0.25, 2] };
function probeModel(bin, model) {
return new Promise((resolve) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-probe-'));
const child = spawn(bin, ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', model, 'Reply with exactly: OK'], { env: cleanEnv(), stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', (d) => { out += d; }); const t = setTimeout(() => child.kill('SIGKILL'), 90000);
child.on('close', () => { clearTimeout(t); try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} let ok = false; for (const line of out.split('\n')) { try { const d = JSON.parse(line); if (d.type === 'item.completed' && d.item && d.item.type === 'agent_message' && /OK/.test(d.item.text || '')) ok = true; } catch (_) {} } resolve(ok); });
child.on('error', () => { clearTimeout(t); resolve(false); });
});
}
/**
* 서버 로그인으로 쓸 수 있는 모델 목록.
* @param {boolean} probe 목록 파일이 없을 때 codex 를 한 번 실행해 파일을 만들지(느림, 최대 90초) 여부
* @returns {Array<{id,label,description,input,output,hidden,retireAt,available}>} 보이는 모델 → 숨김 모델 순
*/
async function serverLoginModels(probe = true) {
const info = codexInfo();
const fmt = (m) => ({ id: m.id, label: `${m.name}${m.id === info.defaultModel ? ' (codex 기본)' : ''}${m.hidden ? ' (목록에 숨김)' : ''}${m.retireAt ? ` (${m.retireAt.slice(0, 10)} 종료 예정${m.upgradeTo ? ' → ' + m.upgradeTo : ''})` : ''}`, description: m.description, input: (PRICE[m.id] || [0, 0])[0], output: (PRICE[m.id] || [0, 0])[1], hidden: !!m.hidden, retireAt: m.retireAt || null, reasoning: m.reasoning, available: true });
let list = cachedModels();
if (!list && probe && info.bin && info.loggedIn) { await probeModel(info.bin, info.defaultModel || CANDIDATES[0]); list = cachedModels(); } // 파일을 만들기 위한 한 번짜리 실행
if (list) return list.map(fmt);
// 목록 파일을 끝내 얻지 못함(로그인 안 됨·옛 codex): 후보 이름으로 — probe 면 실제로 짧게 실행해 되는 것만
const ids = [...new Set([info.defaultModel, ...CANDIDATES].filter(Boolean))];
const mk = (id, ok) => ({ id, label: id + (id === info.defaultModel ? ' (codex 기본)' : '') + (ok === false ? ' (이 계정에서 사용 불가)' : ''), description: '', input: (PRICE[id] || [0, 0])[0], output: (PRICE[id] || [0, 0])[1], hidden: false, retireAt: null, available: ok });
if (!probe || !info.bin) return ids.map((id) => mk(id, undefined));
const results = []; const queue = ids.slice();
await Promise.all([1, 2, 3].map(async () => { while (queue.length) { const id = queue.shift(); results.push(mk(id, await probeModel(info.bin, id))); } }));
return ids.map((id) => results.find((r) => r.id === id)).filter((r) => r && r.available !== false);
}
const RULES = `\n\n[작업 규칙]\n- 파일은 현재 작업 디렉터리(사이트 루트) 안에만 만들고 수정합니다. 그 밖의 경로, 숨김 파일(.git 등), 시스템 파일은 절대 읽거나 쓰지 않습니다.\n- 셸 명령은 파일을 만들거나 확인하는 용도로만 씁니다(패키지 설치·네트워크 접근·프로세스 실행 금지).\n- 마지막 답변은 한국어 요약만 짧게 씁니다.`;
/**
* @param {object} o siteDir, prompt, systemPrompt, toolset('files'|'read'|'none'), model, price{input,output}, onEvent, onStart
*/
function runCodex(o) {
return new Promise((resolve, reject) => {
const bin = resolveBin(); if (!bin) return reject(new Error('codex 실행 파일을 찾지 못했습니다. 서버에 OpenAI Codex CLI 를 설치하세요 (npm i -g @openai/codex)'));
const started = Date.now(); const emit = (ev) => { try { o.onEvent && o.onEvent(ev); } catch (_) {} };
const lastFile = path.join(os.tmpdir(), `codex-last-${process.pid}-${Date.now()}.txt`);
const readOnly = o.toolset === 'read' || o.toolset === 'none';
// v1.6.31 (요구 3): 샌드박스를 만들 수 없으면 작업을 시작하지 않고 이유를 알린다 (제한 없는 실행으로 넘어가지 않는다)
{ const sb = sandboxMode(); if (sb.mode === 'blocked') return reject(Object.assign(new Error('AI 작업을 실행할 안전한 환경이 없습니다: ' + sb.reason), { status: 503, sandboxBlocked: true })); }
const args = ['exec', '--json', '--skip-git-repo-check', '-C', o.siteDir, '-o', lastFile, ...sandboxArgs(bin, readOnly), /* v1.3.17 (S07): 샌드박스 없이 실행하던 것을 codex 샌드박스(작업 폴더에만 쓰기, 네트워크 없음)로 */ ...(o.model ? ['-m', o.model] : []), `${o.systemPrompt || ''}${RULES}\n\n${o.prompt}`];
const child = spawn(bin, args, { cwd: o.siteDir, env: cleanEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: true });
let cancelled = false; const killGroup = (sig) => { try { process.kill(-child.pid, sig); } catch (_) { try { child.kill(sig); } catch (_) {} } };
if (o.onStart) o.onStart({ cancel: () => { if (cancelled) return; cancelled = true; killGroup('SIGTERM'); setTimeout(() => { if (child.exitCode === null && child.signalCode === null) killGroup('SIGKILL'); }, 2000); } });
const usage = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0 }; let turns = 0; let lastText = ''; let errText = ''; let stderr = '';
const live = () => { usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead; emit({ type: 'usage', live: true, usage: { ...usage }, calls: turns }); };
emit({ type: 'log', level: 'info', text: `세션 시작 (모델: ${o.model || 'codex 기본'}, 제공자: openai/codex 서버 로그인)` }); emit({ type: 'progress', text: 'AI 연결됨 · 작업을 구상하고 있습니다' });
child.stderr.on('data', (d) => { stderr += d; if (stderr.length > 20000) stderr = stderr.slice(-20000); });
const rl = readline.createInterface({ input: child.stdout });
rl.on('line', (line) => {
let ev; try { ev = JSON.parse(line); } catch (_) { return; }
const it = ev.item || {};
if (ev.type === 'item.started' && it.type === 'file_change') { for (const c of it.changes || []) emit({ type: 'progress', text: `파일 ${c.kind === 'add' ? '작성' : '수정'} 중: ${path.basename(c.path || '')}` }); }
else if (ev.type === 'item.completed') {
if (it.type === 'agent_message' && it.text) { lastText = it.text.trim(); emit({ type: 'log', level: 'assistant', text: lastText.replace(/<brief>[\s\S]*?<\/brief>/g, '[Brief 갱신됨]') }); emit({ type: 'progress', text: '결과를 정리하고 있습니다' }); }
else if (it.type === 'file_change') { for (const c of it.changes || []) emit({ type: 'log', level: 'tool', text: `파일 ${c.kind === 'add' ? '작성' : '수정'}: ${path.basename(c.path || '')}${it.status === 'failed' ? ' (실패)' : ''}` }); if (it.status === 'failed') emit({ type: 'log', level: 'debug', text: '도구 오류: 파일 변경 실패' }); }
else if (it.type === 'command_execution') { emit({ type: 'log', level: 'tool', text: `명령 실행: ${String(it.command || '').slice(0, 120)}` }); emit({ type: 'progress', text: '파일 구조 확인 중' }); }
else if (it.type === 'reasoning') emit({ type: 'progress', text: '내용을 구상하고 있습니다…' });
}
else if (ev.type === 'turn.completed') { const u = ev.usage || {}; turns += 1; const cached = u.cached_input_tokens || 0; usage.input += Math.max(0, (u.input_tokens || 0) - cached); usage.cacheRead += cached; usage.cacheCreate += u.cache_write_input_tokens || 0; usage.output += u.output_tokens || 0; usage.thinking += u.reasoning_output_tokens || 0; live(); }
else if (ev.type === 'turn.failed' || ev.type === 'error') { errText = (ev.error && ev.error.message) || ev.message || JSON.stringify(ev).slice(0, 300); emit({ type: 'log', level: 'debug', text: `codex 오류: ${errText}` }); }
});
child.on('error', (e) => reject(new Error(`codex 실행 실패 (${bin}): ${e.message}`)));
child.on('close', (code) => {
usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead;
if (cancelled) return reject(Object.assign(new Error('사용자가 작업을 중단했습니다'), { cancelled: true, usage: { ...usage } }));
let text = lastText; try { const t = fs.readFileSync(lastFile, 'utf8').trim(); if (t) text = t; fs.unlinkSync(lastFile); } catch (_) {}
const price = o.price || { input: 0, output: 0 }; const costUsd = (usage.input * (price.input || 0) + usage.cacheRead * (price.input || 0) * 0.1 + usage.output * (price.output || 0)) / 1e6;
const ok = code === 0 && !errText;
// v1.5.7: engine/errorText — 실패 기록이 "Claude 종료" 가 아니라 "Codex 종료: <원인>" 으로 남게 (server.js runCommand)
resolve({ engine: 'codex', errorText: ok ? null : (errText || stderr.trim().split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 2).join(' ').slice(0, 200) || null), ok, subtype: ok ? 'success' : 'error', isError: !ok, resultText: text || errText || (ok ? '' : `codex 종료 코드 ${code}: ${stderr.trim().slice(-400)}`), error: ok ? null : (errText || `codex 종료 코드 ${code}`), usage, costUsd, numTurns: turns, durationMs: Date.now() - started, model: o.model, modelUsage: { [`openai:${o.model || 'codex'}`]: { inputTokens: usage.input, outputTokens: usage.output, cacheReadInputTokens: usage.cacheRead, cacheCreationInputTokens: usage.cacheCreate, costUSD: costUsd } }, permissionDenials: [] });
});
});
}
module.exports = { runCodex, codexInfo, serverLoginModels, cachedModels, sandboxMode };