合并 origin/master 到 chore/rust-compile-warnings:按 M/D/G 分桶解冲突
origin/master = 441be8d62(落后 143 提交);合并前本分支 HEAD = aeebbee28。
冲突 11 处,按「master 侧是否活跃」分三桶处理。
M 桶(master 改过该文件 → 取 master 版本,不把我们的删除加回去):
- agent/codex_app_server/mod.rs(master +348/-108)
- agent/codex_app_server/model_catalog.rs(master +38/-18:CatalogSource::Authenticated / source_for_credential / verify_authenticated_source)
- agent/codex_app_server/model_catalog/auth_handoff.rs(modify/delete → 保留 master 版,OAuth 轮换交接)
- agent/codex_cli.rs(master +190/-44)
- agent/codex_app_server/model_catalog/real_tests.rs:我们把该文件裁到 59 行以适配自建的 capture 签名;master 的 model_catalog.rs 恢复 4 参 capture 后取回 master 的 244 行用例,否则 --all-targets 报 2 处 E0061。
D 桶(master 未动过、我们删除的死码 → 保留删除):
- 合并前已删:patchset.rs、git_inspect.rs、repository_context.rs、generation/pass_artifacts.rs、agent/runtime_models.rs、debug.rs(+debug/debug_drafts.rs) 等。
- process_session/{io,lifecycle,persistence,recovery}.rs 与 process_session/tests/owner_fixture_cleanup.rs → 裁决为「保留删除」:master 在这 4 个文件里只改「死码的错误正文」;实测整棵子树在 AGC 生产构建里 69 条 dead_code、且只被 tests.rs 引用;生产只用到 recovery.rs 的 2 个 shutdown 函数,而那 2 个我们已搬进 process_session/shutdown.rs。
G 桶(双方都改、我们那侧非语义 → 以 master 语义为准,手工合并):
- agent.rs:取 master 的 claude_code_failure_to_turn_error(改名,codex_app_server/mod.rs:5323 依赖该再导出);保留我们的模块集合(provider_request_snapshot,去掉 runtime_models 与 dispatch 的再导出)。
- thread_manager/dispatch.rs:master 的 host_dropped_with_detail(panic 详情)+ 我们的 cfg(not(test))/cfg(test) 收口(测试态不读兜底结果,避免 harness 未用变量告警)。
- generation/llm_request.rs:取 master 的 AgentProgressEmitter(其唯一消费方 maybe_generate_platform_art_asset_step 已在我们那侧删除,随之成为残留,交由后续提交处理)。
- process_session/model.rs:master 的 8 处错误正文(WindowsProcessJob 的 last_os_error)**全部落在我们未裁的区间**,逐 hunk 叠加到我们的 382 行版本(结果 406 行;last_os_error 出现次数与 master 版一致,均为 13)。
- process_session/shutdown.rs:把 master 在 recovery.rs 里给 shutdown 广播加的诊断搬进我们的实现(`process_session.shutdown.send_failed processId=… detail=…`;收敛后的 LiveProcessSession 不再有 process_id 字段,进程身份取注册表 key)。
- docs/project-memory/shared-memory/pitfalls.md:两侧各新增一节,两节都保留。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -44,7 +44,7 @@ use art_manifest::*;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use claude_code_cli::direct_game_creator_claude_code_home_chat;
|
||||
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, 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
@@ -1,5 +1,7 @@
|
||||
//! 每个 Direct 用户回合冻结 SDK 自己解析的完整目录;只移除原生串行补丁注册。
|
||||
use super::OwnedProcessTree;
|
||||
mod auth_handoff;
|
||||
use super::{CodexAppServerCredential, OwnedProcessTree};
|
||||
pub(super) use auth_handoff::OAuthHandoff;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
@@ -7,7 +9,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -16,6 +18,38 @@ const CAPTURE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// 与构建期写入侧车清单的版本同源,避免两处固定版本漂移。
|
||||
const VERSION: &str = super::super::codex_cli::codex_bundle::VERSION;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum CatalogSource {
|
||||
Bundled,
|
||||
Authenticated,
|
||||
}
|
||||
|
||||
pub(super) fn source_for_credential(
|
||||
credential: &CodexAppServerCredential,
|
||||
proxied: bool,
|
||||
) -> Result<CatalogSource, String> {
|
||||
if proxied {
|
||||
return Ok(CatalogSource::Bundled);
|
||||
}
|
||||
let CodexAppServerCredential::AuthBridge { auth_json, .. } = credential else {
|
||||
return Ok(CatalogSource::Bundled);
|
||||
};
|
||||
let auth: Value = serde_json::from_slice(auth_json)
|
||||
.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",
|
||||
None => auth
|
||||
.get("OPENAI_API_KEY")
|
||||
.is_some_and(|value| !value.is_null()),
|
||||
};
|
||||
Ok(if api_key_mode {
|
||||
CatalogSource::Bundled
|
||||
} else {
|
||||
CatalogSource::Authenticated
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn direct_turn_pool_key(route_key: &str, client_turn_id: &str) -> String {
|
||||
format!(
|
||||
"{route_key}:turn:{:x}",
|
||||
@@ -71,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);
|
||||
}
|
||||
@@ -98,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())
|
||||
@@ -135,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());
|
||||
}
|
||||
@@ -168,6 +217,69 @@ fn validate_models(catalog: &Value) -> Result<&Vec<Value>, String> {
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
fn without_legacy_instructions(models: &[Value]) -> Vec<Value> {
|
||||
models
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|mut model| {
|
||||
if let Some(model) = model.as_object_mut() {
|
||||
model.remove("base_instructions");
|
||||
}
|
||||
model
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn verify_authenticated_source(
|
||||
cache: &Value,
|
||||
exported: &Value,
|
||||
bundled: &Value,
|
||||
started_ms: i64,
|
||||
completed_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
if cache.get("client_version").and_then(Value::as_str) != Some(VERSION) {
|
||||
return Err("model-catalog-source-version-mismatch".into());
|
||||
}
|
||||
let fetched = cache
|
||||
.get("fetched_at")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok())
|
||||
.map(|time| time.timestamp_millis())
|
||||
.ok_or("model-catalog-source-time-invalid")?;
|
||||
if fetched < started_ms.saturating_sub(1000) || fetched > completed_ms.saturating_add(1000) {
|
||||
return Err("model-catalog-source-stale".into());
|
||||
}
|
||||
let raw = cache
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("model-catalog-source-models-invalid")?;
|
||||
let actual = without_legacy_instructions(validate_models(exported)?);
|
||||
let raw = without_legacy_instructions(raw);
|
||||
if !raw.is_empty() && raw == actual {
|
||||
return Ok(());
|
||||
}
|
||||
// SDK 对空目录或非 ChatGPT 可见目录会与 bundled 合并,仍必须逐字段相等。
|
||||
let mut merged = without_legacy_instructions(validate_models(bundled)?);
|
||||
for model in raw {
|
||||
let slug = model
|
||||
.get("slug")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("model-catalog-source-model-invalid")?;
|
||||
if let Some(index) = merged
|
||||
.iter()
|
||||
.position(|entry| entry.get("slug").and_then(Value::as_str) == Some(slug))
|
||||
{
|
||||
merged[index] = model;
|
||||
} else {
|
||||
merged.push(model);
|
||||
}
|
||||
}
|
||||
if merged != actual {
|
||||
return Err("model-catalog-source-content-mismatch".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn derive_catalog(original: &Value) -> Result<Value, String> {
|
||||
validate_models(original)?;
|
||||
let mut derived = original.clone();
|
||||
@@ -193,22 +305,82 @@ fn derive_catalog(original: &Value) -> Result<Value, String> {
|
||||
Ok(derived)
|
||||
}
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
.min(i64::MAX as u128) as i64
|
||||
}
|
||||
|
||||
fn catalog_io_error(code: &str, error: &str, private_root: &Path) -> String {
|
||||
let detail = crate::sanitize_diagnostic_message(error, Some(private_root));
|
||||
format!("{code}: {}", detail.chars().take(1200).collect::<String>())
|
||||
}
|
||||
|
||||
/// 只接收本回合可信 SDK 在新私有 HOME 中创建/替换、且子进程已退出的固定工件。
|
||||
/// Windows 提升权限 token 的默认 owner 可能仍为 Administrators;先拒绝链接,再初始化归属。
|
||||
fn harden_sdk_private_artifact(home: &Path, name: &str) -> Result<(), String> {
|
||||
if !matches!(name, "models_cache.json" | "auth.json") {
|
||||
return Err("model-catalog-artifact-not-owned".into());
|
||||
}
|
||||
crate::prepare_game_creator_private_path_for_read(home, true, "当前回合 SDK 私有目录")?;
|
||||
let path = home.join(name);
|
||||
let (file, _) =
|
||||
crate::project::open_project_snapshot_regular_file(&path, "当前回合 SDK 私有工件")?;
|
||||
crate::harden_new_game_creator_private_path(&path, false, "当前回合 SDK 私有工件")?;
|
||||
drop(file);
|
||||
crate::prepare_game_creator_private_path_for_read(&path, false, "当前回合 SDK 私有工件")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn capture(
|
||||
app_server_command: &Command,
|
||||
isolated_home: &Path,
|
||||
source: CatalogSource,
|
||||
cancel: Option<Arc<AtomicBool>>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let original = Box::pin(export_catalog(
|
||||
let cache_path = isolated_home.join("models_cache.json");
|
||||
if source == CatalogSource::Authenticated
|
||||
&& cache_path
|
||||
.try_exists()
|
||||
.map_err(|error| format!("model-catalog-source-unavailable: {error}"))?
|
||||
{
|
||||
return Err("model-catalog-source-not-fresh: 当前回合必须使用新隔离目录".into());
|
||||
}
|
||||
let bundled = Box::pin(export_catalog(
|
||||
capture_command(app_server_command, true)?,
|
||||
cancel.as_ref(),
|
||||
))
|
||||
.await?;
|
||||
validate_models(&original)?;
|
||||
validate_models(&bundled)?;
|
||||
let started_ms = now_ms();
|
||||
let (original, cache_evidence) = if source == CatalogSource::Authenticated {
|
||||
let original = Box::pin(export_catalog(
|
||||
capture_command(app_server_command, false)?,
|
||||
cancel.as_ref(),
|
||||
))
|
||||
.await?;
|
||||
harden_sdk_private_artifact(isolated_home, "models_cache.json").map_err(|error| {
|
||||
catalog_io_error("model-catalog-source-unconfirmed", &error, isolated_home)
|
||||
})?;
|
||||
let cache_text = crate::read_game_creator_private_file_to_string(
|
||||
&cache_path,
|
||||
"模型目录来源",
|
||||
MAX_CATALOG_BYTES as u64,
|
||||
)
|
||||
.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())))});
|
||||
(original, evidence)
|
||||
} else {
|
||||
(bundled, Value::Null)
|
||||
};
|
||||
let derived = derive_catalog(&original)?;
|
||||
if cancel
|
||||
.as_ref()
|
||||
@@ -216,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());
|
||||
}
|
||||
@@ -225,13 +399,14 @@ pub(super) async fn capture(
|
||||
crate::write_game_creator_private_file(&path, &derived_bytes, "当前回合模型目录")
|
||||
.map_err(|error| catalog_io_error("model-catalog-write-failed", &error, isolated_home))?;
|
||||
let receipt = json!({"schemaVersion":"agc-direct-model-catalog.v1","codexVersion":VERSION,
|
||||
"source":"bundled",
|
||||
"source":if source == CatalogSource::Bundled {"bundled"} else {"authenticated-fresh-cache"},
|
||||
"originalSha256":format!("{:x}",Sha256::digest(&original_bytes)),
|
||||
"derivedSha256":format!("{:x}",Sha256::digest(&derived_bytes)),
|
||||
"changedField":"apply_patch_tool_type","modelCount":derived["models"].as_array().unwrap().len(),"cache":Value::Null});
|
||||
"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| {
|
||||
@@ -266,6 +441,56 @@ mod tests {
|
||||
assert!(derive_catalog(&duplicate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_cache_requires_current_version_timestamp_and_exact_metadata() {
|
||||
let original = catalog();
|
||||
let mut cache = json!({"client_version":VERSION,"fetched_at":"2026-09-20T00:00:00Z","models":original["models"]});
|
||||
let time = chrono::DateTime::parse_from_rfc3339("2026-09-20T00:00:00Z")
|
||||
.unwrap()
|
||||
.timestamp_millis();
|
||||
verify_authenticated_source(&cache, &original, &original, time, time + 100).unwrap();
|
||||
assert!(verify_authenticated_source(
|
||||
&cache,
|
||||
&original,
|
||||
&original,
|
||||
time + 300_000,
|
||||
time + 301_000
|
||||
)
|
||||
.is_err());
|
||||
cache["client_version"] = json!("0.148.0");
|
||||
assert!(
|
||||
verify_authenticated_source(&cache, &original, &original, time, time + 100).is_err()
|
||||
);
|
||||
cache["client_version"] = json!(VERSION);
|
||||
cache["models"][0]["context_window"] = json!(1);
|
||||
assert!(
|
||||
verify_authenticated_source(&cache, &original, &original, time, time + 100).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_bridge_respects_explicit_mode_and_legacy_api_key() {
|
||||
for (auth, expected) in [
|
||||
(json!({"OPENAI_API_KEY":"fake"}), CatalogSource::Bundled),
|
||||
(
|
||||
json!({"auth_mode":"chatgpt","OPENAI_API_KEY":"fake","tokens":{}}),
|
||||
CatalogSource::Authenticated,
|
||||
),
|
||||
(json!({"auth_mode":"apikey"}), CatalogSource::Bundled),
|
||||
] {
|
||||
let credential = CodexAppServerCredential::AuthBridge {
|
||||
auth_json: serde_json::to_vec(&auth).unwrap(),
|
||||
api_key: None,
|
||||
fingerprint: "fixture".into(),
|
||||
};
|
||||
assert_eq!(source_for_credential(&credential, false).unwrap(), expected);
|
||||
assert_eq!(
|
||||
source_for_credential(&credential, true).unwrap(),
|
||||
CatalogSource::Bundled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_client_turn_partitions_the_connection_without_leaking_its_identifier() {
|
||||
let first = direct_turn_pool_key("route", "first-private-turn");
|
||||
@@ -306,12 +531,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_rejects_an_existing_cache_before_starting_a_process() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
std::fs::write(home.path().join("models_cache.json"), b"{}").unwrap();
|
||||
let mut command = Command::new(home.path().join("must-never-execute"));
|
||||
command.args(["app-server", "--stdio"]);
|
||||
let error = capture(&command, home.path(), CatalogSource::Authenticated, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.starts_with("model-catalog-source-not-fresh"));
|
||||
assert!(!home.path().join("direct-model-catalog.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_futures_do_not_embed_large_buffers_in_the_main_call_chain() {
|
||||
let mut command = Command::new("not-executed");
|
||||
command.args(["app-server", "--stdio"]);
|
||||
let export = export_catalog(capture_command(&command, true).unwrap(), None);
|
||||
let capture = capture(&command, Path::new("not-read"), None);
|
||||
let capture = capture(
|
||||
&command,
|
||||
Path::new("not-read"),
|
||||
CatalogSource::Bundled,
|
||||
None,
|
||||
);
|
||||
let export_bytes = std::mem::size_of_val(&export);
|
||||
let capture_bytes = std::mem::size_of_val(&capture);
|
||||
assert!(
|
||||
@@ -334,6 +577,17 @@ mod tests {
|
||||
assert!(detail.contains("owner 不属于当前用户"));
|
||||
assert!(!detail.contains("C:\\private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdk_private_artifact_rejects_hardlinks_before_initializing_ownership() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(outside.path(), b"outside").unwrap();
|
||||
std::fs::hard_link(outside.path(), home.path().join("models_cache.json")).unwrap();
|
||||
assert!(harden_sdk_private_artifact(home.path(), "models_cache.json").is_err());
|
||||
assert_eq!(std::fs::read(outside.path()).unwrap(), b"outside");
|
||||
assert!(harden_sdk_private_artifact(home.path(), "other-file").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
//! 仅在宿主进程私有内存中延续同一 OAuth 来源的轮换凭据,绝不回写用户 auth.json。
|
||||
use super::{source_for_credential, CatalogSource, CodexAppServerCredential};
|
||||
use base64::Engine as _;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
const MAX_AUTH_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_SCOPES: usize = 128;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct OAuthIdentity {
|
||||
mode: String,
|
||||
account_id: String,
|
||||
user_id: Option<String>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
route_key: String,
|
||||
identity: OAuthIdentity,
|
||||
generation: String,
|
||||
latest: Result<Vec<u8>, String>,
|
||||
}
|
||||
|
||||
static HANDOFFS: OnceLock<Mutex<HashMap<PathBuf, Entry>>> = OnceLock::new();
|
||||
|
||||
fn handoffs() -> &'static Mutex<HashMap<PathBuf, Entry>> {
|
||||
HANDOFFS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn oauth_identity(auth: &Value) -> Result<OAuthIdentity, String> {
|
||||
let mode = auth
|
||||
.get("auth_mode")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("chatgpt");
|
||||
if !matches!(mode, "chatgpt" | "chatgptAuthTokens") {
|
||||
return Err("model-catalog-auth-handoff-mode: 当前认证不是可延续的 OAuth 身份".into());
|
||||
}
|
||||
let token = auth
|
||||
.pointer("/tokens/id_token")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("model-catalog-auth-handoff-identity: OAuth 身份标记缺失")?;
|
||||
let payload = token
|
||||
.split('.')
|
||||
.nth(1)
|
||||
.ok_or("model-catalog-auth-handoff-identity")?;
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload.trim_end_matches('='))
|
||||
.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")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty());
|
||||
let claims_account = claims_auth
|
||||
.get("chatgpt_account_id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty());
|
||||
if token_account
|
||||
.zip(claims_account)
|
||||
.is_some_and(|(left, right)| left != right)
|
||||
{
|
||||
return Err("model-catalog-auth-handoff-identity: OAuth 账户标记不一致".into());
|
||||
}
|
||||
let account_id = token_account
|
||||
.or(claims_account)
|
||||
.filter(|value| value.len() <= 1024)
|
||||
.ok_or("model-catalog-auth-handoff-identity: OAuth 账户标记缺失")?
|
||||
.to_string();
|
||||
let user_id = claims_auth
|
||||
.get("chatgpt_user_id")
|
||||
.or_else(|| claims_auth.get("user_id"))
|
||||
.or_else(|| claims.get("sub"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
Ok(OAuthIdentity {
|
||||
mode: mode.into(),
|
||||
account_id,
|
||||
user_id,
|
||||
})
|
||||
}
|
||||
|
||||
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(|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(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?;
|
||||
Ok((identity, bytes))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(in super::super) struct OAuthHandoff {
|
||||
root: PathBuf,
|
||||
route_key: String,
|
||||
identity: OAuthIdentity,
|
||||
generation: String,
|
||||
}
|
||||
|
||||
impl OAuthHandoff {
|
||||
/// route_key 已包含路由、项目身份和原始认证来源的 fingerprint;不以轮换后的 token 重新派生。
|
||||
pub(in super::super) fn prepare(
|
||||
root: &Path,
|
||||
route_key: &str,
|
||||
credential: &mut CodexAppServerCredential,
|
||||
) -> Result<Option<Self>, String> {
|
||||
if source_for_credential(credential, false)? != CatalogSource::Authenticated {
|
||||
return Ok(None);
|
||||
}
|
||||
let CodexAppServerCredential::AuthBridge {
|
||||
auth_json, api_key, ..
|
||||
} = credential
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (identity, mut seed) = oauth_bytes(auth_json)?;
|
||||
let mut entries = handoffs()
|
||||
.lock()
|
||||
.map_err(|_| "model-catalog-auth-handoff-unavailable")?;
|
||||
if let Some(previous) = entries
|
||||
.get(root)
|
||||
.filter(|entry| entry.route_key == route_key && entry.identity == identity)
|
||||
{
|
||||
seed = previous.latest.clone()?;
|
||||
}
|
||||
if !entries.contains_key(root) && entries.len() >= MAX_SCOPES {
|
||||
return Err("model-catalog-auth-handoff-capacity".into());
|
||||
}
|
||||
let generation = uuid::Uuid::new_v4().to_string();
|
||||
entries.insert(
|
||||
root.to_path_buf(),
|
||||
Entry {
|
||||
route_key: route_key.into(),
|
||||
identity: identity.clone(),
|
||||
generation: generation.clone(),
|
||||
latest: Ok(seed.clone()),
|
||||
},
|
||||
);
|
||||
*auth_json = seed;
|
||||
*api_key = None;
|
||||
Ok(Some(Self {
|
||||
root: root.into(),
|
||||
route_key: route_key.into(),
|
||||
identity,
|
||||
generation,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 有可能轮换 token 的 SDK 实例开始工作后,未取得其退出结果前不能重用旧凭据。
|
||||
pub(in super::super) fn mark_active(&self) -> Result<(), String> {
|
||||
let mut entries = handoffs()
|
||||
.lock()
|
||||
.map_err(|_| "model-catalog-auth-handoff-unavailable")?;
|
||||
let entry = entries
|
||||
.get_mut(&self.root)
|
||||
.filter(|entry| {
|
||||
entry.generation == self.generation
|
||||
&& entry.route_key == self.route_key
|
||||
&& entry.identity == self.identity
|
||||
})
|
||||
.ok_or("model-catalog-auth-handoff-owner-changed")?;
|
||||
entry.latest =
|
||||
Err("model-catalog-auth-handoff-pending: 上一私有 OAuth 实例的轮换结果尚未确认".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 只在目录捕获进程/模型进程已退出后调用。迟到的旧实例不能覆盖下一回合的轮换结果。
|
||||
pub(in super::super) fn remember(&self, isolated_home: &Path) -> Result<(), String> {
|
||||
let result = super::harden_sdk_private_artifact(isolated_home, "auth.json")
|
||||
.and_then(|()| {
|
||||
crate::read_game_creator_private_file_to_string(
|
||||
&isolated_home.join("auth.json"),
|
||||
"私有 OAuth 轮换状态",
|
||||
MAX_AUTH_BYTES,
|
||||
)
|
||||
})
|
||||
.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 {
|
||||
Err("model-catalog-auth-handoff-identity: OAuth 轮换后的身份改变".into())
|
||||
} else {
|
||||
Ok(bytes)
|
||||
}
|
||||
});
|
||||
let mut entries = handoffs()
|
||||
.lock()
|
||||
.map_err(|_| "model-catalog-auth-handoff-unavailable")?;
|
||||
if let Some(entry) = entries.get_mut(&self.root).filter(|entry| {
|
||||
entry.route_key == self.route_key
|
||||
&& entry.identity == self.identity
|
||||
&& entry.generation == self.generation
|
||||
}) {
|
||||
entry.latest = result.clone();
|
||||
return result.map(|_| ());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn auth(account: &str, token: &str) -> Vec<u8> {
|
||||
let claims = json!({"https://api.openai.com/auth":{"chatgpt_account_id":account,"chatgpt_user_id":"fixture-user"}});
|
||||
let id_token = format!(
|
||||
"e30.{}.fixture",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&claims).unwrap())
|
||||
);
|
||||
serde_json::to_vec(&json!({"auth_mode":"chatgpt","OPENAI_API_KEY":"must-not-cache-api-key", "tokens":{
|
||||
"account_id":account,"id_token":id_token,"access_token":token,"refresh_token":format!("refresh-{token}")}})).unwrap()
|
||||
}
|
||||
|
||||
fn credential(bytes: Vec<u8>) -> CodexAppServerCredential {
|
||||
CodexAppServerCredential::AuthBridge {
|
||||
auth_json: bytes,
|
||||
api_key: Some("must-not-cache-api-key".into()),
|
||||
fingerprint: "source-fixture".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn token(credential: &CodexAppServerCredential) -> String {
|
||||
let CodexAppServerCredential::AuthBridge { auth_json, .. } = credential else {
|
||||
panic!()
|
||||
};
|
||||
let value: Value = serde_json::from_slice(auth_json).unwrap();
|
||||
assert!(value["OPENAI_API_KEY"].is_null());
|
||||
value["tokens"]["access_token"].as_str().unwrap().into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_source_rotation_is_carried_without_writing_the_source_or_caching_api_keys() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let original = auth("account-a", "original");
|
||||
let source_file = root.path().join("user-auth-source.json");
|
||||
std::fs::write(&source_file, &original).unwrap();
|
||||
let private = tempfile::tempdir().unwrap();
|
||||
let handoff = OAuthHandoff::prepare(
|
||||
root.path(),
|
||||
"same-route-and-source",
|
||||
&mut credential(original.clone()),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
private.path().join("auth.json"),
|
||||
auth("account-a", "rotated"),
|
||||
)
|
||||
.unwrap();
|
||||
handoff.remember(private.path()).unwrap();
|
||||
let mut next = credential(original.clone());
|
||||
OAuthHandoff::prepare(root.path(), "same-route-and-source", &mut next).unwrap();
|
||||
assert_eq!(token(&next), "rotated");
|
||||
assert_eq!(std::fs::read(source_file).unwrap(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_identity_route_or_source_never_inherits_a_previous_rotation() {
|
||||
for (new_account, new_scope) in [
|
||||
("account-b", "route-source-a"),
|
||||
("account-a", "route-b-source-a"),
|
||||
("account-a", "route-a-source-b"),
|
||||
] {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let private = tempfile::tempdir().unwrap();
|
||||
let prior = OAuthHandoff::prepare(
|
||||
root.path(),
|
||||
"route-source-a",
|
||||
&mut credential(auth("account-a", "original")),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
private.path().join("auth.json"),
|
||||
auth("account-a", "rotated"),
|
||||
)
|
||||
.unwrap();
|
||||
prior.remember(private.path()).unwrap();
|
||||
let mut next = credential(auth(new_account, "new-source"));
|
||||
OAuthHandoff::prepare(root.path(), new_scope, &mut next).unwrap();
|
||||
assert_eq!(token(&next), "new-source");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_old_instance_cannot_overwrite_new_rotation_and_changed_account_fails_closed() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let private = tempfile::tempdir().unwrap();
|
||||
let old = OAuthHandoff::prepare(
|
||||
root.path(),
|
||||
"scope",
|
||||
&mut credential(auth("account-a", "first")),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let newer = OAuthHandoff::prepare(
|
||||
root.path(),
|
||||
"scope",
|
||||
&mut credential(auth("account-a", "first")),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
std::fs::write(private.path().join("auth.json"), auth("account-a", "newer")).unwrap();
|
||||
newer.remember(private.path()).unwrap();
|
||||
std::fs::write(
|
||||
private.path().join("auth.json"),
|
||||
auth("account-a", "old-late"),
|
||||
)
|
||||
.unwrap();
|
||||
old.remember(private.path()).unwrap();
|
||||
let mut next = credential(auth("account-a", "first"));
|
||||
let current = OAuthHandoff::prepare(root.path(), "scope", &mut next)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(token(&next), "newer");
|
||||
std::fs::write(
|
||||
private.path().join("auth.json"),
|
||||
auth("account-b", "wrong-account"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(current.remember(private.path()).is_err());
|
||||
assert!(OAuthHandoff::prepare(
|
||||
root.path(),
|
||||
"scope",
|
||||
&mut credential(auth("account-a", "first"))
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abandoned_instance_cannot_replay_the_pre_rotation_token() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let private = tempfile::tempdir().unwrap();
|
||||
let owner = OAuthHandoff::prepare(
|
||||
root.path(),
|
||||
"scope",
|
||||
&mut credential(auth("account-a", "first")),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
owner.mark_active().unwrap();
|
||||
assert!(OAuthHandoff::prepare(
|
||||
root.path(),
|
||||
"scope",
|
||||
&mut credential(auth("account-a", "first"))
|
||||
)
|
||||
.is_err());
|
||||
std::fs::write(
|
||||
private.path().join("auth.json"),
|
||||
auth("account-a", "confirmed-rotation"),
|
||||
)
|
||||
.unwrap();
|
||||
owner.remember(private.path()).unwrap();
|
||||
let mut next = credential(auth("account-a", "first"));
|
||||
OAuthHandoff::prepare(root.path(), "scope", &mut next).unwrap();
|
||||
assert_eq!(token(&next), "confirmed-rotation");
|
||||
}
|
||||
}
|
||||
+189
-4
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use base64::Engine as _;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
fn isolated_command(home: &Path) -> Command {
|
||||
let executable = crate::agent::game_creator_codex_cli_executable_path().unwrap();
|
||||
@@ -25,6 +29,115 @@ fn isolated_command(home: &Path) -> Command {
|
||||
command
|
||||
}
|
||||
|
||||
struct LocalModels {
|
||||
endpoint: String,
|
||||
requests: Arc<AtomicUsize>,
|
||||
stop: Arc<AtomicBool>,
|
||||
worker: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl LocalModels {
|
||||
fn new(status: u16, body: Value) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let endpoint = format!("http://{}/v1", listener.local_addr().unwrap());
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let counted = Arc::clone(&requests);
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stopping = Arc::clone(&stop);
|
||||
let body = serde_json::to_vec(&body).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
while !stopping.load(Ordering::Acquire) {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(2)))
|
||||
.unwrap();
|
||||
let mut buffer = [0u8; 4096];
|
||||
let count = stream.read(&mut buffer).unwrap_or_default();
|
||||
if !buffer[..count].starts_with(b"GET /v1/models?") {
|
||||
continue;
|
||||
}
|
||||
counted.fetch_add(1, Ordering::AcqRel);
|
||||
let headers = format!("HTTP/1.1 {status} Fixture\r\nContent-Type: application/json\r\nContent-Length: {}\r\nETag: \"fixture-catalog\"\r\nConnection: close\r\n\r\n", body.len());
|
||||
let _ = stream.write_all(headers.as_bytes());
|
||||
let _ = stream.write_all(&body);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
Self {
|
||||
endpoint,
|
||||
requests,
|
||||
stop,
|
||||
worker: Some(worker),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LocalModels {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Release);
|
||||
if let Some(worker) = self.worker.take() {
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_oauth_command(home: &Path, endpoint: &str) -> Command {
|
||||
let jwt = |body: Value| {
|
||||
format!(
|
||||
"{}.{}.fixture",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(br#"{"alg":"none","typ":"JWT"}"#),
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&body).unwrap())
|
||||
)
|
||||
};
|
||||
let seconds = now_ms() / 1000;
|
||||
let fetched = chrono::DateTime::from_timestamp(seconds, 0)
|
||||
.unwrap()
|
||||
.to_rfc3339();
|
||||
let auth = json!({"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{
|
||||
"id_token":jwt(json!({"exp":seconds+3600,"https://api.openai.com/auth":{"chatgpt_plan_type":"plus","chatgpt_account_id":"local-fixture","chatgpt_user_id":"local-fixture"}})),
|
||||
"access_token":jwt(json!({"exp":seconds+3600})),"refresh_token":"local-fake-refresh-token","account_id":"local-fixture"},"last_refresh":fetched});
|
||||
crate::write_game_creator_private_file(
|
||||
&home.join("auth.json"),
|
||||
&serde_json::to_vec(&auth).unwrap(),
|
||||
"假 OAuth 测试身份",
|
||||
)
|
||||
.unwrap();
|
||||
let mut command = isolated_command(home);
|
||||
command
|
||||
.args([
|
||||
"-c",
|
||||
"cli_auth_credentials_store=\"file\"",
|
||||
"-c",
|
||||
"model_provider=\"fixture\"",
|
||||
"-c",
|
||||
"model_providers.fixture.name=\"Local OAuth fixture\"",
|
||||
"-c",
|
||||
"model_providers.fixture.wire_api=\"responses\"",
|
||||
"-c",
|
||||
"model_providers.fixture.requires_openai_auth=true",
|
||||
"-c",
|
||||
"model_providers.fixture.request_max_retries=0",
|
||||
])
|
||||
.arg("-c")
|
||||
.arg(format!(
|
||||
"model_providers.fixture.base_url={}",
|
||||
serde_json::to_string(endpoint).unwrap()
|
||||
));
|
||||
command
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "runs verified bundled Codex without Provider or personal configuration"]
|
||||
async fn real_bundled_catalog_roundtrip_changes_only_patch_registration() {
|
||||
@@ -33,7 +146,9 @@ async fn real_bundled_catalog_roundtrip_changes_only_patch_registration() {
|
||||
let baseline = export_catalog(capture_command(&command, true).unwrap(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let path = capture(&command, home.path(), None).await.unwrap();
|
||||
let path = capture(&command, home.path(), CatalogSource::Bundled, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let derived: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(derived, derive_catalog(&baseline).unwrap());
|
||||
let mut reloaded = capture_command(&command, false).unwrap();
|
||||
@@ -46,14 +161,84 @@ async fn real_bundled_catalog_roundtrip_changes_only_patch_registration() {
|
||||
assert!(!home.path().join("models_cache.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "runs bundled Codex with fabricated OAuth and loopback-only models endpoint"]
|
||||
async fn real_oauth_catalog_preserves_remote_metadata_and_rejects_successful_fallback() {
|
||||
let baseline_home = tempfile::tempdir().unwrap();
|
||||
let baseline = export_catalog(
|
||||
capture_command(&isolated_command(baseline_home.path()), true).unwrap(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut remote = baseline["models"][0].clone();
|
||||
remote["slug"] = json!("oauth-fixture-current");
|
||||
remote["context_window"] = json!(123456);
|
||||
remote["max_context_window"] = json!(234567);
|
||||
let server = LocalModels::new(200, json!({"models":[remote]}));
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let command = fake_oauth_command(home.path(), &server.endpoint);
|
||||
let path = capture(&command, home.path(), CatalogSource::Authenticated, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let derived: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(derived["models"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(derived["models"][0]["slug"], "oauth-fixture-current");
|
||||
assert_eq!(derived["models"][0]["context_window"], 123456);
|
||||
assert!(derived["models"][0]["apply_patch_tool_type"].is_null());
|
||||
// SDK 对目录拉取有自带的瞬时重试策略(不受 fixture provider 的 request_max_retries 约束),
|
||||
// 负载高时可能重试一次;这里只守住「没有重复成倍拉取」的上界,来源真伪由下面的字段断言证明。
|
||||
let successful_requests = server.requests.load(Ordering::Acquire);
|
||||
assert!(
|
||||
(1..=3).contains(&successful_requests),
|
||||
"目录拉取次数异常:{successful_requests}"
|
||||
);
|
||||
let cache: Value =
|
||||
serde_json::from_slice(&std::fs::read(home.path().join("models_cache.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cache["models"][0]["apply_patch_tool_type"], "freeform",
|
||||
"原始缓存不得被派生逻辑改写"
|
||||
);
|
||||
|
||||
let unavailable = LocalModels::new(503, json!({"error":"local fixture unavailable"}));
|
||||
let failed_home = tempfile::tempdir().unwrap();
|
||||
let failed = capture(
|
||||
&fake_oauth_command(failed_home.path(), &unavailable.endpoint),
|
||||
failed_home.path(),
|
||||
CatalogSource::Authenticated,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
failed.starts_with("model-catalog-source-unconfirmed"),
|
||||
"{failed}"
|
||||
);
|
||||
let failed_requests = unavailable.requests.load(Ordering::Acquire);
|
||||
assert!(
|
||||
(1..=3).contains(&failed_requests),
|
||||
"失败目录拉取次数异常:{failed_requests}"
|
||||
);
|
||||
assert!(!failed_home
|
||||
.path()
|
||||
.join("direct-model-catalog.json")
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_catalog_capture_cannot_spawn_or_write_a_snapshot() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let mut command = Command::new(home.path().join("must-never-execute"));
|
||||
command.args(["app-server", "--stdio"]);
|
||||
let error = capture(&command, home.path(), Some(Arc::new(AtomicBool::new(true))))
|
||||
.await
|
||||
.unwrap_err();
|
||||
let error = capture(
|
||||
&command,
|
||||
home.path(),
|
||||
CatalogSource::Bundled,
|
||||
Some(Arc::new(AtomicBool::new(true))),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error, "model-catalog-cancelled");
|
||||
assert!(!home.path().join("direct-model-catalog.json").exists());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -78,10 +78,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)]
|
||||
@@ -93,14 +99,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)]
|
||||
@@ -209,7 +220,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}"))?;
|
||||
@@ -351,7 +366,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(¤t).map_err(|_| "file-not-found")?;
|
||||
let metadata = std::fs::symlink_metadata(¤t).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>()
|
||||
@@ -3546,7 +3622,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()
|
||||
@@ -3559,16 +3635,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> {
|
||||
@@ -4107,7 +4194,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);
|
||||
@@ -4490,7 +4577,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
|
||||
@@ -4527,7 +4616,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))?;
|
||||
@@ -4630,20 +4720,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),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
reply_result
|
||||
@@ -4691,19 +4789,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(|| {
|
||||
@@ -4719,7 +4827,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",
|
||||
@@ -4861,7 +4971,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 {
|
||||
@@ -4873,7 +4984,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;
|
||||
@@ -4904,7 +5016,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())
|
||||
@@ -4935,7 +5048,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())
|
||||
@@ -7408,6 +7522,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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1052,15 +1052,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
|
||||
@@ -1070,7 +1070,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());
|
||||
}
|
||||
@@ -3436,9 +3436,11 @@ async fn bridge_update_plan(
|
||||
DirectExecutionGateRejection::SessionUnavailable { cause },
|
||||
))
|
||||
}
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
return Err(UpdatePlanError::Gate(
|
||||
DirectExecutionGateRejection::SessionTaskLost,
|
||||
DirectExecutionGateRejection::SessionTaskLost {
|
||||
cause: error.to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -3555,8 +3557,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
|
||||
@@ -3686,7 +3700,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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3920,9 +3937,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!(),
|
||||
};
|
||||
@@ -3985,14 +4002,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(),
|
||||
},
|
||||
)),
|
||||
));
|
||||
}
|
||||
@@ -4185,13 +4204,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 },
|
||||
)),
|
||||
));
|
||||
}
|
||||
@@ -4230,7 +4256,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)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user