继续收敛 CC 与 MCP 具体错误

保留 CC 隔离目录与 Node 版本探测的底层原因

保留 Direct 工具桥指纹、回执和项目路径读取错误

让外部 MCP journal 读取与资源读取返回具体正文
This commit is contained in:
kdletters
2026-10-06 01:04:51 +08:00
parent 81d06c112d
commit 8fa5cc10ee
8 changed files with 105 additions and 41 deletions
@@ -159,7 +159,7 @@ fn configure_sidecar_command(
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
configure_claude_code_environment(command, llm, home);
configure_claude_code_environment(command, llm, home)?;
configure_claude_code_process(command);
Ok(())
}
@@ -466,7 +466,6 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result<String,
let output = std::process::Command::new(&node)
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map_err(|error| {
// `output()` 失败只说明进程没能启动(可执行文件缺失、权限不足或被拦截),
@@ -474,7 +473,19 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result<String,
format!("Claude Agent SDK sidecar 的 Node runtime 启动失败:{error}")
})?;
if !output.status.success() {
return Err("Claude Agent SDK sidecar 的 Node runtime 版本检查失败".to_string());
let detail = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
&String::from_utf8_lossy(&output.stderr),
480,
);
let detail = if detail.trim().is_empty() {
format!("退出码={}", output.status)
} else {
format!("退出码={};stderr={detail}", output.status)
};
return Err(format!(
"Claude Agent SDK sidecar 的 Node runtime 版本检查失败:{detail}"
));
}
Ok(format!(
"claude-agent-sdk-sidecar@{};node={};entry={}",
@@ -617,7 +628,7 @@ fn configure_claude_code_environment(
command: &mut tokio::process::Command,
llm: Option<&GameCreatorLlmConfig>,
isolated_home: &Path,
) {
) -> Result<(), String> {
command.env_clear();
for name in [
"PATH",
@@ -641,7 +652,8 @@ fn configure_claude_code_environment(
copy_env(command, name);
}
let isolated_claude_home = isolated_home.join("claude");
let _ = std::fs::create_dir_all(&isolated_claude_home);
std::fs::create_dir_all(&isolated_claude_home)
.map_err(|error| format!("准备 Claude Code 隔离目录失败:{error}"))?;
command
.env("HOME", isolated_home)
// Windows 上 Node 的 `os.homedir()` 只看 `USERPROFILE`;不隔离它,Claude Code 会去读
@@ -689,6 +701,7 @@ fn configure_claude_code_environment(
command.env("ANTHROPIC_MODEL", llm.model.trim());
}
}
Ok(())
}
fn claude_result_error_detail(value: &serde_json::Value) -> Option<String> {
@@ -2146,7 +2159,7 @@ mod tests {
fn sidecar_environment_is_isolated_from_the_local_claude_code_home() {
let home = std::env::temp_dir().join("agc-cc-isolated-home-test");
let mut command = tokio::process::Command::new("node");
configure_claude_code_environment(&mut command, None, &home);
configure_claude_code_environment(&mut command, None, &home).unwrap();
let envs = command
.as_std()
.get_envs()
@@ -370,7 +370,7 @@ impl ExecutionAdapter {
Ok::<_, String>((session.root.clone(), session))
})
.await
.map_err(|_| "宿主执行身份读取中断")??;
.map_err(|error| format!("宿主执行身份读取中断:{error}"))??;
Ok(Self::new(
root,
session,
@@ -876,13 +876,13 @@ pub(super) fn resolve_direct_codex_project_authority(
return Err("AGC 直连项目根目录必须是绝对路径".to_string());
}
let project_metadata = std::fs::metadata(project_root)
.map_err(|_| "AGC 直连项目根目录不存在或无法读取".to_string())?;
.map_err(|error| format!("AGC 直连项目根目录不存在或无法读取:{error}"))?;
if !project_metadata.is_dir() {
return Err("AGC 直连项目根目录不是目录".to_string());
}
let project_root = project_root
.canonicalize()
.map_err(|_| "AGC 直连项目根目录无法安全解析".to_string())?;
.map_err(|error| format!("AGC 直连项目根目录无法安全解析:{error}"))?;
// 用户选择的项目目录就是 Codex 工作区根,不再强制要求 game/ 子目录。
// 原生文件工具仍由项目文件层拒绝 .agent/**、.git/**、密钥等控制面路径。
Ok((project_root.clone(), project_root))
@@ -2597,8 +2597,10 @@ impl CodexAppServerConnection {
platform_llm::LlmError::InvalidRequest("Direct 连接缺少项目目录".into())
})?
.canonicalize()
.map_err(|_| {
platform_llm::LlmError::InvalidRequest("Direct 项目目录不可用".into())
.map_err(|error| {
platform_llm::LlmError::InvalidRequest(format!(
"Direct 项目目录不可用:{error}"
))
})?;
let bind_root = root.clone();
let bind_executable = executable.clone();
@@ -2612,7 +2614,9 @@ impl CodexAppServerConnection {
session.bind_codex_executor(&bind_executable, &bind_version)
})
.await
.map_err(|_| platform_llm::LlmError::InvalidRequest("Direct 执行器绑定中断".into()))?
.map_err(|error| {
platform_llm::LlmError::InvalidRequest(format!("Direct 执行器绑定中断:{error}"))
})?
.map_err(platform_llm::LlmError::InvalidRequest)?;
Some(root)
} else {
@@ -397,7 +397,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: 会话状态不可用")?
@@ -3990,7 +3990,10 @@ async fn handle_direct_tool_bridge(
let session = super::direct_execution::current(&root)?;
let has_evidence = !session.snapshot()?.evidence.is_empty();
let before = if has_evidence {
super::direct_validation::source_fingerprint(&root).ok()
Some(
super::direct_validation::source_fingerprint(&root)
.map_err(|cause| format!("执行前成果指纹读取失败:{cause}"))?,
)
} else {
None
};
@@ -4213,10 +4216,18 @@ async fn handle_direct_tool_bridge(
let root = state.root.clone();
let passed = result.is_ok();
let finished = tokio::task::spawn_blocking(move || {
let changed = has_evidence
&& before
.zip(super::direct_validation::source_fingerprint(&root).ok())
.is_none_or(|(before, after)| before != after);
let changed = if has_evidence {
match before {
Some(before) => {
let after = super::direct_validation::source_fingerprint(&root)
.map_err(|cause| format!("执行后成果指纹读取失败:{cause}"))?;
before != after
}
None => false,
}
} else {
false
};
if !passed && dispatch_denied {
lease.finish_paid_dispatch_denied(changed)
} else {
@@ -4224,14 +4235,19 @@ async fn handle_direct_tool_bridge(
}
})
.await;
if !matches!(finished, Ok(Ok(()))) {
let receipt_error = match finished {
Ok(Ok(())) => None,
Ok(Err(error)) => Some(format!("执行回执结算失败:{error}")),
Err(error) => Some(format!("执行回执结算任务未返回:{error}")),
};
if let Some(cause) = receipt_error {
return Json(compose_direct_tool_outcome(
&state,
request.tool.as_str(),
&diagnostics_arguments,
dispatch_denied,
Err(ToolCallError::from(
&DirectExecutionGateRejection::ReceiptNotPersisted,
&DirectExecutionGateRejection::ReceiptNotPersisted { cause },
)),
));
}
@@ -4270,7 +4286,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
@@ -1186,17 +1186,22 @@ fn validate_external_mcp_record_arguments(
fn read_external_mcp_journal(root: &Path) -> Result<Vec<Value>, String> {
let path = external_mcp_journal_path(root);
let Ok(bytes) = std::fs::read(&path) else {
return Ok(Vec::new());
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(format!("读取 Codex 返回记录失败:{error}")),
};
if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES {
return Err("Codex 返回记录超过客户端保留上限".to_string());
}
bytes
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(|line| {
serde_json::from_slice::<Value>(line).map_err(|_| "Codex 返回记录格式损坏".to_string())
.enumerate()
.filter(|(_, line)| !line.is_empty())
.map(|(line_number, line)| {
serde_json::from_slice::<Value>(line).map_err(|error| {
format!("Codex 返回记录格式损坏:第 {} 行:{error}", line_number + 1)
})
})
.collect()
}
@@ -1504,15 +1509,20 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option<
if uri != "agc://conversation/codex-responses" {
Some(mcp_error(id, -32602, "未知资源"))
} else {
let text = read_external_mcp_journal(root)
.map(|records| {
records
.iter()
.map(Value::to_string)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
let text = match read_external_mcp_journal(root) {
Ok(records) => records
.iter()
.map(Value::to_string)
.collect::<Vec<_>>()
.join("\n"),
Err(error) => {
return Some(mcp_error(
id,
-32603,
&format!("Codex 返回记录资源读取失败:{error}"),
));
}
};
if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES {
return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限"));
}
@@ -3434,6 +3444,26 @@ mod tests {
.is_err());
}
#[test]
fn external_codex_response_journal_distinguishes_missing_from_read_and_parse_errors() {
let temporary = crate::tests::canonical_test_tempdir("direct-tools-read-response-");
let root = temporary.path();
init_local_game_project_at(root, "direct-tools-read-response", "Codex 返回读取测试")
.expect("init project");
assert!(read_external_mcp_journal(root).unwrap().is_empty());
let journal = external_mcp_journal_path(root);
std::fs::create_dir_all(&journal).unwrap();
let error = read_external_mcp_journal(root).unwrap_err();
assert!(error.contains("读取 Codex 返回记录失败"), "{error}");
std::fs::remove_dir_all(&journal).unwrap();
std::fs::write(&journal, b"{broken-json\n").unwrap();
let error = read_external_mcp_journal(root).unwrap_err();
assert!(error.contains("第 1 行"), "{error}");
assert!(error.contains("Codex 返回记录格式损坏"), "{error}");
}
#[test]
fn recorded_codex_response_only_writes_its_own_journal() {
let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-");
@@ -259,8 +259,8 @@ pub(crate) enum DirectExecutionGateRejection {
SessionUnavailable { cause: String },
/// 取执行会话的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。
SessionTaskLost { cause: String },
/// 工具已返回,但执行回执没有可靠落盘。
ReceiptNotPersisted,
/// 工具已返回,但执行回执没有可靠落盘;保留落盘或回执任务的具体原因。
ReceiptNotPersisted { cause: String },
}
impl ToolFailure for DirectExecutionGateRejection {
@@ -276,10 +276,9 @@ impl ToolFailure for DirectExecutionGateRejection {
Self::SessionTaskLost { cause } => {
format!("宿主执行会话任务未返回:{cause}")
}
Self::ReceiptNotPersisted => {
"工具已返回,但宿主执行回执未可靠落盘;请查看交付状态并核对原操作,不自动重放"
.to_string()
}
Self::ReceiptNotPersisted { cause } => format!(
"工具已返回,但宿主执行回执未可靠落盘:{cause};请查看交付状态并核对原操作,不自动重放"
),
}
}
}
@@ -86,3 +86,5 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时,
- 错误报告对话框读取最新错误事件或诊断日志失败时原先只显示“暂不可用/使用快照”;现把读取 IPC/文件正文附在状态提示中,快照回退行为保持不变。
- WorkspaceLauncher 的清单版本读取、失效事件重读和事件订阅失败原先只写日志或静默回退;现把安全正文送入启动器状态提示,仍保留已有回退刷新路径。
- Direct 补丁事务的目标指纹读取原先把元数据、打开、正文读取和文件超限都折叠为 `None`,可能误判文件缺失或继续比较;现只对确实 `NotFound` 保留 `missing`,其余失败返回目标相对路径、阶段和底层正文,避免补丁结果被错误归因。
- CC/Direct 工具桥的执行前后成果指纹、回执结算 JoinError/落盘失败,以及外部 MCP journal 的读取/JSON 行解析原先仍会被 `.ok()`、固定回执句或“无记录”吞掉;现分别保留指纹阶段、回执任务/落盘原因、journal 行号和底层正文,只有真实 `NotFound` 才视为空记录。
- 外部 MCP `resources/read` 仍把已能返回具体原因的 Codex journal 读取错误 `unwrap_or_default()` 成空资源;现把读取失败按 MCP 错误响应返回,Direct 执行会话当前项目 canonicalize 也保留底层 I/O 正文。