Merge branch 'master' into feat/agc-use-ui-design
Project CI / Repository checks (pull_request) Successful in 3m44s
Project CI / Frontend tests (pull_request) Successful in 4m15s
Project CI / Backend tests (pull_request) Successful in 7m59s
Project CI / Native shell tests (pull_request) Successful in 16m2s

This commit is contained in:
2026-09-02 15:50:18 +08:00
27 changed files with 2967 additions and 178 deletions
@@ -1302,6 +1302,11 @@ assert.deepEqual(
expectedBundledCodexResources,
'AI game creator shell Windows Tauri config must bundle the complete pinned Codex resource set',
);
if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
throw new Error(
'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory',
);
}
if (tauriConfig.app?.withGlobalTauri !== true) {
throw new Error(
@@ -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")
@@ -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() {
@@ -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::<Value>()
@@ -2179,6 +2183,15 @@ async fn bridge_browser_playtest(root: &Path, arguments: &Value) -> Value {
}
}
fn build_controlled_search_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "创建 AGC 受控搜索连接失败".to_string())
}
async fn bridge_web_search(root: &Path, arguments: &Value) -> Value {
let result = async {
enforce_project_permission_policy(root, "project.search")?;
@@ -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<DirectToolBr
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[tokio::test]
async fn controlled_search_client_omits_agc_marker() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind search fixture");
let address = listener.local_addr().expect("search fixture address");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept search request");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("set search fixture timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 1024];
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut buffer).expect("read search request");
assert!(read > 0, "search request closed before headers");
bytes.extend_from_slice(&buffer[..read]);
}
stream
.write_all(
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write search response");
String::from_utf8_lossy(&bytes).into_owned()
});
let client = build_controlled_search_client().expect("build search client");
let response = client
.get(format!("http://{address}/search"))
.send()
.await
.expect("send search request");
let request = server.join().expect("join search fixture");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!request
.to_ascii_lowercase()
.contains("x-genarrative-client:"));
}
#[test]
fn bridge_argument_bounds_are_deterministic() {
@@ -819,15 +819,19 @@ fn direct_tool_bridge_url() -> Result<String, String> {
Ok(value)
}
fn build_direct_tool_bridge_client() -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(1_800))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "创建客户端工具桥连接失败".to_string())
}
async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value {
let result = async {
let url = direct_tool_bridge_url()?;
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(1_800))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "创建客户端工具桥连接失败".to_string())?;
let client = build_direct_tool_bridge_client()?;
let response = client
.post(url)
.json(&json!({ "tool": tool, "arguments": arguments }))
@@ -1147,6 +1151,46 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[tokio::test]
async fn loopback_tool_bridge_client_omits_agc_marker() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind loopback fixture");
let address = listener.local_addr().expect("loopback fixture address");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept loopback request");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("set loopback fixture timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 1024];
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut buffer).expect("read loopback request");
assert!(read > 0, "loopback request closed before headers");
bytes.extend_from_slice(&buffer[..read]);
}
stream
.write_all(
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write loopback response");
String::from_utf8_lossy(&bytes).into_owned()
});
let client = build_direct_tool_bridge_client().expect("build loopback client");
let response = client
.post(format!("http://{address}/tool-test"))
.json(&json!({ "tool": "fixture", "arguments": {} }))
.send()
.await
.expect("send loopback request");
let request = server.join().expect("join loopback fixture");
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
assert!(!request
.to_ascii_lowercase()
.contains("x-genarrative-client:"));
}
#[test]
fn direct_tools_mode_requires_the_exact_private_flag() {
@@ -685,7 +685,7 @@ pub(crate) async fn external_editor_json_request(
request: reqwest::RequestBuilder,
action: &str,
) -> Result<serde_json::Value, String> {
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}"))?;
@@ -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<String, String> {
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] = [
@@ -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())
@@ -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<String> {
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<String> {
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<String> {
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())
);
}
}
@@ -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;
@@ -2665,6 +2670,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;
@@ -2089,7 +2089,7 @@ async fn try_confirm_uploaded_reference(
asset_kind: &str,
) -> Result<Option<String>, 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())?;
@@ -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())?;
@@ -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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(requests.len(), 3, "画板同步应产生项目、换签和媒体请求");
let main_site_requests = requests
.iter()
.filter(|request| !request.starts_with("GET /signed/"))
.collect::<Vec<_>>();
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::<Vec<_>>();
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();
}
@@ -2,6 +2,7 @@
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["nsis"],
"useLocalToolsDir": true,
"resources": {
"resources/codex/win-x64/bin/codex.exe": "codex/win-x64/bin/codex.exe",
"resources/codex/win-x64/bin/codex-code-mode-host.exe": "codex/win-x64/bin/codex-code-mode-host.exe",
@@ -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);
}
@@ -155,6 +155,11 @@ export function registerPlanGddApprovalTests() {
const dialog = openReviseDialog();
typeComment(dialog, '把核心循环压到三步');
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '提交决定' }),
).toHaveProperty('disabled', false);
});
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
@@ -163,6 +168,11 @@ export function registerPlanGddApprovalTests() {
// 原样重试:属于方案 §13.2 的 busy/超时/网络重试,必须复用同一 responseId。
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '提交决定' }),
).toHaveProperty('disabled', false);
});
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
expect(harness.planGddDecisionCalls).toHaveLength(2);
@@ -175,6 +185,11 @@ export function registerPlanGddApprovalTests() {
// 「同 responseId 的审批意图不一致」硬拒,用户改写后的原因永远落不了盘。
typeComment(dialog, '把核心循环压到两步,并去掉天气系统');
harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED');
await waitFor(() => {
expect(
within(dialog).getByRole('button', { name: '提交决定' }),
).toHaveProperty('disabled', false);
});
fireEvent.click(screen.getByRole('button', { name: '提交决定' }));
await waitFor(() => {
expect(harness.planGddDecisionCalls).toHaveLength(3);
@@ -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(