Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
T
suzmii 6f012d419a
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m37s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m46s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m43s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m40s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m20s
Project CI / Frontend tests (pull_request) Successful in 7m41s
Project CI / Repository checks (pull_request) Successful in 7m41s
Project CI / Native shell tests (pull_request) Successful in 11m20s
Project CI / Backend tests (pull_request) Successful in 12m11s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m12s
修复Mac客户端随包运行依赖缺失
统一Codex平台布局并补齐macOS原生组件、完整性清单和资源加载路径
补齐macOS插件资源并保留Windows专属原生桥接边界
增加隔离安装包验证、平台配置门禁与侧车回归测试
声明macOS 15最低系统版本并同步规范及Windows待验收计划
2026-09-18 11:11:07 +08:00

1276 lines
49 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};
#[path = "../../build_support/codex_bundle.rs"]
mod codex_bundle;
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
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();
if let Some(bundled) = game_creator_bundled_codex_cli_path(resource_dir) {
candidates.push(bundled);
}
#[cfg(windows)]
{
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> {
let executable = std::env::current_exe().ok()?;
game_creator_bundled_resource_dir_for(&executable)
}
fn game_creator_bundled_resource_dir_for(executable: &Path) -> Option<PathBuf> {
#[cfg(windows)]
{
executable.parent().map(Path::to_path_buf)
}
#[cfg(target_os = "macos")]
{
let macos = executable.parent()?;
let contents = macos.parent()?;
// 只接受真正的 app bundle 结构,开发态不从任意相邻目录加载程序。
if macos.file_name()? != "MacOS"
|| contents.file_name()? != "Contents"
|| contents.parent()?.extension()? != "app"
{
return None;
}
Some(contents.join("Resources"))
}
#[cfg(not(any(windows, target_os = "macos")))]
{
let _ = executable;
None
}
}
fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option<PathBuf> {
let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))?;
Some(
resource_dir?
.join("coding-agent")
.join(layout.directory)
.join(layout.executable),
)
}
fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result<String, String> {
let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))
.ok_or_else(|| "当前平台不支持内置 Codex CLI".to_string())?;
let bundle_root = executable
.parent()
.and_then(Path::parent)
.ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?;
let manifest_path = bundle_root.join("manifest.json");
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 != codex_bundle::SCHEMA
|| manifest.platform != layout.platform
|| manifest.version.trim().is_empty()
|| layout.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 layout.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()
},
reasoning: 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 = game_creator_bundled_codex_cli_path(Some(&resources)).unwrap();
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(any(windows, target_os = "macos"))]
#[test]
fn bundled_codex_cli_requires_matching_manifest_hash() {
let temp = tempfile::tempdir().expect("temp dir");
let executable = game_creator_bundled_codex_cli_path(Some(temp.path())).unwrap();
let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET")).unwrap();
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 layout.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 manifest = serde_json::json!({
"schemaVersion": codex_bundle::SCHEMA,
"platform": layout.platform,
"version": "codex-cli test",
"files": hashes,
});
let manifest_path = bundle_root.join("manifest.json");
std::fs::write(&manifest_path, manifest.to_string()).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("完整性校验失败"));
std::fs::write(&executable, format!("trusted {}", layout.executable)).unwrap();
let mut wrong_platform = manifest.clone();
wrong_platform["platform"] = serde_json::json!("wrong-platform");
std::fs::write(&manifest_path, wrong_platform.to_string()).unwrap();
assert!(validate_game_creator_bundled_codex_cli(&executable)
.expect_err("wrong platform must be rejected")
.contains("完整性清单不受支持"));
std::fs::write(&manifest_path, manifest.to_string()).unwrap();
let helper = bundle_root.join(layout.files[1]);
std::fs::write(&helper, b"tampered helper").unwrap();
assert!(validate_game_creator_bundled_codex_cli(&executable)
.expect_err("tampered helper must be rejected")
.contains("完整性校验失败"));
std::fs::remove_file(&helper).unwrap();
assert!(validate_game_creator_bundled_codex_cli(&executable)
.expect_err("missing helper must be rejected")
.contains("缺少必需组件"));
}
#[cfg(any(windows, target_os = "macos"))]
#[test]
fn bundled_codex_cli_rejects_a_manifest_without_code_mode_host() {
let temp = tempfile::tempdir().expect("temp dir");
let executable = game_creator_bundled_codex_cli_path(Some(temp.path())).unwrap();
let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET")).unwrap();
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"),
serde_json::json!({
"schemaVersion": codex_bundle::SCHEMA,
"platform": layout.platform,
"version": "codex-cli test",
"files": { layout.executable: hash },
})
.to_string(),
)
.expect("sidecar manifest");
assert!(validate_game_creator_bundled_codex_cli(&executable)
.expect_err("missing code-mode host must fail closed")
.contains("完整性清单不受支持"));
}
#[cfg(target_os = "macos")]
#[test]
fn macos_bundle_resources_and_candidates_do_not_require_a_development_path() {
let executable = Path::new("/Applications/陶泥儿 测试.app/Contents/MacOS/taonier");
let resources = game_creator_bundled_resource_dir_for(executable).unwrap();
assert_eq!(
resources,
Path::new("/Applications/陶泥儿 测试.app/Contents/Resources")
);
let bundled = game_creator_bundled_codex_cli_path(Some(&resources)).unwrap();
let candidates = game_creator_codex_cli_executable_candidates_for(
Some(&resources),
None,
None,
None,
None,
);
assert_eq!(candidates, vec![bundled, PathBuf::from("codex")]);
assert!(
game_creator_bundled_resource_dir_for(Path::new("/tmp/target/release/taonier"))
.is_none()
);
assert!(
game_creator_bundled_resource_dir_for(Path::new("/tmp/Contents/MacOS/taonier"))
.is_none()
);
assert_eq!(
game_creator_codex_cli_executable_candidates_for(None, None, None, None, None),
vec![PathBuf::from("codex")],
);
}
#[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());
}
}