use super::*; use std::path::{Path, PathBuf}; use std::process::Stdio; use sha2::{Digest, Sha256}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "codex/win-x64/bin/codex.exe"; const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = "codex/win-x64/manifest.json"; const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [ "bin/codex.exe", "bin/codex-code-mode-host.exe", "codex-path/rg.exe", "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", "codex-package.json", ]; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024; #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct GameCreatorBundledCodexCliManifest { schema_version: String, platform: String, version: String, files: std::collections::BTreeMap, } fn game_creator_codex_cli_executable_candidates_for( resource_dir: Option<&Path>, app_data: Option<&Path>, local_app_data: Option<&Path>, runtime_config_dir: Option<&Path>, path: Option<&std::ffi::OsStr>, ) -> Vec { let mut candidates = Vec::new(); #[cfg(windows)] { if let Some(resource_dir) = resource_dir { candidates.push(resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)); } fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) { let vendor_root = npm_root .join("node_modules") .join("@openai") .join("codex") .join("node_modules") .join("@openai") .join("codex-win32-x64") .join("vendor"); if let Ok(entries) = std::fs::read_dir(vendor_root) { let mut targets = entries .filter_map(Result::ok) .map(|entry| entry.path().join("bin").join("codex.exe")) .collect::>(); targets.sort(); candidates.extend(targets); } } fn append_desktop_codex_candidates(candidates: &mut Vec, local_app_data: &Path) { let bin_root = local_app_data.join("OpenAI").join("Codex").join("bin"); if let Ok(entries) = std::fs::read_dir(bin_root) { let mut targets = entries .filter_map(Result::ok) .map(|entry| entry.path().join("codex.exe")) .collect::>(); targets.sort(); targets.reverse(); candidates.extend(targets); } } if let Some(app_data) = app_data { append_native_npm_candidates(&mut candidates, &app_data.join("npm")); } if let Some(local_app_data) = local_app_data { append_desktop_codex_candidates(&mut candidates, local_app_data); } if let Some(app_data) = runtime_config_dir.and_then(Path::parent) { append_native_npm_candidates(&mut candidates, &app_data.join("npm")); if let Some(user_profile) = app_data.parent() { append_desktop_codex_candidates(&mut candidates, &user_profile.join("Local")); } } if let Some(path) = path { for entry in std::env::split_paths(&path) { append_native_npm_candidates(&mut candidates, &entry); candidates.push(entry.join("codex.exe")); } } } candidates.push(PathBuf::from(GAME_CREATOR_CODEX_CLI_EXECUTABLE)); candidates } fn game_creator_codex_cli_executable_candidates() -> Vec { game_creator_codex_cli_executable_candidates_for( game_creator_bundled_resource_dir().as_deref(), std::env::var_os("APPDATA").as_deref().map(Path::new), std::env::var_os("LOCALAPPDATA").as_deref().map(Path::new), game_creator_runtime_config_dir().as_deref(), std::env::var_os("PATH").as_deref(), ) } fn game_creator_bundled_resource_dir() -> Option { #[cfg(windows)] { std::env::current_exe() .ok() .and_then(|path| path.parent().map(Path::to_path_buf)) } #[cfg(not(windows))] { None } } fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option { resource_dir.map(|resource_dir| resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)) } fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result { let bundle_root = executable .parent() .and_then(Path::parent) .ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?; let manifest_path = bundle_root.join( Path::new(GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH) .file_name() .expect("bundled Codex manifest file name"), ); let manifest = std::fs::read_to_string(&manifest_path) .map_err(|_| "内置 Codex CLI 缺少完整性清单".to_string()) .and_then(|value| { serde_json::from_str::(&value) .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) })?; if manifest.schema_version != "genarrative-codex-sidecar.v2" || manifest.platform != "win32-x64" || manifest.version.trim().is_empty() || GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES .iter() .any(|relative| { manifest.files.get(*relative).map_or(true, |hash| { hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) }) }) { return Err("内置 Codex CLI 完整性清单不受支持".to_string()); } for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES { let path = bundle_root.join(relative); let bytes = std::fs::read(&path).map_err(|_| { format!( "内置 Codex CLI 缺少必需组件:{}", path.file_name().unwrap_or_default().to_string_lossy() ) })?; let actual = format!("{:x}", Sha256::digest(bytes)); if !actual.eq_ignore_ascii_case(manifest.files.get(relative).expect("validated hash")) { return Err(format!("内置 Codex CLI 完整性校验失败:组件 {relative}")); } } Ok(manifest.version) } fn game_creator_codex_cli_version_at(executable: &Path) -> Result { let mut command = crate::new_windows_background_std_command(executable); let output = command .arg("--version") .stdin(Stdio::null()) .stderr(Stdio::null()) .output() .map_err(|error| error.to_string())?; if !output.status.success() { return Err(format!("版本检查退出状态为 {}", output.status)); } let version = std::str::from_utf8(&output.stdout) .map_err(|_| "版本信息不是 UTF-8".to_string())? .trim(); if !version.starts_with("codex-cli ") || version.len() > 120 { return Err("返回了无法识别的版本信息".to_string()); } Ok(version.to_string()) } pub(crate) fn game_creator_codex_cli_executable_path() -> Result { let mut last_error = None; let mut seen = std::collections::HashSet::new(); let bundled = game_creator_bundled_codex_cli_path(game_creator_bundled_resource_dir().as_deref()); for candidate in game_creator_codex_cli_executable_candidates() { let identity = candidate.to_string_lossy().to_ascii_lowercase(); if !seen.insert(identity) { continue; } let bundled_version = (bundled.as_ref() == Some(&candidate)) .then(|| validate_game_creator_bundled_codex_cli(&candidate)) .transpose(); match bundled_version.and_then(|expected_version| { game_creator_codex_cli_version_at(&candidate).and_then(|actual_version| { if expected_version .as_deref() .is_some_and(|expected| expected != actual_version) { Err("内置 Codex CLI 版本与完整性清单不一致".to_string()) } else { Ok(actual_version) } }) }) { Ok(_) => return Ok(candidate), Err(error) => last_error = Some(error), } } Err(format!( "Codex CLI 未安装或当前 Agent Runner 无法启动;已检查内置 sidecar、PATH 和 npm 全局安装目录{}", last_error .map(|error| format!("(最后错误:{error})")) .unwrap_or_default() )) } struct CodexCliStderrSummary { byte_len: usize, sha256: String, classification: &'static str, } pub(crate) fn game_creator_codex_cli_version_identity() -> Result { let executable = game_creator_codex_cli_executable_path()?; game_creator_codex_cli_version_at(&executable) } pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort( request: &LlmRunRequest, ) -> Option<&'static str> { request .response_reasoning_effort .map(|effort| match effort { platform_llm::LlmResponseReasoningEffort::Low => "low", platform_llm::LlmResponseReasoningEffort::Medium => "medium", platform_llm::LlmResponseReasoningEffort::High => "high", platform_llm::LlmResponseReasoningEffort::Max => "max", }) } pub(in crate::agent) fn game_creator_codex_cli_tool_output_schema( request: &LlmRunRequest, ) -> Option { if request.function_tools.is_empty() { return None; } let names = request .function_tools .iter() .map(|tool| serde_json::Value::String(tool.name.clone())) .collect::>(); Some(serde_json::json!({ "type": "object", "required": ["toolCalls"], "additionalProperties": false, "properties": { "toolCalls": { "type": "array", "minItems": 1, "maxItems": AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, "items": { "type": "object", "required": ["name", "arguments"], "additionalProperties": false, "properties": { "name": { "type": "string", "enum": names }, "arguments": { "type": "string", "description": "严格 JSON 对象字符串,必须符合对应函数 parameters schema" } } } } } })) } pub(in crate::agent) fn render_game_creator_codex_cli_prompt( request: &LlmRunRequest, ) -> Result { let messages = serde_json::to_string_pretty(&request.messages) .map_err(|error| format!("序列化 Codex CLI Agent 消息失败:{error}"))?; let functions = serde_json::to_string_pretty(&request.function_tools) .map_err(|error| format!("序列化 Codex CLI Agent 函数目录失败:{error}"))?; let output_contract = if request.function_tools.is_empty() { "本次没有 Runtime 函数目录。请直接返回请求要求的最终正文;不要使用 Markdown 代码围栏。" .to_string() } else { "本次必须只返回 output schema 要求的 toolCalls 对象。每个 name 必须来自函数目录;arguments 必须是一个严格 JSON 对象的字符串形式,并符合该函数 parameters。不要返回普通正文、Markdown、解释或额外字段。" .to_string() }; let prompt = format!( "你是 Genarrative AI 游戏创作 Runtime 当前节点的推理 Agent。\n\n安全边界:不要调用 Codex 内置 shell、文件、网络、插件、Skill 或子 Agent;不要读取当前临时目录。项目事实只来自下面的消息,项目行动只能通过返回 Runtime 函数请求完成。不得声称已经执行尚未由 Runtime observation 证明的动作。\n\n{output_contract}\n\n以下消息按 role 保持原顺序,是本次节点的完整请求:\n{messages}\n\n以下是当前 Runtime 实际广告的函数目录:\n{functions}" ); if prompt.len() > GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES { return Err(format!( "Codex CLI Agent prompt 超过 {} 字节上限", GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES )); } Ok(prompt) } pub(in crate::agent) fn game_creator_codex_cli_minimal_environment( command: &mut tokio::process::Command, ) { command.env_clear(); for name in [ "PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "CODEX_HOME", "TMPDIR", "TEMP", "TMP", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "SystemRoot", "WINDIR", "ComSpec", "PATHEXT", ] { if let Some(value) = std::env::var_os(name) { command.env(name, value); } } command.env("NO_COLOR", "1").env("TERM", "dumb"); } pub(in crate::agent) fn configure_game_creator_codex_cli_process( command: &mut tokio::process::Command, ) { #[cfg(unix)] { use std::os::unix::process::CommandExt; command.as_std_mut().process_group(0); #[cfg(target_os = "linux")] unsafe { command.as_std_mut().pre_exec(|| { if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 { return Err(std::io::Error::last_os_error()); } if libc::getppid() == 1 { return Err(std::io::Error::new( std::io::ErrorKind::Interrupted, "Codex CLI parent exited before child exec", )); } Ok(()) }); } } #[cfg(windows)] crate::configure_windows_background_tokio_command(command, true); } pub(in crate::agent) async fn terminate_game_creator_codex_cli_process_tree( child: &mut tokio::process::Child, ) { if let Some(process_id) = child.id() { #[cfg(unix)] unsafe { libc::kill(-(process_id as i32), libc::SIGKILL); } #[cfg(windows)] { let mut command = tokio::process::Command::new("taskkill"); command .args(["/PID", &process_id.to_string(), "/T", "/F"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); crate::configure_windows_background_tokio_command(&mut command, false); let _ = command.status().await; } } let _ = child.kill().await; let _ = child.wait().await; } async fn read_game_creator_codex_cli_output( mut reader: R, max_bytes: usize, ) -> Result, String> where R: AsyncRead + Unpin, { let mut bytes = Vec::new(); let mut buffer = [0_u8; 8 * 1024]; let mut exceeded = false; loop { let count = reader .read(&mut buffer) .await .map_err(|error| format!("读取 Codex CLI Agent 输出失败:{error}"))?; if count == 0 { break; } let remaining = max_bytes.saturating_sub(bytes.len()); bytes.extend_from_slice(&buffer[..count.min(remaining)]); exceeded |= count > remaining; } if exceeded { return Err(format!("Codex CLI Agent 输出超过 {max_bytes} 字节上限")); } Ok(bytes) } async fn summarize_game_creator_codex_cli_stderr( mut reader: R, max_bytes: usize, ) -> Result where R: AsyncRead + Unpin, { let mut byte_len = 0_usize; let mut sha256 = Sha256::new(); let mut buffer = [0_u8; 8 * 1024]; loop { let count = reader .read(&mut buffer) .await .map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?; if count == 0 { break; } byte_len = byte_len.saturating_add(count); sha256.update(&buffer[..count]); } Ok(CodexCliStderrSummary { byte_len, sha256: format!("{:x}", sha256.finalize()), classification: if byte_len == 0 { "empty" } else if byte_len > max_bytes { "oversized" } else { "nonempty" }, }) } fn parse_game_creator_codex_cli_response( stdout: &[u8], request: &LlmRunRequest, ) -> Result { let stdout = std::str::from_utf8(stdout).map_err(|_| { platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string()) })?; let mut response_id = None; let mut final_message = None; let mut usage = None; let mut completed = false; for (index, line) in stdout.lines().enumerate() { if line.trim().is_empty() { continue; } let event = serde_json::from_str::(line).map_err(|_| { platform_llm::LlmError::Deserialize(format!( "Codex CLI Agent JSONL 第 {} 行无效", index.saturating_add(1) )) })?; match event.get("type").and_then(serde_json::Value::as_str) { Some("thread.started") => { response_id = event .get("thread_id") .and_then(serde_json::Value::as_str) .map(str::to_string); } Some("item.completed") if event .pointer("/item/type") .and_then(serde_json::Value::as_str) == Some("agent_message") => { final_message = event .pointer("/item/text") .and_then(serde_json::Value::as_str) .map(str::to_string); } Some("turn.completed") => { completed = true; let prompt_tokens = event .pointer("/usage/input_tokens") .and_then(serde_json::Value::as_u64) .unwrap_or(0); let completion_tokens = event .pointer("/usage/output_tokens") .and_then(serde_json::Value::as_u64) .unwrap_or(0); let total_tokens = prompt_tokens.saturating_add(completion_tokens); if total_tokens > 0 { usage = Some(platform_llm::LlmTokenUsage { prompt_tokens, completion_tokens, total_tokens, }); } } Some("turn.failed") => { return Err(platform_llm::LlmError::Transport( "Codex CLI Agent 返回失败终态".to_string(), )); } _ => {} } } if !completed { return Err(platform_llm::LlmError::Deserialize( "Codex CLI Agent 缺少 turn.completed 终态".to_string(), )); } let text = final_message .filter(|text| !text.trim().is_empty()) .ok_or(platform_llm::LlmError::EmptyResponse)?; let tool_calls = if request.function_tools.is_empty() { Vec::new() } else { let envelope = serde_json::from_str::(&text).map_err(|_| { platform_llm::LlmError::Deserialize( "Codex CLI Agent structured output 不是严格 JSON".to_string(), ) })?; let calls = envelope .get("toolCalls") .and_then(serde_json::Value::as_array) .ok_or_else(|| { platform_llm::LlmError::Deserialize( "Codex CLI Agent structured output 缺少 toolCalls".to_string(), ) })?; calls .iter() .enumerate() .map(|(index, call)| { let name = call .get("name") .and_then(serde_json::Value::as_str) .ok_or_else(|| { platform_llm::LlmError::Deserialize( "Codex CLI Agent tool call 缺少 name".to_string(), ) })?; let arguments = call .get("arguments") .and_then(serde_json::Value::as_str) .ok_or_else(|| { platform_llm::LlmError::Deserialize( "Codex CLI Agent tool call 缺少 arguments".to_string(), ) })?; if !request.function_tools.iter().any(|tool| tool.name == name) { return Err(platform_llm::LlmError::Deserialize( "Codex CLI Agent 返回未广告函数".to_string(), )); } Ok(platform_llm::LlmToolCall { id: format!("codex-cli-call-{}", index.saturating_add(1)), name: name.to_string(), arguments: arguments.to_string(), }) }) .collect::, _>>()? }; Ok(platform_llm::LlmRunResponse { provider: platform_llm::LlmProvider::OpenAiCompatible, model: "codex-cli".to_string(), text: if tool_calls.is_empty() { text } else { String::new() }, finish_reason: Some("stop".to_string()), response_id, usage, tool_calls, responses_output: Vec::new(), }) } async fn request_game_creator_agent_codex_cli_with_executable( executable: &std::ffi::OsStr, request: LlmRunRequest, ) -> Result { let prompt = render_game_creator_codex_cli_prompt(&request) .map_err(platform_llm::LlmError::InvalidRequest)?; let temp_dir = tempfile::Builder::new() .prefix("genarrative-agc-codex-cli-") .tempdir() .map_err(|error| { platform_llm::LlmError::Transport(format!("创建 Codex CLI Agent 临时目录失败:{error}")) })?; let schema_path = if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { let path = temp_dir.path().join("tool-output.schema.json"); let content = serde_json::to_vec(&schema).map_err(|error| { platform_llm::LlmError::InvalidRequest(format!( "序列化 Codex CLI Agent output schema 失败:{error}" )) })?; std::fs::write(&path, content).map_err(|error| { platform_llm::LlmError::Transport(format!( "写入 Codex CLI Agent output schema 失败:{error}" )) })?; Some(path) } else { None }; let mut command = tokio::process::Command::new(executable); command .arg("exec") .arg("--json") .arg("--ephemeral") .arg("--ignore-user-config") .arg("--ignore-rules") .arg("--skip-git-repo-check") .arg("--sandbox") .arg("read-only") .arg("--disable") .arg("shell_tool") .arg("--config") .arg("approval_policy=\"never\"") .arg("--cd") .arg(temp_dir.path()); if let Some(effort) = game_creator_codex_cli_reasoning_effort(&request) { command .arg("--config") .arg(format!("model_reasoning_effort=\"{effort}\"")); } if let Some(path) = schema_path.as_ref() { command.arg("--output-schema").arg(path); } command .arg("-") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); game_creator_codex_cli_minimal_environment(&mut command); configure_game_creator_codex_cli_process(&mut command); let mut child = command.spawn().map_err(|error| { platform_llm::LlmError::InvalidConfig(format!( "Codex CLI Agent 不可用,请安装并登录 Codex CLI:{error}" )) })?; let mut stdin = child.stdin.take().ok_or_else(|| { platform_llm::LlmError::Transport("Codex CLI Agent stdin 未建立".to_string()) })?; // 短生命周期 CLI 可能在 prompt 写完前退出;继续采集退出状态和脱敏 // stderr,确保调用方拿到可行动的进程错误,而不是平台相关的 BrokenPipe。 let mut stdin_error = None; if let Err(error) = stdin.write_all(prompt.as_bytes()).await { if error.kind() == std::io::ErrorKind::BrokenPipe { stdin_error = Some(format!("写入 Codex CLI Agent prompt 失败:{error}")); } else { return Err(platform_llm::LlmError::Transport(format!( "写入 Codex CLI Agent prompt 失败:{error}" ))); } } if let Err(error) = stdin.shutdown().await { if error.kind() == std::io::ErrorKind::BrokenPipe { stdin_error.get_or_insert_with(|| format!("关闭 Codex CLI Agent stdin 失败:{error}")); } else { return Err(platform_llm::LlmError::Transport(format!( "关闭 Codex CLI Agent stdin 失败:{error}" ))); } } drop(stdin); let stdout = child.stdout.take().ok_or_else(|| { platform_llm::LlmError::Transport("Codex CLI Agent stdout 未建立".to_string()) })?; let stderr = child.stderr.take().ok_or_else(|| { platform_llm::LlmError::Transport("Codex CLI Agent stderr 未建立".to_string()) })?; let stdout_task = tokio::spawn(read_game_creator_codex_cli_output( stdout, GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES, )); let stderr_task = tokio::spawn(summarize_game_creator_codex_cli_stderr( stderr, GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, )); let timeout_ms = request .request_timeout_ms .unwrap_or(GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS) .max(1); let wait = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), child.wait()).await; let (status, timed_out) = match wait { Ok(Ok(status)) => (Some(status), false), Ok(Err(error)) => { terminate_game_creator_codex_cli_process_tree(&mut child).await; return Err(platform_llm::LlmError::Transport(format!( "等待 Codex CLI Agent 退出失败:{error}" ))); } Err(_) => { terminate_game_creator_codex_cli_process_tree(&mut child).await; (None, true) } }; let stdout = stdout_task .await .map_err(|_| { platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string()) })? .map_err(platform_llm::LlmError::Transport)?; let stderr = stderr_task .await .map_err(|_| { platform_llm::LlmError::Transport("Codex CLI Agent stderr 读取任务失败".to_string()) })? .map_err(platform_llm::LlmError::Transport)?; if timed_out { return Err(platform_llm::LlmError::Timeout { attempts: 1 }); } let status = status.expect("non-timeout Codex CLI wait has exit status"); if stderr.classification == "oversized" { return Err(platform_llm::LlmError::Transport(format!( "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}", GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, stderr.classification, stderr.byte_len, stderr.sha256 ))); } if !status.success() { return Err(platform_llm::LlmError::Transport(format!( "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置", status .code() .map(|code| code.to_string()) .unwrap_or_else(|| "none".to_string()), stderr.classification, stderr.byte_len, stderr.sha256 ))); } if let Some(error) = stdin_error { return Err(platform_llm::LlmError::Transport(error)); } parse_game_creator_codex_cli_response(&stdout, &request) } pub(in crate::agent) async fn request_game_creator_agent_codex_cli( request: LlmRunRequest, ) -> Result { let executable = game_creator_codex_cli_executable_path().map_err(platform_llm::LlmError::InvalidConfig)?; request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request).await } #[cfg(test)] mod tests { use super::*; fn tool_request() -> LlmRunRequest { LlmRunRequest::single_turn("系统", "任务").with_function_tools(vec![ platform_llm::LlmFunctionTool::new( "runtime_tool_file_read", "读取文件", serde_json::json!({"type":"object"}), ), ]) } #[test] fn codex_cli_reasoning_effort_preserves_max_wire_value() { let request = LlmRunRequest::single_turn("系统", "任务") .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Max); assert_eq!( game_creator_codex_cli_reasoning_effort(&request), Some("max") ); } #[cfg(windows)] #[test] fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() { let temp = tempfile::tempdir().expect("temp dir"); let app_data = temp.path().join("app-data"); let vendor = app_data .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor"); std::fs::create_dir_all(vendor.join("z-target/bin")).expect("z target"); std::fs::create_dir_all(vendor.join("a-target/bin")).expect("a target"); let path_dir = temp.path().join("path"); std::fs::create_dir_all(&path_dir).expect("path dir"); let candidates = game_creator_codex_cli_executable_candidates_for( None, Some(&app_data), None, None, Some(path_dir.as_os_str()), ); assert_eq!( candidates[0], vendor.join("a-target/bin/codex.exe"), "native npm targets must be deterministic and precede PATH" ); assert_eq!(candidates[1], vendor.join("z-target/bin/codex.exe")); assert_eq!(candidates[2], path_dir.join("codex.exe")); assert_eq!(candidates.last(), Some(&PathBuf::from("codex"))); } #[cfg(windows)] #[test] fn codex_cli_candidates_discover_native_npm_target_from_path_without_appdata() { let temp = tempfile::tempdir().expect("temp dir"); let npm_root = temp.path().join("npm"); let native = npm_root .join("node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor") .join("x86_64-pc-windows-msvc/bin/codex.exe"); std::fs::create_dir_all(native.parent().expect("native parent")) .expect("native target directory"); let candidates = game_creator_codex_cli_executable_candidates_for( None, None, None, None, Some(npm_root.as_os_str()), ); assert_eq!(candidates[0], native); assert_eq!(candidates[1], npm_root.join("codex.exe")); } #[cfg(windows)] #[test] fn codex_cli_candidates_discover_native_npm_target_from_runtime_config_dir() { let temp = tempfile::tempdir().expect("temp dir"); let app_data = temp.path().join("roaming"); let config_dir = app_data.join("world.genarrative.ai-game-creator"); let native = app_data .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor") .join("x86_64-pc-windows-msvc/bin/codex.exe"); std::fs::create_dir_all(native.parent().expect("native parent")) .expect("native target directory"); let candidates = game_creator_codex_cli_executable_candidates_for( None, None, None, Some(&config_dir), None, ); assert_eq!(candidates[0], native); } #[cfg(windows)] #[test] fn codex_cli_candidates_discover_desktop_native_target() { let temp = tempfile::tempdir().expect("temp dir"); let local_app_data = temp.path().join("local"); let older = local_app_data.join("OpenAI/Codex/bin/111/codex.exe"); let newer = local_app_data.join("OpenAI/Codex/bin/222/codex.exe"); std::fs::create_dir_all(older.parent().expect("older parent")).expect("older dir"); std::fs::create_dir_all(newer.parent().expect("newer parent")).expect("newer dir"); let candidates = game_creator_codex_cli_executable_candidates_for( None, None, Some(&local_app_data), None, None, ); assert_eq!(candidates[0], newer); assert_eq!(candidates[1], older); } #[cfg(windows)] #[test] fn codex_cli_candidates_prefer_bundled_sidecar_before_npm_and_path() { let temp = tempfile::tempdir().expect("temp dir"); let resources = temp.path().join("resources"); let bundled = resources.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH); std::fs::create_dir_all(bundled.parent().expect("bundled parent")).expect("bundled dir"); let app_data = temp.path().join("app-data"); let native = app_data .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor") .join("x86_64-pc-windows-msvc/bin/codex.exe"); std::fs::create_dir_all(native.parent().expect("native parent")).expect("native dir"); let candidates = game_creator_codex_cli_executable_candidates_for( Some(&resources), Some(&app_data), None, None, None, ); assert_eq!(candidates[0], bundled); assert_eq!(candidates[1], native); } #[cfg(windows)] #[test] fn bundled_codex_cli_requires_matching_manifest_hash() { let temp = tempfile::tempdir().expect("temp dir"); let executable = temp .path() .join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH); std::fs::create_dir_all(executable.parent().expect("sidecar parent")).expect("sidecar dir"); let bundle_root = executable .parent() .and_then(Path::parent) .expect("bundle root"); let mut hashes = std::collections::BTreeMap::new(); for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES { let path = bundle_root.join(relative); std::fs::create_dir_all(path.parent().expect("component parent")) .expect("component dir"); let bytes = format!("trusted {relative}"); std::fs::write(&path, bytes.as_bytes()).expect("component bytes"); hashes.insert(relative, format!("{:x}", Sha256::digest(bytes.as_bytes()))); } let files = serde_json::to_string(&hashes).expect("component hashes"); std::fs::write( bundle_root.join("manifest.json"), format!( r#"{{"schemaVersion":"genarrative-codex-sidecar.v2","platform":"win32-x64","version":"codex-cli test","files":{files}}}"# ), ) .expect("sidecar manifest"); assert_eq!( validate_game_creator_bundled_codex_cli(&executable).expect("trusted sidecar"), "codex-cli test" ); std::fs::write(&executable, b"modified sidecar").expect("tamper sidecar"); assert!(validate_game_creator_bundled_codex_cli(&executable) .expect_err("tampered sidecar must be rejected") .contains("完整性校验失败")); } #[cfg(windows)] #[test] fn bundled_codex_cli_rejects_a_manifest_without_code_mode_host() { let temp = tempfile::tempdir().expect("temp dir"); let executable = temp .path() .join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH); std::fs::create_dir_all(executable.parent().expect("sidecar parent")).expect("sidecar dir"); let bundle_root = executable .parent() .and_then(Path::parent) .expect("bundle root"); let bytes = b"codex only"; std::fs::write(&executable, bytes).expect("codex bytes"); let hash = format!("{:x}", Sha256::digest(bytes)); std::fs::write( bundle_root.join("manifest.json"), format!( r#"{{"schemaVersion":"genarrative-codex-sidecar.v2","platform":"win32-x64","version":"codex-cli test","files":{{"bin/codex.exe":"{hash}"}}}}"# ), ) .expect("sidecar manifest"); assert!(validate_game_creator_bundled_codex_cli(&executable) .expect_err("missing code-mode host must fail closed") .contains("完整性清单不受支持")); } #[cfg(windows)] #[test] fn codex_cli_resolver_finds_current_native_install() { let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable"); assert!(executable.is_absolute()); assert_eq!( game_creator_codex_cli_version_identity().expect("Codex CLI version"), game_creator_codex_cli_version_at(&executable).expect("same executable version") ); } #[test] fn codex_cli_mode_renders_runtime_messages_and_structured_tool_contract() { let prompt = render_game_creator_codex_cli_prompt(&tool_request()).expect("render prompt"); assert!(prompt.contains("runtime_tool_file_read")); assert!(prompt.contains("项目行动只能通过返回 Runtime 函数请求完成")); assert!(!prompt.contains(GAME_CREATOR_CODEX_CLI_EXECUTABLE)); let schema = game_creator_codex_cli_tool_output_schema(&tool_request()).expect("schema"); assert_eq!( schema.pointer("/properties/toolCalls/items/properties/name/enum/0"), Some(&serde_json::json!("runtime_tool_file_read")) ); } #[test] fn codex_cli_mode_removes_ambient_api_key_from_effective_environment() { let mut command = tokio::process::Command::new("codex"); command.env("CODEX_API_KEY", "ambient-secret"); game_creator_codex_cli_minimal_environment(&mut command); assert!(!command .as_std() .get_envs() .any(|(name, value)| name == "CODEX_API_KEY" && value.is_some())); } #[tokio::test] async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() { let output = vec![b'x'; 33]; let error = read_game_creator_codex_cli_output(output.as_slice(), 32) .await .expect_err("unterminated output over the cap must fail"); assert!(error.contains("超过 32 字节上限")); let stderr = b"private-auth-detail-without-newline"; let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256) .await .expect("summarize stderr"); assert_eq!(summary.classification, "nonempty"); assert_eq!(summary.byte_len, stderr.len()); assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr))); assert!(!summary.sha256.contains("private-auth-detail")); } #[test] fn codex_cli_mode_maps_structured_agent_message_to_existing_tool_calls() { let stdout = concat!( "{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}\n", "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"{\\\"toolCalls\\\":[{\\\"name\\\":\\\"runtime_tool_file_read\\\",\\\"arguments\\\":\\\"{\\\\\\\"reason\\\\\\\":\\\\\\\"检查\\\\\\\",\\\\\\\"input\\\\\\\":{\\\\\\\"path\\\\\\\":\\\\\\\"game/index.html\\\\\\\"}}\\\"}]}\"}}\n", "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":7}}\n" ); let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &tool_request()) .expect("parse response"); assert_eq!(response.response_id.as_deref(), Some("thread-1")); assert_eq!(response.tool_calls.len(), 1); assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); assert_eq!(response.usage.expect("usage").total_tokens, 19); } #[test] fn codex_cli_mode_maps_plain_final_message_and_requires_terminal_event() { let request = LlmRunRequest::single_turn("系统", "任务"); let stdout = concat!( "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"完成\"}}\n", "{\"type\":\"turn.completed\",\"usage\":{}}\n" ); let response = parse_game_creator_codex_cli_response(stdout.as_bytes(), &request) .expect("parse response"); assert_eq!(response.text, "完成"); assert!(response.tool_calls.is_empty()); assert!(matches!( parse_game_creator_codex_cli_response( b"{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"x\"}}\n", &request ), Err(platform_llm::LlmError::Deserialize(_)) )); let recovered_stdout = concat!( "{\"type\":\"error\",\"message\":\"Reconnecting\"}\n", "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"恢复成功\"}}\n", "{\"type\":\"turn.completed\",\"usage\":{}}\n" ); assert_eq!( parse_game_creator_codex_cli_response(recovered_stdout.as_bytes(), &request) .expect("短暂 error 事件后完成") .text, "恢复成功" ); } #[cfg(unix)] #[tokio::test] async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); let executable = temp.path().join("fake-codex"); std::fs::write( &executable, r#"#!/bin/sh prompt="$(cat)" case "$prompt" in *"CODEx_STDIN_MARKER"*) ;; *) exit 41 ;; esac has_exec=0 has_json=0 has_ephemeral=0 has_ignore_user_config=0 has_read_only=0 has_shell_disabled=0 previous="" for argument in "$@"; do [ "$argument" = "exec" ] && has_exec=1 [ "$argument" = "--json" ] && has_json=1 [ "$argument" = "--ephemeral" ] && has_ephemeral=1 [ "$argument" = "--ignore-user-config" ] && has_ignore_user_config=1 [ "$previous:$argument" = "--sandbox:read-only" ] && has_read_only=1 [ "$previous:$argument" = "--disable:shell_tool" ] && has_shell_disabled=1 previous="$argument" done [ "$has_exec$has_json$has_ephemeral$has_ignore_user_config$has_read_only$has_shell_disabled" = "111111" ] || exit 42 printf '%s\n' '{"type":"thread.started","thread_id":"fake-thread"}' printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"fixture-ok"}}' printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":2}}' "#, ) .expect("write fake codex"); let mut permissions = std::fs::metadata(&executable) .expect("fake metadata") .permissions(); permissions.set_mode(0o700); std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); let request = LlmRunRequest::single_turn("系统", "CODEx_STDIN_MARKER"); let response = request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request) .await .expect("fake codex request"); assert_eq!(response.text, "fixture-ok"); assert_eq!(response.response_id.as_deref(), Some("fake-thread")); assert_eq!(response.usage.expect("usage").total_tokens, 5); } #[cfg(unix)] #[tokio::test] async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); let executable = temp.path().join("fake-codex-failure"); std::fs::write( &executable, "#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n", ) .expect("write fake codex"); let mut permissions = std::fs::metadata(&executable) .expect("fake metadata") .permissions(); permissions.set_mode(0o700); std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); let error = request_game_creator_agent_codex_cli_with_executable( executable.as_os_str(), LlmRunRequest::single_turn("系统", "任务"), ) .await .expect_err("fake Codex should fail") .to_string(); assert!(error.contains("code=43")); assert!(error.contains("stderrClass=nonempty")); assert!(error.contains("stderrBytes=19")); assert!(error.contains("stderrSha256=")); assert!(error.contains("登录状态")); assert!(!error.contains("secret-auth-detail")); } #[cfg(unix)] #[tokio::test] async fn codex_cli_mode_enforces_runtime_request_timeout() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); let executable = temp.path().join("fake-codex-timeout"); std::fs::write(&executable, "#!/bin/sh\ncat >/dev/null\nsleep 30\n") .expect("write fake codex"); let mut permissions = std::fs::metadata(&executable) .expect("fake metadata") .permissions(); permissions.set_mode(0o700); std::fs::set_permissions(&executable, permissions).expect("chmod fake codex"); let error = request_game_creator_agent_codex_cli_with_executable( executable.as_os_str(), LlmRunRequest::single_turn("系统", "任务").with_request_timeout_ms(20), ) .await .expect_err("fake Codex should time out"); assert_eq!(error, platform_llm::LlmError::Timeout { attempts: 1 }); } #[tokio::test] #[ignore = "requires an installed and authenticated Codex CLI"] async fn codex_cli_real_smoke_uses_existing_local_auth() { let request = LlmRunRequest::single_turn( "只回复指定文本,不使用任何工具。", "请只回复 CODEX_CLI_SMOKE_OK", ); let response = request_game_creator_agent_codex_cli(request) .await .expect("real Codex CLI request"); assert_eq!(response.text.trim(), "CODEX_CLI_SMOKE_OK"); assert!(response.tool_calls.is_empty()); } }