修复AGC主站跨Origin重定向标记泄漏

主站 Client factory 默认仅跟随同 origin 重定向

跨 origin 3xx 直接停止,避免 AGC 标记发送到 OSS 或第三方

新增同源、跨源、链式重定向和显式 Policy::none() 定向测试

同步 Issue226 实施方案、验收记录和复审修复文档
This commit is contained in:
2026-09-02 04:56:54 +00:00
parent 62771c1650
commit fa448bba59
6 changed files with 365 additions and 25 deletions
@@ -3,18 +3,46 @@ 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()
}
})
}
pub(crate) fn agc_main_site_client_builder() -> reqwest::ClientBuilder {
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
);
reqwest::Client::builder().default_headers(headers)
reqwest::Client::builder()
.default_headers(headers)
.redirect(agc_main_site_redirect_policy())
}
#[cfg(test)]
mod tests {
use super::{agc_main_site_client_builder, AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE};
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};
@@ -58,6 +86,53 @@ mod tests {
})
}
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");
@@ -117,4 +192,183 @@ mod tests {
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())
);
}
}
@@ -0,0 +1,69 @@
# Issue #226:跨 origin 重定向标记泄漏复审修复
更新时间:2026-09-02
关联 Issue`#226 添加客户端特殊标识`;交接 Issue`#225 添加客户端埋点统计`
## 1. 修复结论
采纳评审意见:AGC 主站 Client 的默认重定向策略不能继续使用 reqwest 的“任意 origin 默认跟随”,否则 `default_headers` 中的 `X-Genarrative-Client: agc` 可能被复制到 OSS 或第三方 origin。
本次修复将工厂默认策略调整为:
- 目标 URL 与原始请求 URL 的完整 originscheme + host + effective port)相同:委托 reqwest 默认 redirect policy。
- origin 不同:`stop`,返回当前 3xx,不发起下一跳请求。
- 无法取得原始 URL 或 origin 无法确认:按 fail-closed 处理,停止重定向。
这不是把 factory 改成全局 `Policy::none()`。同 origin 的 redirect 仍保留原有默认限制、方法和请求体处理;调用点显式设置的 `Policy::none()` 继续有效。
## 2. 修改范围
只修改 AGC Rust HTTP Client factory 和其定向测试:
- `apps/ai-game-creator-shell/src-tauri/src/http_client.rs`
- 本地实施方案、阶段计划和本复审记录
不修改:
- #225 的主站 tracking、数据库和后台代码。
- OSS/签名下载、Provider、受控搜索、loopback、更新下载等第三方 Client。
- 各业务调用点的 timeout、connect timeout、认证、幂等或请求体逻辑。
## 3. 重定向行为契约
| 场景 | 处理 | 标记是否到达下一跳 |
|---|---|---:|
| 同 scheme、host、effective port | 继续按 reqwest 默认策略 | 是,仍是主站 origin |
| scheme、host 或端口任一不同 | 返回当前 3xx,不 follow | 否,不发起请求 |
| 调用点显式 `Policy::none()` | 继续不 follow | 否 |
使用 `Url::origin()` 比较,不比较 URL 字符串前缀;路径、查询参数和 fragment 不参与 origin 判断。
## 4. 测试证据
新增或调整 `http_client` 定向测试,覆盖:
- 完整 origin 比较:默认端口等价,scheme/host/非默认端口变化视为跨 origin。
- 同 origin 302:下一跳实际收到 `X-Genarrative-Client: agc`,最终响应成功。
- 跨 origin 302:当前响应为 302,外部 listener 没有收到连接。
- 显式 `Policy::none()`:仍然覆盖 factory 默认策略。
验证命令及结果:
```text
cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture
6 tests passed
```
## 5. 对 #225 的交接
本修复不改变 Header 契约:
```http
X-Genarrative-Client: agc
```
主站只会收到客户端实际发出的主站请求;跨 origin 3xx 不会产生第二个第三方请求,因此不会出现客户端把该标记发送到 OSS/第三方 origin 的情况。#225 不需要回改 tracking、数据库或后台设计。
## 6. 后续限制
`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy。当前已审计生产调用点只有显式 `Policy::none()`,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。
@@ -6,12 +6,12 @@
- `#226 添加客户端特殊标识`:本方案实际实施范围
- `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施
状态:阶段 6 已完成(客户端实现、#225 交接与最终门禁已收口)<br>
状态:阶段 6 已完成;已补齐跨 origin 重定向安全复审修复<br>
本方案包含 #226 客户端代码修改,不包含 #225 主站接收、落库和后台统计代码
## 1. 一句话交付结果
让 AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带 `X-Genarrative-Client: agc`,同时保持现有认证、幂等、timeout、connect timeout、redirect 和上传/下载安全边界不变;第三方请求不携带该标记。
让 AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带 `X-Genarrative-Client: agc`,同时保持同源重定向及现有认证、幂等、timeout、connect timeout 和上传/下载安全边界;跨 origin 重定向不继续请求,第三方请求不携带该标记。
## 2. Issue 边界
@@ -98,7 +98,7 @@ X-Genarrative-Client: agc
## 4. 方案一的落地结构
本次采用“统一 Client 工厂 + `default_headers`”方案,不引入 origin-aware RequestBuilder 包装器。
本次采用“统一 Client 工厂 + `default_headers` + origin-safe redirect policy”方案,不引入 origin-aware RequestBuilder 包装器。
总体结构:
@@ -112,6 +112,7 @@ TS 主站请求
Rust 主站请求
→ 主站 Client Builder factory
→ default_headers 注入 X-Genarrative-Client: agc
→ 默认只跟随同 origin 重定向,跨 origin 返回当前 3xx
→ 保留调用方原有 Client 配置
Rust 第三方请求
@@ -156,6 +157,7 @@ Rust 第三方请求
- 创建 `reqwest::ClientBuilder`
- 设置 `X-Genarrative-Client: agc` 默认 Header。
- 设置只允许同 origin 的默认重定向策略;同源时委托 reqwest 默认策略,跨 origin 时停止。
- 不负责 token、API Key、幂等键、请求体、重试或错误解析。
示意结构:
@@ -167,7 +169,22 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder {
reqwest::header::HeaderName::from_static("x-genarrative-client"),
reqwest::header::HeaderValue::from_static("agc"),
);
reqwest::Client::builder().default_headers(headers)
let default_policy = reqwest::redirect::Policy::default();
let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| {
let same_origin = attempt
.previous()
.first()
.map(|initial| initial.origin() == attempt.url().origin())
.unwrap_or(false);
if same_origin {
default_policy.redirect(attempt)
} else {
attempt.stop()
}
});
reqwest::Client::builder()
.default_headers(headers)
.redirect(redirect_policy)
}
```
@@ -189,7 +206,7 @@ let client = agc_main_site_client_builder()
- connect timeout。
- request timeout。
- redirect policy。
- 调用方显式设置的 redirect policy;未显式设置时保留同源默认重定向、默认限制和方法处理,跨 origin 重定向由 factory 停止
- `no_proxy` 或其他已有网络策略(仅属于原请求的情况下保留)。
- Bearer access token/API Key 的设置方式。
- `Idempotency-Key` 的设置方式和原值。
@@ -233,7 +250,7 @@ let client = agc_main_site_client_builder()
- 主站项目、素材库、素材目录、上传凭证、对象确认、资源登记、图片生成/编辑/抠图、视频/动画/音频生成以及 generation 查询请求复用带 `X-Genarrative-Client: agc` 的 Client。
- `assets/read-url` 换签请求使用主站 factory;签名 URL 媒体下载仍使用独立的 `build_external_asset_download_client`,不会携带主站标记。
- OSS multipart 上传、Provider、受控搜索、loopback 工具桥和更新下载没有切换到主站 factory。
- 保留原有 timeout、connect timeout、redirect、`no_proxy`、Bearer/API Key、Idempotency-Key、请求体和响应处理;未修改 `ExternalEditorBindingAccess` 的路由映射语义。
- 保留原有 timeout、connect timeout、`no_proxy`、Bearer/API Key、Idempotency-Key、请求体和响应处理;同源重定向保持默认语义,跨 origin 重定向由 factory 阻断;未修改 `ExternalEditorBindingAccess` 的路由映射语义。
- 阶段 3 请求捕获验证:`sync_canvas_project_assets_downloads_external_resources`1 test passed)确认账号态项目请求和 `assets/read-url` 带标记,签名媒体下载不带标记。
- 阶段 3 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`2 tests passed);`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_downloads_external_resources -- --nocapture`1 test passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check``npm run check:encoding``git diff --check` 均通过。
@@ -379,7 +396,7 @@ Rust 层:
- 原有 Authorization 仍被发送。
- 原有 Idempotency-Key 仍被发送且值不变。
- 请求体和 Content-Type 不变。
- redirect policy 不变
- 同源 redirect policy 的默认跟随、限制和方法处理保持不变;跨 origin 3xx 不发起下一跳
- timeout 和 connect timeout 不被统一 factory 覆盖成单一值。
- 第三方请求没有因共享 Client 改造而意外继承主站 Header。
@@ -428,7 +445,7 @@ Rust 层:
|---|---|
| TS `clientHttp` 定向测试 | 通过,14 tests passed |
| AGC TS 类型检查(含 skill-pack/config 检查) | 通过 |
| Rust `http_client` factory 测试 | 通过,2 tests passed |
| Rust `http_client` factory 测试 | 通过,7 tests passed;覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()` |
| Rust OSS/签名 URL/Provider/搜索/loopback/更新下载负向测试 | 通过,`omits_agc_marker` 4 tests passedloopback 认证边界 1 test passed |
| Rust 账号态主站 mock 捕获 | 通过,1 test passed |
| Rust External Key 态主站 mock 捕获 | 通过,1 test passed |
@@ -436,7 +453,7 @@ Rust 层:
| Rust 提交响应丢失幂等回归 | 通过,1 test passed |
| Rust fmt、编码、diff 空白检查 | 通过 |
本阶段未执行真实发布环境线上 smoke;账号态和 External Key 态证据来自本地 mock/custom `apiBaseUrl` fixture。真实发布环境 smoke 属于发布前或 `#225` 联调门禁,不改变本次 `#226` 客户端契约。
本阶段未执行真实发布环境线上 smoke;账号态和 External Key 态证据来自本地 mock/custom `apiBaseUrl` fixture。真实发布环境 smoke 属于发布前或 `#225` 联调门禁,不改变本次 `#226` 客户端契约。跨 origin 重定向复审已通过本地双 listener 和链式重定向测试确认不会发起第三方下一跳。
可直接粘贴到 `#225` 的交接评论:
@@ -6,7 +6,7 @@
- `#226 添加客户端特殊标识`:本计划全部实施范围
- `#225 添加客户端埋点统计`:只接收交接契约,不在本计划实现
当前状态:阶段 6 已完成;阶段 0 的证据与验收记录见
当前状态:阶段 6 已完成;跨 origin 重定向复审修复已完成;阶段 0 的证据与验收记录见
[【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md)。
## 1. 交付目标
@@ -17,7 +17,7 @@ AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求
X-Genarrative-Client: agc
```
同时保持现有认证、幂等、timeout、connect timeout、redirect、`no_proxy`、请求体和响应处理语义不变OSS、签名下载、Provider、搜索、loopback 和更新下载请求不携带该标记。
同时保持同源重定向及现有认证、幂等、timeout、connect timeout、`no_proxy`、请求体和响应处理语义;跨 origin 3xx 不继续请求OSS、签名下载、Provider、搜索、loopback 和更新下载请求不携带该标记。
## 2. 明确不做项
@@ -149,11 +149,11 @@ TS 定向测试必须证明:
fn agc_main_site_client_builder() -> reqwest::ClientBuilder;
```
工厂返回的 builder 允许调用方继续设置原有配置:
工厂返回的 builder 允许调用方继续设置原有配置;未显式覆盖时,factory 默认只跟随同 origin 重定向
- connect timeout
- request timeout
- redirect policy
- 显式 redirect policy(覆盖 factory 默认策略)
- 原有 `no_proxy` 或其他网络策略
### 6.2 阶段验收
@@ -162,7 +162,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder;
- 从 factory build 出的 Client 默认带 `X-Genarrative-Client: agc`
- 请求级 Authorization 和 Idempotency-Key 仍可正常设置。
- factory 不自动改变 timeout、redirect 或 proxy 配置。
- factory 不改变 timeout 或 proxy 配置;默认 redirect 只跟随同 origin,调用方显式 `Policy::none()` 仍可覆盖
- 不新增完整 `AgcMainSiteClient` 类型。
- 不把第三方下载或 Provider Client 接入该 factory。
@@ -176,7 +176,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder;
- 新增 `src-tauri/src/http_client.rs`,提供 `agc_main_site_client_builder()`
- factory 仅通过 `default_headers` 注入 `x-genarrative-client: agc`,不接管认证、幂等、请求体、重试或错误解析。
- 调用方仍可在返回的 builder 上继续配置 connect timeout、request timeout、redirect policy 和 `no_proxy`
- 调用方仍可在返回的 builder 上继续配置 connect timeout、request timeout、显式 redirect policy 和 `no_proxy`;未显式配置时跨 origin 重定向不会被跟随
- 新增两个本地 TCP fixture 单元测试,验证真实发送请求带标记、请求级 Authorization/Idempotency-Key 保留,且 factory 不自动注入认证信息。
- 阶段 2 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`2 tests passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 通过。
- 阶段 2 未迁移任何业务 Client 创建点;迁移属于阶段 3。
@@ -189,7 +189,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder;
- 主站用途的 `reqwest::Client::builder()` 改用 AGC 主站 builder factory。
- 主站用途的 `reqwest::Client::new()` 改为 builder 创建,以便设置默认 Header。
- 保留每个调用原有 timeout、connect timeout、redirect、认证和幂等设置。
- 保留每个调用原有 timeout、connect timeout、认证和幂等设置;同源 redirect 语义保留,跨 origin redirect 统一阻断
重点检查文件:
@@ -234,7 +234,7 @@ fn agc_main_site_client_builder() -> reqwest::ClientBuilder;
- 登录账号态路径带标记。
- 开发者 API Key 态 External v1 路径带标记。
- 生成提交和任务轮询都带标记。
- 请求体、Bearer/API Key、Idempotency-Key、timeout 和 redirect policy 未变化
- 请求体、Bearer/API Key、Idempotency-Key、timeout 和同源 redirect 处理未变化;跨 origin redirect 不发起第二跳
- 没有为了加标记而修改 `ExternalEditorBindingAccess` 的路由映射语义。
### 7.4 阶段完成判据
@@ -377,7 +377,7 @@ tracking metadataclient = "agc"
### 10.4 阶段 6 实施记录
阶段 6 已完成,交给 `#225` 的固定材料已在
阶段 6 已完成,跨 origin 重定向复审修复已收口;交给 `#225` 的固定材料已在
[【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md)
第 12 节集中确认:Header 为 `X-Genarrative-Client: agc`tracking metadata 使用 `client: "agc"`,主站按实际 method/path 和真实认证主体记录;缺失/空值/未知 Header 不拒绝请求。该交接不要求 `#226` 再回改客户端,也不要求本阶段实现 `#225` 的主站 tracking、数据库或后台代码。
@@ -385,7 +385,7 @@ tracking metadataclient = "agc"
- TS `clientHttp` 定向测试:14 tests passed。
- AGC TS 类型检查:通过。
- Rust `http_client` factory2 tests passed。
- Rust `http_client` factory7 tests passed,覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()`
- Rust `omits_agc_marker`4 tests passedloopback 认证边界:1 test passed。
- 账号态主站 mock 捕获:1 test passed。
- External Key 态主站 mock 捕获:1 test passed。
@@ -391,6 +391,6 @@ impl AgcMainSiteHttp {
- 原 Authorization 不变。
- 原 Idempotency-Key 不变。
- 原请求 body 不变。
- 原 timeout redirect policy 不变
- 原 timeout 不变;同源 redirect policy 语义不变,跨 origin redirect 由主站 Client factory 阻断
- 浏览器跨域预检允许 `X-Genarrative-Client`
- 未识别 marker 时主站业务请求不受影响。
@@ -3,7 +3,7 @@
更新时间:`2026-09-02`
实施范围:`#226 添加客户端特殊标识`
交接范围:`#225 添加客户端埋点统计`
执行结论:阶段 6 通过;#226 客户端实现、边界回归、交接材料和最终门禁已收口。未修改 #225 主站代码、数据库、OpenAPI 或后台实现。
执行结论:阶段 6 通过;#226 客户端实现、跨 origin 重定向安全复审、边界回归、交接材料和最终门禁已收口。未修改 #225 主站代码、数据库、OpenAPI 或后台实现。
## 1. 阶段边界
@@ -97,7 +97,7 @@ X-Genarrative-Client: agc
|---|---|---|
| TS 统一出口正向测试 | `npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts` | 通过,14 tests passed |
| AGC TS 类型和配置检查 | `npm run ai-game-creator-shell:typecheck` | 通过;skill-pack/config 检查通过 |
| Rust 主站 Client factory | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,2 tests passed |
| Rust 主站 Client factory 与 origin-safe redirect policy | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,7 tests passed;同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()` 均覆盖 |
| 第三方请求负向矩阵 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture` | 通过,4 tests passed |
| loopback 认证/请求边界 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer -- --nocapture` | 通过,1 test passed |
| 账号态主站请求捕获 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_can_generate_platform_art_asset -- --nocapture` | 通过,1 test passed |
@@ -112,7 +112,7 @@ Rust 测试输出包含仓库既有的 unused/dead-code warning;本任务相
## 5. 发布环境限制与后续交接
本阶段没有执行真实发布环境线上 smoke。账号态和 External Key 态的路径、Header、认证/幂等语义来自本地 mock/custom `apiBaseUrl` fixtureOSS/签名 URL/Provider/搜索/loopback/更新下载边界来自本地请求捕获。发布前或 `#225` 联调时,应由主站侧补做真实环境 Header 接收、tracking metadata 写入和后台查询验证,不要求客户端回改本次设计。
本阶段没有执行真实发布环境线上 smoke。账号态和 External Key 态的路径、Header、认证/幂等语义来自本地 mock/custom `apiBaseUrl` fixtureOSS/签名 URL/Provider/搜索/loopback/更新下载边界来自本地请求捕获;跨 origin 重定向来自双 listener 和链式重定向 fixture。发布前或 `#225` 联调时,应由主站侧补做真实环境 Header 接收、tracking metadata 写入和后台查询验证,不要求客户端回改本次设计。
## 6. 可直接粘贴到 #225 的评论