From 658f8958dfb70bf6efb362e58b7fdbd017573554 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 2 Sep 2026 05:26:01 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=E4=B8=BB=E7=AB=99?= =?UTF-8?q?=E5=90=8C=E5=90=8D=E6=A0=87=E8=AE=B0=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加统一请求终结器,覆盖调用方传入的同名客户端标记 补齐账号态、External Key态、生成轮询和资源编辑主站请求 保持OSS、签名下载、Provider、搜索、loopback和更新下载边界不变 补充同名Header覆盖回归测试并同步Issue226方案与验收文档 --- .../src-tauri/src/agent/direct_runtime.rs | 14 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 32 ++-- .../src/agent/generation/canvas_generation.rs | 36 ++-- .../src-tauri/src/assets.rs | 41 ++--- .../src-tauri/src/http_client.rs | 44 ++++- .../src/project/asset_canvas/generation.rs | 4 +- .../src-tauri/src/project/resource_editor.rs | 172 +++++++++--------- ...Issue226跨Origin重定向标记泄漏修复-2026-09-02.md | 27 ++- ...Issue226-AGC客户端主站请求统一标记-2026-09-01.md | 14 +- ...】AGC主站请求标记统一注入两种方案-2026-09-01.md | 32 +++- ...收】Issue226阶段6交接与最终门禁-2026-09-02.md | 2 +- 11 files changed, 257 insertions(+), 161 deletions(-) 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 faafb514c..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 @@ -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 a8addecf2..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 @@ -1818,21 +1818,23 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val 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::() 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 b435982c0..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 {},服务端是否已产生副作用未知", 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 168b2320f..ad87b4f5e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -380,15 +380,15 @@ async fn create_private_external_editor_api_credentials_from_platform_session( .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 { @@ -720,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!( @@ -1243,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())); 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 index 6ee6cfcfc..758df9f67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/http_client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/http_client.rs @@ -26,17 +26,33 @@ fn agc_main_site_redirect_policy() -> reqwest::redirect::Policy { }) } -pub(crate) fn agc_main_site_client_builder() -> reqwest::ClientBuilder { +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(headers) + .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::{ @@ -157,6 +173,30 @@ mod tests { 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"); 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 eb8be4c02..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)?; 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 cf428e5e2..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()); diff --git a/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md b/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md index 927e3b5eb..ca6b18e5b 100644 --- a/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md +++ b/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md @@ -15,11 +15,14 @@ 这不是把 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 和其定向测试: +修改 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` - 本地实施方案、阶段计划和本复审记录 不修改: @@ -38,7 +41,18 @@ 使用 `Url::origin()` 比较,不比较 URL 字符串前缀;路径、查询参数和 fragment 不参与 origin 判断。 -## 4. 测试证据 +## 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` 定向测试,覆盖: @@ -46,15 +60,16 @@ - 同 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 -6 tests passed +8 tests passed ``` -## 5. 对 #225 的交接 +## 6. 对 #225 的交接 本修复不改变 Header 契约: @@ -64,6 +79,6 @@ X-Genarrative-Client: agc 主站只会收到客户端实际发出的主站请求;跨 origin 3xx 不会产生第二个第三方请求,因此不会出现客户端把该标记发送到 OSS/第三方 origin 的情况。#225 不需要回改 tracking、数据库或后台设计。 -## 6. 后续限制 +## 7. 后续限制 -`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy。当前已审计生产调用点只有显式 `Policy::none()`,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。 +`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 index 9bd8355d3..3cdbc576d 100644 --- a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md +++ b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md @@ -112,6 +112,7 @@ TS 主站请求 Rust 主站请求 → 主站 Client Builder factory → default_headers 注入 X-Genarrative-Client: agc + → 请求发送前由主站请求终结器覆盖同名 Header → 默认只跟随同 origin 重定向,跨 origin 返回当前 3xx → 保留调用方原有 Client 配置 @@ -160,6 +161,8 @@ Rust 第三方请求 - 设置只允许同 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 @@ -188,7 +191,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder { } ``` -实际实现可放在 AGC Rust 已有的客户端公共模块中;不新增完整 `AgcMainSiteClient` RequestBuilder 包装器,不在本 Issue 提前实现方案二。 +实际实现可放在 AGC Rust 已有的客户端公共模块中;本 Issue 只增加轻量请求终结器,不新增完整 `AgcMainSiteClient` RequestBuilder 包装器,不提前实现方案二。 ### 6.2 保留现有 Client 配置 @@ -230,7 +233,7 @@ let client = agc_main_site_client_builder() - 主站 API Client 的 `Client::builder()` 改用 AGC 主站 builder。 - 生产主站 API 中使用 `Client::new()` 的位置改用 AGC 主站 builder,并保留原 timeout 等配置。 -- 只访问主站的专用 Client 可以直接使用默认 Header。 +- 只访问主站的专用 Client 仍使用 factory 的默认 Header,并在最终 `.send()` 前经过 `with_agc_main_site_marker`;不能依赖 `default_headers` 覆盖请求级同名 Header。 - 一个 Client 如果同时访问主站和第三方地址,不得直接改成主站 Client;应拆分用途或保持第三方 Client 独立。 ### 6.4 不应替换的 Client @@ -270,6 +273,13 @@ let client = agc_main_site_client_builder() - 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` 的职责: diff --git a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md b/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md index e9ceecbd8..7c938c0ab 100644 --- a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md +++ b/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md @@ -121,7 +121,7 @@ Tauri HTTP 插件和 Rust `reqwest` 不受浏览器 CORS 限制,但仍应遵 ### 4.1 核心思路 -为 Rust 层建立一个只允许服务于主站 API 的 `reqwest::Client` 构造函数,并通过 `default_headers` 自动加入客户端标记。 +为 Rust 层建立一个只允许服务于主站 API 的 `reqwest::Client` 构造函数,并通过 `default_headers` 自动加入客户端标记;请求发送前再由轻量终结器覆盖同名 Header,确保调用方不能伪造标记值。 第三方上传、签名下载、Provider 和搜索继续使用现有独立 Client,不带标记。 @@ -135,11 +135,25 @@ fn build_agc_main_site_client(timeout: Duration) -> Result Result