From f9ce08ab2bf1ea940fd266153054122a82a695db Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 2 Sep 2026 03:10:58 +0000 Subject: [PATCH] =?UTF-8?q?=E9=98=B6=E6=AE=B54=EF=BC=9A=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=96=B9=E8=AF=B7=E6=B1=82=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E8=B4=9F=E5=90=91=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保持 OSS、签名下载、Provider、搜索、loopback 和更新下载使用独立 Client。 为搜索、loopback 和更新下载提取独立构造点,保留原有 no_proxy、timeout 和 redirect 配置。 新增 OSS/签名传输、搜索、loopback 和更新下载的无标记请求捕获测试。 为 Codex Provider 代理转发增加无主站标记断言。 更新 Issue 226 阶段方案与验收计划,记录阶段 4 完成。 --- .../src/agent/codex_provider_proxy.rs | 4 ++ .../src-tauri/src/agent/direct_tool_bridge.rs | 55 ++++++++++++++++-- .../src-tauri/src/agent/direct_tools_mcp.rs | 56 +++++++++++++++++-- .../src-tauri/src/assets.rs | 36 ++++++++++++ .../src-tauri/src/main.rs | 50 ++++++++++++++++- ...Issue226-AGC客户端主站请求统一标记-2026-09-01.md | 10 +++- ...26-AGC客户端主站请求标记分阶段验收-2026-09-01.md | 11 +++- 7 files changed, 207 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index f4d82651c..5df545d75 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -252,6 +252,10 @@ mod tests { .and_then(|value| value.to_str().ok()), Some("Bearer fixture-provider-key") ); + assert!( + headers.get("x-genarrative-client").is_none(), + "Provider 请求不得携带 AGC 主站标记" + ); Response::builder() .status(StatusCode::OK) .header("content-type", "application/json") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index f3246b2a4..a8addecf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -2181,6 +2181,15 @@ async fn bridge_browser_playtest(root: &Path, arguments: &Value) -> Value { } } +fn build_controlled_search_client() -> Result { + reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(20)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "创建 AGC 受控搜索连接失败".to_string()) +} + async fn bridge_web_search(root: &Path, arguments: &Value) -> Value { let result = async { enforce_project_permission_policy(root, "project.search")?; @@ -2190,12 +2199,7 @@ async fn bridge_web_search(root: &Path, arguments: &Value) -> Value { DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS, )?; let max_results = bridge_search_max_results(arguments)?; - let client = reqwest::Client::builder() - .no_proxy() - .timeout(std::time::Duration::from_secs(20)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|_| "创建 AGC 受控搜索连接失败".to_string())?; + let client = build_controlled_search_client()?; let response = client .get(DIRECT_TOOL_BRIDGE_SEARCH_URL) .query(&[("q", query.as_str())]) @@ -2321,6 +2325,45 @@ pub(crate) async fn start_direct_tool_bridge(root: &Path) -> Result 0, "search request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + } + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write search response"); + String::from_utf8_lossy(&bytes).into_owned() + }); + + let client = build_controlled_search_client().expect("build search client"); + let response = client + .get(format!("http://{address}/search")) + .send() + .await + .expect("send search request"); + let request = server.join().expect("join search fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert!(!request + .to_ascii_lowercase() + .contains("x-genarrative-client:")); + } #[test] fn bridge_argument_bounds_are_deterministic() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index b0790fe6b..ba1dffecc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -819,15 +819,19 @@ fn direct_tool_bridge_url() -> Result { Ok(value) } +fn build_direct_tool_bridge_client() -> Result { + reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(1_800)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "创建客户端工具桥连接失败".to_string()) +} + async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { let result = async { let url = direct_tool_bridge_url()?; - let client = reqwest::Client::builder() - .no_proxy() - .timeout(std::time::Duration::from_secs(1_800)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|_| "创建客户端工具桥连接失败".to_string())?; + let client = build_direct_tool_bridge_client()?; let response = client .post(url) .json(&json!({ "tool": tool, "arguments": arguments })) @@ -1147,6 +1151,46 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + use std::io::{Read, Write}; + + #[tokio::test] + async fn loopback_tool_bridge_client_omits_agc_marker() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback fixture"); + let address = listener.local_addr().expect("loopback fixture address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept loopback request"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .expect("set loopback fixture timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !bytes.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).expect("read loopback request"); + assert!(read > 0, "loopback request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + } + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write loopback response"); + String::from_utf8_lossy(&bytes).into_owned() + }); + + let client = build_direct_tool_bridge_client().expect("build loopback client"); + let response = client + .post(format!("http://{address}/tool-test")) + .json(&json!({ "tool": "fixture", "arguments": {} })) + .send() + .await + .expect("send loopback request"); + let request = server.join().expect("join loopback fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert!(!request + .to_ascii_lowercase() + .contains("x-genarrative-client:")); + } #[test] fn direct_tools_mode_requires_the_exact_private_flag() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 8ac6eee3a..168b2320f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -2082,6 +2082,42 @@ mod tests { String::from_utf8_lossy(&bytes).into_owned() } + #[tokio::test] + async fn external_asset_transfer_client_omits_agc_marker() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind transfer fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("transfer address") + ); + let upload_url = url::Url::parse(&format!("{base_url}/upload")).expect("upload URL"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept transfer request"); + let request = read_asset_test_request(&mut stream); + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write transfer response"); + request + }); + + let client = build_external_asset_download_client(&upload_url, &base_url, true) + .await + .expect("build external asset transfer client"); + let response = client + .post(upload_url) + .body("fixture-upload") + .send() + .await + .expect("send transfer request"); + let request = server.join().expect("join transfer fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert!(!request + .to_ascii_lowercase() + .contains("x-genarrative-client:")); + } + #[test] fn canvas_download_accepts_supported_image_magic() { let cases: [(&str, &str, &[u8]); 4] = [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 4a4897bec..a415b02a9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -50,6 +50,10 @@ const AGC_UPDATE_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com"; const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "agc-update-download-progress"; +fn build_agc_update_download_client() -> reqwest::Client { + reqwest::Client::new() +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] struct AgcUpdateDownloadProgress { @@ -118,7 +122,7 @@ async fn download_agc_update( if filename.is_empty() || filename.len() > 128 { return Err("更新文件名无效".to_string()); } - let response = reqwest::Client::new() + let response = build_agc_update_download_client() .get(parsed) .send() .await @@ -2656,6 +2660,50 @@ mod diagnostic_log_tests { } } +#[cfg(test)] +mod update_client_tests { + use super::*; + use std::io::{Read, Write}; + + #[tokio::test] + async fn update_download_client_omits_agc_marker() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind update fixture"); + let address = listener.local_addr().expect("update fixture address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept update request"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .expect("set update fixture timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !bytes.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).expect("read update request"); + assert!(read > 0, "update request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + } + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write update response"); + String::from_utf8_lossy(&bytes).into_owned() + }); + + let client = build_agc_update_download_client(); + let response = client + .get(format!("http://{address}/update.exe")) + .send() + .await + .expect("send update request"); + let request = server.join().expect("join update fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert!(!request + .to_ascii_lowercase() + .contains("x-genarrative-client:")); + } +} + #[cfg(test)] mod tests; pub mod ui_editor; diff --git a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md index e27bc2432..12458a7fa 100644 --- a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md +++ b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md @@ -6,7 +6,7 @@ - `#226 添加客户端特殊标识`:本方案实际实施范围 - `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施 -状态:阶段 3 已完成,待阶段 4 实施
+状态:阶段 4 已完成,待阶段 5 实施
本方案包含 #226 客户端代码修改,不包含 #225 主站接收、落库和后台统计代码 ## 1. 一句话交付结果 @@ -237,6 +237,14 @@ let client = agc_main_site_client_builder() - 阶段 3 请求捕获验证:`sync_canvas_project_assets_downloads_external_resources`(1 test passed)确认账号态项目请求和 `assets/read-url` 带标记,签名媒体下载不带标记。 - 阶段 3 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`(2 tests passed);`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_downloads_external_resources -- --nocapture`(1 test passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`、`npm run check:encoding` 和 `git diff --check` 均通过。 +### 6.6 阶段 4 实施记录 + +- 保持第三方请求的独立 Client 边界:`build_external_asset_download_client`、Codex Provider 代理、受控搜索、loopback 工具桥和更新下载均未接入主站 factory。 +- 为搜索、loopback 和更新下载提取独立的 Client 构造点,保留原有 `no_proxy`、timeout 和 redirect 配置,并明确验证这些 Client 不携带 `X-Genarrative-Client`。 +- 新增 OSS/签名传输 Client、受控搜索 Client、loopback 工具桥 Client 和更新下载 Client 的本地 TCP 负向测试;Provider 代理转发测试增加无标记断言。 +- 阶段 4 验证结果:`omits_agc_marker` 负向测试 4 个通过;Provider 代理测试 1 个通过;阶段 3 的画板同步请求捕获测试继续作为签名 URL 媒体下载无标记证据。 +- 未修改主站 API、`ExternalEditorBindingAccess` 路由映射、认证语义、OSS/Provider/搜索/loopback/更新的网络安全策略或 #225 代码。 + ## 7. 调用语义和现有路由边界 本次不改变 `ExternalEditorBindingAccess` 的职责: diff --git a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md index a0ac327cf..e7eb31f6d 100644 --- a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md +++ b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md @@ -6,7 +6,7 @@ - `#226 添加客户端特殊标识`:本计划全部实施范围 - `#225 添加客户端埋点统计`:只接收交接契约,不在本计划实现 -当前状态:阶段 3 已完成;阶段 4 尚未开始。阶段 0 的证据与验收记录见 +当前状态:阶段 4 已完成;阶段 5 尚未开始。阶段 0 的证据与验收记录见 [【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md)。 ## 1. 交付目标 @@ -284,6 +284,15 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder; - 没有出现“为了复用 factory,把第三方请求也带上 Header”的情况。 - 负向测试失败时能定位到具体请求类别,而不是只报告一个总失败。 +### 8.4 阶段 4 实施记录 + +- `build_external_asset_download_client` 的 OSS/签名传输 Client 保持独立;新增 `external_asset_transfer_client_omits_agc_marker` 本地 TCP 测试。 +- 受控搜索 Client 提取为独立构造点,新增 `controlled_search_client_omits_agc_marker` 测试。 +- loopback 工具桥 Client 提取为独立构造点,新增 `loopback_tool_bridge_client_omits_agc_marker` 测试。 +- 更新下载保留独立裸 Client,新增 `update_download_client_omits_agc_marker` 测试。 +- Codex Provider 代理转发测试增加 `x-genarrative-client` 缺失断言;阶段 3 的画板同步测试继续确认签名媒体下载无标记。 +- 阶段 4 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture`(4 tests passed);`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer -- --nocapture`(1 test passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 和 `git diff --check` 通过。 + ## 9. 阶段 5:账号态、External 态和语义回归 ### 9.1 工作内容