阶段4:隔离第三方请求并补充负向验收
Project CI / Repository checks (pull_request) Successful in 2m59s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Backend tests (pull_request) Successful in 6m45s
Project CI / Native shell tests (pull_request) Failing after 17m25s

保持 OSS、签名下载、Provider、搜索、loopback 和更新下载使用独立 Client。

为搜索、loopback 和更新下载提取独立构造点,保留原有 no_proxy、timeout 和 redirect 配置。

新增 OSS/签名传输、搜索、loopback 和更新下载的无标记请求捕获测试。

为 Codex Provider 代理转发增加无主站标记断言。

更新 Issue 226 阶段方案与验收计划,记录阶段 4 完成。
This commit is contained in:
2026-09-02 03:10:58 +00:00
parent dce703b221
commit f9ce08ab2b
7 changed files with 207 additions and 15 deletions
@@ -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")
@@ -2181,6 +2181,15 @@ async fn bridge_browser_playtest(root: &Path, arguments: &Value) -> Value {
}
}
fn build_controlled_search_client() -> Result<reqwest::Client, String> {
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<DirectToolBr
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[tokio::test]
async fn controlled_search_client_omits_agc_marker() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind search fixture");
let address = listener.local_addr().expect("search fixture address");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept search request");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("set search 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 search request");
assert!(read > 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() {
@@ -819,15 +819,19 @@ fn direct_tool_bridge_url() -> Result<String, String> {
Ok(value)
}
fn build_direct_tool_bridge_client() -> Result<reqwest::Client, String> {
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() {
@@ -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] = [
@@ -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;
@@ -6,7 +6,7 @@
- `#226 添加客户端特殊标识`:本方案实际实施范围
- `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施
状态:阶段 3 已完成,待阶段 4 实施<br>
状态:阶段 4 已完成,待阶段 5 实施<br>
本方案包含 #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` 的职责:
@@ -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 工作内容