Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
T
lhk229 54ae60576d 修复策划 Agent CI 回归
按 rustfmt 整理策划 Runtime 与相关测试字面量
首页做方案用例改为断言设计 Agent 命令
2026-09-10 11:19:24 +00:00

1212 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, String>,
}
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<PathBuf> {
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<PathBuf>, 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::<Vec<_>>();
targets.sort();
candidates.extend(targets);
}
}
fn append_desktop_codex_candidates(candidates: &mut Vec<PathBuf>, 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::<Vec<_>>();
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<PathBuf> {
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<PathBuf> {
#[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<PathBuf> {
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<String, String> {
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::<GameCreatorBundledCodexCliManifest>(&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<String, String> {
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<PathBuf, String> {
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<String, String> {
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<serde_json::Value> {
if request.function_tools.is_empty() {
return None;
}
let names = request
.function_tools
.iter()
.map(|tool| serde_json::Value::String(tool.name.clone()))
.collect::<Vec<_>>();
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<String, String> {
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<R>(
mut reader: R,
max_bytes: usize,
) -> Result<Vec<u8>, 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<R>(
mut reader: R,
max_bytes: usize,
) -> Result<CodexCliStderrSummary, String>
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<platform_llm::LlmRunResponse, platform_llm::LlmError> {
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::<serde_json::Value>(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::<serde_json::Value>(&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::<Result<Vec<_>, _>>()?
};
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<platform_llm::LlmRunResponse, platform_llm::LlmError> {
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<platform_llm::LlmRunResponse, platform_llm::LlmError> {
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());
}
}