Merge branch 'master' into feat/agc-use-ui-design
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user