Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs
T
kdletters 5550e24f83
Project CI / Repository checks (push) Successful in 1m13s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Backend tests (push) Successful in 3m57s
Project CI / Native shell tests (push) Failing after 6m11s
接入 Codex 节点 Agent 并完善总控交互
新增 Codex app-server 与 CLI 节点执行模式并保留 Provider 回退
加固节点凭据隔离、终态未知回收、进程生命周期与持久恢复边界
修复资源画布等价刷新闪烁
为 Supervisor steer 增加 LLM 回复与条件中断
补齐配置界面、测试和技术文档
2026-08-10 20:58:43 +08:00

784 lines
30 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::process::Stdio;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
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;
struct CodexCliStderrSummary {
byte_len: usize,
sha256: String,
classification: &'static str,
}
pub(in crate::agent) fn game_creator_codex_cli_version_identity() -> Result<String, String> {
let output = std::process::Command::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE)
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map_err(|_| "Codex CLI 未安装或不在当前 Agent Runner PATH 中".to_string())?;
if !output.status.success() {
return Err("Codex CLI 版本检查失败".to_string());
}
let version = std::str::from_utf8(&output.stdout)
.map_err(|_| "Codex CLI 版本信息不是 UTF-8".to_string())?
.trim();
if !version.starts_with("codex-cli ") || version.len() > 120 {
return Err("Codex CLI 返回了无法识别的版本信息".to_string());
}
Ok(version.to_string())
}
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",
})
}
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、文件、网络、MCP、插件、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,
})
}
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())
})?;
stdin.write_all(prompt.as_bytes()).await.map_err(|error| {
platform_llm::LlmError::Transport(format!("写入 Codex CLI Agent prompt 失败:{error}"))
})?;
stdin.shutdown().await.map_err(|error| {
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
)));
}
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> {
request_game_creator_agent_codex_cli_with_executable(
std::ffi::OsStr::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE),
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_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());
}
}