阶段4:隔离第三方请求并补充负向验收
保持 OSS、签名下载、Provider、搜索、loopback 和更新下载使用独立 Client。 为搜索、loopback 和更新下载提取独立构造点,保留原有 no_proxy、timeout 和 redirect 配置。 新增 OSS/签名传输、搜索、loopback 和更新下载的无标记请求捕获测试。 为 Codex Provider 代理转发增加无主站标记断言。 更新 Issue 226 阶段方案与验收计划,记录阶段 4 完成。
This commit is contained in:
@@ -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 工作内容
|
||||
|
||||
Reference in New Issue
Block a user