From 65d7a57eb770e6db4dde150f830ab80081b791aa Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 2 Sep 2026 14:39:01 +0800 Subject: [PATCH] =?UTF-8?q?AGC=E5=AF=B9=E4=B8=BB=E7=AB=99=E7=9A=84?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E6=B7=BB=E5=8A=A0header=20(#241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/241 Co-authored-by: Linghong Co-committed-by: Linghong --- .../src/agent/codex_provider_proxy.rs | 4 + .../src-tauri/src/agent/direct_runtime.rs | 16 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 91 +++- .../src-tauri/src/agent/direct_tools_mcp.rs | 56 ++- .../src/agent/generation/canvas_generation.rs | 40 +- .../src-tauri/src/assets.rs | 85 +++- .../src-tauri/src/commands.rs | 4 +- .../src-tauri/src/http_client.rs | 414 +++++++++++++++ .../src-tauri/src/main.rs | 51 +- .../src/project/asset_canvas/generation.rs | 8 +- .../src-tauri/src/project/resource_editor.rs | 174 +++---- .../src-tauri/src/tests/project.rs | 111 ++++- .../src/services/clientHttp.ts | 13 +- .../tests/clientHttp.test.ts | 110 +++- ...Issue226跨Origin重定向标记泄漏修复-2026-09-02.md | 84 ++++ ...Issue226-AGC客户端主站请求统一标记-2026-09-01.md | 470 ++++++++++++++++++ ...26-AGC客户端主站请求标记分阶段验收-2026-09-01.md | 408 +++++++++++++++ ...单】AGC客户端主站调用与可标记点-2026-09-01.md | 228 +++++++++ ...】AGC主站请求标记统一注入两种方案-2026-09-01.md | 414 +++++++++++++++ ...】Issue226阶段0现状基线与契约冻结-2026-09-01.md | 184 +++++++ ...收】Issue226阶段6交接与最终门禁-2026-09-02.md | 119 +++++ 21 files changed, 2911 insertions(+), 173 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/http_client.rs create mode 100644 local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md create mode 100644 local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md create mode 100644 local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md create mode 100644 local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md create mode 100644 local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md create mode 100644 local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md create mode 100644 local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md 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_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index f81a8ec89..6189558e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -2484,7 +2484,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( task_id: art_spec.task_id.clone(), reference_resource_ids: Vec::new(), }; - let client = reqwest::Client::builder() + let client = crate::http_client::agc_main_site_client_builder() .timeout(std::time::Duration::from_secs(60)) .build() .map_err(|error| format!("创建陶泥儿只读资源恢复客户端失败:{error}"))?; @@ -2493,12 +2493,14 @@ async fn recover_direct_taonier_spritesheet_read_only_at( percent_encode_query_component(&remote_art_spec.canvas_project_id) )); access.validate_frozen_session()?; - let response = client - .get(format!("{api_base_url}{route}")) - .bearer_auth(&api_key) - .send() - .await - .map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?; + let response = crate::http_client::with_agc_main_site_marker( + client + .get(format!("{api_base_url}{route}")) + .bearer_auth(&api_key), + ) + .send() + .await + .map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?; access.validate_frozen_session()?; let status = response.status(); if !status.is_success() { 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 ae2df3ff1..57e511bdb 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 @@ -1810,27 +1810,31 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val .to_string(); let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?; let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?; - let client = reqwest::Client::new(); + let client = crate::http_client::agc_main_site_client_builder() + .build() + .map_err(|_| "创建抠图服务连接失败".to_string())?; let context = prepare_external_canvas_generation_context(&state.root, &client, &access).await?; let fingerprint = format!("{}\0{}", source_asset_id, asset_name); let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; let route = "/api/external/v1/editor/images/background-removals"; - let response = client - .post(format!("{}{}", api_base_url, route)) - .bearer_auth(api_key) - .header("Idempotency-Key", idempotency_key) - .json(&json!({ - "sourceImageSrc": source_resource_id, - "projectId": manifest.project_id, - "assetKind": source_asset.kind, - "assetFolderId": context.asset_folder_id, - "assetLabel": asset_name, - "sourceResourceId": source_resource_id, - })) - .send() - .await - .map_err(|error| format!("抠图服务提交失败:{error}"))?; + let response = crate::http_client::with_agc_main_site_marker( + client + .post(format!("{}{}", api_base_url, route)) + .bearer_auth(api_key) + .header("Idempotency-Key", idempotency_key) + .json(&json!({ + "sourceImageSrc": source_resource_id, + "projectId": manifest.project_id, + "assetKind": source_asset.kind, + "assetFolderId": context.asset_folder_id, + "assetLabel": asset_name, + "sourceResourceId": source_resource_id, + })), + ) + .send() + .await + .map_err(|error| format!("抠图服务提交失败:{error}"))?; let status = response.status(); let payload = response .json::() @@ -2179,6 +2183,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")?; @@ -2188,12 +2201,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())]) @@ -2319,6 +2327,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/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index ee9b32f60..e3cee4f47 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -685,7 +685,7 @@ pub(crate) async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, ) -> Result { - let response = request + let response = crate::http_client::with_agc_main_site_marker(request) .send() .await .map_err(|error| format!("{action}失败:{error}"))?; @@ -963,22 +963,24 @@ pub(crate) async fn submit_external_generation_request( } let request_body_json = serde_json::to_string(&request_body) .map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?; - let response = client - .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route(endpoint) - )) - .bearer_auth(api_key) - .header("Idempotency-Key", idempotency_key) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(request_body_json) - .send() - .await - .map_err(|error| { - format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}" - ) - })?; + let response = crate::http_client::with_agc_main_site_marker( + client + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(endpoint) + )) + .bearer_auth(api_key) + .header("Idempotency-Key", idempotency_key) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(request_body_json), + ) + .send() + .await + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}" + ) + })?; if response.status().is_server_error() { return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知", @@ -2401,11 +2403,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}")) }) .transpose()?; - let client = reqwest::Client::builder() + let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(60)) .build() .map_err(|error| format!("创建 External Editor HTTP 客户端失败:{error}"))?; - let submit_client = reqwest::Client::builder() + let submit_client = crate::http_client::agc_main_site_client_builder() .timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT) .build() .map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?; 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 9580640b9..ad87b4f5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -374,21 +374,21 @@ async fn create_private_external_editor_api_credentials_from_platform_session( .to_string() })?; let api_base_url = normalize_external_editor_api_base_url(&session.api_base_url)?; - let client = reqwest::Client::builder() + let client = crate::http_client::agc_main_site_client_builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| format!("创建本机开发者 Key 客户端失败:{error}"))?; - let response = client - .post(format!("{api_base_url}/api/profile/api-keys")) - .bearer_auth(&session.access_token) - .json(&serde_json::json!({ "name": DIRECT_EXTERNAL_EDITOR_API_KEY_NAME })) - .send() - .await - .map_err(|error| { - format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}") - })?; + let response = crate::http_client::with_agc_main_site_marker( + client + .post(format!("{api_base_url}/api/profile/api-keys")) + .bearer_auth(&session.access_token) + .json(&serde_json::json!({ "name": DIRECT_EXTERNAL_EDITOR_API_KEY_NAME })), + ) + .send() + .await + .map_err(|error| format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}"))?; let status = response.status(); if !status.is_success() { return Err(match status { @@ -709,7 +709,9 @@ pub(crate) async fn sync_canvas_project_assets_at( init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; let api_base_url = resolve_canvas_sync_api_base_url(api_base_url)?; let api_key = resolve_canvas_sync_api_key(api_key)?; - let client = reqwest::Client::new(); + let client = crate::http_client::agc_main_site_client_builder() + .build() + .map_err(|error| format!("创建画板同步客户端失败:{error}"))?; let project_url = format!( "{}{}", api_base_url, @@ -718,12 +720,12 @@ pub(crate) async fn sync_canvas_project_assets_at( percent_encode_query_component(canvas_project_id) )) ); - let project_response = client - .get(project_url) - .bearer_auth(&api_key) - .send() - .await - .map_err(|error| format!("读取画板项目失败:{error}"))?; + let project_response = crate::http_client::with_agc_main_site_marker( + client.get(project_url).bearer_auth(&api_key), + ) + .send() + .await + .map_err(|error| format!("读取画板项目失败:{error}"))?; let project_status = project_response.status(); if !project_status.is_success() { return Err(format!( @@ -1120,7 +1122,7 @@ where if max_bytes == 0 { return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string()); } - let secure_client = reqwest::Client::builder() + let secure_client = crate::http_client::agc_main_site_client_builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(60)) .redirect(reqwest::redirect::Policy::none()) @@ -1241,12 +1243,11 @@ pub(crate) async fn resolve_external_asset_signed_url( api_key: &str, read_url: String, ) -> Result { - let response = client - .get(read_url) - .bearer_auth(api_key) - .send() - .await - .map_err(|error| format!("换签画板资产失败:{error}"))?; + let response = + crate::http_client::with_agc_main_site_marker(client.get(read_url).bearer_auth(api_key)) + .send() + .await + .map_err(|error| format!("换签画板资产失败:{error}"))?; let status = response.status(); if !status.is_success() { return Err(format!("换签画板资产失败:HTTP {}", status.as_u16())); @@ -2080,6 +2081,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/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index a989aa710..55f86012f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -3280,7 +3280,7 @@ async fn fetch_agent_editor_asset_records( resolve_canvas_sync_api_credentials(None, None)?; let access = ExternalEditorBindingAccess::new(&api_base_url, &bearer_token, frozen_session.as_ref())?; - let client = reqwest::Client::builder() + let client = crate::http_client::agc_main_site_client_builder() .connect_timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(60)) .redirect(reqwest::redirect::Policy::none()) @@ -3830,7 +3830,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( } let access = ExternalEditorBindingAccess::new(&api_base_url, &bearer_token, frozen_session.as_ref())?; - let client = reqwest::Client::builder() + let client = crate::http_client::agc_main_site_client_builder() .connect_timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(60)) .redirect(reqwest::redirect::Policy::none()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/http_client.rs b/apps/ai-game-creator-shell/src-tauri/src/http_client.rs new file mode 100644 index 000000000..758df9f67 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/http_client.rs @@ -0,0 +1,414 @@ +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; + +const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; + +fn same_origin(initial: &reqwest::Url, next: &reqwest::Url) -> bool { + initial.origin() == next.origin() +} + +fn agc_main_site_redirect_policy() -> reqwest::redirect::Policy { + // Keep reqwest's default same-origin behavior, but fail closed before a + // custom marker can be copied to a different origin. + let default_policy = reqwest::redirect::Policy::default(); + reqwest::redirect::Policy::custom(move |attempt| { + let origin_matches = attempt + .previous() + .first() + .map(|initial| same_origin(initial, attempt.url())) + .unwrap_or(false); + + if origin_matches { + default_policy.redirect(attempt) + } else { + attempt.stop() + } + }) +} + +fn agc_main_site_marker_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static(AGC_CLIENT_MARKER_HEADER), + HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE), + ); + headers +} + +pub(crate) fn agc_main_site_client_builder() -> reqwest::ClientBuilder { + reqwest::Client::builder() + .default_headers(agc_main_site_marker_headers()) + .redirect(agc_main_site_redirect_policy()) +} + +/// Finalize a request sent through the AGC main-site client. +/// +/// `ClientBuilder::default_headers` only fills a missing request header. A +/// request-level header with the same name would otherwise win, so use +/// `RequestBuilder::headers` here to replace any caller-provided value with +/// the reserved AGC marker after all business headers have been configured. +pub(crate) fn with_agc_main_site_marker( + request: reqwest::RequestBuilder, +) -> reqwest::RequestBuilder { + request.headers(agc_main_site_marker_headers()) +} + +#[cfg(test)] +mod tests { + use super::{ + agc_main_site_client_builder, same_origin, AGC_CLIENT_MARKER_HEADER, + AGC_CLIENT_MARKER_VALUE, + }; + use reqwest::header::AUTHORIZATION; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::time::Duration; + + fn read_http_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set HTTP fixture read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = stream.read(&mut buffer).expect("read HTTP fixture request"); + assert!(read > 0, "HTTP fixture request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + if bytes.windows(4).any(|value| value == b"\r\n\r\n") { + break; + } + } + String::from_utf8_lossy(&bytes).into_owned() + } + + fn request_header(request: &str, expected_name: &str) -> Option { + request.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case(expected_name) + .then(|| value.trim().to_string()) + }) + } + + fn spawn_http_fixture(listener: TcpListener) -> std::thread::JoinHandle { + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept HTTP fixture request"); + let request = read_http_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 HTTP fixture response"); + request + }) + } + + fn spawn_redirect_fixture( + listener: TcpListener, + location: String, + ) -> std::thread::JoinHandle { + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept HTTP redirect request"); + let request = read_http_request(&mut stream); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(response.as_bytes()) + .expect("write HTTP redirect response"); + request + }) + } + + #[test] + fn main_site_redirect_policy_compares_full_origin() { + let origin_matches = |initial: &str, next: &str| { + let initial = reqwest::Url::parse(initial).expect("parse initial URL"); + let next = reqwest::Url::parse(next).expect("parse next URL"); + same_origin(&initial, &next) + }; + + assert!(origin_matches( + "https://main.example/api/first", + "https://main.example/api/second" + )); + assert!(origin_matches( + "https://main.example", + "https://main.example:443/api/second" + )); + assert!(!origin_matches( + "https://main.example", + "http://main.example/api/second" + )); + assert!(!origin_matches( + "https://main.example", + "https://cdn.example/api/second" + )); + assert!(!origin_matches( + "https://main.example:8443", + "https://main.example:9443/api/second" + )); + } + + #[tokio::test] + async fn factory_sets_the_agc_marker_as_a_default_header() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("read HTTP fixture address"); + let fixture = spawn_http_fixture(listener); + let client = agc_main_site_client_builder() + .build() + .expect("build AGC main-site client"); + let response = client + .get(format!("http://{address}/api/auth/me")) + .send() + .await + .expect("send request"); + let request = fixture.join().expect("join HTTP fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert_eq!( + request_header(&request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert!(request_header(&request, AUTHORIZATION.as_str()).is_none()); + assert!(request_header(&request, "idempotency-key").is_none()); + } + + #[tokio::test] + async fn request_finalizer_overrides_a_caller_provided_marker() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("read HTTP fixture address"); + let fixture = spawn_http_fixture(listener); + let client = agc_main_site_client_builder() + .build() + .expect("build AGC main-site client"); + let request = client + .get(format!("http://{address}/api/auth/me")) + .header(AGC_CLIENT_MARKER_HEADER, "spoofed-value"); + let response = super::with_agc_main_site_marker(request) + .send() + .await + .expect("send finalized request"); + let request = fixture.join().expect("join HTTP fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert_eq!( + request_header(&request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + } + + #[tokio::test] + async fn factory_keeps_request_headers_and_transport_options_configurable() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("read HTTP fixture address"); + let fixture = spawn_http_fixture(listener); + let client = agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(60)) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("build configured AGC main-site client"); + let response = client + .post(format!("http://{address}/api/editor/images/generations")) + .bearer_auth("fixture-token") + .header("Idempotency-Key", "fixture-id") + .send() + .await + .expect("send configured request"); + let request = fixture.join().expect("join HTTP fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert_eq!( + request_header(&request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert_eq!( + request_header(&request, AUTHORIZATION.as_str()), + Some("Bearer fixture-token".to_string()) + ); + assert_eq!( + request_header(&request, "idempotency-key"), + Some("fixture-id".to_string()) + ); + } + + #[tokio::test] + async fn factory_follows_same_origin_redirects_with_the_agc_marker() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("read HTTP fixture address"); + let fixture = std::thread::spawn(move || { + let (mut first_stream, _) = listener.accept().expect("accept first HTTP request"); + let first_request = read_http_request(&mut first_stream); + first_stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: /api/auth/me/final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write same-origin redirect response"); + + let (mut second_stream, _) = listener.accept().expect("accept redirected HTTP request"); + let second_request = read_http_request(&mut second_stream); + second_stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write final HTTP response"); + + (first_request, second_request) + }); + let client = agc_main_site_client_builder() + .timeout(Duration::from_secs(2)) + .no_proxy() + .build() + .expect("build AGC main-site client"); + + let response = client + .get(format!("http://{address}/api/auth/me")) + .send() + .await + .expect("send request"); + let (first_request, second_request) = fixture.join().expect("join HTTP fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert_eq!( + request_header(&first_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert_eq!( + request_header(&second_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + } + + #[tokio::test] + async fn factory_stops_cross_origin_redirects_before_sending_the_marker() { + let source_listener = TcpListener::bind("127.0.0.1:0").expect("bind source fixture"); + let source_address = source_listener + .local_addr() + .expect("read source fixture address"); + let target_listener = TcpListener::bind("127.0.0.1:0").expect("bind target fixture"); + target_listener + .set_nonblocking(true) + .expect("configure target fixture"); + let target_address = target_listener + .local_addr() + .expect("read target fixture address"); + let source_fixture = spawn_redirect_fixture( + source_listener, + format!("http://{target_address}/oss/object"), + ); + let client = agc_main_site_client_builder() + .timeout(Duration::from_secs(2)) + .no_proxy() + .build() + .expect("build AGC main-site client"); + + let response = client + .get(format!("http://{source_address}/api/assets/read-url")) + .send() + .await + .expect("send request"); + let source_request = source_fixture.join().expect("join source fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + assert_eq!( + request_header(&source_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert!(matches!( + target_listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + + #[tokio::test] + async fn factory_allows_same_origin_then_blocks_cross_origin_redirect_chain() { + let source_listener = TcpListener::bind("127.0.0.1:0").expect("bind source fixture"); + let source_address = source_listener + .local_addr() + .expect("read source fixture address"); + let target_listener = TcpListener::bind("127.0.0.1:0").expect("bind target fixture"); + target_listener + .set_nonblocking(true) + .expect("configure target fixture"); + let target_address = target_listener + .local_addr() + .expect("read target fixture address"); + let fixture = std::thread::spawn(move || { + let (mut first_stream, _) = source_listener + .accept() + .expect("accept first source request"); + let first_request = read_http_request(&mut first_stream); + first_stream + .write_all( + b"HTTP/1.1 302 Found\r\nLocation: /api/auth/me/second\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write same-origin redirect response"); + + let (mut second_stream, _) = source_listener + .accept() + .expect("accept second source request"); + let second_request = read_http_request(&mut second_stream); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{target_address}/oss/object\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + second_stream + .write_all(response.as_bytes()) + .expect("write cross-origin redirect response"); + + (first_request, second_request) + }); + let client = agc_main_site_client_builder() + .timeout(Duration::from_secs(2)) + .no_proxy() + .build() + .expect("build AGC main-site client"); + + let response = client + .get(format!("http://{source_address}/api/auth/me")) + .send() + .await + .expect("send request"); + let (first_request, second_request) = fixture.join().expect("join source fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + assert_eq!( + request_header(&first_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert_eq!( + request_header(&second_request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + assert!(matches!( + target_listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + + #[tokio::test] + async fn explicit_no_redirect_policy_still_overrides_the_factory_policy() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP fixture"); + let address = listener.local_addr().expect("read HTTP fixture address"); + let fixture = + spawn_redirect_fixture(listener, format!("http://{address}/api/auth/me/final")); + let client = agc_main_site_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .expect("build no-redirect AGC main-site client"); + + let response = client + .get(format!("http://{address}/api/auth/me")) + .send() + .await + .expect("send request"); + let request = fixture.join().expect("join HTTP fixture"); + + assert_eq!(response.status(), reqwest::StatusCode::FOUND); + assert_eq!( + request_header(&request, AGC_CLIENT_MARKER_HEADER), + Some(AGC_CLIENT_MARKER_VALUE.to_string()) + ); + } +} 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 245918c85..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 @@ -245,6 +249,7 @@ mod debug; mod delegation; mod git_inspect; mod goal; +mod http_client; mod image_inspect; mod isolated_agent; mod patchset; @@ -2655,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/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 6c92674d8..61f5f5306 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -2089,7 +2089,7 @@ async fn try_confirm_uploaded_reference( asset_kind: &str, ) -> Result, ReferencePreparationError> { let endpoint = resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm"); - let response = authorize_canvas_request( + let response = crate::http_client::with_agc_main_site_marker(authorize_canvas_request( client .post(format!("{api_base_url}{endpoint}")) .json(&serde_json::json!({ @@ -2102,7 +2102,7 @@ async fn try_confirm_uploaded_reference( "accessPolicy": "private", })), api_mode, - ) + )) .send() .await .map_err(|_| ReferencePreparationError::ConfirmFailed)?; @@ -3726,11 +3726,11 @@ async fn reconcile_generation( .to_string(), ); } - let client = reqwest::Client::builder() + let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(60)) .build() .map_err(|_| "无法创建图片生成 HTTP 客户端".to_string())?; - let submit_client = reqwest::Client::builder() + let submit_client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(35 * 60)) .build() .map_err(|_| "无法创建图片生成提交客户端".to_string())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index bb8296f32..5ca59b742 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1619,22 +1619,24 @@ async fn request_resource_edit_upload_ticket( .as_ref() .ok_or_else(|| "源媒体上传缺少文件内容".to_string())?; access.validate_frozen_session()?; - let response = client - .post(format!( - "{}{}", - access.api_base_url(), - access.api_route("/api/external/v1/assets/direct-upload-tickets") - )) - .bearer_auth(access.bearer_token()) - .json(&resource_edit_upload_ticket_payload( - input, - source, - binding_key_sha256, - bytes.len(), - )) - .send() - .await - .map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?; + let response = crate::http_client::with_agc_main_site_marker( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/direct-upload-tickets") + )) + .bearer_auth(access.bearer_token()) + .json(&resource_edit_upload_ticket_payload( + input, + source, + binding_key_sha256, + bytes.len(), + )), + ) + .send() + .await + .map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?; let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { if let Some(error) = post_response_session_error { @@ -1801,25 +1803,27 @@ async fn confirm_resource_edit_source( .as_ref() .ok_or_else(|| "确认源媒体上传缺少文件内容".to_string())?; access.validate_frozen_session()?; - let response = client - .post(format!( - "{}{}", - access.api_base_url(), - access.api_route("/api/external/v1/assets/objects/confirm") - )) - .bearer_auth(access.bearer_token()) - .json(&serde_json::json!({ - "bucket": bucket, - "objectKey": object_key, - "contentType": source.media_type, - "contentLength": bytes.len(), - "contentHash": source.source_sha256, - "assetKind": source.asset_kind, - "accessPolicy": "private", - })) - .send() - .await - .map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?; + let response = crate::http_client::with_agc_main_site_marker( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/objects/confirm") + )) + .bearer_auth(access.bearer_token()) + .json(&serde_json::json!({ + "bucket": bucket, + "objectKey": object_key, + "contentType": source.media_type, + "contentLength": bytes.len(), + "contentHash": source.source_sha256, + "assetKind": source.asset_kind, + "accessPolicy": "private", + })), + ) + .send() + .await + .map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?; let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { if let Some(error) = post_response_session_error { @@ -1902,34 +1906,36 @@ async fn register_resource_edit_source_image( percent_encode_query_component(remote_project_id) ); access.validate_frozen_session()?; - let response = client - .post(format!( - "{}{}", - access.api_base_url(), - access.api_route(&endpoint) - )) - .bearer_auth(access.bearer_token()) - .header( - "Idempotency-Key", - format!("game-creator-resource-{binding_key}"), - ) - .json(&serde_json::json!({ - "imageSrc": format!("/{}", object_key.trim_start_matches('/')), - "objectKey": object_key, - "assetObjectId": asset_object_id, - "width": decoded.width(), - "height": decoded.height(), - "sourceType": "uploaded", - "assetKind": source.asset_kind, - "generationInputs": { - "source": RESOURCE_EDIT_QUEUE_SOURCE, - "localAssetId": local_asset_id, - "sourceSha256": source.source_sha256, - }, - })) - .send() - .await - .map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?; + let response = crate::http_client::with_agc_main_site_marker( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route(&endpoint) + )) + .bearer_auth(access.bearer_token()) + .header( + "Idempotency-Key", + format!("game-creator-resource-{binding_key}"), + ) + .json(&serde_json::json!({ + "imageSrc": format!("/{}", object_key.trim_start_matches('/')), + "objectKey": object_key, + "assetObjectId": asset_object_id, + "width": decoded.width(), + "height": decoded.height(), + "sourceType": "uploaded", + "assetKind": source.asset_kind, + "generationInputs": { + "source": RESOURCE_EDIT_QUEUE_SOURCE, + "localAssetId": local_asset_id, + "sourceSha256": source.source_sha256, + }, + })), + ) + .send() + .await + .map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?; let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { if let Some(error) = post_response_session_error { @@ -2485,19 +2491,21 @@ async fn submit_resource_edit_remote( } } access.validate_frozen_session()?; - let response = client - .post(format!( - "{}{}", - access.api_base_url(), - access.api_route(endpoint) - )) - .bearer_auth(access.bearer_token()) - .header("Idempotency-Key", &ledger.idempotency_key) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body_value.to_string()) - .send() - .await - .map_err(|error| format!("result-unknown: 资源编辑请求已发出但未取得确定响应:{error}"))?; + let response = crate::http_client::with_agc_main_site_marker( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route(endpoint) + )) + .bearer_auth(access.bearer_token()) + .header("Idempotency-Key", &ledger.idempotency_key) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body_value.to_string()), + ) + .send() + .await + .map_err(|error| format!("result-unknown: 资源编辑请求已发出但未取得确定响应:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { @@ -2608,12 +2616,12 @@ async fn wait_for_resource_edit_remote( access.validate_frozen_session()?; tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; access.validate_frozen_session()?; - let response = client - .get(&status_url) - .bearer_auth(access.bearer_token()) - .send() - .await - .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; + let response = crate::http_client::with_agc_main_site_marker( + client.get(&status_url).bearer_auth(access.bearer_token()), + ) + .send() + .await + .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); @@ -3098,7 +3106,7 @@ async fn prepare_remote_resource_edit( "result-unknown: 历史站内资源编辑 operation 不能由 External v1 自动重放".to_string(), ); } - let client = reqwest::Client::builder() + let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(35 * 60)) .build() .map_err(|_| "无法创建资源编辑 HTTP 客户端".to_string())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 1b1ecd5a6..33759f02d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -993,6 +993,37 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { .iter() .find(|request| request.starts_with("POST /api/editor/icon-spritesheets/generations ")) .expect("canvas generation request"); + let request_header = |request: &str, expected_name: &str| { + request.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case(expected_name) + .then(|| value.trim().to_string()) + }) + }; + let main_site_requests = canvas_requests + .iter() + .filter(|request| { + request.starts_with("GET /api/editor/") + || request.starts_with("POST /api/editor/") + || request.starts_with("GET /api/assets/") + || request.starts_with("POST /api/assets/") + || request.starts_with("GET /api/runtime/") + }) + .collect::>(); + assert!(!main_site_requests.is_empty()); + assert!(main_site_requests.iter().all(|request| { + request + .to_ascii_lowercase() + .contains("x-genarrative-client: agc") + })); + assert_eq!( + request_header(generation_request, "authorization"), + Some("Bearer editor-runtime-key".to_string()) + ); + let generation_idempotency_key = + request_header(generation_request, "idempotency-key").expect("generation idempotency key"); + assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok()); + assert!(generation_request.contains(r#""source":"ai-game-creator-client""#)); assert_eq!( canvas_requests .iter() @@ -1011,6 +1042,14 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { 3, "fixture should exercise queued, running, and completed states" ); + assert!(canvas_requests + .iter() + .filter(|request| request.starts_with("GET /api/runtime/external-generation/jobs/")) + .all(|request| { + request + .to_ascii_lowercase() + .contains("x-genarrative-client: agc") + })); for expected in [ r#""referenceId":"resource-icon-spec""#, r#""iconDescriptions":"#, @@ -2052,7 +2091,8 @@ fn import_canvas_export_zip_copies_files_and_registers_assets() { #[tokio::test] async fn sync_canvas_project_assets_downloads_external_resources() { let root = unique_project_path(); - let base_url = spawn_mock_external_canvas_api_server(); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_external_canvas_api_server_with_capture(3, Some(request_sender)); let _platform_session = crate::platform_session::install_test_platform_session( "canvas-sync-user", "test-editor-api-key", @@ -2097,6 +2137,75 @@ async fn sync_canvas_project_assets_downloads_external_resources() { assert!(agent_db.contains("\"recordType\":\"canvas.project_sync\"")); assert!(!agent_db.contains("test-editor-api-key")); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!(requests.len(), 3, "画板同步应产生项目、换签和媒体请求"); + let main_site_requests = requests + .iter() + .filter(|request| !request.starts_with("GET /signed/")) + .collect::>(); + assert_eq!(main_site_requests.len(), 2); + assert!(main_site_requests.iter().all(|request| { + request + .to_ascii_lowercase() + .contains("x-genarrative-client: agc") + })); + let signed_download = requests + .iter() + .find(|request| request.starts_with("GET /signed/")) + .expect("signed media download request"); + assert!(!signed_download + .to_ascii_lowercase() + .contains("x-genarrative-client:")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker() { + let _platform_session = crate::platform_session::clear_test_platform_session(); + let root = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let base_url = spawn_mock_external_canvas_api_server_with_capture(3, Some(request_sender)); + let api_key = "tnr_sk_phase5_fixture"; + let result = crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ), + sync_canvas_project_assets_at( + &root, + "canvas-project-1", + Some(base_url.clone()), + Some(api_key.to_string()), + ), + ) + .await + .expect("developer key canvas project sync"); + + assert_eq!(result.canvas_project_id, "canvas-project-1"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!(requests.len(), 3); + let project_request = requests + .iter() + .find(|request| request.starts_with("GET /api/external/v1/editor/projects/")) + .expect("External v1 project request"); + let read_url_request = requests + .iter() + .find(|request| request.starts_with("GET /api/external/v1/assets/read-url?")) + .expect("External v1 read URL request"); + for request in [project_request, read_url_request] { + let normalized = request.to_ascii_lowercase(); + assert!(normalized.contains("x-genarrative-client: agc")); + assert!(normalized.contains("authorization: bearer tnr_sk_phase5_fixture")); + } + let signed_download = requests + .iter() + .find(|request| request.starts_with("GET /signed/")) + .expect("signed media download request"); + assert!(!signed_download + .to_ascii_lowercase() + .contains("x-genarrative-client:")); + fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src/services/clientHttp.ts b/apps/ai-game-creator-shell/src/services/clientHttp.ts index d8f2a9902..5bc5740b9 100644 --- a/apps/ai-game-creator-shell/src/services/clientHttp.ts +++ b/apps/ai-game-creator-shell/src/services/clientHttp.ts @@ -2,6 +2,8 @@ import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world'; export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world'; +export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client'; +export const AGC_CLIENT_MARKER_VALUE = 'agc'; export type ClientServerPreset = 'release' | 'dev' | 'custom'; @@ -125,6 +127,12 @@ type ClientHttpTarget = { url: string; }; +function withAgcClientMarker(init: RequestInit): RequestInit { + const headers = new Headers(init.headers); + headers.set(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE); + return { ...init, headers }; +} + function currentClientHttpContext(): ClientHttpContext { return { isDevelopment: import.meta.env.DEV, @@ -177,8 +185,9 @@ export async function fetchClientHttp( ? currentContext : { ...currentContext, serverBaseUrl }, ); + const markedInit = withAgcClientMarker(init); if (target.transport === 'tauri-http') { - return tauriHttpFetch(target.url, init); + return tauriHttpFetch(target.url, markedInit); } - return fetch(target.url, init); + return fetch(target.url, markedInit); } diff --git a/apps/ai-game-creator-shell/tests/clientHttp.test.ts b/apps/ai-game-creator-shell/tests/clientHttp.test.ts index 8b6114d3a..5c0f8d235 100644 --- a/apps/ai-game-creator-shell/tests/clientHttp.test.ts +++ b/apps/ai-game-creator-shell/tests/clientHttp.test.ts @@ -1,8 +1,16 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { + API_RESPONSE_ENVELOPE_HEADER, + API_RESPONSE_ENVELOPE_VERSION, +} from '../../../packages/shared/src/http'; +import { + AGC_CLIENT_MARKER_HEADER, + AGC_CLIENT_MARKER_VALUE, AGC_DEVELOPMENT_API_BASE_URL, AGC_RELEASE_API_BASE_URL, + fetchClientHttp, getClientServerBaseUrl, getClientServerSelection, normalizeClientServerBaseUrl, @@ -11,8 +19,106 @@ import { setClientServerSelection, } from '../src/services/clientHttp'; +vi.mock('@tauri-apps/plugin-http', () => ({ + fetch: vi.fn(), +})); + describe('AGC client HTTP transport', () => { - afterEach(() => resetClientServerSelectionForTests()); + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + resetClientServerSelectionForTests(); + }); + + it('adds the AGC marker while preserving and overriding request headers', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + const inputHeaders = new Headers({ + Authorization: 'Bearer fixture-token', + 'X-Request-ID': 'request-123', + [API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION, + [AGC_CLIENT_MARKER_HEADER]: 'caller-value', + }); + const init: RequestInit = { + method: 'POST', + headers: inputHeaders, + body: '{}', + credentials: 'same-origin', + }; + + await fetchClientHttp('/api/auth/me', init); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [target, forwardedInit] = fetchMock.mock.calls[0] as [ + string, + RequestInit, + ]; + const forwardedHeaders = new Headers(forwardedInit.headers); + expect(target).toBe('/api/auth/me'); + expect(forwardedHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe( + AGC_CLIENT_MARKER_VALUE, + ); + expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token'); + expect(forwardedHeaders.get('X-Request-ID')).toBe('request-123'); + expect(forwardedHeaders.get(API_RESPONSE_ENVELOPE_HEADER)).toBe( + API_RESPONSE_ENVELOPE_VERSION, + ); + expect(forwardedInit.method).toBe('POST'); + expect(forwardedInit.body).toBe('{}'); + expect(forwardedInit.credentials).toBe('same-origin'); + expect(inputHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe('caller-value'); + }); + + it.each([ + '/api/auth/me', + '/api/profile/dashboard', + '/api/editor/projects', + '/api/assets/read-bytes?objectKey=fixture', + ])('marks %s through the shared Web transport', async (url) => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal('fetch', fetchMock); + + await fetchClientHttp(url, {}); + + const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect( + new Headers(forwardedInit.headers).get(AGC_CLIENT_MARKER_HEADER), + ).toBe(AGC_CLIENT_MARKER_VALUE); + }); + + it('adds the AGC marker to the Tauri HTTP transport', async () => { + const tauriFetchMock = vi.mocked(tauriHttpFetch); + tauriFetchMock.mockResolvedValue(new Response(null, { status: 204 })); + vi.stubEnv('MODE', 'production'); + vi.stubEnv('DEV', false); + vi.stubGlobal('window', { + __TAURI__: {}, + location: { protocol: 'tauri:' }, + }); + + await fetchClientHttp( + '/api/auth/me', + { headers: { Authorization: 'Bearer fixture-token' } }, + { serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL }, + ); + + expect(tauriFetchMock).toHaveBeenCalledTimes(1); + const [target, forwardedInit] = tauriFetchMock.mock.calls[0] as [ + string, + RequestInit, + ]; + const forwardedHeaders = new Headers(forwardedInit.headers); + expect(target).toBe(`${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`); + expect(forwardedHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe( + AGC_CLIENT_MARKER_VALUE, + ); + expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token'); + }); it('keeps local development requests on the Vite API proxy', () => { expect( diff --git a/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md b/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md new file mode 100644 index 000000000..ca6b18e5b --- /dev/null +++ b/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md @@ -0,0 +1,84 @@ +# Issue #226:跨 origin 重定向标记泄漏复审修复 + +更新时间:2026-09-02 +关联 Issue:`#226 添加客户端特殊标识`;交接 Issue:`#225 添加客户端埋点统计` + +## 1. 修复结论 + +采纳评审意见:AGC 主站 Client 的默认重定向策略不能继续使用 reqwest 的“任意 origin 默认跟随”,否则 `default_headers` 中的 `X-Genarrative-Client: agc` 可能被复制到 OSS 或第三方 origin。 + +本次修复将工厂默认策略调整为: + +- 目标 URL 与原始请求 URL 的完整 origin(scheme + host + effective port)相同:委托 reqwest 默认 redirect policy。 +- origin 不同:`stop`,返回当前 3xx,不发起下一跳请求。 +- 无法取得原始 URL 或 origin 无法确认:按 fail-closed 处理,停止重定向。 + +这不是把 factory 改成全局 `Policy::none()`。同 origin 的 redirect 仍保留原有默认限制、方法和请求体处理;调用点显式设置的 `Policy::none()` 继续有效。 + +另针对评审发现的同名 Header 覆盖缺口,补充了主站请求终结器:`default_headers` 继续负责普通请求的默认注入;所有已审计的 AGC 主站请求在发送前统一调用 `with_agc_main_site_marker`,使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,确保最终值固定为 `agc`。 + +## 2. 修改范围 + +修改 AGC Rust HTTP Client factory、主站请求终结器、已审计主站直发调用点和定向测试: + +- `apps/ai-game-creator-shell/src-tauri/src/http_client.rs` +- 已审计主站请求所在的 `assets.rs`、`commands.rs`、`canvas_generation.rs`、`direct_runtime.rs`、`direct_tool_bridge.rs`、`project/asset_canvas/generation.rs`、`project/resource_editor.rs` +- 本地实施方案、阶段计划和本复审记录 + +不修改: + +- #225 的主站 tracking、数据库和后台代码。 +- OSS/签名下载、Provider、受控搜索、loopback、更新下载等第三方 Client。 +- 各业务调用点的 timeout、connect timeout、认证、幂等或请求体逻辑。 + +## 3. 重定向行为契约 + +| 场景 | 处理 | 标记是否到达下一跳 | +|---|---|---:| +| 同 scheme、host、effective port | 继续按 reqwest 默认策略 | 是,仍是主站 origin | +| scheme、host 或端口任一不同 | 返回当前 3xx,不 follow | 否,不发起请求 | +| 调用点显式 `Policy::none()` | 继续不 follow | 否 | + +使用 `Url::origin()` 比较,不比较 URL 字符串前缀;路径、查询参数和 fragment 不参与 origin 判断。 + +## 4. 同名 Header 覆盖契约 + +`reqwest` 的 `ClientBuilder::default_headers` 只会为请求补充缺失字段,请求级同名 Header 默认优先。因此不能把 `default_headers` 单独当作“不可伪造”的约束。 + +当前实现由两层组成: + +- factory 设置 `default_headers`,覆盖普通未显式设置 Header 的请求; +- `with_agc_main_site_marker(request)` 在主站请求发送前用 `RequestBuilder::headers` 替换同名字段。`external_editor_json_request` 已内置该终结器,直接 `.send()` 的主站路径也显式经过该终结器。 + +这样既保留方案一的 Client factory 形态,又满足“调用方传入同名 Header 时最终仍为 `X-Genarrative-Client: agc`”的冻结契约。 + +## 5. 测试证据 + +新增或调整 `http_client` 定向测试,覆盖: + +- 完整 origin 比较:默认端口等价,scheme/host/非默认端口变化视为跨 origin。 +- 同 origin 302:下一跳实际收到 `X-Genarrative-Client: agc`,最终响应成功。 +- 跨 origin 302:当前响应为 302,外部 listener 没有收到连接。 +- 显式 `Policy::none()`:仍然覆盖 factory 默认策略。 +- 请求级伪造同名 Header:终结器覆盖调用方值,服务端只收到 `agc`。 + +验证命令及结果: + +```text +cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture +8 tests passed +``` + +## 6. 对 #225 的交接 + +本修复不改变 Header 契约: + +```http +X-Genarrative-Client: agc +``` + +主站只会收到客户端实际发出的主站请求;跨 origin 3xx 不会产生第二个第三方请求,因此不会出现客户端把该标记发送到 OSS/第三方 origin 的情况。#225 不需要回改 tracking、数据库或后台设计。 + +## 7. 后续限制 + +`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy,或新增请求时绕过终结器。当前已审计生产调用点均已经过终结器,只有显式 `Policy::none()` 的 redirect 覆盖,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。 diff --git a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md new file mode 100644 index 000000000..3cdbc576d --- /dev/null +++ b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md @@ -0,0 +1,470 @@ +# Issue #226:AGC 客户端主站请求统一标记实施方案 + +更新时间:2026-09-02
+关联 Issue: + +- `#226 添加客户端特殊标识`:本方案实际实施范围 +- `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施 + +状态:阶段 6 已完成;已补齐跨 origin 重定向安全复审修复
+本方案包含 #226 客户端代码修改,不包含 #225 主站接收、落库和后台统计代码 + +## 1. 一句话交付结果 + +让 AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带 `X-Genarrative-Client: agc`,同时保持同源重定向及现有认证、幂等、timeout、connect timeout 和上传/下载安全边界;跨 origin 重定向不继续请求,第三方请求不携带该标记。 + +## 2. Issue 边界 + +### 2.1 本次只做 #226 + +本次只修改 AGC 客户端: + +- Web/TS 统一请求出口。 +- Tauri/Rust 主站专用 HTTP Client 构造方式。 +- 主站请求与第三方请求的 Client 边界。 +- 客户端侧定向测试。 + +### 2.2 本次不做 #225 + +本次不修改: + +- `server-rs` 请求上下文。 +- 主站 `tracking.rs`、tracking middleware 或 route tracking 规格。 +- SpacetimeDB `tracking_event` schema、migration、bindings 或索引。 +- 后台埋点查询、筛选和报表。 +- External v1 OpenAPI、请求 DTO 和响应 DTO。 + +### 2.3 不恢复或扩展其他旧标记 + +AGC 当前已有部分 body 内来源值: + +- `ai-game-creator-client` +- `game-creator-account-binding` +- `game-creator-resource-editor` + +本次不删除、不改写这些现有业务字段,也不把它们扩展成统一调用标记。它们继续服务各自的生成/资源登记语义;统一来源以 HTTP Header 为准。 + +## 3. 冻结的客户端标记契约 + +### 3.1 Header + +```http +X-Genarrative-Client: agc +``` + +约定: + +- Header 名按 HTTP 规则大小写不敏感;客户端发送时固定使用上面的拼写。 +- 值固定为小写 `agc`。 +- 客户端应覆盖调用方传入的同名 Header,避免业务层伪造其他值。 +- 不携带 token、用户 ID、API Key、版本号或项目 ID。 +- 不将该 Header 用作鉴权、权限、计费或账号归属证明。 + +### 3.2 发送范围 + +只要请求目标是当前选定的 Genarrative 主站 origin,并且属于主站业务/API 请求,就携带该 Header。 + +包括: + +- 登录、刷新、登出和用户查询。 +- 账户、钱包和充值接口。 +- 项目、画布、素材库和素材读取接口。 +- 上传凭证、对象确认和项目资源登记接口。 +- 图片、图集、去背景、角色动画、视频、音效和背景音乐生成接口。 +- 生成任务异步轮询接口。 +- 登录账号态映射后的 `/api/editor`、`/api/assets`、`/api/runtime` 请求。 +- 开发者 API Key 态的 `/api/external/v1` 请求。 +- 创建本机开发者 API Key 的 `/api/profile/api-keys` 请求。 + +不包括: + +- 更新清单和更新包下载。 +- OSS/对象存储 multipart 上传。 +- `read-url` 返回的签名地址下载。 +- LLM Provider 请求。 +- AGC 受控搜索。 +- loopback 工具桥。 +- 浏览器试玩页面和任意外部网页请求。 + +### 3.3 主站 origin + +主站 origin 必须来自当前选定的 `serverBaseUrl` / `apiBaseUrl`,包括: + +- 发布地址。 +- 开发地址。 +- 用户配置的合法自定义地址。 + +不能只根据硬编码域名判断,也不能因为 URL 路径包含 `/api` 就认定它是主站。 + +## 4. 方案一的落地结构 + +本次采用“统一 Client 工厂 + `default_headers` + origin-safe redirect policy”方案,不引入 origin-aware RequestBuilder 包装器。 + +总体结构: + +```text +TS 主站请求 + → fetchClientHttp + → 解析当前选定主站目标 + → 注入 X-Genarrative-Client: agc + → fetch / Tauri HTTP plugin + +Rust 主站请求 + → 主站 Client Builder factory + → default_headers 注入 X-Genarrative-Client: agc + → 请求发送前由主站请求终结器覆盖同名 Header + → 默认只跟随同 origin 重定向,跨 origin 返回当前 3xx + → 保留调用方原有 Client 配置 + +Rust 第三方请求 + → 独立 Client + → 不携带主站标记 +``` + +## 5. TS 实施方案 + +### 5.1 统一入口 + +文件: + +- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) + +在 `fetchClientHttp` 中: + +1. 按当前已有逻辑解析最终目标 URL。 +2. 保持现有的服务器地址校验和 transport 选择。 +3. 使用 `Headers` 合并 `init.headers`。 +4. 设置 `X-Genarrative-Client: agc`。 +5. 按现有逻辑调用浏览器 `fetch` 或 Tauri HTTP plugin。 + +### 5.2 需要保持的 Header + +不得覆盖或删除: + +- `Authorization` +- `Content-Type` +- `x-genarrative-response-envelope` +- 调用方已有的 `X-Request-ID` + +### 5.3 TS 侧不应修改的请求 + +[appUpdate.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/appUpdate.ts) 中直接访问更新清单的请求不经过 `fetchClientHttp`,继续保持现状,不纳入主站业务标记。 + +## 6. Rust 实施方案 + +### 6.1 主站 Client Builder factory + +在 AGC Tauri/Rust 侧增加一个小型、无业务语义的主站 Client Builder 入口。推荐职责只有: + +- 创建 `reqwest::ClientBuilder`。 +- 设置 `X-Genarrative-Client: agc` 默认 Header。 +- 设置只允许同 origin 的默认重定向策略;同源时委托 reqwest 默认策略,跨 origin 时停止。 +- 不负责 token、API Key、幂等键、请求体、重试或错误解析。 + +由于 `reqwest::ClientBuilder::default_headers` 只补充请求中缺失的字段,主站请求在发送前还必须经过 `with_agc_main_site_marker(request)`。该终结器使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,保证最终值固定为 `agc`;`external_editor_json_request` 已内置该步骤,直接 `.send()` 的主站路径显式调用该终结器。 + +示意结构: + +```rust +fn agc_main_site_client_builder() -> reqwest::ClientBuilder { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::HeaderName::from_static("x-genarrative-client"), + reqwest::header::HeaderValue::from_static("agc"), + ); + let default_policy = reqwest::redirect::Policy::default(); + let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { + let same_origin = attempt + .previous() + .first() + .map(|initial| initial.origin() == attempt.url().origin()) + .unwrap_or(false); + if same_origin { + default_policy.redirect(attempt) + } else { + attempt.stop() + } + }); + reqwest::Client::builder() + .default_headers(headers) + .redirect(redirect_policy) +} +``` + +实际实现可放在 AGC Rust 已有的客户端公共模块中;本 Issue 只增加轻量请求终结器,不新增完整 `AgcMainSiteClient` RequestBuilder 包装器,不提前实现方案二。 + +### 6.2 保留现有 Client 配置 + +主站请求原有 builder 配置必须在 factory 返回的 builder 上继续设置: + +```rust +let client = agc_main_site_client_builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(60)) + .redirect(reqwest::redirect::Policy::none()) + .build()?; +``` + +必须保留的语义包括: + +- connect timeout。 +- request timeout。 +- 调用方显式设置的 redirect policy;未显式设置时保留同源默认重定向、默认限制和方法处理,跨 origin 重定向由 factory 停止。 +- `no_proxy` 或其他已有网络策略(仅属于原请求的情况下保留)。 +- Bearer access token/API Key 的设置方式。 +- `Idempotency-Key` 的设置方式和原值。 +- Content-Type、multipart、JSON body 和响应解析方式。 + +`reqwest::Client::new()` 不能配置 `default_headers`。如果它被用于生产主站请求,需要改为从 builder 创建;不能为了少改一行而遗漏标记。 + +### 6.3 只替换主站请求的创建点 + +潜在涉及的生产文件: + +- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) +- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) +- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) +- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) +- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) +- [asset_canvas/generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) +- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) + +替换原则: + +- 主站 API Client 的 `Client::builder()` 改用 AGC 主站 builder。 +- 生产主站 API 中使用 `Client::new()` 的位置改用 AGC 主站 builder,并保留原 timeout 等配置。 +- 只访问主站的专用 Client 仍使用 factory 的默认 Header,并在最终 `.send()` 前经过 `with_agc_main_site_marker`;不能依赖 `default_headers` 覆盖请求级同名 Header。 +- 一个 Client 如果同时访问主站和第三方地址,不得直接改成主站 Client;应拆分用途或保持第三方 Client 独立。 + +### 6.4 不应替换的 Client + +以下请求明确保持独立 Client,不加主站标记: + +- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) 的 Provider 请求。 +- `build_external_asset_download_client` 创建的签名素材下载 Client。 +- OSS 表单直传 Client。 +- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) 的受控搜索 Client。 +- loopback 客户端工具桥 Client。 +- [main.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/main.rs) 的更新下载请求。 + +### 6.5 阶段 3 实施记录 + +- 已将生产代码中明确用于主站 API 的 Client 创建点切换为 `crate::http_client::agc_main_site_client_builder()`,覆盖 `assets.rs`、`commands.rs`、`canvas_generation.rs`、`direct_runtime.rs`、`direct_tool_bridge.rs`、`asset_canvas/generation.rs` 和 `resource_editor.rs`。 +- 主站项目、素材库、素材目录、上传凭证、对象确认、资源登记、图片生成/编辑/抠图、视频/动画/音频生成以及 generation 查询请求复用带 `X-Genarrative-Client: agc` 的 Client。 +- `assets/read-url` 换签请求使用主站 factory;签名 URL 媒体下载仍使用独立的 `build_external_asset_download_client`,不会携带主站标记。 +- OSS multipart 上传、Provider、受控搜索、loopback 工具桥和更新下载没有切换到主站 factory。 +- 保留原有 timeout、connect timeout、`no_proxy`、Bearer/API Key、Idempotency-Key、请求体和响应处理;同源重定向保持默认语义,跨 origin 重定向由 factory 阻断;未修改 `ExternalEditorBindingAccess` 的路由映射语义。 +- 阶段 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 代码。 + +### 6.7 阶段 5 实施记录 + +- 账号态回归使用真实平台会话 fixture,验证 `/api/editor`、`/api/assets` 和 `/api/runtime` 实际路径均携带 `X-Genarrative-Client: agc`。 +- 账号态生成请求继续携带 Bearer、UUID Idempotency-Key 和原有 `generationInputs.source = ai-game-creator-client`;排队、运行中和完成状态仍按原语义轮询,提交只发生一次。 +- External Key 态回归清除平台会话,使用 `tnr_sk_phase5_fixture` 和本地自定义 `apiBaseUrl`,验证 `/api/external/v1/editor/projects/*` 与 `/api/external/v1/assets/read-url` 携带相同标记和 API Key,签名媒体下载不带标记。 +- External v1 endpoint、账号态路由映射、请求体、认证、幂等、异步轮询和结果未知语义均未改变。 +- 阶段 5 验证:`background_agent_runtime_can_generate_platform_art_asset`、`sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker` 和 `generation_submit_response_loss_is_not_retried` 均通过;Rust fmt、编码检查和 `git diff --check` 通过。 + +### 6.8 同名 Header 覆盖复审修复记录(2026-09-02) + +- 评审确认 `reqwest` 的 `default_headers` 只补充缺失字段,请求级同名 Header 会优先,不能单独满足“标记值固定为 `agc`”的契约。 +- 新增 `crate::http_client::with_agc_main_site_marker`,在主站请求发送前用 `RequestBuilder::headers` 替换 `X-Genarrative-Client`;`external_editor_json_request` 内置该终结器,直接发送的主站请求也显式接入。 +- 已补齐账号态、External Key 态、生成/轮询、资源编辑、素材读取、对象确认和抠图等主站直发路径;OSS/签名下载、Provider、搜索、loopback 和更新下载仍保持独立 Client。 +- 新增回归测试验证调用方传入伪造同名 Header 时最终只发送 `X-Genarrative-Client: agc`;`http_client` 定向测试共 8 个通过。 + +## 7. 调用语义和现有路由边界 + +本次不改变 `ExternalEditorBindingAccess` 的职责: + +- 继续负责平台账号态/开发者 Key 态的凭据访问。 +- 继续负责 `/api/external/v1` 到 `/api/editor`、`/api/assets`、`/api/runtime` 的路径映射。 +- 继续负责冻结会话和账号切换校验。 + +统一标记只附加在 HTTP Client 层,不改变: + +- External v1 语义 endpoint。 +- 账号态实际 endpoint。 +- 请求体中的 `generationInputs`。 +- operationId、Idempotency-Key 或本地账本。 + +主站后续记录时必须使用实际到达的 HTTP path;不能仅使用客户端账本中保存的 External v1 endpoint。 + +## 8. #225 的交接契约(本次不实现,但现在冻结) + +以下内容是为了让 `#225` 可以直接按固定契约接收,不需要反向修改 `#226` 的设计。 + +### 8.1 主站接收规则 + +主站读取: + +```http +X-Genarrative-Client: agc +``` + +规则: + +- Header 名大小写不敏感。 +- 精确值 `agc` 视为 AGC 客户端。 +- 缺失、空值或未知值视为“未标记”,不拒绝请求,也不改变业务行为。 +- Header 不参与权限和计费。 +- 不从请求体中的 `generationInputs.source` 推导统一客户端标记。 + +### 8.2 tracking metadata 形态 + +为避免 #225 重新设计字段,建议固定写入现有 tracking metadata JSON: + +```json +{ + "route": "/api/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc" +} +``` + +约定: + +- JSON key 固定为 `client`。 +- AGC 值固定为 `agc`。 +- 缺失时不写 `client`,不写空字符串。 +- 第一阶段复用 `metadata_json`,不要求 `tracking_event` 新增列。 + +### 8.3 认证主体 + +主站记录标记时仍以真实认证主体为准: + +- 登录账号态:记录现有 `AuthenticatedAccessToken` 解析出的用户维度。 +- External API Key 态:记录 `ExternalApiPrincipal` 的 `owner_user_id`,必要时保留 `key_id` 维度。 +- 不记录 access token 或 API Key 明文。 + +### 8.4 路由覆盖 + +`#225` 应同时考虑: + +- `/api/auth/*` +- `/api/profile/*` +- `/api/assets/*` +- `/api/editor/*` +- `/api/runtime/*` +- `/api/external/v1/*` + +特别是 `/api/external/v1/*` 当前没有完整 route tracking spec,不能只依赖已有内部 `/api/editor` 路由规格。 + +### 8.5 成功与失败 + +`#226` 会给成功、失败、重试和轮询请求使用同一个来源标记。`#225` 是否继续只记录成功响应,或扩展为记录失败请求,应由 #225 按埋点目标决定,但不能要求 #226 为失败请求换另一种标记。 + +建议 #225 至少保留: + +- 实际 method/path。 +- response status。 +- request_id。 +- client=`agc`。 +- user/owner/key principal 的安全维度。 + +## 9. 定向测试方案 + +### 9.1 主站请求带标记 + +TS 层: + +- mock `fetchClientHttp` 的最终 transport。 +- 验证认证、素材和钱包请求均出现 `X-Genarrative-Client: agc`。 +- 验证已有 Authorization、envelope Header 和自定义 request ID 仍存在。 + +Rust 层: + +- 使用主站 mock server 接收项目查询、素材读取、生成提交和任务轮询。 +- 验证账号态映射路径携带标记。 +- 验证开发者 API Key 态 External v1 路径携带标记。 +- 验证长 timeout/短 timeout 的不同 Client profile 都携带标记。 + +### 9.2 第三方请求不带标记 + +使用 mock 或请求捕获断言以下请求没有 `X-Genarrative-Client`: + +- OSS multipart 上传。 +- 签名 URL 下载。 +- LLM Provider。 +- 受控网页搜索。 +- loopback 工具桥。 +- 更新清单/更新包下载。 + +### 9.3 语义保持 + +定向测试还应确认: + +- 原有 Authorization 仍被发送。 +- 原有 Idempotency-Key 仍被发送且值不变。 +- 请求体和 Content-Type 不变。 +- 同源 redirect policy 的默认跟随、限制和方法处理保持不变;跨 origin 3xx 不发起下一跳。 +- timeout 和 connect timeout 不被统一 factory 覆盖成单一值。 +- 第三方请求没有因共享 Client 改造而意外继承主站 Header。 + +## 10. 实施顺序 + +1. 固定 Header 常量和文档契约。 +2. 在 TS `fetchClientHttp` 加统一注入。 +3. 增加 Rust 主站 Client Builder factory。 +4. 按用途盘点并只替换主站 Client 创建点。 +5. 检查所有 `Client::new()` 主站生产调用是否已迁移到 builder。 +6. 检查 OSS、签名下载、Provider、搜索、loopback、更新下载仍使用独立 Client。 +7. 增加主站正向测试和第三方负向测试。 +8. 运行定向测试、类型检查、Rust check/test、`npm run check:encoding` 和 `git diff --check`。 +9. 将本方案中的第 8 节交给 `#225`,主站按该契约接收,不要求客户端二次改设计。 + +## 11. 完成判据 + +只有同时满足以下条件,`#226` 才算完成: + +- TS 主站业务请求统一带 `X-Genarrative-Client: agc`。 +- Rust 主站业务请求统一带同一 Header。 +- 账号态和开发者 API Key 态均覆盖。 +- 生成提交和异步轮询均覆盖。 +- 主站专用 Client 保留原有网络和业务语义。 +- OSS、签名 URL、Provider、搜索、loopback 和更新下载不带标记。 +- 客户端测试覆盖正向和负向边界。 +- 不修改 `#225` 所需的主站数据设计;`#225` 可直接读取 Header 并按本文约定写入 tracking metadata。 + +## 12. 阶段 6 实施记录与交接状态 + +更新时间:`2026-09-02` + +阶段 6 已完成,`#226` 客户端侧可以交付评审;`#225` 不需要因客户端实现回改设计。固定交接材料如下: + +- 请求 Header:`X-Genarrative-Client: agc`。 +- tracking metadata:复用现有 `metadata_json`,写入 `client: "agc"`;缺失时不写空字符串。 +- 主站记录实际收到的 method/path;不能只记录客户端账本中的 External v1 endpoint。 +- 登录账号态按真实用户维度记录;External API Key 态按 `ExternalApiPrincipal.owner_user_id` 记录,必要时保留 `key_id` 维度。 +- Header 只用于来源审计/统计,不参与鉴权、权限、计费或账号归属;不记录 token、API Key 明文或签名 URL。 +- `#225` 应同时覆盖账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 和 API Key 态 `/api/external/v1/*`。 +- Header 缺失、空值或未知值按未标记处理,不拒绝业务请求;客户端不需要为失败、重试或轮询请求更换标记。 + +最终门禁结果: + +| 门禁 | 结果 | +|---|---| +| TS `clientHttp` 定向测试 | 通过,14 tests passed | +| AGC TS 类型检查(含 skill-pack/config 检查) | 通过 | +| Rust `http_client` factory 测试 | 通过,7 tests passed;覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()` | +| Rust OSS/签名 URL/Provider/搜索/loopback/更新下载负向测试 | 通过,`omits_agc_marker` 4 tests passed;loopback 认证边界 1 test passed | +| Rust 账号态主站 mock 捕获 | 通过,1 test passed | +| Rust External Key 态主站 mock 捕获 | 通过,1 test passed | +| Rust 签名 URL 下载边界 | 通过,1 test passed | +| Rust 提交响应丢失幂等回归 | 通过,1 test passed | +| Rust fmt、编码、diff 空白检查 | 通过 | + +本阶段未执行真实发布环境线上 smoke;账号态和 External Key 态证据来自本地 mock/custom `apiBaseUrl` fixture。真实发布环境 smoke 属于发布前或 `#225` 联调门禁,不改变本次 `#226` 客户端契约。跨 origin 重定向复审已通过本地双 listener 和链式重定向测试确认不会发起第三方下一跳。 + +可直接粘贴到 `#225` 的交接评论: + +> `#226` 客户端侧已完成并冻结交接契约:AGC 主站业务请求统一发送 `X-Genarrative-Client: agc`。账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External API Key 态 `/api/external/v1/*` 均覆盖;OSS/签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。主站可按实际 method/path 读取 Header,并在现有 tracking `metadata_json` 中写入 `client: "agc"`;Header 缺失/空值/未知值按未标记处理且不拒绝请求。登录态按真实用户维度记录,External Key 态按 `owner_user_id`(必要时 `key_id`)记录,不记录 token/API Key 明文或签名 URL。客户端正向、负向、账号态、External Key 态和幂等回归均已通过,`#225` 不需要让 `#226` 回改设计。` diff --git a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md new file mode 100644 index 000000000..e0a9eb589 --- /dev/null +++ b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md @@ -0,0 +1,408 @@ +# Issue #226:AGC 客户端主站请求标记分阶段实施与验收计划 + +更新时间:2026-09-02
+关联 Issue: + +- `#226 添加客户端特殊标识`:本计划全部实施范围 +- `#225 添加客户端埋点统计`:只接收交接契约,不在本计划实现 + +当前状态:阶段 6 已完成;跨 origin 重定向复审修复已完成;阶段 0 的证据与验收记录见 +[【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md)。 + +## 1. 交付目标 + +AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带: + +```http +X-Genarrative-Client: agc +``` + +同时保持同源重定向及现有认证、幂等、timeout、connect timeout、`no_proxy`、请求体和响应处理语义;跨 origin 3xx 不继续请求;OSS、签名下载、Provider、搜索、loopback 和更新下载请求不携带该标记。 + +## 2. 明确不做项 + +本计划不修改: + +- 主站 `server-rs`。 +- `/api/external/v1` OpenAPI 或 DTO。 +- SpacetimeDB schema、migration、bindings。 +- 主站 tracking middleware、route tracking 或后台查询。 +- `#225` 的数据库字段和管理页实现。 +- 现有 `generationInputs.source` 等业务 body 字段。 + +## 3. 阶段总览 + +```text +阶段 0 现状基线与契约冻结 + ↓ +阶段 1 TS 统一出口 + ↓ +阶段 2 Rust 主站 Client Builder factory + ↓ +阶段 3 主站 Client 创建点迁移 + ↓ +阶段 4 第三方请求隔离与负向验证 + ↓ +阶段 5 账号态/External 态和请求语义回归 + ↓ +阶段 6 #225 交接与最终门禁 +``` + +每个阶段都应先完成本阶段验收,再进入下一阶段;阶段之间不依赖 `#225` 已经实现。 + +## 4. 阶段 0:现状基线与契约冻结 + +### 4.1 工作内容 + +- 确认当前工作树无其他相关未提交修改。 +- 复核 AGC TS 主站统一出口:`fetchClientHttp`。 +- 复核 Rust 主站请求和第三方请求的 Client 创建点。 +- 固定 Header 名和值:`X-Genarrative-Client: agc`。 +- 固定主站 origin 判定依据:当前选定的 `serverBaseUrl` / `apiBaseUrl`。 +- 固定第三方排除列表。 +- 固定 #225 接收时使用的 metadata 约定:`metadata.client = "agc"`。 + +### 4.2 不应发生的改动 + +- 不修改生产代码。 +- 不修改主站代码或 OpenAPI。 +- 不新增数据库字段。 + +### 4.3 验收标准 + +- 已有调用清单和实施方案文档可定位到实际源码。 +- 正向范围和排除范围没有未决歧义。 +- Header 契约、metadata 交接字段和未知值行为已写入 Issue/方案文档。 +- `git status --short` 只反映预期的文档或无相关修改。 + +### 4.4 阶段产物 + +- 本方案文档。 +- Issue #226 的实现范围评论草案(正式评论待外部 Issue 操作确认)。 +- 交给 #225 的固定 Header/metadata 交接说明。 +- 阶段 0 基线与验收记录。 + +## 5. 阶段 1:TS 统一出口注入 + +### 5.1 工作内容 + +修改范围限定在: + +- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) +- 对应的 `clientHttp` 定向测试或现有测试 fixture + +在 `fetchClientHttp` 中: + +1. 保持现有目标 URL 解析和服务器地址校验。 +2. 使用 `Headers` 合并原有 `RequestInit.headers`。 +3. 注入 `X-Genarrative-Client: agc`。 +4. 保持浏览器 `fetch` 与 Tauri HTTP plugin 两条 transport 行为不变。 + +认证和业务函数不逐个添加 Header: + +- `clientAuth.ts` 继续调用 `fetchClientHttp`。 +- `clientApi.ts` 继续调用 `fetchClientHttp`。 + +更新清单请求 [appUpdate.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/appUpdate.ts) 不迁移到该统一出口,也不纳入主站业务标记。 + +### 5.2 阶段验收 + +TS 定向测试必须证明: + +- `/api/auth/*` 请求带 `X-Genarrative-Client: agc`。 +- `/api/profile/*` 请求带 `X-Genarrative-Client: agc`。 +- `/api/editor/*` 请求带 `X-Genarrative-Client: agc`。 +- `/api/assets/*` 请求带 `X-Genarrative-Client: agc`。 +- 原 `Authorization` 保留。 +- 原 envelope Header 保留。 +- 原 `X-Request-ID` 保留。 +- 调用方传入其他同名值时,最终值仍为 `agc`。 +- 更新清单请求不因本阶段被改为携带主站业务标记。 + +### 5.3 阶段完成判据 + +- TS 层所有已确认的主站业务请求经过 `fetchClientHttp` 并可观察到标记。 +- 没有在业务 API 文件中出现重复 Header 注入。 +- 相关 TS 定向测试通过。 + +### 5.4 阶段 1 实施记录 + +- `clientHttp.ts` 新增固定 Header 常量,并在 `fetchClientHttp` 内通过 `Headers` 合并并覆盖同名调用方 Header。 +- Web `fetch` 与 Tauri HTTP plugin 都接收同一份带标记的 `RequestInit`;原始 `RequestInit.headers` 不被修改。 +- `clientHttp.test.ts` 已覆盖 `/api/auth/*`、`/api/profile/*`、`/api/editor/*`、`/api/assets/*`,并分别验证 Web/Tauri transport、Authorization、envelope Header、`X-Request-ID` 和同名 Header 覆盖。 +- 阶段 1 验证命令:`npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts`(14 tests passed);`npm run ai-game-creator-shell:typecheck` 通过。 +- `appUpdate.ts` 未接入 `fetchClientHttp`,更新下载边界保持不变。 + +## 6. 阶段 2:Rust 主站 Client Builder factory + +### 6.1 工作内容 + +在 AGC Tauri/Rust 侧增加小型主站 Client Builder 入口,职责限定为: + +- 创建 `reqwest::ClientBuilder`。 +- 通过 `default_headers` 注入 `X-Genarrative-Client: agc`。 +- 不负责认证、幂等、重试、请求体、错误解析或账本。 + +建议抽象为 builder 工厂而不是完整 RequestBuilder facade,以保持当前方案一的低复杂度: + +```rust +fn agc_main_site_client_builder() -> reqwest::ClientBuilder; +``` + +工厂返回的 builder 允许调用方继续设置原有配置;未显式覆盖时,factory 默认只跟随同 origin 重定向: + +- connect timeout +- request timeout +- 显式 redirect policy(覆盖 factory 默认策略) +- 原有 `no_proxy` 或其他网络策略 + +### 6.2 阶段验收 + +增加最小单元测试或 mock 请求测试,证明: + +- 从 factory build 出的 Client 默认带 `X-Genarrative-Client: agc`。 +- 请求级 Authorization 和 Idempotency-Key 仍可正常设置。 +- factory 不改变 timeout 或 proxy 配置;默认 redirect 只跟随同 origin,调用方显式 `Policy::none()` 仍可覆盖。 +- 不新增完整 `AgcMainSiteClient` 类型。 +- 不把第三方下载或 Provider Client 接入该 factory。 + +### 6.3 阶段完成判据 + +- 主站 Client 的统一构造入口已经存在。 +- factory 的职责没有扩展到业务编排。 +- 现有测试或新增测试能锁定 Header 默认值。 + +### 6.4 阶段 2 实施记录 + +- 新增 `src-tauri/src/http_client.rs`,提供 `agc_main_site_client_builder()`。 +- factory 仅通过 `default_headers` 注入 `x-genarrative-client: agc`,不接管认证、幂等、请求体、重试或错误解析。 +- 调用方仍可在返回的 builder 上继续配置 connect timeout、request timeout、显式 redirect policy 和 `no_proxy`;未显式配置时跨 origin 重定向不会被跟随。 +- 新增两个本地 TCP fixture 单元测试,验证真实发送请求带标记、请求级 Authorization/Idempotency-Key 保留,且 factory 不自动注入认证信息。 +- 阶段 2 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`(2 tests passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 通过。 +- 阶段 2 未迁移任何业务 Client 创建点;迁移属于阶段 3。 + +## 7. 阶段 3:主站 Client 创建点迁移 + +### 7.1 工作内容 + +只替换生产代码中用于主站 API 的 Client 创建方式: + +- 主站用途的 `reqwest::Client::builder()` 改用 AGC 主站 builder factory。 +- 主站用途的 `reqwest::Client::new()` 改为 builder 创建,以便设置默认 Header。 +- 保留每个调用原有 timeout、connect timeout、认证和幂等设置;同源 redirect 语义保留,跨 origin redirect 统一阻断。 + +重点检查文件: + +- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) +- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) +- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) +- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) +- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) +- [asset_canvas/generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) +- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) + +### 7.2 主站请求覆盖清单 + +迁移后应覆盖: + + +- `/api/profile/api-keys` +- `/api/editor/projects` +- `/api/editor/projects/{projectId}` +- `/api/editor/assets/library` +- `/api/editor/assets/folders` +- `/api/assets/direct-upload-tickets` +- `/api/assets/objects/confirm` +- `/api/assets/read-url` +- `/api/editor/projects/{projectId}/resources` +- `/api/editor/images/generations` +- `/api/editor/images/edits` +- `/api/editor/images/background-removals` +- `/api/editor/icon-spritesheets/generations` +- `/api/editor/character-animations/generations` +- `/api/editor/videos/generations` +- `/api/editor/audios/sound-effects/generations` +- `/api/editor/audios/background-music/generations` +- `/api/runtime/external-generation/jobs/{operationId}` +- 开发者 API Key 态对应的 `/api/external/v1/*` 路径 + +### 7.3 阶段验收 + +代码审查和请求捕获必须证明: + +- 上述主站请求均使用带默认 Header 的 Client。 +- 登录账号态路径带标记。 +- 开发者 API Key 态 External v1 路径带标记。 +- 生成提交和任务轮询都带标记。 +- 请求体、Bearer/API Key、Idempotency-Key、timeout 和同源 redirect 处理未变化;跨 origin redirect 不发起第二跳。 +- 没有为了加标记而修改 `ExternalEditorBindingAccess` 的路由映射语义。 + +### 7.4 阶段完成判据 + +- 主站生产请求的 Client 创建点完成迁移。 +- 没有把所有 Rust Client 粗暴替换成主站 Client。 +- 旧的第三方 Client 边界仍然清晰。 + +### 7.5 阶段 3 实施记录 + +- 已迁移 7 个生产 Rust 文件中的主站 Client 创建点:`assets.rs`、`commands.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`、`agent/direct_tool_bridge.rs`、`project/asset_canvas/generation.rs` 和 `project/resource_editor.rs`。 +- 迁移覆盖账号态实际 `/api/editor`、`/api/assets`、`/api/runtime` 路径,以及开发者 Key 态 `/api/external/v1` 路径;`assets/read-url` 的主站换签请求也通过 factory 创建。 +- 签名 URL 下载和 OSS multipart 上传仍由独立安全下载 Client 负责;Provider、搜索、loopback 和更新下载保持裸 Client。 +- 未修改 `ExternalEditorBindingAccess` 路由映射、凭据、请求体、operationId、Idempotency-Key 或本地账本语义。 +- 请求捕获测试已验证账号态画板同步的项目请求和换签请求带 `X-Genarrative-Client: agc`,签名媒体下载不带该 Header。 +- 阶段 3 验证:`http_client` 定向测试 2 个通过;`sync_canvas_project_assets_downloads_external_resources` 通过;Rust fmt、编码检查和 `git diff --check` 通过。 + +## 8. 阶段 4:第三方请求隔离与负向验证 + +### 8.1 工作内容 + +逐项确认以下 Client 不使用主站 factory: + +- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) +- `build_external_asset_download_client`。 +- OSS multipart 上传 Client。 +- 受控搜索 Client。 +- loopback 工具桥 Client。 +- 更新清单/更新包下载 Client。 + +### 8.2 阶段验收 + +为每类请求配置 mock 或请求捕获,断言: + +- OSS multipart 上传没有 `X-Genarrative-Client`。 +- 签名 URL 下载没有 `X-Genarrative-Client`。 +- LLM Provider 没有 `X-Genarrative-Client`。 +- 搜索请求没有 `X-Genarrative-Client`。 +- loopback 工具桥没有 `X-Genarrative-Client`。 +- 更新下载没有 `X-Genarrative-Client`。 + +同时断言这些请求原有的 `no_proxy`、redirect、超时和安全校验仍生效。 + +### 8.3 阶段完成判据 + +- 所有明确排除项均通过负向测试。 +- 没有出现“为了复用 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 工作内容 + +覆盖以下身份和地址组合: + +| 场景 | 认证 | 路径形态 | +|---|---|---| +| 平台账号态 | access token | `/api/editor`、`/api/assets`、`/api/runtime` | +| 开发者 Key 态 | `tnr_sk_...` | `/api/external/v1` | +| 开发环境 | 当前开发主站 origin | 主站实际路径 | +| 发布环境 | 当前发布主站 origin | 主站实际路径 | +| 自定义服务器 | 合法自定义 `apiBaseUrl` | 自定义主站实际路径 | + +### 9.2 阶段验收 + +- 账号态和开发者 Key 态都收到相同 Header 值 `agc`。 +- External v1 语义 endpoint 不因加 Header 改变。 +- 账号态路由映射不因加 Header 改变。 +- 自定义合法主站地址仍能携带标记。 +- 非主站地址不会被主站 Client 使用。 +- 认证失败、幂等重试、异步轮询和结果未知语义不变。 +- 现有 body 内 `generationInputs.source` 值保持原样。 + +### 9.3 阶段完成判据 + +- 关键请求矩阵全部有正向或负向证据。 +- 没有发生 token、API Key、Cookie、签名 URL 或项目绝对路径泄漏到日志/测试输出。 +- 没有因为 Header 增加而改变业务状态码或重试策略。 + +### 9.4 阶段 5 实施记录 + +- 账号态生成回归:`background_agent_runtime_can_generate_platform_art_asset` 捕获并检查 `/api/editor`、`/api/assets`、`/api/runtime` 请求,确认统一标记、Bearer、UUID Idempotency-Key、单次提交和三阶段轮询;同时锁定 `generationInputs.source = ai-game-creator-client`。 +- External Key 态与自定义服务器回归:`sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker` 在无平台会话下使用 `tnr_sk_phase5_fixture` 和自定义本地 `apiBaseUrl`,确认 External v1 项目/换签请求带标记,签名媒体下载不带标记。 +- 结果未知语义回归:`generation_submit_response_loss_is_not_retried` 通过,确认提交响应丢失时保留原 Idempotency-Key 且不重复提交。 +- 账号态画板同步测试继续覆盖主站项目请求、`assets/read-url` 和签名下载的正负边界。 +- 阶段 5 定向测试均通过;阶段 6 只剩 #225 交接材料复核与最终门禁,不在本阶段修改主站代码。 + +## 10. 阶段 6:#225 交接与最终门禁 + +### 10.1 交给 #225 的固定契约 + +`#225` 可以直接按以下约定实现,不需要要求 #226 回改客户端设计: + +```text +请求 Header:X-Genarrative-Client: agc +tracking metadata:client = "agc" +``` + +主站处理规则: + +- Header 名大小写不敏感。 +- 值为 `agc` 时识别为 AGC。 +- 缺失、空值或未知值按未标记处理,不拒绝业务请求。 +- 记录实际收到的 method/path,而不是客户端账本中的 External v1 endpoint。 +- 登录态使用真实登录用户维度。 +- External API Key 态使用 `ExternalApiPrincipal.owner_user_id`,必要时保留 `key_id`。 +- 不记录 token、API Key 明文或签名 URL。 +- External v1 不能因为当前没有 route spec 而漏记。 + +### 10.2 #226 最终门禁 + +完成 #226 前,不要求 #225 已经合并;但必须把以下材料交给 #225: + +- Header 名和值。 +- 主站/第三方请求边界。 +- 账号态和 External Key 态路径说明。 +- 正向测试结果摘要。 +- 负向测试结果摘要。 +- `metadata.client = "agc"` 的落库约定。 + +### 10.3 最终验证命令 + +按改动范围运行: + +- AGC TS 相关定向测试。 +- AGC Rust 相关定向测试或 `cargo test` 子集。 +- 必要的 mock HTTP 请求捕获测试。 +- `npm run check:encoding`。 +- `git diff --check`。 +- 检查 `git status --short`,确保没有构建产物、日志、凭据和无关文件。 + +### 10.4 阶段 6 实施记录 + +阶段 6 已完成,跨 origin 重定向复审修复已收口;交给 `#225` 的固定材料已在 +[【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md) +第 12 节集中确认:Header 为 `X-Genarrative-Client: agc`,tracking metadata 使用 `client: "agc"`,主站按实际 method/path 和真实认证主体记录;缺失/空值/未知 Header 不拒绝请求。该交接不要求 `#226` 再回改客户端,也不要求本阶段实现 `#225` 的主站 tracking、数据库或后台代码。 + +最终门禁已通过: + +- TS `clientHttp` 定向测试:14 tests passed。 +- AGC TS 类型检查:通过。 +- Rust `http_client` factory:7 tests passed,覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()`。 +- Rust `omits_agc_marker`:4 tests passed;loopback 认证边界:1 test passed。 +- 账号态主站 mock 捕获:1 test passed。 +- External Key 态主站 mock 捕获:1 test passed。 +- 签名 URL 下载边界:1 test passed。 +- 提交响应丢失幂等回归:1 test passed。 +- Rust fmt、`npm run check:encoding`、`git diff --check`:通过。 + +发布环境说明:本阶段没有执行真实发布环境线上 smoke;账号态/External Key 态使用本地 mock 和自定义 `apiBaseUrl` fixture 验证。真实发布环境 smoke 留给发布前或 `#225` 联调门禁,不影响 `#226` 客户端实现完成判定。 + +## 11. 建议提交/评审节奏 + +为方便阶段验收,建议在同一个 `#226` PR 内按以下逻辑组织提交或至少按阶段分组: + +1. 契约常量、TS 统一出口和 TS 测试。 +2. Rust 主站 Client Builder factory。 +3. Rust 主站 Client 创建点迁移。 +4. 第三方请求隔离和负向测试。 +5. 账号态/External 态回归、文档和最终门禁。 + +如果仓库提交策略要求单提交,也应在 PR 描述中按上述五组列出,确保每组都能单独 review 和验收。 diff --git a/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md b/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md new file mode 100644 index 000000000..5cbe08b61 --- /dev/null +++ b/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md @@ -0,0 +1,228 @@ +# AGC 客户端主站调用与可标记点清单 + +更新时间:2026-09-01
+范围:`apps/ai-game-creator-shell` 对 Genarrative 主站的调用扫描
+状态:只读盘点,未修改客户端、主站、OpenAPI 或数据库代码 + +## 1. 使用说明 + +本清单把调用分成三类: + +1. **已在 AGC 源码中发现的实际主站调用**:后续应纳入统一客户端标记范围。 +2. **主站 External v1 契约存在、但本次没有在 AGC 非测试源码中发现直接调用的接口**:作为潜在漏项保留,落地前需再次确认调用方。 +3. **明确不是主站的请求**:不应计入 AGC 主站调用统计,也不应加主站调用标记。 + +同一 AGC 能力可能有两套真实路径: + +- 登录账号态:使用平台账号 access token,External 语义路径会映射到站内 `/api/editor`、`/api/assets`、`/api/runtime`。 +- 开发者 API Key 态:使用 `tnr_sk_...`,保留 `/api/external/v1/...` 路径。 + +因此记录时应以主站实际收到的 `method + path + marker + request_id` 为准,不要只依赖客户端账本中的 External v1 endpoint。 + +## 2. 建议的统一标记 + +推荐先使用一个稳定请求头: + +```http +X-Genarrative-Client: agc +``` + +可继续复用已有 `X-Request-ID` 作为单次请求关联 ID。若后续需要区分一次客户端请求与重试,可另加每请求唯一的 `X-Genarrative-Client-Request-Id`,但不属于本次必须项。 + +标记只用于来源审计和统计,不得用于鉴权、权限、计费或账号归属判断。主站仍应以登录 access token 或 External API Key 的真实认证主体为准。 + +## 3. 已发现的实际调用:Web/TS 层 + +统一网络出口: + +- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) +- `fetchClientHttp` 根据环境使用浏览器 `fetch` 或 Tauri HTTP 插件。 +- 这一层适合统一注入标记,但不能覆盖 Tauri/Rust 的 `reqwest` 直连。 + +### 3.1 认证调用 + +统一封装:`clientAuth.ts` 的 `requestAuthJson`。 + +| 编号 | 方法 | 路径 | 用途 | 认证 | 标记建议 | 代码位置 | +|---|---|---|---|---|---|---| +| TS-A01 | GET | `/api/auth/me` | 获取当前用户 | Bearer,可选 | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:169) | +| TS-A02 | POST | `/api/auth/refresh` | 刷新 access token | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:183) | +| TS-A03 | POST | `/api/auth/entry` | 手机号+密码登录 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:210) | +| TS-A04 | POST | `/api/auth/phone/send-code` | 发送登录验证码 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:232) | +| TS-A05 | POST | `/api/auth/phone/login` | 手机号+验证码登录 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:253) | +| TS-A06 | POST | `/api/auth/logout` | 退出登录;失败时可能刷新后重试 | Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:275) | + +### 3.2 素材、账户和钱包调用 + +统一封装:`clientApi.ts` 的 `requestClientApi` / `requestClientApiBytes`。 + +| 编号 | 方法 | 路径 | 用途 | 标记建议 | 代码位置 | +|---|---|---|---|---|---| +| TS-B01 | GET | `/api/editor/assets/library` | 读取平台素材库 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:164) | +| TS-B02 | GET | `/api/assets/read-url?objectKey=...` | 获取素材签名读取地址 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:176) | +| TS-B03 | GET | `/api/assets/read-bytes?objectKey=...` | 读取素材二进制 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:190) | +| TS-B04 | GET | `/api/profile/dashboard` | 读取余额/账户概览 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:198) | +| TS-B05 | GET | `/api/profile/recharge-center` | 读取充值中心 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:206) | +| TS-B06 | POST | `/api/profile/recharge/orders` | 创建充值订单 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:214) | +| TS-B07 | POST | `/api/profile/recharge/orders/{orderId}/wechat/confirm` | 确认微信支付订单 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:229) | +| TS-B08 | GET | `/api/profile/wallet-ledger` | 读取钱包流水 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:237) | + +## 4. 已发现的实际调用:Tauri/Rust 层 + +Rust 层直接使用 `reqwest`,不会经过 TS 的 `fetchClientHttp`,必须单独覆盖。 + +### 4.1 开发者 Key 与项目/素材上下文 + +核心路由分流在: + +- [external_editor_bindings.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs:88) + +| 编号 | External v1 语义路径 | 账号态实际路径 | 用途 | 标记建议 | 代码位置 | +|---|---|---|---|---|---| +| RS-C01 | `POST /api/profile/api-keys` | 同路径 | 创建本机 AGC 开发者 API Key | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:384) | +| RS-C02 | `GET /api/external/v1/editor/projects` | `GET /api/editor/projects` | 列出/查找画布项目 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1119) | +| RS-C03 | `POST /api/external/v1/editor/projects` | `POST /api/editor/projects` | 创建画布项目 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1151) | +| RS-C04 | `GET /api/external/v1/editor/projects/{projectId}` | `GET /api/editor/projects/{projectId}` | 读取项目、恢复或同步 | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:717) | +| RS-C05 | `GET /api/external/v1/editor/assets/library` | `GET /api/editor/assets/library` | 读取账户素材库 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1196) | +| RS-C06 | `POST /api/external/v1/editor/assets/folders` | `POST /api/editor/assets/folders` | 创建素材文件夹 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1224) | +| RS-C07 | `POST /api/external/v1/assets/direct-upload-tickets` | `POST /api/assets/direct-upload-tickets` | 获取对象存储直传凭证 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1532) | +| RS-C08 | `POST /api/external/v1/assets/objects/confirm` | `POST /api/assets/objects/confirm` | 确认已上传对象 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1629) | +| RS-C09 | `GET /api/external/v1/assets/read-url` | `GET /api/assets/read-url` | 换签读取素材 | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:1245) | +| RS-C10 | `POST /api/external/v1/editor/projects/{projectId}/resources` | `POST /api/editor/projects/{projectId}/resources` | 登记项目画布资源 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1659) | + +### 4.2 生成、去背景和异步轮询 + +| 编号 | 方法 | External v1 语义路径 | 账号态实际路径 | 用途 | 标记建议 | 代码位置 | +|---|---|---|---|---|---|---| +| RS-G01 | POST | `/api/external/v1/editor/images/generations` | `/api/editor/images/generations` | 图片生成 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:941) | +| RS-G02 | POST | `/api/external/v1/editor/images/edits` | `/api/editor/images/edits` | 图片编辑/重绘 | 应加 | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2827) | +| RS-G03 | POST | `/api/external/v1/editor/images/background-removals` | `/api/editor/images/background-removals` | 图片去背景 | 应加 | [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs:1818) | +| RS-G04 | POST | `/api/external/v1/editor/icon-spritesheets/generations` | `/api/editor/icon-spritesheets/generations` | 图标图集/切片生成 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:6290) | +| RS-G05 | POST | `/api/external/v1/editor/character-animations/generations` | `/api/editor/character-animations/generations` | 角色动画生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2344) | +| RS-G06 | POST | `/api/external/v1/editor/videos/generations` | `/api/editor/videos/generations` | 视频生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2373) | +| RS-G07 | POST | `/api/external/v1/editor/audios/sound-effects/generations` | `/api/editor/audios/sound-effects/generations` | 音效生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2391) | +| RS-G08 | POST | `/api/external/v1/editor/audios/background-music/generations` | `/api/editor/audios/background-music/generations` | 背景音乐生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2408) | +| RS-G09 | GET | `/api/external/v1/generations/{operationId}` | `/api/runtime/external-generation/jobs/{operationId}` | External Key/账号态生成任务轮询 | 应加 | [external_editor_bindings.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs:100) | + +资源编辑器统一提交和轮询还位于: + +- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2498) +- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2614) + +### 4.3 Direct Runtime 恢复与账户素材导入 + +| 编号 | 方法 | 路径 | 用途 | 标记建议 | 代码位置 | +|---|---|---|---|---|---| +| RS-R01 | GET | `/api/external/v1/editor/projects/{projectId}` 或 `/api/editor/projects/{projectId}` | Direct Runtime 只读恢复项目资源 | 应加 | [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs:2487) | +| RS-R02 | GET | `/api/external/v1/assets/read-url` 或 `/api/assets/read-url` | 账户素材导入前换签 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3833) | +| RS-R03 | GET | `/api/external/v1/editor/assets/library` 或 `/api/editor/assets/library` | 查询当前账号素材并按 assetId 选择 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3283) | +| RS-R04 | GET | `/api/external/v1/editor/projects/{projectId}` 或 `/api/editor/projects/{projectId}` | 读取项目画布资源清单 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3327) | + +## 5. 已知的现有 body 内来源字段 + +这些字段不是统一 Header,且不会被全局 HTTP tracking middleware 自动读取,只能作为业务请求内部来源信息: + +| 来源值 | 出现位置 | 覆盖范围 | 说明 | +|---|---|---|---| +| `ai-game-creator-client` | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:951) | 部分图片生成请求 | 账号态图片生成 helper 会写入 `generationInputs.source` | +| `game-creator-account-binding` | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2311) | 项目资源登记 | 表示 AGC 账户绑定上下文 | +| `game-creator-resource-editor` | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2796) | 资源编辑生成输入 | 表示 AGC 资源编辑链路 | + +这些 body 字段不能替代统一请求标记,因为它们不覆盖登录、列表、素材读取、钱包、上传凭证、对象确认和任务轮询等请求;并且主站 tracking 当前不会解析生成请求 body 中的 `generationInputs.source`。 + +## 6. External v1 契约中存在、但本次未发现 AGC 直接调用的潜在接口 + +这些接口在 [genarrative-external-v1.openapi.json](C:/projects/narrative/Genarrative/docs/openapi/genarrative-external-v1.openapi.json) 中存在。它们应保留在后续核对清单中,但本次不把它们误报为 AGC 已实际调用: + +### 6.1 项目管理 + +- `GET /api/external/v1/editor/projects/recent` +- `DELETE /api/external/v1/editor/projects/{projectId}` +- `PATCH /api/external/v1/editor/projects/{projectId}/metadata` +- `PATCH /api/external/v1/editor/projects/{projectId}/canvas` + +### 6.2 素材文件夹和素材记录管理 + +- `PATCH /api/external/v1/editor/assets/folders/{folderId}` +- `DELETE /api/external/v1/editor/assets/folders/{folderId}` +- `POST /api/external/v1/editor/assets` +- `PATCH /api/external/v1/editor/assets/{assetId}` +- `DELETE /api/external/v1/editor/assets/{assetId}` + +### 6.3 UI 设计图素材提取 + +- `POST /api/external/v1/editor/ui-designs/assets/extractions` + +当前 AGC Prompt 明确要求不要误用这个接口,而是通过图片生成链路生成 UI 原型,因此它目前属于契约潜在接口,不属于已确认实际调用。 + +### 6.4 External API 发现和协议接口 + +这些是公开契约/集成发现能力,不属于普通 AGC 主站业务调用;若未来 AGC 直接接入 MCP 或远程 Skill,再单独纳入: + +- `GET /api/external/v1/openapi.json` +- `GET /api/external/v1/agent-integration.json` +- `GET /api/external/v1/skill/SKILL.md` +- `GET /api/external/v1/skill.zip` +- `POST /api/external/v1/mcp` + +## 7. 明确排除的请求 + +以下请求不是 AGC 调用主站,不应进入主站调用标记统计: + +- LLM Provider:`POST {provider}/responses`,见 [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs)。 +- OSS/对象存储上传:主站返回直传票据后,客户端向票据 host 发出的 multipart POST。 +- 签名素材下载:`read-url` 返回临时地址后,对签名地址发出的二进制 GET。 +- AGC 受控网页搜索:见 [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs:2201)。 +- 本地 Tauri IPC、文件读写、浏览器试玩页面请求。 + +## 8. 主站承接标记的现状 + +### 8.1 请求上下文 + +主站请求上下文位于: + +- [request_context.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/request_context.rs) + +当前已有 `request_id`、operation 和计时信息,但没有 client/source/marker 字段。 + +### 8.2 全局 tracking + +主站全局 middleware 和 route tracking 位于: + +- [app.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/app.rs) +- [tracking.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/tracking.rs) + +当前特点: + +- 主要在成功响应后写 route tracking event。 +- `/api/auth`、`/api/profile`、`/api/assets`、`/api/editor`、`/api/runtime` 已有部分 route spec。 +- `/api/external/v1/*` 当前没有 route spec,所以 External API Key 模式的 AGC 调用不会自动进入现有 route tracking。 +- External API 鉴权中间件已有 `ExternalApiPrincipal`,可提供 `owner_user_id` 和 `key_id`,但现有成功 route tracking 只显式读取登录态 `AuthenticatedAccessToken`,需要单独确认 External principal 的接入方式。 + +### 8.3 数据库和后台 + +现有 SpacetimeDB `tracking_event` 已有 `metadata_json`,适合第一阶段记录: + +```json +{ + "client": "agc", + "route": "/api/editor/images/generations", + "method": "POST", + "status": 202 +} +``` + +后台当前 tracking 查询支持 event、user、scope、日期等结构化条件,不能直接按 `metadata_json.client` 高效筛选。若后续需要高频按客户端筛选,再考虑新增独立 `client_marker` 字段和索引;这会涉及 schema、migration、绑定和相关门禁。 + +## 9. 后续实现前的核对顺序 + +本文件只作为调用盘点,不代表已经授权改造。真正落地前建议按以下顺序重新核对: + +1. 以本清单的“实际调用”表为准,逐个确认非测试源码仍有调用。 +2. 先覆盖 TS `fetchClientHttp`。 +3. 再覆盖 Rust 的公共 External Editor 请求构造点和剩余直接 `reqwest` 调用。 +4. 确认账号态映射路径与开发者 Key External v1 路径都能携带同一标记。 +5. 主站解析 Header 时使用白名单值,未知值按未标记处理,不影响业务请求。 +6. route tracking 同时补充 External v1 路径和 External API principal 维度。 +7. 第一阶段优先写 `metadata_json`,暂不急于改 SpacetimeDB schema。 +8. 验证成功、失败、重试、异步轮询、401、上传凭证和钱包请求是否都能按预期区分。 diff --git a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md b/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md new file mode 100644 index 000000000..7c938c0ab --- /dev/null +++ b/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md @@ -0,0 +1,414 @@ +# AGC 主站请求标记统一注入两种方案 + +更新时间:2026-09-01
+关联 Issue:`#226 添加客户端特殊标识`、`#225 添加客户端埋点统计`
+状态:方案说明,未实施代码修改 + +## 1. 背景 + +AGC 客户端同时存在两套 HTTP 出口: + +1. Web/TS 层,通过 `fetchClientHttp` 使用浏览器 `fetch` 或 Tauri HTTP 插件。 +2. Tauri/Rust 层,通过多个 `reqwest::Client` 直接调用主站、对象存储、签名下载地址、LLM Provider 和本地工具桥。 + +目标是让 AGC 发往主站的请求统一携带来源标记,而不是在每个业务 API 调用点重复写 Header。 + +推荐标记: + +```http +X-Genarrative-Client: agc +``` + +该标记只用于来源审计、埋点和统计,不参与鉴权、权限、计费或账号归属判断。 + +## 2. 目标与不做项 + +### 2.1 目标 + +- AGC 发往当前选定主站 origin 的请求统一携带客户端标记。 +- 同时覆盖登录账号态和开发者 API Key 态。 +- 同时覆盖普通业务请求、生成提交和异步任务轮询。 +- 避免在每个 `/api/...` 调用点重复设置 Header。 +- 不把标记发送给 OSS、签名资源地址、LLM Provider、网页搜索或本地工具桥。 +- 保持现有 access token、API Key、幂等键和请求体语义不变。 + +### 2.2 不做项 + +- 本方案不定义主站数据库字段和后台页面实现,主站接收与落库属于 `#225`。 +- 不使用客户端标记替代真实认证主体。 +- 不修改 External v1 请求 DTO 或 OpenAPI 请求体。 +- 不通过 DNS、系统代理或网络抓包层注入 Header。 +- 不给进程内所有 `reqwest` 请求无差别设置默认 Header。 + +## 3. 两种方案的共同部分 + +无论 Rust 层选择哪种方案,Web/TS 层都可以直接在统一出口注入。 + +### 3.1 Web/TS 统一出口 + +文件: + +- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) + +处理方式: + +1. `fetchClientHttp` 解析最终请求地址。 +2. 确认目标 origin 等于当前选定的 `serverBaseUrl`。 +3. 在传给浏览器 `fetch` 或 Tauri HTTP 插件前设置: + +```http +X-Genarrative-Client: agc +``` + +示意代码: + +```ts +const headers = new Headers(init.headers); +headers.set('X-Genarrative-Client', 'agc'); + +return transport(target.url, { + ...init, + headers, +}); +``` + +需要保留调用方原有 Header,尤其是: + +- `Authorization` +- `Content-Type` +- `x-genarrative-response-envelope` +- `X-Request-ID`,如果调用方已提供 + +### 3.2 主站 origin 的定义 + +不能只写死以下两个地址: + +```text +https://dev.genarrative.world +https://www.genarrative.world +``` + +AGC 还支持自定义服务器,判断依据应是当前请求绑定的权威 `serverBaseUrl` / `apiBaseUrl`,并比较规范化后的 origin: + +```text +scheme + host + effective port +``` + +路径、query 和 fragment 不属于 origin 判断条件。 + +### 3.3 必须排除的请求 + +以下请求不能携带主站客户端标记: + +- `direct-upload-tickets` 返回的对象存储 multipart 上传地址。 +- `read-url` 返回的临时签名下载地址。 +- LLM Provider 的 `/responses` 等请求。 +- AGC 受控网页搜索请求。 +- 本地 loopback 工具桥请求。 +- 游戏试玩页面、外部网页或用户输入的任意 URL。 + +### 3.4 CORS + +浏览器环境向不同 origin 的主站发送自定义 Header 时可能触发 CORS 预检。主站及自定义部署需要允许: + +```http +Access-Control-Allow-Headers: X-Genarrative-Client +``` + +Tauri HTTP 插件和 Rust `reqwest` 不受浏览器 CORS 限制,但仍应遵守相同的目标 origin 边界。 + +## 4. 方案一:专用主站 HTTP Client 工厂 + +### 4.1 核心思路 + +为 Rust 层建立一个只允许服务于主站 API 的 `reqwest::Client` 构造函数,并通过 `default_headers` 自动加入客户端标记;请求发送前再由轻量终结器覆盖同名 Header,确保调用方不能伪造标记值。 + +第三方上传、签名下载、Provider 和搜索继续使用现有独立 Client,不带标记。 + +示意代码: + +```rust +fn build_agc_main_site_client(timeout: Duration) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::HeaderName::from_static("x-genarrative-client"), + reqwest::header::HeaderValue::from_static("agc"), + ); + + let default_policy = reqwest::redirect::Policy::default(); + let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { + let same_origin = attempt + .previous() + .first() + .map(|initial| initial.origin() == attempt.url().origin()) + .unwrap_or(false); + if same_origin { + default_policy.redirect(attempt) + } else { + attempt.stop() + } + }); + + reqwest::Client::builder() + .default_headers(headers) + .connect_timeout(Duration::from_secs(10)) + .timeout(timeout) + .redirect(redirect_policy) + .build() + .map_err(|error| format!("创建 AGC 主站 HTTP 客户端失败:{error}")) +} +``` + +调用代码仍然保持普通 `reqwest` 写法: + +```rust +let client = build_agc_main_site_client(Duration::from_secs(60))?; + +let response = with_agc_main_site_marker( + client + .get(format!("{api_base_url}{route}")) + .bearer_auth(token), +) + .send() + .await?; +``` + +业务请求不再逐个调用 `.header("X-Genarrative-Client", "agc")`;由统一终结器在发送前覆盖调用方同名值。 + +### 4.2 预计改动范围 + +主要改动是把主站用途的 `reqwest::Client::new()` / `Client::builder()` 替换为统一工厂,而不是修改每个 API endpoint。 + +潜在涉及文件: + +- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) +- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) +- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) +- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) +- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) +- [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) +- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) + +不应迁移到主站 Client 的文件或请求: + +- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) +- `build_external_asset_download_client` 创建的签名资源下载 Client。 +- OSS multipart 上传 Client。 +- 受控搜索和 loopback 工具桥 Client。 + +### 4.3 优点 + +- 实现简单,符合当前仓库优先简单设计的原则。 +- 业务请求代码基本不变。 +- Header 由 factory 默认注入,终结器在发送前统一覆盖同名值。 +- 不需要新增 HTTP middleware 框架或复杂泛型包装。 +- 便于为普通请求和长时间生成提交提供不同 timeout,但共享同一标记配置。 +- 可以通过单元测试检查 Client 发出的请求自动带标记。 + +### 4.4 缺点和风险 + +- `default_headers` 对该 Client 发出的所有请求生效,不会再次判断目标域名。 +- `default_headers` 不能覆盖请求级同名 Header;新增的主站直发路径若绕过请求终结器,仍可能发送错误值。 +- 如果后续有人误用主站 Client 请求 OSS、签名 URL 或 Provider,标记会被带到第三方。 +- 当前存在多个不同 timeout 的 Client,需要提供少量参数或几个明确的工厂函数。 +- 仍需要替换现有主站 Client 的创建点,无法做到完全零调用点改动。 + +### 4.5 风险控制 + +保持控制简单,不引入复杂网络状态机: + +- 主站 Client 只在持有权威 `apiBaseUrl` 的模块中创建。 +- 主站 Client 不传给通用下载函数。 +- 签名下载继续由 `build_external_asset_download_client` 单独创建。 +- OSS 上传继续使用单独 Client。 +- 工厂名称明确包含 `main_site`,避免误用。 +- 所有 AGC 主站请求的最终 `.send()` 前统一经过 `with_agc_main_site_marker`;新增直发路径必须同步接入该终结器。 +- 定向测试断言主站 mock 收到标记、第三方 mock 未收到标记。 + +## 5. 方案二:按目标 origin 的请求包装器 + +### 5.1 核心思路 + +不依赖 Client 的默认 Header,而是统一通过一个请求构造入口创建主站 RequestBuilder。 + +包装器在构造请求时: + +1. 解析目标 URL。 +2. 解析当前权威 `apiBaseUrl`。 +3. 比较两者 origin。 +4. 只有同源时加入 AGC 标记。 +5. 非同源时拒绝,或明确返回不带标记的普通请求。 + +更安全的做法是主站包装器直接拒绝非同源 URL。 + +示意代码: + +```rust +fn main_site_request( + client: &reqwest::Client, + method: reqwest::Method, + api_base_url: &str, + route: &str, +) -> Result { + let base = url::Url::parse(api_base_url) + .map_err(|_| "AGC 主站地址无效".to_string())?; + let target = base + .join(route) + .map_err(|_| "AGC 主站请求路径无效".to_string())?; + + if target.origin() != base.origin() { + return Err("AGC 主站请求越出当前服务器 origin".to_string()); + } + + Ok(client + .request(method, target) + .header("X-Genarrative-Client", "agc")) +} +``` + +调用示意: + +```rust +let response = main_site_request( + &client, + reqwest::Method::GET, + access.api_base_url(), + &route, +)? +.bearer_auth(access.bearer_token()) +.send() +.await?; +``` + +### 5.2 预计改动范围 + +所有主站 `.get(...)`、`.post(...)`、`.patch(...)`、`.delete(...)` 构造点需要切换为包装器,或统一封装为一个 `AgcMainSiteHttp` 类型。 + +例如: + +```rust +struct AgcMainSiteHttp { + client: reqwest::Client, + base_url: url::Url, +} + +impl AgcMainSiteHttp { + fn get(&self, route: &str) -> Result; + fn post(&self, route: &str) -> Result; +} +``` + +下载、OSS 和 Provider 请求继续直接使用普通 `reqwest::Client`。 + +### 5.3 优点 + +- 每次请求都会校验真实目标 origin。 +- 即使包装器被误用于第三方 URL,也可以失败关闭,不会泄漏标记。 +- 主站 URL 拼接和 origin 校验有单一权威实现。 +- 适合未来出现更多动态 URL、多个部署 origin 或更严格的请求来源策略。 +- 可以在同一个入口继续注入 request ID、客户端版本等非敏感请求元信息。 + +### 5.4 缺点和风险 + +- 需要修改更多请求构造点。 +- 容易把简单的 Header 注入扩大成新的 HTTP 抽象层。 +- 现有代码已经有 `ExternalEditorBindingAccess`、路由映射、冻结会话校验等概念,再新增完整 HTTP facade 会增加概念数量。 +- 如果包装器同时承接鉴权、重试、错误解析、幂等和下载,很容易过度设计。 +- 对只需要固定来源标记的当前需求,复杂度高于方案一。 + +### 5.5 风险控制 + +- 包装器只负责 URL 构造、origin 校验和固定 Header,不负责业务错误解析。 +- 不在包装器中自动重试 POST。 +- 不把 token、API Key 或幂等键保存到长生命周期对象。 +- `ExternalEditorBindingAccess` 继续负责账号态/开发者 Key 路由映射和冻结会话校验。 +- 业务层继续明确设置 Bearer、Idempotency-Key 和请求体。 + +## 6. 两种方案对比 + +| 对比项 | 方案一:专用主站 Client 工厂 | 方案二:origin 请求包装器 | +|---|---|---| +| Header 注入位置 | `reqwest::Client::default_headers` | 每次构造 RequestBuilder 时 | +| 业务调用点改动 | 较少,主要替换 Client 创建点 | 较多,需要替换请求构造点 | +| 第三方泄漏防护 | 依赖 Client 使用边界 | 每次请求显式 origin 校验 | +| 实现复杂度 | 低 | 中 | +| 新增概念数量 | 少 | 较多 | +| 支持不同 timeout | 通过工厂参数或少量变体 | 共用 Client 或包装器配置 | +| 对动态 URL 的安全性 | 一般 | 高 | +| 当前需求适配度 | 高 | 中 | +| 后续扩展请求元信息 | 可以,但仍是 Client 级别 | 更灵活,可按请求控制 | +| 误用后的行为 | 可能把标记发给非主站 | 可拒绝非同源请求 | + +## 7. 推荐选择 + +当前推荐 **方案一:专用主站 HTTP Client 工厂**。 + +理由: + +- 当前需求只是给主站请求增加稳定来源标记。 +- 主站、签名下载、OSS 和 Provider 已有相对明确的客户端边界。 +- 不需要为一个固定 Header 引入新的 HTTP facade。 +- 改动集中在 Client 创建点,业务请求路径和错误语义变化较小。 +- 更符合仓库“优先简单、避免过度设计”的约束。 + +方案一需要明确遵守:主站 Client 不能用于签名下载、OSS 上传和 Provider 请求。如果实施时发现多个模块无法可靠维持这条边界,或存在大量由外部数据生成的动态目标 URL,再改选方案二。 + +不建议一开始同时实现两种方案。二选一即可,避免形成“Client 默认 Header + RequestBuilder 再加一次 Header”的重复机制。 + +## 8. 建议的 PR 边界 + +### 8.1 `#226 添加客户端特殊标识` + +负责: + +- TS `fetchClientHttp` 统一注入标记。 +- Rust 按选定方案统一注入标记。 +- 主站请求携带标记。 +- 第三方请求不携带标记。 +- 必要的 CORS Header 配置验证。 + +不负责: + +- tracking event 落库。 +- External v1 route tracking。 +- 后台筛选和统计展示。 + +### 8.2 `#225 添加客户端埋点统计` + +负责: + +- 主站读取并校验客户端标记。 +- 结合登录用户或 `ExternalApiPrincipal` 记录认证主体。 +- 将标记写入 `tracking_event.metadata_json` 或后续确定的结构化字段。 +- 补齐 `/api/external/v1/*` tracking。 +- 后台查询、筛选或统计。 + +## 9. 验收清单 + +### 9.1 正向请求 + +- TS 登录请求带 `X-Genarrative-Client: agc`。 +- TS 素材、钱包请求带标记。 +- Rust 账号态 `/api/editor/*` 请求带标记。 +- Rust 账号态 `/api/assets/*` 请求带标记。 +- Rust 账号态 `/api/runtime/external-generation/jobs/*` 轮询带标记。 +- Rust API Key 态 `/api/external/v1/*` 请求带标记。 +- `/api/profile/api-keys` 请求带标记。 +- 自定义主站 origin 的请求带标记。 + +### 9.2 排除请求 + +- OSS multipart 上传不带标记。 +- 签名 URL 下载不带标记。 +- LLM Provider 请求不带标记。 +- AGC 受控搜索不带标记。 +- 本地工具桥请求不带标记。 + +### 9.3 兼容性 + +- 原 Authorization 不变。 +- 原 Idempotency-Key 不变。 +- 原请求 body 不变。 +- 原 timeout 不变;同源 redirect policy 语义不变,跨 origin redirect 由主站 Client factory 阻断。 +- 浏览器跨域预检允许 `X-Genarrative-Client`。 +- 未识别 marker 时主站业务请求不受影响。 diff --git a/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md b/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md new file mode 100644 index 000000000..533a5f401 --- /dev/null +++ b/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md @@ -0,0 +1,184 @@ +# Issue #226 阶段 0:现状基线与契约冻结验收记录 + +更新时间:2026-09-01
+关联 Issue:`#226 添加客户端特殊标识`;交接 Issue:`#225 添加客户端埋点统计`
+执行结论:阶段 0 本地基线与契约冻结通过;未修改生产代码、主站、OpenAPI 或数据库。
+远程 Issue 评论未直接提交,本文第 7 节提供可直接粘贴的评论草案。 + +## 1. 本阶段边界 + +阶段 0 只完成四件事: + +1. 记录当前工作树和源码基线。 +2. 核对 TS 统一请求出口,以及 Rust 主站/第三方 Client 创建边界。 +3. 冻结客户端 Header、主站 origin 和正负向范围。 +4. 冻结交给 `#225` 的 tracking metadata 约定。 + +本阶段明确不做: + +- 不改 `apps/ai-game-creator-shell` 生产实现。 +- 不改 `server-rs`、OpenAPI、SpacetimeDB schema、migration、bindings 或后台。 +- 不把现有 body 内 `generationInputs.source` 等字段升级成统一请求标记。 +- 不把所有 `reqwest::Client` 粗暴替换成主站 Client。 + +## 2. 当前仓库基线 + +| 项目 | 结果 | +|---|---| +| 工作目录 | `C:\projects\narrative\Genarrative` | +| 当前分支 | `feat/agc_call_header` | +| 当前 HEAD | `b14e42d9b` — `AGC支持安装扩展skill、MCP (#233)` | +| 阶段开始前工作树 | `git status --short` 为空 | +| 编码检查 | `npm run check:encoding` 通过,检查 5647 个文件 | +| Diff 空白检查 | `git diff --check` 通过 | +| 阶段 0 代码改动 | 无 | + +说明:本记录和相关方案/清单属于预期本地文档变更。当前仓库通过 `.git/info/exclude` 忽略整个 `local-docs/`,因此这些材料不会出现在 `git status` 或 `git diff` 中;生产代码工作树仍保持干净。 + +## 3. 源码边界核对 + +### 3.1 TS 统一出口已确认 + +AGC Web/TS 侧的统一网络入口是: + +- `apps/ai-game-creator-shell/src/services/clientHttp.ts:163` 的 `fetchClientHttp`。 +- `clientAuth.ts` 通过 `requestAuthJson` 调用该入口。 +- `clientApi.ts` 通过 `requestClientApi` / `requestClientApiBytes` 调用该入口。 + +因此阶段 1 只需要在 `fetchClientHttp` 统一注入 Header,即可覆盖认证、素材、账户和钱包等 TS 请求;更新下载 `clientHttp` 之外的独立路径仍需保持排除。 + +### 3.2 Rust 不是统一 Client + +Rust 侧已确认存在多个独立 `reqwest::Client::new()` / `Client::builder()` 创建点,不能依赖 TS 入口覆盖。主站相关的生产创建点主要分布在: + +- `assets.rs:377`、`:712`:开发者 Key、项目同步/恢复。 +- `commands.rs:3283`、`:3833`:账户素材库和素材下载前换签。 +- `agent/generation/canvas_generation.rs:2404`、`:2408`:External Editor 生成轮询/提交。 +- `project/asset_canvas/generation.rs:3729`、`:3733`:画布生成轮询/提交。 +- `project/resource_editor.rs:3101`:资源编辑生成/轮询公共入口。 +- `agent/direct_runtime.rs:2487`:Direct Runtime 只读恢复。 +- `agent/direct_tool_bridge.rs:1813`:受控去背景调用前的主站上下文准备。 + +公共路由与凭据映射抽象已确认存在于: + +- `project/external_editor_bindings.rs:40` 的 `ExternalEditorBindingAccess`。 +- `ExternalEditorBindingAccess::api_route(...)` 负责账号态 `/api/editor`、`/api/assets`、`/api/runtime` 与 API Key 态 `/api/external/v1` 的路径映射。 + +### 3.3 明确的第三方/非主站边界 + +以下调用保留原有 Client,不进入主站标记范围: + +- OSS/对象存储 multipart 上传。 +- `read-url` 返回的签名 URL 二进制下载。 +- LLM Provider `/responses`:`agent/codex_provider_proxy.rs:207`。 +- AGC 受控网页搜索:`agent/direct_tool_bridge.rs:2191`,带有独立 `no_proxy` 语义。 +- loopback 工具桥、本地 IPC、试玩页面请求。 +- 更新清单和更新包下载:`main.rs:120`。 + +完整的实际调用表、潜在 External v1 接口和排除项以 +[【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md) +为准。 + +## 4. 冻结的客户端标记契约 + +### 4.1 Header + +```http +X-Genarrative-Client: agc +``` + +固定规则: + +- Header 名按 HTTP 规则大小写不敏感;客户端发送时固定使用 `X-Genarrative-Client`。 +- 值固定为小写 `agc`。 +- 统一出口/主站 Client 应覆盖调用方传入的同名 Header,避免业务层伪造其他值。 +- 不携带 token、API Key、用户 ID、项目 ID 或版本号。 +- 只用于来源审计和统计,不参与鉴权、权限、计费或账号归属判断。 + +### 4.2 Origin 与发送范围 + +主站判断依据固定为当前选定并已规范化的 `serverBaseUrl` / `apiBaseUrl`,比较完整 origin(scheme + host + port),而不是只看路径或字符串前缀。 + +应带标记的请求包括: + +- `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 +- API Key 态 `/api/external/v1/*`。 +- 项目、画布、素材库、上传凭证、对象确认、资源登记、图片/图集/去背景/角色动画/视频/音频生成和异步轮询。 +- `/api/profile/api-keys`。 + +不得带标记的请求包括:OSS、签名下载、Provider、受控搜索、loopback、更新下载及任意外部网页请求。 + +### 4.3 保留的请求语义 + +后续实现必须保留每个调用点已有的: + +- `timeout`、`connect_timeout`、redirect policy 和 `no_proxy`。 +- Authorization/Bearer、External API Key、Idempotency-Key 和既有业务 Header。 +- Content-Type、请求体、响应状态处理、重试和异步轮询语义。 + +## 5. 冻结给 #225 的交接契约 + +主站接收同名 Header: + +```http +X-Genarrative-Client: agc +``` + +主站规则: + +- 精确值 `agc` 识别为 AGC。 +- Header 缺失、空值或未知值按“未标记”处理,不拒绝请求,不改变业务行为。 +- 不从 `generationInputs.source` 推导统一客户端标记。 +- 认证主体仍以真实账号态或 `ExternalApiPrincipal` 为准,不记录 token/API Key 明文。 + +第一阶段建议复用现有 `tracking_event.metadata_json`,固定 JSON key/value: + +```json +{ + "route": "/api/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc" +} +``` + +交接要求: + +- 记录主站实际收到的 method/path,不只记录客户端账本中的 External v1 endpoint。 +- `/api/external/v1/*` 也要纳入 tracking 覆盖,不能只依赖内部 `/api/editor` 路由规格。 +- 缺失标记时不写 `client` 空字符串;未知值按未标记处理。 +- 成功、失败、重试和轮询请求沿用同一 Header;是否记录失败请求由 `#225` 的埋点目标决定,不要求 `#226` 回改标记设计。 + +## 6. 阶段 0 验收结果 + +| 验收项 | 结果 | 证据 | +|---|---|---| +| 当前工作树基线已确认 | 通过 | 本文第 2 节;阶段开始前 `git status --short` 为空 | +| TS 统一出口已定位 | 通过 | `clientHttp.ts:163`、`clientAuth.ts`、`clientApi.ts` | +| Rust 主站/第三方 Client 边界已定位 | 通过 | 本文第 3 节;完整清单见扫描文档 | +| 正向范围无未决歧义 | 通过 | 本文第 4.2 节 | +| 排除范围无未决歧义 | 通过 | 本文第 3.3、4.2 节 | +| Header、metadata、未知值行为已冻结 | 通过 | 本文第 4、5 节;实施方案第 3、8 节 | +| 未修改生产代码/主站/契约/数据库 | 通过 | `git diff --check`;本阶段仅新增/更新被本地忽略的 `local-docs` | + +阶段 0 可退出,允许进入阶段 1。阶段 1 的入口条件是直接按本文契约实现 TS `fetchClientHttp`,无需等待 `#225`。 + +## 7. Issue #226 实现范围评论草案 + +> 本次 #226 只做 AGC 客户端侧请求标记,不修改 #225 的主站 tracking、数据库或后台实现。 +> +> 冻结 Header:`X-Genarrative-Client: agc`。TS 在 `fetchClientHttp` 统一注入;Rust 增加主站专用 Client builder factory,通过 `default_headers` 注入,并只替换主站请求的 Client 创建点。账号态 `/api/editor`、`/api/assets`、`/api/runtime` 与 API Key 态 `/api/external/v1` 都发送同一标记。 +> +> 保留各调用点现有 timeout、connect timeout、redirect、no_proxy、Authorization/Bearer、API Key、Idempotency-Key、请求体和响应语义。OSS 上传、签名 URL 下载、LLM Provider、受控搜索、loopback、更新下载和外部网页请求不得带标记。 +> +> #225 接收约定:精确值 `agc` 识别为 AGC;缺失/空值/未知值按未标记处理且不拒绝请求。第一阶段复用 `tracking_event.metadata_json`,写入 `client: "agc"`,并按主站实际 method/path 记录;不要求 #226 为 #225 回改设计。 + +正式提交评论前,应将本文草案与 #226 当前讨论串核对一次;本阶段未直接执行远程 Issue 写操作。 + +## 8. 阶段 1 入口与风险提示 + +- 阶段 1 只改 TS `fetchClientHttp` 及其定向测试,先验证 Header 合并、同名 Header 覆盖和两条 transport 行为。 +- 阶段 2/3 再处理 Rust factory 与生产创建点;测试 fixture 中的裸 Client 不应被机械替换。 +- `default_headers` 只放稳定来源标记;Authorization、Idempotency-Key 和每请求动态 Header 继续保留在调用点。 +- 后续定向测试必须同时覆盖账号态和 API Key 态,以及主站正向和第三方负向请求。 diff --git a/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md b/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md new file mode 100644 index 000000000..1f879b1b8 --- /dev/null +++ b/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md @@ -0,0 +1,119 @@ +# Issue #226 阶段 6:#225 交接与最终门禁验收记录 + +更新时间:`2026-09-02` +实施范围:`#226 添加客户端特殊标识` +交接范围:`#225 添加客户端埋点统计` +执行结论:阶段 6 通过;#226 客户端实现、跨 origin 重定向安全复审、边界回归、交接材料和最终门禁已收口。未修改 #225 主站代码、数据库、OpenAPI 或后台实现。 + +## 1. 阶段边界 + +本阶段只完成: + +1. 复核并固定交给 `#225` 的 Header、tracking metadata、认证主体和路径边界。 +2. 汇总阶段 1~5 的正向、负向和语义回归证据。 +3. 执行最终定向测试、编码/格式/空白检查和工作树检查。 +4. 更新实施方案与分阶段计划的完成状态。 + +本阶段明确不做: + +- 不修改 `server-rs`、主站 tracking middleware、route tracking 或后台。 +- 不修改 SpacetimeDB `tracking_event` schema、migration、bindings 或索引。 +- 不修改 External v1 OpenAPI、DTO 或请求响应语义。 +- 不执行真实发布环境线上写入或埋点验证。 + +## 2. 当前仓库状态 + +阶段 6 开始时: + +| 项目 | 结果 | +|---|---| +| 分支 | `feat/agc_call_header` | +| HEAD | `8059bbcb5`(已合并最新 `origin/master`) | +| 工作树 | 干净 | +| `origin/master` | `4a2f5be6c`,同步 AGC 更新下载域名门禁 | + +阶段 0~5 的提交保持不变,本阶段只补充交接/验收文档。 + +## 3. 给 #225 的固定交接契约 + +### 3.1 客户端请求标记 + +```http +X-Genarrative-Client: agc +``` + +- Header 名大小写不敏感;值精确为小写 `agc` 时识别为 AGC。 +- 缺失、空值或未知值按未标记处理,不拒绝请求,也不改变业务行为。 +- Header 只用于来源审计和统计,不参与鉴权、权限、计费或账号归属。 +- 不记录 access token、API Key 明文、签名 URL、项目绝对路径、用户隐私或客户端版本号。 + +### 3.2 tracking metadata + +第一阶段复用现有 tracking `metadata_json`,固定 JSON key/value: + +```json +{ + "route": "/api/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc" +} +``` + +约定: + +- `client` key 固定;AGC 值固定为 `agc`。 +- Header 缺失时不写 `client` 空字符串。 +- 记录主站实际收到的 method/path,不只记录客户端账本中的 External v1 endpoint。 +- `/api/external/v1/*` 不能因为缺少完整 route spec 而漏记。 + +### 3.3 认证主体 + +- 登录账号态:沿用主站现有 access token 解析出的用户维度。 +- External API Key 态:使用 `ExternalApiPrincipal.owner_user_id`,必要时保留 `key_id` 维度。 +- Header 与认证主体独立处理;不能用 Header 代替认证,也不能从 `generationInputs.source` 推导客户端标记。 + +### 3.4 路由和边界 + +应识别的主站请求: + +- 账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 +- External API Key 态 `/api/external/v1/*`。 +- 生成提交、异步轮询、项目/素材/资源登记和 `/api/assets/read-url` 换签。 + +明确不应识别为 AGC 主站业务请求: + +- OSS multipart 上传。 +- 签名 URL/OSS 媒体下载。 +- LLM/Codex Provider。 +- AGC 受控搜索。 +- loopback 工具桥。 +- 更新清单、更新包下载和任意外部网页请求。 + +## 4. 最终门禁结果 + +| 验收项 | 命令/证据 | 结果 | +|---|---|---| +| TS 统一出口正向测试 | `npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts` | 通过,14 tests passed | +| AGC TS 类型和配置检查 | `npm run ai-game-creator-shell:typecheck` | 通过;skill-pack/config 检查通过 | +| Rust 主站 Client factory、请求终结器与 origin-safe redirect policy | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,8 tests passed;同源跟随、跨 origin 阻断、链式重定向、显式 `Policy::none()` 和请求级同名 Header 覆盖均覆盖 | +| 第三方请求负向矩阵 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture` | 通过,4 tests passed | +| loopback 认证/请求边界 | `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 test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_can_generate_platform_art_asset -- --nocapture` | 通过,1 test passed | +| External Key 态和自定义 origin | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker -- --nocapture` | 通过,1 test passed | +| `read-url` 与签名下载边界 | `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 test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml generation_submit_response_loss_is_not_retried -- --nocapture` | 通过,1 test passed | +| Rust 格式 | `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` | 通过 | +| 中文编码 | `npm run check:encoding` | 通过,5653 个文件 | +| Diff 空白 | `git diff --check` | 通过 | + +Rust 测试输出包含仓库既有的 unused/dead-code warning;本任务相关测试均无 error 或 failure。 + +## 5. 发布环境限制与后续交接 + +本阶段没有执行真实发布环境线上 smoke。账号态和 External Key 态的路径、Header、认证/幂等语义来自本地 mock/custom `apiBaseUrl` fixture;OSS/签名 URL/Provider/搜索/loopback/更新下载边界来自本地请求捕获;跨 origin 重定向来自双 listener 和链式重定向 fixture。发布前或 `#225` 联调时,应由主站侧补做真实环境 Header 接收、tracking metadata 写入和后台查询验证,不要求客户端回改本次设计。 + +## 6. 可直接粘贴到 #225 的评论 + +> `#226` 客户端侧已完成并冻结交接契约:AGC 主站业务请求统一发送 `X-Genarrative-Client: agc`。账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External API Key 态 `/api/external/v1/*` 均覆盖;OSS/签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。主站可按实际 method/path 读取 Header,并在现有 tracking `metadata_json` 中写入 `client: "agc"`;Header 缺失/空值/未知值按未标记处理且不拒绝请求。登录态按真实用户维度记录,External Key 态按 `owner_user_id`(必要时 `key_id`)记录,不记录 token/API Key 明文或签名 URL。客户端正向、负向、账号态、External Key 态和幂等回归均已通过,`#225` 不需要让 `#226` 回改设计。`