Merge branch 'master' into fix/chat-history-style
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 11:46:42 +08:00
188 changed files with 7161 additions and 2245 deletions
@@ -16,7 +16,7 @@ use shared_contracts::runtime::{
ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
};
use std::time::Duration;
use std::{path::Path, time::Duration};
use url::Url;
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
@@ -37,7 +37,35 @@ fn build_client() -> Result<reqwest::Client, String> {
.connect_timeout(Duration::from_secs(10))
.timeout(HTTP_TIMEOUT)
.build()
.map_err(|_| "创建账户网络客户端失败".to_string())
.map_err(|error| format!("创建账户网络客户端失败:{error}"))
}
fn describe_account_request_failure(error: &reqwest::Error) -> String {
let kind = if error.is_timeout() {
"请求超时"
} else if error.is_connect() {
"连接失败"
} else if error.is_body() {
"响应正文读取失败"
} else if error.is_request() {
"请求发送失败"
} else {
"网络请求失败"
};
let mut causes = Vec::new();
let mut source = std::error::Error::source(error);
while let Some(current) = source {
let text = current.to_string();
if !text.is_empty() && !causes.contains(&text) {
causes.push(text);
}
source = current.source();
}
if causes.is_empty() {
kind.to_string()
} else {
format!("{kind}:{}", causes.join(" → "))
}
}
fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result<String, String> {
@@ -87,30 +115,55 @@ fn error_code(body: &str) -> Option<String> {
error_field(body, "code")
}
fn safe_error_body_detail(body: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(body).ok();
let detail = value
.as_ref()
.and_then(|value| value.get("error").or(Some(value)))
.and_then(|value| serde_json::to_string(value).ok())
.or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?;
if matches!(detail.trim(), "{}" | "null" | "\"\"") {
return None;
}
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
600,
);
(!detail.trim().is_empty()).then_some(detail)
}
fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
let server_detail = error_message(body)
.or_else(|| error_code(body))
.or_else(|| safe_error_body_detail(body));
if status == StatusCode::UNAUTHORIZED {
return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string();
return match server_detail {
Some(detail) => {
format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}")
}
None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(),
};
}
if status == StatusCode::FORBIDDEN {
return format!(
"permission-denied: {}",
error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string())
server_detail.unwrap_or_else(|| "当前账号无权执行此操作".to_string())
);
}
let detail = error_message(body)
.or_else(|| error_code(body))
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
let detail = server_detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
format!("{fallback}:{detail}")
}
fn unwrap_envelope(body: &str, fallback: &str) -> Result<Value, String> {
let value: Value =
serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?;
let value: Value = serde_json::from_str(body)
.map_err(|error| format!("{fallback}:服务端响应不是合法 JSON:{error}"))?;
if value.get("ok").and_then(Value::as_bool) == Some(false) {
let detail = error_message(body)
.or_else(|| error_code(body))
.unwrap_or_else(|| "服务器未返回错误信息".to_string());
.or_else(|| safe_error_body_detail(body))
.unwrap_or_else(|| "服务端返回 ok=false,但未提供 error/code/message 详情".to_string());
return Err(format!("{fallback}:{detail}"));
}
Ok(value.get("data").cloned().unwrap_or(value))
@@ -158,22 +211,21 @@ async fn request_json<T: DeserializeOwned>(
request = request.json(&body);
}
let response = request.send().await.map_err(|error| {
if error.is_timeout() {
format!("{fallback}:请求超时,请稍后重试")
} else {
format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试")
}
format!(
"{fallback}:{};请确认配套后端或 API 代理已启动后重试",
describe_account_request_failure(&error)
)
})?;
let status = response.status();
let text = response
.text()
.await
.map_err(|_| format!("{fallback}:读取响应失败"))?;
.map_err(|error| format!("{fallback}:读取响应失败:{error}"))?;
if !status.is_success() {
return Err(map_http_error(status, &text, fallback));
}
let data = unwrap_envelope(&text, fallback)?;
serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效"))
serde_json::from_value(data).map_err(|error| format!("{fallback}:响应格式无效:{error}"))
}
#[tauri::command]
@@ -344,6 +396,14 @@ mod tests {
map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"),
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试"
);
assert_eq!(
map_http_error(
StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"access token expired"}}"#,
"读取失败",
),
"authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired"
);
assert_eq!(
map_http_error(
StatusCode::FORBIDDEN,
@@ -45,7 +45,7 @@ pub mod tool;
pub(crate) use art_manifest::*;
use claude_code_cli::*;
pub(crate) use claude_code_cli::{
cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error,
cancel_direct_claude_code_turn_at, claude_code_failure_to_turn_error,
direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat,
game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity,
};
File diff suppressed because it is too large Load Diff
@@ -367,7 +367,7 @@ impl ExecutionAdapter {
Ok::<_, String>((session.root.clone(), session))
})
.await
.map_err(|_| "宿主执行身份读取中断")??;
.map_err(|error| format!("宿主执行身份读取中断:{error}"))??;
Ok(Self::new(
root,
session,
@@ -1031,9 +1031,13 @@ impl ExecutionAdapter {
}
fn report(&self) -> String {
direct_delivery::terminal_report(&self.session).unwrap_or_else(|| {
"本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into()
})
match direct_delivery::terminal_report(&self.session) {
Ok(Some(report)) => report,
Ok(None) => "本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into(),
Err(error) => format!(
"本轮执行已停止,读取宿主交付状态失败:{error};请核对保留的证据与未完成项。"
),
}
}
async fn drain(&self) {
@@ -1132,8 +1136,9 @@ impl ExecutionAdapter {
self.interrupt("收尾仍有未结算操作,交付尚未完成。").await;
HostOutcome::Report(self.report())
}
Err(_) => {
self.interrupt("交付复核失败,已停止本轮并保留证据。").await;
Err(error) => {
let message = format!("交付复核失败:{error};已停止本轮并保留证据。");
self.interrupt(&message).await;
HostOutcome::Report(self.report())
}
}
File diff suppressed because it is too large Load Diff
@@ -35,7 +35,7 @@ pub(super) fn source_for_credential(
return Ok(CatalogSource::Bundled);
};
let auth: Value = serde_json::from_slice(auth_json)
.map_err(|_| "model-catalog-auth-invalid: 认证桥接内容无效")?;
.map_err(|error| format!("model-catalog-auth-invalid: 认证桥接内容无效:{error}"))?;
// 与 SDK resolved_mode 一样,显式 auth_mode 优先;旧文件才根据 API Key 判定。
let api_key_mode = match auth.get("auth_mode").and_then(Value::as_str) {
Some(mode) => mode == "apikey",
@@ -105,7 +105,7 @@ async fn read_bounded(mut stream: impl AsyncRead + Unpin, limit: usize) -> Resul
let count = stream
.read(&mut buffer)
.await
.map_err(|_| "model-catalog-read-failed")?;
.map_err(|error| format!("model-catalog-read-failed: {error}"))?;
if count == 0 {
return Ok(output);
}
@@ -132,36 +132,51 @@ async fn export_catalog(
if cancel.is_some_and(|flag| flag.load(Ordering::Acquire)) {
return Err("model-catalog-cancelled".into());
}
let mut child = command.spawn().map_err(|_| "model-catalog-spawn-failed")?;
let mut child = command
.spawn()
.map_err(|error| format!("model-catalog-spawn-failed: {error}"))?;
let tree = match OwnedProcessTree::attach(&child) {
Ok(tree) => tree,
Err(_) => {
Err(error) => {
let _ = child.start_kill();
let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
return Err("model-catalog-process-owner-unavailable".into());
return Err(format!("model-catalog-process-owner-unavailable: {error}"));
}
};
let result = if let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) {
let collected = async {
let (stdout, _, status) = tokio::try_join!(
let (stdout, stderr, status) = tokio::try_join!(
read_bounded(stdout, MAX_CATALOG_BYTES),
read_bounded(stderr, 64 * 1024),
async {
child
.wait()
.await
.map_err(|_| "model-catalog-wait-failed".to_string())
.map_err(|error| format!("model-catalog-wait-failed: {error}"))
},
)?;
if !status.success() {
return Err("model-catalog-export-failed".into());
let detail = String::from_utf8_lossy(&stderr).trim().to_string();
let detail = crate::sanitize_diagnostic_message(
&detail,
Some(Path::new("__agc_no_project_root__")),
);
return Err(if detail.is_empty() {
format!("model-catalog-export-failed: exitStatus={status}")
} else {
format!(
"model-catalog-export-failed: exitStatus={status}; stderr={}",
detail.chars().take(1200).collect::<String>()
)
});
}
serde_json::from_slice(&stdout).map_err(|_| "model-catalog-json-invalid".into())
serde_json::from_slice(&stdout)
.map_err(|error| format!("model-catalog-json-invalid: {error}"))
};
tokio::select! {
biased;
_ = cancelled(cancel) => Err("model-catalog-cancelled".into()),
result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err("model-catalog-timeout".into())),
result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err(format!("model-catalog-timeout: exceeded {} ms", CAPTURE_TIMEOUT.as_millis()))),
}
} else {
Err("model-catalog-stdio-unavailable".into())
@@ -169,7 +184,7 @@ async fn export_catalog(
let proof = tree
.shutdown(&mut child)
.await
.map_err(|_| "model-catalog-process-exit-unconfirmed")?;
.map_err(|error| format!("model-catalog-process-exit-unconfirmed: {error}"))?;
if !proof.main_process_exited || !proof.observed_tree_empty {
return Err("model-catalog-process-exit-unconfirmed".into());
}
@@ -329,7 +344,7 @@ pub(super) async fn capture(
if source == CatalogSource::Authenticated
&& cache_path
.try_exists()
.map_err(|_| "model-catalog-source-unavailable")?
.map_err(|error| format!("model-catalog-source-unavailable: {error}"))?
{
return Err("model-catalog-source-not-fresh: 当前回合必须使用新隔离目录".into());
}
@@ -354,9 +369,11 @@ pub(super) async fn capture(
"模型目录来源",
MAX_CATALOG_BYTES as u64,
)
.map_err(|_| "model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源")?;
let cache: Value =
serde_json::from_str(&cache_text).map_err(|_| "model-catalog-source-invalid")?;
.map_err(|error| {
format!("model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源:{error}")
})?;
let cache: Value = serde_json::from_str(&cache_text)
.map_err(|error| format!("model-catalog-source-invalid: {error}"))?;
verify_authenticated_source(&cache, &original, &bundled, started_ms, now_ms())?;
let evidence = json!({"cacheSha256":format!("{:x}", Sha256::digest(cache_text.as_bytes())),
"fetchedAt":cache.get("fetched_at"),"etagSha256":cache.get("etag").and_then(Value::as_str).map(|etag|format!("{:x}",Sha256::digest(etag.as_bytes())))});
@@ -371,8 +388,10 @@ pub(super) async fn capture(
{
return Err("model-catalog-cancelled".into());
}
let original_bytes = serde_json::to_vec(&original).map_err(|_| "model-catalog-json-invalid")?;
let derived_bytes = serde_json::to_vec(&derived).map_err(|_| "model-catalog-json-invalid")?;
let original_bytes = serde_json::to_vec(&original)
.map_err(|error| format!("model-catalog-json-invalid: {error}"))?;
let derived_bytes = serde_json::to_vec(&derived)
.map_err(|error| format!("model-catalog-json-invalid: {error}"))?;
if derived_bytes.len() > MAX_CATALOG_BYTES {
return Err("model-catalog-output-limit".into());
}
@@ -386,7 +405,8 @@ pub(super) async fn capture(
"changedField":"apply_patch_tool_type","modelCount":derived["models"].as_array().unwrap().len(),"cache":cache_evidence});
crate::write_game_creator_private_file(
&isolated_home.join("direct-model-catalog-receipt.json"),
&serde_json::to_vec(&receipt).map_err(|_| "model-catalog-receipt-invalid")?,
&serde_json::to_vec(&receipt)
.map_err(|error| format!("model-catalog-receipt-invalid: {error}"))?,
"模型目录来源回执",
)
.map_err(|error| {
@@ -47,9 +47,9 @@ fn oauth_identity(auth: &Value) -> Result<OAuthIdentity, String> {
.ok_or("model-catalog-auth-handoff-identity")?;
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.map_err(|_| "model-catalog-auth-handoff-identity")?;
let claims: Value =
serde_json::from_slice(&payload).map_err(|_| "model-catalog-auth-handoff-identity")?;
.map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?;
let claims: Value = serde_json::from_slice(&payload)
.map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?;
let claims_auth = &claims["https://api.openai.com/auth"];
let token_account = auth
.pointer("/tokens/account_id")
@@ -87,12 +87,13 @@ fn oauth_bytes(bytes: &[u8]) -> Result<(OAuthIdentity, Vec<u8>), String> {
if bytes.len() > MAX_AUTH_BYTES as usize {
return Err("model-catalog-auth-handoff-size".into());
}
let mut auth: Value =
serde_json::from_slice(bytes).map_err(|_| "model-catalog-auth-handoff-invalid")?;
let mut auth: Value = serde_json::from_slice(bytes)
.map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?;
let identity = oauth_identity(&auth)?;
// 显式 OAuth 文件可能还残留一个 API Key;私有轮换缓存从不携带它。
auth["OPENAI_API_KEY"] = Value::Null;
let bytes = serde_json::to_vec(&auth).map_err(|_| "model-catalog-auth-handoff-invalid")?;
let bytes = serde_json::to_vec(&auth)
.map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?;
Ok((identity, bytes))
}
@@ -181,7 +182,9 @@ impl OAuthHandoff {
MAX_AUTH_BYTES,
)
})
.map_err(|_| "model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态".to_string())
.map_err(|error| {
format!("model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态:{error}")
})
.and_then(|text| oauth_bytes(text.as_bytes()))
.and_then(|(identity, bytes)| {
if identity != self.identity {
@@ -30,7 +30,7 @@ impl ProcessTreeExitProof {
}
}
pub(super) struct OwnedProcessTree {
pub(crate) struct OwnedProcessTree {
pid: u32,
#[cfg(unix)]
start_identity: String,
@@ -41,20 +41,26 @@ pub(super) struct OwnedProcessTree {
impl OwnedProcessTree {
/// 调用方只能在此成功后发送 initialize,保证所有受控模型执行都在归属内。
pub(super) fn attach(child: &Child) -> Result<Self, String> {
pub(crate) fn attach(child: &Child) -> Result<Self, String> {
Self::attach_with_role(child, "Codex")
}
/// 具名角色版本:Claude Code sidecar 与 Codex app-server 都是受控执行器,
/// 共用同一套 Job Object 归属与退出证明,只是 Job 名字要能区分来源便于排障。
pub(crate) fn attach_with_role(child: &Child, role: &str) -> Result<Self, String> {
let pid = child.id().ok_or("app-server-process-owner-missing")?;
#[cfg(unix)]
let start_identity =
crate::process_identity::external_agent_runner_process_start_identity(pid)
.map_err(|_| "app-server-process-identity-unknown")?
.map_err(|error| format!("app-server-process-identity-unknown:{error}"))?
.filter(|value| !value.is_empty())
.ok_or("app-server-process-identity-unknown")?;
.ok_or_else(|| "app-server-process-identity-unknown:启动身份为空".to_string())?;
#[cfg(windows)]
let job = crate::process_session::WindowsProcessJob::assign_tokio_named(
child,
&format!("Local\\AGCCodex-{}", uuid::Uuid::new_v4()),
&format!("Local\\AGC{role}-{}", uuid::Uuid::new_v4()),
)
.map_err(|_| "app-server-process-job-unavailable")?;
.map_err(|error| format!("app-server-process-job-unavailable:{error}"))?;
#[cfg(not(any(windows, unix)))]
return Err("app-server-process-control-unsupported".into());
#[cfg(any(windows, unix))]
@@ -68,7 +74,7 @@ impl OwnedProcessTree {
})
}
pub(super) async fn shutdown(&self, child: &mut Child) -> Result<ProcessTreeExitProof, String> {
pub(crate) async fn shutdown(&self, child: &mut Child) -> Result<ProcessTreeExitProof, String> {
let mut stored = self.exit_proof.lock().await;
if let Some(proof) = stored.as_ref() {
return proof.clone();
@@ -78,7 +84,7 @@ impl OwnedProcessTree {
result
}
pub(super) async fn recorded_exit_proof(&self) -> Result<ProcessTreeExitProof, String> {
pub(crate) async fn recorded_exit_proof(&self) -> Result<ProcessTreeExitProof, String> {
self.exit_proof
.lock()
.await
@@ -91,16 +97,26 @@ impl OwnedProcessTree {
return Err("app-server-process-owner-mismatch".into());
}
let deadline = tokio::time::Instant::now() + STOP_TIMEOUT;
if self.terminate_owned_tree().is_err() {
if let Err(error) = self.terminate_owned_tree() {
// 只清理仍持有的直属 Child 句柄;不能据此报告子树已回收。
let _ = child.start_kill();
let _ = tokio::time::timeout_at(deadline, child.wait()).await;
return Err("app-server-process-tree-termination-uncertain".into());
let kill_error = child
.start_kill()
.err()
.map(|error| format!(";直属进程终止失败:{error}"))
.unwrap_or_default();
let wait_error = match tokio::time::timeout_at(deadline, child.wait()).await {
Ok(Ok(_)) => String::new(),
Ok(Err(error)) => format!(";等待直属进程退出失败:{error}"),
Err(_) => ";等待直属进程退出超时".to_string(),
};
return Err(format!(
"app-server-process-tree-termination-uncertain:{error}{kill_error}{wait_error}"
));
}
tokio::time::timeout_at(deadline, child.wait())
.await
.map_err(|_| "app-server-process-exit-timeout")?
.map_err(|_| "app-server-process-exit-unconfirmed")?;
.map_err(|_| "app-server-process-exit-timeout:等待直属进程退出超过 5 秒".to_string())?
.map_err(|error| format!("app-server-process-exit-unconfirmed:{error}"))?;
loop {
if self.observed_tree_empty()? {
break;
@@ -127,7 +143,7 @@ impl OwnedProcessTree {
{
self.job
.is_empty()
.map_err(|_| "app-server-process-tree-state-unknown".into())
.map_err(|error| format!("app-server-process-tree-state-unknown:{error}"))
}
#[cfg(unix)]
{
@@ -145,7 +161,10 @@ impl OwnedProcessTree {
if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
Ok(true)
} else {
Err("app-server-process-tree-state-unknown".into())
Err(format!(
"app-server-process-tree-state-unknown:{}",
std::io::Error::last_os_error()
))
}
}
#[cfg(not(any(windows, unix)))]
@@ -159,7 +178,7 @@ impl OwnedProcessTree {
{
self.job
.terminate()
.map_err(|_| "app-server-process-tree-termination-uncertain".into())
.map_err(|error| format!("app-server-process-tree-termination-uncertain:{error}"))
}
#[cfg(unix)]
{
@@ -169,7 +188,7 @@ impl OwnedProcessTree {
// leader 已消失或 PID 被复用时不盲杀 PGID;保留不确定状态。
let current =
crate::process_identity::external_agent_runner_process_start_identity(self.pid)
.map_err(|_| "app-server-process-identity-unknown")?;
.map_err(|error| format!("app-server-process-identity-unknown:{error}"))?;
if current.as_deref() != Some(self.start_identity.as_str()) {
return Err("app-server-process-owner-mismatch".into());
}
@@ -177,7 +196,10 @@ impl OwnedProcessTree {
if result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
Ok(())
} else {
Err("app-server-process-tree-termination-uncertain".into())
Err(format!(
"app-server-process-tree-termination-uncertain:{}",
std::io::Error::last_os_error()
))
}
}
#[cfg(not(any(windows, unix)))]
@@ -270,7 +292,9 @@ fn linux_process_group_members(
impl Drop for OwnedProcessTree {
fn drop(&mut self) {
// Drop 只做应急回收,永远不伪造完成证明;正式终态必须 await shutdown。
let _ = self.terminate_owned_tree();
if let Err(error) = self.terminate_owned_tree() {
app_log!("agent.codex_app_server.process_tree.drop_cleanup_failed error={error}");
}
}
}
@@ -805,7 +805,10 @@ impl TurnError {
native: native_kind(&detail),
}
}
LlmError::Upstream { status_code, .. } if *status_code == 409 => {
LlmError::Upstream {
status_code,
message,
} if *status_code == 409 && is_mud_points_upstream_message(message) => {
ModelCallKind::PaidCreditsInsufficient
}
LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed {
@@ -836,6 +839,15 @@ impl TurnError {
}
}
fn is_mud_points_upstream_message(message: &str) -> bool {
let normalized = message.to_ascii_lowercase();
message.contains("泥点余额不足")
|| message.contains("可消费泥点不足")
|| normalized.contains("insufficient_mud_points")
|| normalized.contains("insufficient-mud-points")
|| normalized.contains("mud points insufficient")
}
/// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::Unclassified`]。
///
/// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。
@@ -1232,9 +1244,24 @@ mod tests {
// 没有字段可读的变体只带判别键。
assert_eq!(
serde_json::to_value(TurnFailure::HostDropped).expect("serialize"),
serde_json::to_value(TurnFailure::HostDropped(crate::agent::HostDropped {
detail: None,
}))
.expect("serialize"),
serde_json::json!({ "type": "hostDropped" })
);
let historical: TurnFailure =
serde_json::from_value(serde_json::json!({ "type": "hostDropped" }))
.expect("old failure remains readable");
assert!(
matches!(historical, TurnFailure::HostDropped(payload) if payload.detail.is_none())
);
let current: TurnFailure = serde_json::from_value(serde_json::json!({
"type": "hostDropped", "detail": "宿主任务提前退出:IPC EPIPE",
}))
.expect("current failure remains readable");
assert!(matches!(current, TurnFailure::HostDropped(payload)
if payload.detail.as_deref() == Some("宿主任务提前退出:IPC EPIPE")));
}
/// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。
@@ -1361,6 +1388,27 @@ mod tests {
assert!(!projected.is_model_repairable());
}
#[test]
fn non_payment_upstream_409_keeps_the_upstream_status_and_detail() {
let projected = TurnError::from_model_call(&LlmError::Upstream {
status_code: 409,
message: "Claude Code 返回冲突(HTTP 409):session already active".into(),
});
match projected {
TurnError::ModelCallFailed(payload) => {
assert_eq!(
payload.kind,
ModelCallKind::UpstreamFailed {
status_code: 409,
native: None,
}
);
assert!(payload.detail.contains("session already active"));
}
other => panic!("expected upstream 409 failure, got {other:?}"),
}
}
#[test]
fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() {
let rate_limited = TurnError::from_model_call(&LlmError::Upstream {
@@ -149,10 +149,10 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result<String,
.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())
.map_err(|error| format!("内置 Codex CLI 缺少完整性清单:{error}"))
.and_then(|value| {
serde_json::from_str::<GameCreatorBundledCodexCliManifest>(&value)
.map_err(|_| "内置 Codex CLI 完整性清单无效".to_string())
.map_err(|error| format!("内置 Codex CLI 完整性清单无效:{error}"))
})?;
if manifest.schema_version != codex_bundle::SCHEMA
|| manifest.platform != layout.platform
@@ -167,9 +167,9 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result<String,
}
for relative in layout.files {
let path = bundle_root.join(relative);
let bytes = std::fs::read(&path).map_err(|_| {
let bytes = std::fs::read(&path).map_err(|error| {
format!(
"内置 Codex CLI 缺少必需组件:{}",
"内置 Codex CLI 缺少必需组件:{}:{error}",
path.file_name().unwrap_or_default().to_string_lossy()
)
})?;
@@ -195,7 +195,7 @@ pub(in crate::agent) fn game_creator_codex_cli_version_at(
return Err(format!("版本检查退出状态为 {}", output.status));
}
let version = std::str::from_utf8(&output.stdout)
.map_err(|_| "版本信息不是 UTF-8".to_string())?
.map_err(|error| format!("版本信息不是 UTF-8:{error}"))?
.trim();
if !version.starts_with("codex-cli ") || version.len() > 120 {
return Err("返回了无法识别的版本信息".to_string());
@@ -283,6 +283,7 @@ struct CodexCliStderrSummary {
byte_len: usize,
sha256: String,
classification: &'static str,
detail: Option<String>,
}
pub(crate) fn game_creator_codex_cli_version_identity() -> Result<String, String> {
@@ -487,18 +488,32 @@ where
{
let mut byte_len = 0_usize;
let mut sha256 = Sha256::new();
let mut preview = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let count = reader
.read(&mut buffer)
.await
.map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?;
.map_err(|error| format!("读取 Codex CLI Agent stderr 失败:{error}"))?;
if count == 0 {
break;
}
byte_len = byte_len.saturating_add(count);
sha256.update(&buffer[..count]);
let remaining = max_bytes.saturating_sub(preview.len());
preview.extend_from_slice(&buffer[..count.min(remaining)]);
}
let detail = if preview.is_empty() {
None
} else {
let text = String::from_utf8_lossy(&preview);
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
text.trim(),
480,
);
(!detail.trim().is_empty()).then_some(detail)
};
Ok(CodexCliStderrSummary {
byte_len,
sha256: format!("{:x}", sha256.finalize()),
@@ -509,6 +524,7 @@ where
} else {
"nonempty"
},
detail,
})
}
@@ -516,23 +532,34 @@ 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 stdout = std::str::from_utf8(stdout).map_err(|error| {
platform_llm::LlmError::Deserialize(format!("Codex CLI Agent JSONL 不是 UTF-8:{error}"))
})?;
let mut response_id = None;
let mut final_message = None;
let mut usage = None;
let mut completed = false;
let mut last_event_detail = None;
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(|_| {
let event = serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
line,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent JSONL 第 {} 行无效",
"Codex CLI Agent JSONL 第 {} 行无效:{error};原文={detail}",
index.saturating_add(1)
))
})?;
last_event_detail = Some(crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
line,
480,
));
match event.get("type").and_then(serde_json::Value::as_str) {
Some("thread.started") => {
response_id = event
@@ -571,17 +598,35 @@ fn parse_game_creator_codex_cli_response(
}
}
Some("turn.failed") => {
return Err(platform_llm::LlmError::Transport(
"Codex CLI Agent 返回失败终态".to_string(),
));
let mut detail = event
.get("error")
.or_else(|| event.get("message"))
.map(serde_json::Value::to_string)
.unwrap_or_default();
if detail.trim().is_empty() {
detail = serde_json::to_string(&event).unwrap_or_default();
}
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
detail.trim_matches('"'),
480,
);
return Err(platform_llm::LlmError::Transport(if detail.is_empty() {
"Codex CLI Agent 返回失败终态".to_string()
} else {
format!("Codex CLI Agent 返回失败终态:{detail}")
}));
}
_ => {}
}
}
if !completed {
return Err(platform_llm::LlmError::Deserialize(
"Codex CLI Agent 缺少 turn.completed 终态".to_string(),
));
return Err(platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent 缺少 turn.completed 终态;最后事件={}",
last_event_detail
.filter(|detail| !detail.trim().is_empty())
.unwrap_or_else(|| "<无有效事件>".to_string())
)));
}
let text = final_message
.filter(|text| !text.trim().is_empty())
@@ -589,18 +634,30 @@ fn parse_game_creator_codex_cli_response(
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 envelope = serde_json::from_str::<serde_json::Value>(&text).map_err(|error| {
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&text,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent structured output JSON 无效:{error};原文={detail}"
))
})?;
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(),
)
let detail =
serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".to_string());
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent structured output 缺少 toolCalls;原文={detail}"
))
})?;
calls
.iter()
@@ -610,17 +667,26 @@ fn parse_game_creator_codex_cli_response(
.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 detail = serde_json::to_string(call)
.unwrap_or_else(|_| "<不可序列化>".to_string());
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&detail,
480,
);
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent tool call 第 {} 项缺少 name;原文={detail}",
index.saturating_add(1)
))
})?;
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(),
)
platform_llm::LlmError::Deserialize(format!(
"Codex CLI Agent tool call 第 {} 项缺少 arguments;name={name}",
index.saturating_add(1)
))
})?;
if !request.function_tools.iter().any(|tool| tool.name == name) {
return Err(platform_llm::LlmError::Deserialize(
@@ -780,14 +846,18 @@ async fn request_game_creator_agent_codex_cli_with_executable(
};
let stdout = stdout_task
.await
.map_err(|_| {
platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string())
.map_err(|error| {
platform_llm::LlmError::Transport(format!(
"Codex CLI Agent stdout 读取任务失败:{error}"
))
})?
.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(|error| {
platform_llm::LlmError::Transport(format!(
"Codex CLI Agent stderr 读取任务失败:{error}"
))
})?
.map_err(platform_llm::LlmError::Transport)?;
if timed_out {
@@ -796,23 +866,33 @@ async fn request_game_creator_agent_codex_cli_with_executable(
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={}",
"Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}{}",
GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES,
stderr.classification,
stderr.byte_len,
stderr.sha256
stderr.sha256,
stderr
.detail
.as_deref()
.map(|detail| format!(";stderrDetail={detail}"))
.unwrap_or_default()
)));
}
if !status.success() {
return Err(platform_llm::LlmError::Transport(format!(
"Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置",
"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
stderr.sha256,
stderr
.detail
.as_deref()
.map(|detail| format!(";stderrDetail={detail}"))
.unwrap_or_default()
)));
}
if let Some(error) = stdin_error {
@@ -1126,21 +1206,24 @@ mod tests {
}
#[tokio::test]
async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() {
async fn codex_cli_mode_bounds_unterminated_output_and_keeps_sanitized_stderr() {
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 stderr = b"provider connection refused";
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"));
assert_eq!(
summary.detail.as_deref(),
Some("provider connection refused")
);
}
#[test]
@@ -1190,6 +1273,67 @@ mod tests {
);
}
#[test]
fn codex_cli_mode_preserves_failed_terminal_and_invalid_json_details() {
let request = LlmRunRequest::single_turn("系统", "任务");
let failed = parse_game_creator_codex_cli_response(
b"{\"type\":\"turn.failed\",\"error\":{\"message\":\"HTTP 502 upstream overloaded\",\"api_key\":\"fixture-secret\"}}\n",
&request,
)
.expect_err("failed terminal must be returned as an error");
let failed_text = failed.to_string();
assert!(failed_text.contains("HTTP 502 upstream overloaded"));
assert!(!failed_text.contains("fixture-secret"));
let failed_without_message = parse_game_creator_codex_cli_response(
b"{\"type\":\"turn.failed\",\"code\":\"EPIPE\",\"status\":\"closed\"}\n",
&request,
)
.expect_err("failed terminal without message must retain event detail");
let failed_without_message = failed_without_message.to_string();
assert!(failed_without_message.contains("EPIPE"));
assert!(failed_without_message.contains("closed"));
let invalid = parse_game_creator_codex_cli_response(
b"{not-json Authorization: Bearer fixture-secret}\n",
&request,
)
.expect_err("invalid JSONL must preserve a bounded safe line detail");
let invalid_text = invalid.to_string();
assert!(invalid_text.contains("JSONL 第 1 行无效"));
assert!(invalid_text.contains("原文="));
assert!(!invalid_text.contains("fixture-secret"));
}
#[test]
fn codex_cli_mode_preserves_missing_terminal_and_structured_output_details() {
let request = tool_request();
let missing_terminal = parse_game_creator_codex_cli_response(
b"{\"type\":\"item.completed\",\"item\":{\"type\":\"error\",\"message\":\"IPC EPIPE\"}}\n",
&request,
)
.expect_err("missing terminal must retain the last protocol event");
let missing_terminal = missing_terminal.to_string();
assert!(missing_terminal.contains("缺少 turn.completed 终态"));
assert!(missing_terminal.contains("IPC EPIPE"));
let malformed_event = serde_json::json!({
"type": "item.completed",
"item": { "type": "agent_message", "text": r#"{"toolCalls":["# }
});
let malformed_stdout = format!(
"{}\n{{\"type\":\"turn.completed\"}}\n",
serde_json::to_string(&malformed_event).expect("serialize malformed event")
);
let malformed_structured =
parse_game_creator_codex_cli_response(malformed_stdout.as_bytes(), &request)
.expect_err("malformed structured output must retain parse detail");
let malformed_structured = malformed_structured.to_string();
assert!(malformed_structured.contains("structured output JSON 无效"));
assert!(malformed_structured.contains("line"));
assert!(malformed_structured.contains("toolCalls"));
}
#[cfg(unix)]
#[tokio::test]
async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() {
@@ -1246,14 +1390,14 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens
#[cfg(unix)]
#[tokio::test]
async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() {
async fn codex_cli_mode_preserves_sanitized_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",
"#!/bin/sh\nprintf '%s\\n' 'Authorization: Bearer secret-auth-detail; out of memory (ENOMEM)' >&2\nexit 43\n",
)
.expect("write fake codex");
let mut permissions = std::fs::metadata(&executable)
@@ -1271,10 +1415,12 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens
.to_string();
assert!(error.contains("code=43"));
assert!(error.contains("stderrClass=nonempty"));
assert!(error.contains("stderrBytes=19"));
assert!(error.contains("stderrBytes="));
assert!(error.contains("stderrSha256="));
assert!(error.contains("out of memory (ENOMEM)"));
assert!(error.contains("[redacted-secret]"));
assert!(error.contains("登录状态"));
assert!(!error.contains("secret-auth-detail"));
assert!(!error.contains("Bearer secret-auth-detail"));
}
#[cfg(unix)]
@@ -147,11 +147,11 @@ fn normalize_codex_provider_upstream(value: &str) -> Result<String, String> {
Ok(value.to_string())
}
fn proxy_error(status: StatusCode, message: &'static str) -> Response<Body> {
fn proxy_error(status: StatusCode, message: impl Into<Body>) -> Response<Body> {
Response::builder()
.status(status)
.header("content-type", "text/plain; charset=utf-8")
.body(Body::from(message))
.body(message.into())
.unwrap_or_else(|_| Response::new(Body::empty()))
}
@@ -175,18 +175,19 @@ fn is_codex_account_limit_header(name: &HeaderName) -> bool {
name.as_str().to_ascii_lowercase().starts_with("x-codex-")
}
fn parallel_direct_request(body: &[u8]) -> Result<Vec<u8>, &'static str> {
let mut value: serde_json::Value =
serde_json::from_slice(body).map_err(|_| "provider request JSON invalid")?;
fn parallel_direct_request(body: &[u8]) -> Result<Vec<u8>, String> {
let mut value: serde_json::Value = serde_json::from_slice(body)
.map_err(|error| format!("provider request JSON invalid:{error}"))?;
let object = value
.as_object_mut()
.ok_or("provider request must be an object")?;
.ok_or_else(|| "provider request must be an object".to_string())?;
// SDK 的未知模型回退目录默认关闭并行。Direct 的宿主已负责许可、依赖和冲突,
// 因此明确请求标准 Responses 并行能力;不伪造模型名称或工具的只读标记。
object.insert("parallel_tool_calls".into(), serde_json::Value::Bool(true));
let bytes = serde_json::to_vec(&value).map_err(|_| "provider request JSON invalid")?;
let bytes = serde_json::to_vec(&value)
.map_err(|error| format!("provider request JSON serialization failed:{error}"))?;
if bytes.len() > CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES {
return Err("provider request too large");
return Err("provider request too large".to_string());
}
Ok(bytes)
}
@@ -233,15 +234,21 @@ async fn proxy_codex_provider_request(
let (parts, body) = request.into_parts();
let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await {
Ok(body) => body,
Err(_) => return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large"),
Err(error) => {
return proxy_error(
StatusCode::PAYLOAD_TOO_LARGE,
format!("provider request body read failed:{error}"),
)
}
};
let body = if state.parallel_tool_calls {
match tokio::task::spawn_blocking(move || parallel_direct_request(&body)).await {
Ok(Ok(bytes)) => axum::body::Bytes::from(bytes),
_ => {
Ok(Err(error)) => return proxy_error(StatusCode::BAD_REQUEST, error),
Err(error) => {
return proxy_error(
StatusCode::BAD_REQUEST,
"provider request JSON invalid or oversized",
StatusCode::INTERNAL_SERVER_ERROR,
format!("provider request rewrite task failed:{error}"),
)
}
}
@@ -264,10 +271,10 @@ async fn proxy_codex_provider_request(
}
let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() {
Ok(value) => value,
Err(_) => {
Err(error) => {
return proxy_error(
StatusCode::INTERNAL_SERVER_ERROR,
"provider proxy credential invalid",
format!("provider proxy credential invalid:{error}"),
)
}
};
@@ -281,7 +288,12 @@ async fn proxy_codex_provider_request(
.await
{
Ok(response) => response,
Err(_) => return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable"),
Err(error) => {
return proxy_error(
StatusCode::BAD_GATEWAY,
format!("provider upstream unavailable:{error}"),
)
}
};
let status = upstream.status();
let upstream_headers = upstream.headers().clone();
@@ -310,7 +322,12 @@ async fn proxy_codex_provider_request(
}
response
.body(Body::from_stream(stream))
.unwrap_or_else(|_| proxy_error(StatusCode::BAD_GATEWAY, "provider response invalid"))
.unwrap_or_else(|error| {
proxy_error(
StatusCode::BAD_GATEWAY,
format!("provider response invalid:{error}"),
)
})
}
#[cfg(test)]
@@ -1247,8 +1247,17 @@ async fn finish_design_command(
// panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有
// design_debug(task-local 提供项目根),不进入用户可见消息。
fn design_panic_error(_payload: Box<dyn std::any::Any + Send>) -> String {
DESIGN_PANIC_PUBLIC_ERROR.to_string()
fn design_panic_error(payload: Box<dyn std::any::Any + Send>) -> String {
let raw = payload
.downcast_ref::<&str>()
.map(|value| (*value).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_default();
let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 600);
if detail.trim().is_empty() {
return DESIGN_PANIC_PUBLIC_ERROR.to_string();
}
format!("策划运行发生内部错误:{detail};本轮已中断,可直接重试;若反复出现请反馈。")
}
#[cfg(test)]
@@ -1680,7 +1689,9 @@ pub(crate) async fn continue_design_agent_session(
input,
capture,
|event| {
let _ = app.emit("design-agent-update", event);
if let Err(error) = app.emit("design-agent-update", event) {
app_log!("design_agent.update.emit_failed detail={error}");
}
},
)
.await
@@ -1707,7 +1718,9 @@ pub(crate) async fn decide_design_phase(
approved,
capture,
|event| {
let _ = app.emit("design-agent-update", event);
if let Err(error) = app.emit("design-agent-update", event) {
app_log!("design_agent.update.emit_failed detail={error}");
}
},
)
.await
@@ -2808,10 +2821,9 @@ mod tests {
.expect("panic 必须转成可恢复视图而不是向上传播");
assert!(!view.running);
assert!(view.can_retry);
assert_eq!(
view.session.last_error.as_deref(),
Some(DESIGN_PANIC_PUBLIC_ERROR)
);
let last_error = view.session.last_error.as_deref().expect("panic detail");
assert!(last_error.contains("注入的策划工具 panic"), "{last_error}");
assert!(last_error.contains("本轮已中断"), "{last_error}");
let session = read_design_session(&root)
.expect("read session")
.expect("session exists");
@@ -499,7 +499,7 @@ pub(crate) fn list_design_workspace_files(
Ok(entries) => entries
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取工作区失败:{error}"))?,
Err(_) => continue,
Err(error) => return Err(format!("读取工作区目录失败:{error}")),
};
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
@@ -551,7 +551,7 @@ pub(crate) fn import_design_workspace_file(
) -> Result<String, String> {
let root = PathBuf::from(project_path.trim());
let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?;
let _ = app.emit(
if let Err(error) = app.emit(
"design-agent-update",
serde_json::json!({
"projectPath": root.to_string_lossy(),
@@ -561,7 +561,9 @@ pub(crate) fn import_design_workspace_file(
"text": null,
"view": null,
}),
);
) {
app_log!("design_agent.workspace.emit_failed detail={error}");
}
Ok(relative_path)
}
@@ -881,9 +883,12 @@ fn search_design_text(
return Ok(());
}
if path.is_file() {
let Ok(text) = fs::read_to_string(path) else {
return Ok(());
};
let text = fs::read_to_string(path).map_err(|error| {
format!(
"搜索工作区文件失败:{}:{error}",
design_tool_location(root, path)
)
})?;
let display = design_tool_location(root, path);
for (index, line) in text.lines().enumerate() {
if line.contains(query) {
@@ -39,7 +39,7 @@ pub(super) async fn update_plan(
input: &Value,
) -> Result<Value, String> {
let mut plan: DirectPlanInput = serde_json::from_value(input.clone())
.map_err(|_| "direct-plan-invalid: 计划只接受explanation与plan(step,status)")?;
.map_err(|error| format!("direct-plan-invalid: 计划参数解析失败:{error}"))?;
if plan.plan.is_empty() || plan.plan.len() > 32 {
return Err("direct-plan-invalid: 计划需包含1到32步".into());
}
@@ -58,11 +58,12 @@ pub(super) async fn update_plan(
}
*explanation = truncate_agent_runtime_text(explanation, 1200);
}
let value = serde_json::to_value(plan).map_err(|_| "direct-plan-invalid")?;
let value =
serde_json::to_value(plan).map_err(|error| format!("direct-plan-invalid:{error}"))?;
let session = Arc::clone(session);
tokio::task::spawn_blocking(move || session.update_plan(value))
.await
.map_err(|_| "direct-plan-worker-exited")?
.map_err(|error| format!("direct-plan-worker-exited:{error}"))?
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -199,13 +200,13 @@ struct InitialDelivery {
fn initial_delivery_path(root: &Path) -> Result<PathBuf, String> {
let root = root
.canonicalize()
.map_err(|_| "delivery-project-unavailable")?;
.map_err(|error| format!("delivery-project-unavailable:{error}"))?;
let host = crate::game_creator_runtime_config_dir()
.ok_or("delivery-host-unavailable: 缺少客户端私有目录")?;
crate::ensure_game_creator_private_directory_tree(&host, "客户端交付目录")?;
let host = host
.canonicalize()
.map_err(|_| "delivery-host-unavailable")?;
.map_err(|error| format!("delivery-host-unavailable:{error}"))?;
if host.starts_with(&root) {
return Err("delivery-host-invalid: 权威交付状态不能位于项目内".into());
}
@@ -220,14 +221,14 @@ fn initial_delivery_path(root: &Path) -> Result<PathBuf, String> {
fn read_initial_delivery(path: &Path, project_id: &str) -> Result<Option<InitialDelivery>, String> {
if !path
.try_exists()
.map_err(|_| "delivery-initial-state-unavailable")?
.map_err(|error| format!("delivery-initial-state-unavailable:{error}"))?
{
return Ok(None);
}
let value: InitialDelivery = serde_json::from_str(
&crate::read_game_creator_private_file_to_string(path, "首次交付状态", 16 * 1024)?,
)
.map_err(|_| "delivery-initial-state-invalid")?;
.map_err(|error| format!("delivery-initial-state-invalid:{error}"))?;
if value.schema_version != "agc-initial-web-delivery.v1" || value.project_id != project_id {
return Err("delivery-initial-state-identity".into());
}
@@ -249,7 +250,7 @@ fn is_new_web_delivery(root: &Path) -> Result<bool, String> {
let receipt = resolve_local_project_path(root, ".agent/runtime/web-scaffold-preparation.json")?;
if !receipt
.try_exists()
.map_err(|_| "delivery-scaffold-receipt-unavailable")?
.map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))?
{
return Ok(false);
}
@@ -257,12 +258,12 @@ fn is_new_web_delivery(root: &Path) -> Result<bool, String> {
let mut bytes = Vec::new();
file.take(16 * 1024 + 1)
.read_to_end(&mut bytes)
.map_err(|_| "delivery-scaffold-receipt-unavailable")?;
.map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))?;
if bytes.len() > 16 * 1024 {
return Err("delivery-scaffold-receipt-invalid".into());
}
let receipt: Value =
serde_json::from_slice(&bytes).map_err(|_| "delivery-scaffold-receipt-invalid")?;
let receipt: Value = serde_json::from_slice(&bytes)
.map_err(|error| format!("delivery-scaffold-receipt-invalid:{error}"))?;
let expected: BTreeMap<_, _> = crate::project::trusted_web_scaffold_files()
.into_iter()
.map(|(path, content)| (path, format!("{:x}", Sha256::digest(content.as_bytes()))))
@@ -270,7 +271,8 @@ fn is_new_web_delivery(root: &Path) -> Result<bool, String> {
if receipt["schemaVersion"] != "agc-web-scaffold-preparation.v1"
|| receipt["projectId"] != project.project_id
|| receipt["templateHashes"]
!= serde_json::to_value(expected).map_err(|_| "delivery-scaffold-template-invalid")?
!= serde_json::to_value(expected)
.map_err(|error| format!("delivery-scaffold-template-invalid:{error}"))?
{
return Err("delivery-scaffold-receipt-invalid".into());
}
@@ -281,7 +283,7 @@ fn is_new_web_delivery(root: &Path) -> Result<bool, String> {
project_id: project.project_id,
completed: false,
})
.map_err(|_| "delivery-initial-state-invalid")?,
.map_err(|error| format!("delivery-initial-state-invalid:{error}"))?,
"首次交付状态",
)?;
Ok(true)
@@ -302,7 +304,7 @@ fn mark_initial_delivered(root: &Path, ledger: &ExecutionLedger) -> Result<(), S
project_id: ledger.project_id.clone(),
completed: true,
})
.map_err(|_| "delivery-initial-state-invalid")?,
.map_err(|error| format!("delivery-initial-state-invalid:{error}"))?,
"首次交付状态",
)
}
@@ -343,7 +345,7 @@ fn evidence_file_hash(root: &Path, relative: &str) -> Result<String, String> {
loop {
let count = file
.read(&mut buffer)
.map_err(|_| "delivery-artifact-read")?;
.map_err(|error| format!("delivery-artifact-read:{error}"))?;
if count == 0 {
break;
}
@@ -409,8 +411,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result<Assessment, String> {
checks: vec![json!({"id":"contract","passed":false,"detail":"尚未登记本轮验收合同"})],
});
};
let contract: FrozenContract =
serde_json::from_value(value.clone()).map_err(|_| "delivery-frozen-contract-invalid")?;
let contract: FrozenContract = serde_json::from_value(value.clone())
.map_err(|error| format!("delivery-frozen-contract-invalid:{error}"))?;
if contract.schema_version != CONTRACT_SCHEMA {
return Err("delivery-frozen-contract-invalid".into());
}
@@ -449,8 +451,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result<Assessment, String> {
})
}
pub(super) fn terminal_report(session: &Arc<ExecutionSession>) -> Option<String> {
session.snapshot().ok()?.terminal_report
pub(super) fn terminal_report(session: &Arc<ExecutionSession>) -> Result<Option<String>, String> {
session.snapshot().map(|snapshot| snapshot.terminal_report)
}
pub(super) async fn status(root: &Path, session: &Arc<ExecutionSession>) -> Result<Value, String> {
@@ -477,7 +479,7 @@ pub(super) async fn try_seal(root: &Path, session: &Arc<ExecutionSession>) -> Re
session.begin_sealing(ledger.revision)
})
.await
.map_err(|_| "delivery-seal-worker-exited")?
.map_err(|error| format!("delivery-seal-worker-exited:{error}"))?
}
pub(super) async fn finish_sealing(
@@ -503,7 +505,7 @@ pub(super) async fn finish_sealing(
session.complete(report.clone())?;
if mark_initial_delivered(&root,&ledger).is_err() { app_log!("首次交付标记未写入,后续保持更严格的新项目验收要求"); }
Ok(Some(report))
}).await.map_err(|_| "delivery-finalize-worker-exited")?
}).await.map_err(|error| format!("delivery-finalize-worker-exited:{error}"))?
}
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
@@ -514,15 +516,28 @@ pub(super) async fn review_reply(
root: &Path,
session: &Arc<ExecutionSession>,
) -> Result<Option<String>, TurnError> {
if let Some(report) = terminal_report(session) {
let existing_report = terminal_report(session).map_err(|error| {
TurnError::turn_failed(
FailureStage::CodeGeneration,
format!("读取交付终态失败:{error}"),
)
})?;
if let Some(report) = existing_report {
return Ok(Some(report));
}
let ledger = session.snapshot()?;
if ledger.contract.is_none() {
let finished = session.finish_without_contract();
// 关闭失败时优先呈现已持久化的终态报告,保留真实失败原因。
if let Some(report) = terminal_report(session) {
return Ok(Some(report));
match terminal_report(session) {
Ok(Some(report)) => return Ok(Some(report)),
Ok(None) => {}
Err(error) => {
return Err(TurnError::turn_failed(
FailureStage::CodeGeneration,
format!("读取交付终态失败:{error}"),
))
}
}
finished?;
return Ok(None);
@@ -531,16 +546,23 @@ pub(super) async fn review_reply(
if let Some(report) = finish_sealing(root, session).await? {
return Ok(Some(report));
}
if let Some(report) = terminal_report(session) {
let terminal_report = terminal_report(session).map_err(|error| {
TurnError::turn_failed(
FailureStage::CodeGeneration,
format!("读取交付终态失败:{error}"),
)
})?;
if let Some(report) = terminal_report {
return Ok(Some(report));
}
}
let review_session = Arc::clone(session);
let review = tokio::task::spawn_blocking(move || review_session.record_delivery_review())
.await
.map_err(|_| "delivery-review-worker-exited")??;
.map_err(|error| format!("delivery-review-worker-exited:{error}"))??;
let assessment = status(root, session).await?;
let detail = serde_json::to_string(&assessment).map_err(|_| "delivery-review-invalid")?;
let detail = serde_json::to_string(&assessment)
.map_err(|error| format!("delivery-review-invalid:{error}"))?;
if review >= ledger.max_runs {
let missing = assessment
.pointer("/assessment/checks")
@@ -567,7 +589,7 @@ pub(super) async fn review_reply(
let saved = report.clone();
tokio::task::spawn_blocking(move || session.interrupt(saved))
.await
.map_err(|_| "delivery-review-worker-exited")??;
.map_err(|error| format!("delivery-review-worker-exited:{error}"))??;
return Ok(Some(report));
}
Err(TurnError::ReviewRequired {
@@ -73,10 +73,16 @@ pub(crate) fn emit_direct_active_turns_changed() {
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let _ = app.emit(
if let Err(error) = app.emit(
DIRECT_ACTIVE_TURNS_CHANGED_EVENT,
serde_json::json!({ "revision": revision }),
);
) {
app_log!(
"agent.direct_events.emit_failed event={} revision={} detail={error}",
DIRECT_ACTIVE_TURNS_CHANGED_EVENT,
revision
);
}
}
#[cfg(test)]
@@ -88,14 +94,19 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag
let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else {
return;
};
let _ = app.emit(
if let Err(error) = app.emit(
"game-creator-agent-progress",
GameCreatorAgentProgressEvent {
project_path: root.to_string_lossy().into_owned(),
stage: stage.to_string(),
message: message.to_string(),
},
);
) {
app_log!(
"agent.direct_events.emit_failed event=game-creator-agent-progress stage={} detail={error}",
stage
);
}
}
#[derive(Clone)]
@@ -204,7 +215,11 @@ pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
if envelope.token != expected_token {
continue;
}
let _ = app.emit("game-creator-manifest-invalidated", envelope.event);
if let Err(error) = app.emit("game-creator-manifest-invalidated", envelope.event) {
app_log!(
"agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}"
);
}
}
})
.map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?;
@@ -360,7 +375,11 @@ pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str
agent_id: agent_id.to_string(),
};
if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() {
let _ = app.emit("game-creator-manifest-invalidated", event);
if let Err(error) = app.emit("game-creator-manifest-invalidated", event) {
app_log!(
"agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}"
);
}
return;
}
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
@@ -171,11 +171,11 @@ struct CodexExecutorIdentity {
fn executor_digest(path: &Path) -> Result<String, String> {
use std::io::Read;
let mut file =
File::open(path).map_err(|_| "direct-execution-executor: 无法读取已选择的执行器")?;
let mut file = File::open(path)
.map_err(|error| format!("direct-execution-executor: 无法读取已选择的执行器:{error}"))?;
let before = file
.metadata()
.map_err(|_| "direct-execution-executor: 执行器身份不可用")?;
.map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?;
if !before.is_file() || before.len() > 512 * 1024 * 1024 {
return Err("direct-execution-executor: 执行器类型或大小无效".into());
}
@@ -185,7 +185,7 @@ fn executor_digest(path: &Path) -> Result<String, String> {
loop {
let read = file
.read(&mut buffer)
.map_err(|_| "direct-execution-executor: 执行器读取失败")?;
.map_err(|error| format!("direct-execution-executor: 执行器读取失败:{error}"))?;
if read == 0 {
break;
}
@@ -197,7 +197,7 @@ fn executor_digest(path: &Path) -> Result<String, String> {
}
let after = file
.metadata()
.map_err(|_| "direct-execution-executor: 执行器身份不可用")?;
.map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?;
if bytes != before.len()
|| after.len() != before.len()
|| before.modified().ok() != after.modified().ok()
@@ -421,7 +421,7 @@ fn closed_error(phase: ExecutionPhase) -> String {
pub(super) fn current(root: &Path) -> Result<Arc<ExecutionSession>, String> {
let root = root
.canonicalize()
.map_err(|_| "direct-execution-project: 项目不可用")?;
.map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?;
sessions()
.lock()
.map_err(|_| "direct-execution-state: 会话状态不可用")?
@@ -468,7 +468,7 @@ pub(super) async fn begin(
)
})
.await
.map_err(|_| "direct-execution-start: 宿主状态初始化退出")??;
.map_err(|error| format!("direct-execution-start: 宿主状态初始化退出:{error}"))??;
let root = session.root.clone();
{
let mut registry = sessions()
@@ -504,7 +504,7 @@ pub(super) fn open_with_analytics_at(
config.validate()?;
let root = root
.canonicalize()
.map_err(|_| "direct-execution-project: 项目不可用")?;
.map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?;
if turn.is_empty() || turn.len() > 512 || request_hash.len() != 64 {
return Err("direct-execution-identity: 回合身份无效".into());
}
@@ -512,17 +512,17 @@ pub(super) fn open_with_analytics_at(
return Err("direct-execution-host: 权威状态不得写入模型项目目录".into());
}
crate::ensure_game_creator_private_directory_tree(host, "宿主执行状态目录")
.map_err(|_| "direct-execution-host: 无法准备私有执行状态目录")?;
.map_err(|error| format!("direct-execution-host: 无法准备私有执行状态目录:{error}"))?;
let host = host
.canonicalize()
.map_err(|_| "direct-execution-host: 状态目录不可用")?;
.map_err(|error| format!("direct-execution-host: 状态目录不可用:{error}"))?;
if host.starts_with(&root) {
return Err("direct-execution-host: 权威状态不得写入模型项目目录".into());
}
let project_key = hash(root.to_string_lossy().as_bytes());
let directory = host.join(&project_key);
crate::ensure_game_creator_private_directory_tree(&directory, "宿主项目执行状态")
.map_err(|_| "direct-execution-host: 无法准备私有项目执行状态")?;
.map_err(|error| format!("direct-execution-host: 无法准备私有项目执行状态:{error}"))?;
let key = hash(turn.as_bytes());
let lock_path = directory.join(format!("{key}.lock"));
if lock_path.exists() {
@@ -535,7 +535,7 @@ pub(super) fn open_with_analytics_at(
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|_| "direct-execution-owner: 无法打开执行归属锁")?;
.map_err(|error| format!("direct-execution-owner: 无法打开执行归属锁:{error}"))?;
owner
.try_lock()
.map_err(|_| "direct-execution-active: 同一回合仍由其他执行器持有")?;
@@ -728,7 +728,7 @@ impl ExecutionSession {
}
next.revision = data.ledger.revision.saturating_add(1);
let bytes = serde_json::to_vec(&next)
.map_err(|_| "direct-execution-persistence: 状态序列化失败")?;
.map_err(|error| format!("direct-execution-persistence: 状态序列化失败:{error}"))?;
if bytes.len() > MAX_STATE_BYTES {
return Err("direct-execution-capacity: 宿主状态超过保留上限".into());
}
@@ -786,7 +786,10 @@ impl ExecutionSession {
}
pub(super) fn update_plan(&self, plan: Value) -> Result<Value, String> {
if !plan.is_object()
|| serde_json::to_vec(&plan).map_err(|_| "计划格式无效")?.len() > 32 * 1024
|| serde_json::to_vec(&plan)
.map_err(|error| format!("计划格式无效:{error}"))?
.len()
> 32 * 1024
{
return Err("direct-plan-invalid: 计划必须为有界对象".into());
}
@@ -191,42 +191,60 @@ fn validate_argv(executable: &Path, patch: &str) -> Result<(), String> {
Ok(())
}
fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap<String, Option<String>> {
targets
.iter()
.map(|target| {
let digest = (|| {
match std::fs::symlink_metadata(&target.absolute) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Some("missing".into())
}
Ok(_) => {}
Err(_) => return None,
}
fn target_fingerprints(
targets: &[PatchTarget],
) -> Result<BTreeMap<String, Option<String>>, String> {
let mut fingerprints = BTreeMap::new();
for target in targets {
let digest = match std::fs::symlink_metadata(&target.absolute) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some("missing".into()),
Ok(_) => {
let (mut file, metadata) =
open_project_snapshot_regular_file(&target.absolute, "补丁指纹").ok()?;
open_project_snapshot_regular_file(&target.absolute, "补丁指纹").map_err(
|error| format!("补丁指纹读取失败:{}:{error}", target.relative),
)?;
if metadata.len() > TARGET_HASH_MAX_BYTES {
return None;
return Err(format!(
"补丁指纹读取失败:目标文件 {} 大小 {} bytes,超过 {} bytes 上限",
target.relative,
metadata.len(),
TARGET_HASH_MAX_BYTES
));
}
let mut digest = Sha256::new();
let mut buffer = [0u8; 16 * 1024];
let mut total = 0u64;
loop {
let count = file.read(&mut buffer).ok()?;
let count = file.read(&mut buffer).map_err(|error| {
format!(
"补丁指纹读取失败:{}:读取文件正文失败:{error}",
target.relative
)
})?;
if count == 0 {
break;
}
total += count as u64;
if total > TARGET_HASH_MAX_BYTES {
return None;
return Err(format!(
"补丁指纹读取失败:目标文件 {} 在读取期间超过 {} bytes 上限",
target.relative, TARGET_HASH_MAX_BYTES
));
}
digest.update(&buffer[..count]);
}
Some(format!("file:{:x}", digest.finalize()))
})();
(target.relative.clone(), digest)
})
.collect()
}
Err(error) => {
return Err(format!(
"补丁指纹检查失败:{}:读取文件元数据失败:{error}",
target.relative
));
}
};
fingerprints.insert(target.relative.clone(), digest);
}
Ok(fingerprints)
}
fn analytics_patch_changes(
@@ -278,7 +296,7 @@ fn run_transaction(
let targets = collect_targets(root, &parsed.hunks)?;
let executable = session.codex_executor()?;
validate_argv(&executable, &parsed.patch)?;
let before = target_fingerprints(&targets);
let before = target_fingerprints(&targets)?;
let operation = session.admit(EffectKind::Write, None)?;
let process = runtime.block_on(crate::command_exec::run_owned_codex_patch_at(
root,
@@ -323,7 +341,7 @@ fn run_transaction(
)
}
};
let after = target_fingerprints(&targets);
let after = target_fingerprints(&targets)?;
let changed_paths = targets
.iter()
.filter(|target| {
@@ -436,6 +454,44 @@ mod tests {
(temp, root.canonicalize().unwrap())
}
#[test]
fn target_fingerprints_keep_missing_distinct_and_report_read_failures() {
let (_temp, root) = project();
let missing = PatchTarget {
relative: "game/missing.txt".into(),
absolute: root.join("game/missing.txt"),
};
let fingerprints = target_fingerprints(std::slice::from_ref(&missing)).unwrap();
assert_eq!(
fingerprints.get("game/missing.txt"),
Some(&Some("missing".into()))
);
let directory = root.join("game/fingerprint-directory");
std::fs::create_dir_all(&directory).unwrap();
let directory_target = PatchTarget {
relative: "game/fingerprint-directory".into(),
absolute: directory,
};
let error = target_fingerprints(std::slice::from_ref(&directory_target)).unwrap_err();
assert!(error.contains("补丁指纹读取失败"), "{error}");
assert!(
error.contains("打开补丁指纹失败") || error.contains("必须是普通文件"),
"{error}"
);
let oversized = root.join("game/fingerprint-oversized.bin");
let file = std::fs::File::create(&oversized).unwrap();
file.set_len(TARGET_HASH_MAX_BYTES + 1).unwrap();
let oversized_target = PatchTarget {
relative: "game/fingerprint-oversized.bin".into(),
absolute: oversized,
};
let error = target_fingerprints(std::slice::from_ref(&oversized_target)).unwrap_err();
assert!(error.contains("超过"), "{error}");
assert!(error.contains("补丁指纹读取失败"), "{error}");
}
#[test]
fn parser_paths_include_every_move_source_and_destination_before_any_write() {
let (_temp, root) = project();
@@ -52,7 +52,9 @@ struct ContextIdentity {
fn context_identity(root: &Path) -> Result<ContextIdentity, String> {
let manifest = read_existing_manifest_for_project(root)?;
let revision = read_game_creator_agent_runtime_project_revision(root)?.revision;
let canonical = root.canonicalize().map_err(|_| "项目上下文目录无法解析")?;
let canonical = root
.canonicalize()
.map_err(|error| format!("项目上下文目录无法解析:{error}"))?;
let active = list_active_turns()?.into_iter().find(|turn| {
Path::new(&turn.project_path).canonicalize().ok().as_ref() == Some(&canonical)
});
@@ -194,7 +196,7 @@ pub(super) fn external_read_is_protected(display: &str) -> bool {
}
/// O_NOFOLLOW 只保护末段;项目外路径还必须逐段拒绝目录链接与 Windows 重解析点。
pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str> {
pub(super) fn reject_external_read_links(path: &Path) -> Result<(), String> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
@@ -202,9 +204,15 @@ pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str
if matches!(component, std::path::Component::Prefix(_)) {
continue;
}
let metadata = std::fs::symlink_metadata(&current).map_err(|_| "file-not-found")?;
let metadata = std::fs::symlink_metadata(&current).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
"file-not-found".to_string()
} else {
format!("读取路径元数据失败:{error}")
}
})?;
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
return Err("linked-path");
return Err("linked-path".to_string());
}
}
Ok(())
@@ -237,50 +245,53 @@ fn normalize_requests(root: &Path, arguments: &Value) -> Result<Vec<FileRequest>
Ok(files)
}
fn bounded_bytes(root: &Path, raw: &str) -> Result<Vec<u8>, &'static str> {
let target = resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path")?;
fn bounded_bytes(root: &Path, raw: &str) -> Result<Vec<u8>, String> {
let target =
resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path".to_string())?;
let path = if let Some(relative) = target.project_relative.as_deref() {
if relative.is_empty() {
return Err("not-safe-regular-file");
return Err("not-safe-regular-file".to_string());
}
reject_agent_runtime_private_control_path(relative).map_err(|_| "private-control-path")?;
reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path")?;
reject_agent_runtime_private_control_path(relative)
.map_err(|_| "private-control-path".to_string())?;
reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path".to_string())?;
if should_skip_project_snapshot_path(relative) {
return Err("excluded-project-path");
return Err("excluded-project-path".to_string());
}
// 检查每段目录,避免父目录 junction/symlink 绕过叶子 O_NOFOLLOW。
let mut component = root.to_path_buf();
for segment in relative.split('/') {
component.push(segment);
let metadata = std::fs::symlink_metadata(&component).map_err(|_| "file-not-found")?;
let metadata = std::fs::symlink_metadata(&component)
.map_err(|error| format!("file-not-found:{error}"))?;
if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) {
return Err("linked-path");
return Err("linked-path".to_string());
}
}
component
} else {
if external_read_is_protected(&target.display) {
return Err("sensitive-path");
return Err("sensitive-path".to_string());
}
reject_external_read_links(&target.absolute)?;
target.absolute
};
let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取")
.map_err(|_| "not-safe-regular-file")?;
.map_err(|error| format!("not-safe-regular-file:{error}"))?;
if metadata.len() > MAX_FILE_BYTES as u64 {
return Err("file-too-large");
return Err("file-too-large".to_string());
}
read_bounded(file)
}
fn read_bounded(reader: impl Read) -> Result<Vec<u8>, &'static str> {
fn read_bounded(reader: impl Read) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
reader
.take(MAX_FILE_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| "file-read-failed")?;
.map_err(|error| format!("file-read-failed:{error}"))?;
if bytes.len() > MAX_FILE_BYTES {
return Err("file-grew-over-limit");
return Err("file-grew-over-limit".to_string());
}
Ok(bytes)
}
@@ -320,7 +331,7 @@ struct ReadResult {
}
fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult {
let error = |code| ReadResult {
let error = |code: String| ReadResult {
value: json!({"path":input.path,"status":"error","code":code}),
source_hash: None,
};
@@ -331,16 +342,16 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult
let source_hash = sha256(&bytes);
let text = match std::str::from_utf8(&bytes) {
Ok(text) if !text.contains('\0') => text,
_ => return error("not-utf8-text"),
_ => return error("not-utf8-text".to_string()),
};
let lines: Vec<_> = text.split_inclusive('\n').collect();
let safe_text = safe_source_text(root, text);
let safe_lines: Vec<_> = safe_text.split_inclusive('\n').collect();
if safe_lines.len() != lines.len() {
return error("redaction-line-boundary-error");
return error("redaction-line-boundary-error".to_string());
}
if input.start_line > lines.len().saturating_add(1) {
return error("line-out-of-range");
return error("line-out-of-range".to_string());
}
let start = input.start_line - 1;
let requested_count = input.max_lines.min(lines.len().saturating_sub(start));
@@ -361,7 +372,7 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult
value,
source_hash: Some(source_hash),
},
None => error("line-exceeds-response-budget"),
None => error("line-exceeds-response-budget".to_string()),
}
}
@@ -414,7 +425,7 @@ where
context_identity(&identity_root)
})
.await
.map_err(|_| "项目上下文读取任务中断")??;
.map_err(|error| format!("项目上下文读取任务中断:{error}"))??;
let item_budget = MAX_ITEM_BYTES.min(response_budget.saturating_sub(4096) / files.len());
let reader = Arc::new(reader);
let root = root.to_path_buf();
@@ -425,7 +436,7 @@ where
let path = request.path.clone();
let item = tokio::task::spawn_blocking(move || reader(&root, &request, item_budget))
.await
.map_err(|_| "文件批量读取任务中断".to_string())?;
.map_err(|error| format!("文件批量读取任务中断:{error}"))?;
Ok::<_, String>((index, path, item))
}
}))
@@ -460,10 +471,10 @@ where
.collect::<HashSet<_>>()
})
.await
.map_err(|_| "批量读取复核任务中断")?;
.map_err(|error| format!("批量读取复核任务中断:{error}"))?;
let after = tokio::task::spawn_blocking(move || context_identity(&root))
.await
.map_err(|_| "项目上下文复核任务中断")??;
.map_err(|error| format!("项目上下文复核任务中断:{error}"))??;
let stale = before.project_id != after.project_id
|| before.revision != after.revision
|| before.turn_id != after.turn_id
@@ -480,7 +491,7 @@ where
"status":if stale {"stale"} else {"ready"},"activity":before.activity,"turnStatus":before.status,
"capturedAt":unix_millis().min(u128::from(u64::MAX)) as u64,"atomicSnapshot":false,"files":files});
if serde_json::to_vec(&result)
.map_err(|_| "上下文序列化失败")?
.map_err(|error| format!("上下文序列化失败:{error}"))?
.len()
> response_budget
{
@@ -530,7 +541,7 @@ pub(super) async fn prefetch_turn_input(
.collect::<Vec<_>>()
})
.await
.map_err(|_| "项目预取扫描任务中断")?;
.map_err(|error| format!("项目预取扫描任务中断:{error}"))?;
if files.is_empty() {
return Ok(None);
}
@@ -571,7 +582,7 @@ mod tests {
let mut reader = std::io::Cursor::new(vec![b'a'; MAX_FILE_BYTES * 2]);
assert_eq!(
read_bounded(&mut reader).unwrap_err(),
"file-grew-over-limit"
"file-grew-over-limit".to_string()
);
assert_eq!(reader.position(), MAX_FILE_BYTES as u64 + 1);
}
@@ -595,7 +606,7 @@ mod tests {
] {
assert_eq!(
bounded_bytes(&root, &path.to_string_lossy()),
Err("linked-path")
Err("linked-path".to_string())
);
}
std::fs::write(base.join("ordinary.txt"), "ordinary").unwrap();
@@ -3,7 +3,7 @@ use serde_json::Value;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Default)]
#[derive(Default, Debug)]
pub(crate) struct DirectProjectHistoryAccumulator {
text_by_item_id: BTreeMap<String, String>,
}
@@ -2091,7 +2091,7 @@ fn record_direct_codex_failure_facts(
"未能保存项目诊断"
};
// sidecar 照写,但引用不进用户可见文案。
let _ = persist_agent_runtime_error(
if let Err(error) = persist_agent_runtime_error(
root,
client_turn_id,
"direct-codex",
@@ -2103,10 +2103,27 @@ fn record_direct_codex_failure_facts(
serde_json::json!({
"legacyDiagnosticWritten": diagnostic_written,
}),
)
.ok();
) {
// 失败载荷仍然会带着原始 detail 返回前端;这里额外保留统一错误事件落盘失败的
// OS/JSON/时钟正文,不能让诊断写入失败也被 `.ok()` 吞掉。
app_log!(
"agent.runtime.error_persist_failed source=direct-codex stage={} code={} detail={}",
stage,
error_code,
error
);
}
let safe_detail = redact_agent_runtime_error(root, &detail, 420)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let detail_suffix = if safe_detail.trim().is_empty() {
"详情:未提供具体错误正文".to_string()
} else {
format!("详情:{safe_detail}")
};
let public_text = format!(
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}",
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{};{}",
stage,
error_code,
retryable,
@@ -2115,6 +2132,7 @@ fn record_direct_codex_failure_facts(
.unwrap_or("未提供可安全展示的详细原因"),
recovery_hint,
diagnostics_suffix,
detail_suffix,
);
// 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。
// 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。
@@ -2549,6 +2567,53 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
.collect()
}
/// 完成判定前的输入完整性检查。
///
/// 运行态的“是否引用平台素材”是业务判定;源码读取、清单解析和已登记图片读取则是
/// 宿主 I/O。后者失败时不能把权限、损坏 JSON 或磁盘错误伪装成“没有引用素材”,否则
/// 回合会继续走同一条通用返修提示,用户看不到真正原因。
fn validate_direct_completion_inputs(root: &Path) -> Result<(), String> {
let entry = agent_runtime_game_entry_relative_path(root);
let source_paths = direct_codex_game_outputs(root)
.into_iter()
.map(|(relative_path, _, _)| relative_path)
.chain(direct_npm_source_paths(root))
.collect::<std::collections::BTreeSet<_>>();
for relative_path in source_paths {
let path = root.join(&relative_path);
if !path.exists() {
continue;
}
if !path.is_file() {
return Err(format!("读取游戏源码失败:{} 不是文件", relative_path));
}
std::fs::read_to_string(&path)
.map_err(|error| format!("读取游戏源码 {} 失败:{error}", relative_path))?;
}
if !root.join(&entry).is_file() {
return Ok(());
}
let manifest =
read_manifest_for_project(root).map_err(|error| format!("读取项目清单失败:{error}"))?;
for asset in manifest.assets.iter().filter(|asset| {
asset.media_type.starts_with("image/")
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
// 完整图集可以合法地没有切片文件;切片是可选的运行时投影,不能让它的
// 缺失覆盖真正的 spritesheet 完整性判断。
&& !asset.local_path.starts_with("assets/art-spritesheet-slices/")
}) {
let Some(relative_path) = direct_normalized_project_asset_path(&asset.local_path) else {
continue;
};
let path = root.join(&relative_path);
let bytes = std::fs::read(&path)
.map_err(|error| format!("读取已登记平台素材 {} 失败:{error}", relative_path))?;
validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {relative_path}"))
.map_err(|error| format!("校验已登记平台素材 {} 失败:{error}", relative_path))?;
}
Ok(())
}
fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec<String> {
let Ok(manifest) = read_manifest_for_project(root) else {
return Vec::new();
@@ -2667,6 +2732,9 @@ fn direct_game_output_completion_error(root: &Path) -> Option<String> {
if !root.join(entry).is_file() {
return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态"));
}
if let Err(error) = validate_direct_completion_inputs(root) {
return Some(format!("完成判定读取项目文件失败:{error}"));
}
if !direct_game_sources_reference_taonier_art_package(root) {
return Some(
"游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成"
@@ -2907,7 +2975,15 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
access.validate_frozen_session()?;
let status = response.status();
if !status.is_success() {
return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16()));
let body = response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
return Err(crate::assets::external_asset_http_failure(
"读取陶泥儿画布资源",
status,
&body,
));
}
let payload = response
.json::<serde_json::Value>()
@@ -3547,7 +3623,7 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource {
}
}
fn direct_codex_output_fingerprint(root: &Path) -> String {
fn direct_codex_output_fingerprint_checked(root: &Path) -> Result<String, String> {
let mut hasher = Sha256::new();
let mut paths: Vec<String> = direct_codex_game_outputs(root)
.into_iter()
@@ -3560,16 +3636,27 @@ fn direct_codex_output_fingerprint(root: &Path) -> String {
hasher.update(local_path.as_bytes());
hasher.update([0]);
let path = root.join(local_path);
match std::fs::read(path) {
match std::fs::read(&path) {
Ok(bytes) => {
hasher.update([1]);
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
Err(_) => hasher.update([0]),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update([0]),
Err(error) => {
return Err(format!(
"读取直连 Codex 输出文件失败:{}:{error}",
path.display()
));
}
}
}
format!("{:x}", hasher.finalize())
Ok(format!("{:x}", hasher.finalize()))
}
#[cfg(test)]
fn direct_codex_output_fingerprint(root: &Path) -> String {
direct_codex_output_fingerprint_checked(root).expect("读取直连 Codex 输出指纹")
}
fn direct_npm_source_paths(root: &Path) -> Vec<String> {
@@ -4108,7 +4195,7 @@ fn sync_direct_codex_project_file_projection_at(
root: &Path,
previous_output_fingerprint: Option<&str>,
) -> Result<(), String> {
let current_output_fingerprint = direct_codex_output_fingerprint(root);
let current_output_fingerprint = direct_codex_output_fingerprint_checked(root)?;
let outputs_changed = previous_output_fingerprint
.map(|previous| previous != current_output_fingerprint)
.unwrap_or(true);
@@ -4491,7 +4578,9 @@ async fn run_direct_game_creator_turn_inner(
direct_turn_trace("run-analytics-accepted");
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
let result: Result<String, TurnError> = async {
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
let terminal_report = super::direct_delivery::terminal_report(&execution_session)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, format!("读取交付终态失败:{error}")))?;
if let Some(report) = terminal_report {
return Ok(report);
}
// CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID
@@ -4528,7 +4617,8 @@ async fn run_direct_game_creator_turn_inner(
let stream_enabled = load_game_creator_app_config()
.map(|config| config.llm.stream)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
let previous_output_fingerprint = direct_codex_output_fingerprint(root);
let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
let base_system_prompt =
build_direct_codex_system_prompt_with_creation_type(root, creation_type)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
@@ -4631,20 +4721,28 @@ async fn run_direct_game_creator_turn_inner(
}
// 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果,
// 第二次拿到 `None` 时还会把空串当成回复返回。
Err(error) => match super::direct_delivery::terminal_report(&execution_session)
{
Some(report) => break Ok(report),
None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
&& error.is_model_repairable() =>
{
let detail =
truncate_agent_runtime_text(&error.diagnostic_detail(), 1800);
emitter.emit("running", Some("error-feedback"));
attempt += 1;
feedback_prompt = direct_codex_error_feedback_prompt(&detail);
Err(error) => {
let original_detail = error.diagnostic_detail();
match super::direct_delivery::terminal_report(&execution_session) {
Ok(Some(report)) => break Ok(report),
Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
&& error.is_model_repairable() =>
{
let detail =
truncate_agent_runtime_text(&original_detail, 1800);
emitter.emit("running", Some("error-feedback"));
attempt += 1;
feedback_prompt = direct_codex_error_feedback_prompt(&detail);
}
Ok(None) => break Err(error),
Err(report_error) => break Err(TurnError::turn_failed(
FailureStage::CodeGeneration,
format!(
"读取交付终态失败:{report_error};原始回合失败:{original_detail}"
),
)),
}
None => break Err(error),
},
}
}
};
drop(observer);
@@ -4693,19 +4791,29 @@ async fn run_direct_game_creator_turn_inner(
}
// 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None`
// 时还会绕过下面那条"未返回结果"的兜底错误)。
Err(error) => match super::direct_delivery::terminal_report(&execution_session)
{
Some(report) => break Some(report),
None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
&& error.is_model_repairable() =>
{
let detail =
truncate_agent_runtime_text(&error.diagnostic_detail(), 1800);
attempt += 1;
feedback_prompt = direct_codex_error_feedback_prompt(&detail);
Err(error) => {
let original_detail = error.diagnostic_detail();
match super::direct_delivery::terminal_report(&execution_session) {
Ok(Some(report)) => break Some(report),
Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
&& error.is_model_repairable() =>
{
let detail =
truncate_agent_runtime_text(&original_detail, 1800);
attempt += 1;
feedback_prompt = direct_codex_error_feedback_prompt(&detail);
}
Ok(None) => return Err(error),
Err(report_error) => {
return Err(TurnError::turn_failed(
FailureStage::CodeGeneration,
format!(
"读取交付终态失败:{report_error};原始回合失败:{original_detail}"
),
))
}
}
None => return Err(error),
},
}
}
};
response.ok_or_else(|| {
@@ -4721,7 +4829,9 @@ async fn run_direct_game_creator_turn_inner(
if let Some(emitter) = turn_emitter {
emitter.emit("finalizing", Some("response-finalization"));
}
if direct_codex_output_fingerprint(root) != previous_output_fingerprint {
let current_output_fingerprint = direct_codex_output_fingerprint_checked(root)
.map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?;
if current_output_fingerprint != previous_output_fingerprint {
emit_direct_game_creator_progress(
root,
"project.sync",
@@ -4863,7 +4973,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
.await
.map_err(|error| TurnError::turn_failed(FailureStage::ArtPreparation, error))?;
}
let previous_output_fingerprint = direct_codex_output_fingerprint(root);
let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
let mut system_prompt = build_direct_codex_system_prompt(root)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
if prepare_art {
@@ -4875,7 +4986,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
}
let _initial_reply =
direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()).await?;
let initial_output_fingerprint = direct_codex_output_fingerprint(root);
let initial_output_fingerprint = direct_codex_output_fingerprint_checked(root)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
let mut completion_error = direct_game_output_completion_error(root);
let mut evidence_attempts = 0_usize;
let mut evidence: Option<BrowserValidationResult> = None;
@@ -4906,7 +5018,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏");
let mut reply =
direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt).await?;
let repaired_fingerprint = direct_codex_output_fingerprint(root);
let repaired_fingerprint = direct_codex_output_fingerprint_checked(root)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
completion_error = direct_game_output_completion_error(root);
if completion_error.is_none()
&& (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str())
@@ -4937,7 +5050,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
"试玩未通过,正在进行最后一次自主修复",
);
reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt).await?;
let final_fingerprint = direct_codex_output_fingerprint(root);
let final_fingerprint = direct_codex_output_fingerprint_checked(root)
.map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?;
completion_error = direct_game_output_completion_error(root);
if completion_error.is_none()
&& browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str())
@@ -7410,6 +7524,7 @@ mod tests {
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary="));
assert!(error.contains("<redacted-url>"), "{error}");
assert!(error.contains("<absolute-path>"), "{error}");
assert!(error.contains("详情:读取陶泥儿画布资源失败"), "{error}");
assert!(!error.contains("authorization=Bearer secret"), "{error}");
assert!(!error.contains("?token=secret"), "{error}");
assert!(!error.contains("provider.example"), "{error}");
@@ -160,7 +160,7 @@ mod tests {
TurnFailure::TurnInterrupted(payload) => payload.detail.clone(),
TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(),
TurnFailure::Unclassified(payload) => payload.detail.clone(),
TurnFailure::HostDropped => String::new(),
TurnFailure::HostDropped(payload) => payload.detail.clone().unwrap_or_default(),
}
}
@@ -1053,15 +1053,15 @@ fn bridge_resource_request_fingerprint(input: &DirectResourceGenerationInput) ->
fn bridge_png_content(root: &Path, path: &Path) -> Result<String, String> {
let root = root
.canonicalize()
.map_err(|_| "工具桥项目根无法安全解析".to_string())?;
.map_err(|error| format!("工具桥项目根无法安全解析:{error}"))?;
let path = path
.canonicalize()
.map_err(|_| "工具桥图片不存在".to_string())?;
.map_err(|error| format!("工具桥图片不存在或无法解析:{error}"))?;
if !path.starts_with(&root) {
return Err("工具桥图片越出当前项目边界".to_string());
}
let metadata =
std::fs::symlink_metadata(&path).map_err(|_| "读取工具桥图片失败".to_string())?;
std::fs::symlink_metadata(&path).map_err(|error| format!("读取工具桥图片失败:{error}"))?;
if metadata.file_type().is_symlink()
|| !metadata.is_file()
|| metadata.len() > DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES
@@ -1071,7 +1071,7 @@ fn bridge_png_content(root: &Path, path: &Path) -> Result<String, String> {
let (mut file, _) = open_project_snapshot_regular_file(&path, "工具桥图片")?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|_| "读取工具桥图片失败".to_string())?;
.map_err(|error| format!("读取工具桥图片失败:{error}"))?;
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
return Err("工具桥图片不是有效 PNG".to_string());
}
@@ -3438,9 +3438,11 @@ async fn bridge_update_plan(
DirectExecutionGateRejection::SessionUnavailable { cause },
))
}
Err(_) => {
Err(error) => {
return Err(UpdatePlanError::Gate(
DirectExecutionGateRejection::SessionTaskLost,
DirectExecutionGateRejection::SessionTaskLost {
cause: error.to_string(),
},
))
}
};
@@ -3557,8 +3559,20 @@ async fn bridge_web_search_at(
cause: error.to_string(),
})?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
body.trim(),
600,
);
return Err(WebSearchError::HttpStatusNotSuccess {
status: response.status().as_u16(),
status: status.as_u16(),
detail: (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\""))
.then_some(detail),
});
}
if let Some(length) = response
@@ -3688,7 +3702,10 @@ async fn bridge_editor_execute(
))
}
Ok(Err(error)) => Err(error),
Err(_) => Err(EditorExecuteError::ExecutionUnconfirmed { editor }),
Err(error) => Err(EditorExecuteError::ExecutionUnconfirmed {
editor,
cause: error.to_string(),
}),
}
}
@@ -3922,9 +3939,9 @@ async fn bridge_cocos_call(
),
error.to_string(),
),
Err(_) => (
Err(error) => (
true,
"Cocos execute worker 退出,执行结果需要核对".to_string(),
format!("Cocos execute worker 退出:{error};执行结果需要核对"),
),
Ok(Ok(_)) => unreachable!(),
};
@@ -3987,14 +4004,16 @@ async fn handle_direct_tool_bridge(
)),
));
}
Err(_) => {
Err(error) => {
return Json(compose_direct_tool_outcome(
&state,
request.tool.as_str(),
&diagnostics_arguments,
false,
Err(ToolCallError::from(
&DirectExecutionGateRejection::PermitTaskLost,
&DirectExecutionGateRejection::PermitTaskLost {
cause: error.to_string(),
},
)),
));
}
@@ -4187,13 +4206,20 @@ async fn handle_direct_tool_bridge(
let finished =
tokio::task::spawn_blocking(move || operation.finish(passed, false, None)).await;
if !matches!(finished, Ok(Ok(()))) {
// 合并口径:结算调用沿用 master 的 `operation.finish` 形状,但失败时必须把
// 真实原因带回调用方,不能只剩一句无正文的通用失败(错误收敛分支的意图)。
let cause = match finished {
Ok(Err(error)) => format!("执行回执结算失败:{error}"),
Err(error) => format!("执行回执结算任务未返回:{error}"),
Ok(Ok(())) => "执行回执结算未确认".to_string(),
};
return Json(compose_direct_tool_outcome(
&state,
request.tool.as_str(),
&diagnostics_arguments,
dispatch_denied,
Err(ToolCallError::from(
&DirectExecutionGateRejection::ReceiptNotPersisted,
&DirectExecutionGateRejection::ReceiptNotPersisted { cause },
)),
));
}
@@ -4232,7 +4258,7 @@ async fn start_tool_bridge_for_source(
}
let root = root
.canonicalize()
.map_err(|_| "AGC 工具桥项目目录无法安全解析".to_string())?;
.map_err(|error| format!("AGC 工具桥项目目录无法安全解析:{error}"))?;
let route = format!("/tool-{}", uuid::Uuid::new_v4().simple());
let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.await
File diff suppressed because it is too large Load Diff
@@ -79,17 +79,17 @@ fn evidence_hashes(root: &Path, result: &Value) -> Result<Value, String> {
return Err("validation-evidence: 证据不属于受控验证目录".into());
}
let path = resolve_local_project_path(root, relative)?;
let meta =
std::fs::symlink_metadata(&path).map_err(|_| "validation-evidence: 证据文件丢失")?;
let meta = std::fs::symlink_metadata(&path)
.map_err(|error| format!("validation-evidence: 证据文件丢失:{error}"))?;
if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 16 * 1024 * 1024 {
return Err("validation-evidence: 证据类型或大小无效".into());
}
let mut bytes = Vec::new();
std::fs::File::open(path)
.map_err(|_| "validation-evidence: 无法读取证据")?
.map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))?
.take(16 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)
.map_err(|_| "validation-evidence: 无法读取证据")?;
.map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))?;
if bytes.len() > 16 * 1024 * 1024 {
return Err("validation-evidence: 证据文件在读取时超限".into());
}
@@ -212,14 +212,15 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result<String, String> {
let mut bytes = 0u64;
let mut visited = 0usize;
while let Some(directory) = directories.pop() {
for entry in
std::fs::read_dir(&directory).map_err(|_| "validation-fingerprint: 读取项目失败")?
for entry in std::fs::read_dir(&directory)
.map_err(|error| format!("validation-fingerprint: 读取项目失败:{error}"))?
{
visited += 1;
if visited > 20_000 {
return Err("validation-fingerprint: 项目文件数超限".into());
}
let entry = entry.map_err(|_| "validation-fingerprint: 读取目录项失败")?;
let entry = entry
.map_err(|error| format!("validation-fingerprint: 读取目录项失败:{error}"))?;
let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
if matches!(
name.as_str(),
@@ -242,7 +243,7 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result<String, String> {
}
let ty = entry
.file_type()
.map_err(|_| "validation-fingerprint: 读取类型失败")?;
.map_err(|error| format!("validation-fingerprint: 读取类型失败:{error}"))?;
let path = entry.path();
if !include_outputs
&& name == "dist"
@@ -278,14 +279,14 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result<String, String> {
if name.starts_with("game-creator.config") {
continue;
}
let mut file =
std::fs::File::open(&path).map_err(|_| "validation-fingerprint: 打开输入失败")?;
let mut file = std::fs::File::open(&path)
.map_err(|error| format!("validation-fingerprint: 打开输入失败:{error}"))?;
let mut digest = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.map_err(|_| "validation-fingerprint: 读取输入失败")?;
.map_err(|error| format!("validation-fingerprint: 读取输入失败:{error}"))?;
if read == 0 {
break;
}
@@ -358,7 +359,7 @@ async fn start_for_current_turn(
reserve(&root, session, &key, &fingerprint, &source, build)
})
.await
.map_err(|_| "验证预约任务退出".to_string())?
.map_err(|error| format!("验证预约任务退出:{error}"))?
}
async fn finish_async(
@@ -370,7 +371,7 @@ async fn finish_async(
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || finish(&root, &reservation, result, passed))
.await
.map_err(|_| "验证回执任务退出".to_string())?
.map_err(|error| format!("验证回执任务退出:{error}"))?
}
#[derive(Serialize)]
@@ -933,8 +933,8 @@ pub(crate) fn classify_external_generation_initial_response(
))
}
_ => Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}",
status.as_u16()
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {}",
format_external_http_error("平台图片生成", status, &payload.to_string())
)),
}
}
@@ -975,7 +975,7 @@ async fn accepted_generation_is_authoritatively_failed_once(
),
)
.await
.map_err(|_| "查询平台图片生成任务超时".to_string())??;
.map_err(|_| format!("查询平台图片生成任务超时(3000 ms);operationId={operation_id}"))??;
access.validate_frozen_session()?;
let generation = platform_generation_status_data(&payload);
match json_string_field(generation, "status").as_deref() {
@@ -1005,7 +1005,10 @@ pub(crate) async fn external_editor_json_request(
})?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let body = response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
return Err(format_external_http_error(action, status, &body));
}
@@ -1144,6 +1147,27 @@ fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Ve
}
}
fn summarize_external_generation_failure(generation: &serde_json::Value) -> String {
let source = generation.get("error").unwrap_or(generation);
let mut fields = Vec::new();
collect_external_http_error_fields(source, &mut fields);
let detail = if fields.is_empty() {
match source {
serde_json::Value::String(value) => value.trim().to_string(),
_ => serde_json::to_string(source).unwrap_or_default(),
}
} else {
fields.join(";")
};
let detail = redact_secret_tokens(&redact_absolute_path_tokens(&detail));
let detail = detail.trim().chars().take(600).collect::<String>();
if detail.is_empty() {
"平台图片生成任务返回 failed,但响应缺少 error 详情".to_string()
} else {
detail
}
}
pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 {
external_editor_response_data(payload)
.get("pollAfterMs")
@@ -1273,8 +1297,7 @@ async fn wait_for_external_generation_result_inner(
return Ok(result);
}
Some("failed") => {
let error = json_string_field(generation, "error")
.unwrap_or_else(|| "服务器未返回错误信息".to_string());
let error = summarize_external_generation_failure(generation);
return Err(format!(
"平台图片生成任务失败:{error};operationId={operation_id}"
));
@@ -1341,9 +1364,14 @@ pub(crate) async fn submit_external_generation_request(
)
})?;
if response.status().is_server_error() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知",
response.status().as_u16()
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 {},服务端是否已产生副作用未知",
format_external_http_error("平台图片生成", status, &body)
));
}
Ok(response)
@@ -1371,11 +1399,14 @@ async fn resume_prepared_external_generation_at(
let status = response.status();
if !status.is_success() {
post_submit_session?;
let body = response.text().await.unwrap_or_default();
let body = response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留",
status.as_u16()
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 {};原生成账本已保留",
format_external_http_error("External Editor prepared 恢复提交", status, &body)
));
}
let submission_payload_result = response
@@ -1594,7 +1625,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
return Err("绑定素材目录不存在,无法继续生成".to_string());
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let body = response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
return Err(format_external_http_error(
"读取绑定素材目录",
@@ -1948,7 +1982,7 @@ fn read_validated_platform_art_reference_at(
}
let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?;
let decoded = image::load_from_memory(&bytes)
.map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?;
.map_err(|error| format!("参考素材不是可解析图片:{}:{error}", source.local_path))?;
Ok((bytes, decoded))
}
@@ -2092,11 +2126,11 @@ async fn upload_manifest_asset_remote_reference_at(
};
let upload_url =
validate_external_asset_download_url(&ticket.host, access.api_base_url(), true)
.map_err(|_| "当前账号参考图上传地址不安全".to_string())?;
.map_err(|error| format!("当前账号参考图上传地址无效:{error}"))?;
let upload_client =
build_external_asset_download_client(&upload_url, access.api_base_url(), true)
.await
.map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?;
.map_err(|error| format!("无法创建当前账号参考图上传客户端:{error}"))?;
access.validate_frozen_session()?;
let form = ticket
.form_fields
@@ -2109,17 +2143,23 @@ async fn upload_manifest_asset_remote_reference_at(
// 请求发出前就结束生命周期。
.file_name(file_name.clone())
.mime_str(&source.media_type)
.map_err(|_| "参考图媒体类型不能用于上传".to_string())?;
.map_err(|error| format!("参考图媒体类型不能用于上传:{error}"))?;
let upload_response = upload_client
.post(upload_url)
.multipart(form.part("file", part))
.send()
.await
.map_err(|_| "上传当前账号参考图失败".to_string())?;
.map_err(|error| format!("上传当前账号参考图失败:{error}"))?;
if upload_response.status().as_u16() != ticket.success_action_status {
return Err(format!(
"上传当前账号参考图失败:HTTP {}",
upload_response.status().as_u16()
let status = upload_response.status();
let body = upload_response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
return Err(crate::assets::external_asset_http_failure(
"上传当前账号参考图",
status,
&body,
));
}
access.validate_frozen_session()?;
@@ -3505,7 +3545,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
if !status.is_success() {
post_submit_session?;
binding_access.validate_frozen_session()?;
let body = response.text().await.unwrap_or_default();
let body = response
.text()
.await
.unwrap_or_else(|error| format!("响应正文读取失败:{error}"));
crate::platform_maintenance::watch_platform_response(status.as_u16(), &body);
binding_access.validate_frozen_session()?;
if external_generation_submit_rejection_is_definitive(status) {
@@ -8523,6 +8566,21 @@ mod canvas_generation_tests {
ColorType, ImageEncoder,
};
#[test]
fn failed_generation_without_error_field_keeps_response_envelope_detail() {
let detail = summarize_external_generation_failure(&serde_json::json!({
"status": "failed",
"code": "provider-rejected",
"message": "上游拒绝请求",
"operationId": "private-operation-id",
"api_key": "private-key"
}));
assert!(detail.contains("provider-rejected"), "{detail}");
assert!(detail.contains("上游拒绝请求"), "{detail}");
assert!(!detail.contains("private-operation-id"), "{detail}");
assert!(!detail.contains("private-key"), "{detail}");
}
#[test]
fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() {
assert_eq!(
@@ -12751,6 +12809,7 @@ mod canvas_generation_tests {
.await;
server.join().expect("join accepted failure fixture");
assert!(error.contains("平台图片生成任务失败"), "{error}");
assert!(error.contains("provider rejected request"), "{error}");
assert!(request_receiver
.recv_timeout(Duration::from_secs(1))
.expect("failed operation request")

Some files were not shown because too many files have changed in this diff Show More