diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index d40b85804..2d94613ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1776,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st if normalized.contains("permission-denied") || normalized.contains("http 403") { return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"; } - if error.contains("项目正在被其他写操作占用") { + if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { return "当前项目仍有写入正在结束,请稍后再次发送该需求"; } if error.contains("身份不唯一") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index bdbeb222c..d1dead65a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1473,7 +1473,14 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { return Err("工具参数 content 不能包含 NUL".to_string()); } reject_command_output_wrapper(content)?; - let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?; + // Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写", + // 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。 + // 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义: + // 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "direct-codex.file.write", + )?; let written = write_local_project_file_at(root, &path, content)?; let revision = advance_agent_runtime_project_revision_locked(root)?; Ok::<_, String>(json!({ @@ -1493,6 +1500,27 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value { } } +/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async, +/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个 +/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime, +/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。 +/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。 +async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value { + let task_root = root.clone(); + match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await { + Ok(result) => result, + Err(error) => bridge_tool_result( + redact_agent_runtime_error( + &root, + &format!("agc_write_file 阻塞任务未返回:{error}"), + 480, + ), + Vec::new(), + true, + ), + } +} + fn bridge_safe_account_asset_projection(asset: &Value) -> Option { let asset_id = asset.get("assetId").and_then(Value::as_str)?; if asset_id.trim().is_empty() { @@ -2307,7 +2335,9 @@ async fn handle_direct_tool_bridge( bridge_list_registered_assets(&state.root, &request.arguments) } "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), - "agc_write_file" => bridge_write_file(&state.root, &request.arguments), + "agc_write_file" => { + bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await + } "agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await, "agc_import_account_assets" => { bridge_import_account_assets(&state, &request.arguments).await @@ -2712,6 +2742,185 @@ mod tests { ); } + /// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功, + /// 而不是互相报"项目正在被其他写操作占用"。 + #[test] + fn bridge_write_file_serializes_parallel_writes_in_one_round() { + let temporary = tempfile::tempdir().expect("create parallel direct write root"); + init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试") + .expect("initialize parallel direct write root"); + let root = temporary.path().to_path_buf(); + let paths = (0..4) + .map(|index| format!("game/parallel-{index}.js")) + .collect::>(); + + let results = std::thread::scope(|scope| { + let handles = paths + .iter() + .map(|path| { + let root = root.clone(); + let path = path.clone(); + scope.spawn(move || { + let result = bridge_write_file( + &root, + &json!({ "path": path, "content": format!("// {path}\n") }), + ); + (path, result) + }) + }) + .collect::>(); + handles + .into_iter() + .map(|handle| handle.join().expect("parallel direct write must not panic")) + .collect::>() + }); + + for (path, result) in &results { + assert_eq!( + result.get("isError").and_then(Value::as_bool), + Some(false), + "parallel direct write of {path} must succeed: {result}" + ); + assert_eq!( + fs::read_to_string(root.join(path)).expect("read parallel direct write"), + format!("// {path}\n") + ); + } + } + + /// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时, + /// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。 + #[test] + fn bridge_write_file_waits_for_a_short_same_process_project_writer() { + let temporary = tempfile::tempdir().expect("create contended direct write root"); + init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试") + .expect("initialize contended direct write root"); + let root = temporary.path().to_path_buf(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let holder_barrier = std::sync::Arc::clone(&barrier); + let holder_root = root.clone(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "concurrent-writer") + .expect("acquire a short-lived project writer"); + holder_barrier.wait(); + std::thread::sleep(std::time::Duration::from_millis(300)); + drop(lock); + }); + barrier.wait(); + + let result = bridge_write_file( + &root, + &json!({ "path": "game/waited.js", "content": "// waited\n" }), + ); + + holder.join().expect("join the short-lived project writer"); + assert_eq!( + result.get("isError").and_then(Value::as_bool), + Some(false), + "the direct write must wait out a short same-process writer: {result}" + ); + assert_eq!( + fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"), + "// waited\n" + ); + } + + /// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池, + /// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步 + /// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。 + #[tokio::test] + async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + let temporary = tempfile::tempdir().expect("create blocking pool write root"); + init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试") + .expect("initialize blocking pool write root"); + let state = direct_tool_bridge_state(temporary.path().to_path_buf()); + + // 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。 + let holder_root = temporary.path().to_path_buf(); + let lock = acquire_project_write_lock(&holder_root, "concurrent-writer") + .expect("acquire the concurrent project writer"); + let holder = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(250)); + drop(lock); + }); + + // 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。 + let heartbeat = Arc::new(AtomicBool::new(false)); + let heartbeat_writer = Arc::clone(&heartbeat); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + heartbeat_writer.store(true, Ordering::SeqCst); + }); + + let response = handle_direct_tool_bridge( + axum::extract::State(state), + axum::Json(DirectToolBridgeRequest { + tool: "agc_write_file".to_string(), + arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }), + }), + ) + .await + .0; + + holder.join().expect("join the concurrent project writer"); + assert!( + heartbeat.load(Ordering::SeqCst), + "写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker" + ); + assert_eq!( + response.get("isError").and_then(Value::as_bool), + Some(false), + "the pooled direct write must still wait out the holder: {response}" + ); + assert_eq!( + fs::read_to_string(temporary.path().join("game/blocking-pool.js")) + .expect("read pooled direct write"), + "// pooled\n" + ); + } + + /// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。 + #[cfg(unix)] + #[test] + fn bridge_write_file_does_not_project_permission_denial_as_contention() { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir().expect("create acl direct write root"); + init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试") + .expect("initialize acl direct write root"); + let agent_directory = temporary.path().join(".agent"); + let original = fs::metadata(&agent_directory) + .expect("read control directory metadata") + .permissions(); + fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500)) + .expect("drop write permission on the control directory"); + + let result = bridge_write_file( + temporary.path(), + &json!({ "path": "game/acl-denied.js", "content": "// denied\n" }), + ); + fs::set_permissions(&agent_directory, original) + .expect("restore control directory permission"); + + if result.get("isError").and_then(Value::as_bool) != Some(true) { + // 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。 + return; + } + let text = result + .pointer("/content/0/text") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!( + !text.contains("项目正在被其他写操作占用"), + "a permission denial must not be projected as lock contention: {text}" + ); + assert!(!temporary.path().join("game/acl-denied.js").exists()); + } + #[test] fn resource_request_uuid_is_stable_v4_and_domain_separated() { let operation = direct_resource_request_uuid("turn-1", "operation", "abc"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 71d9f51e6..95b17611d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1822,27 +1822,45 @@ const AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200; /// Take the project write lock, riding out transient contention for at most /// `max_attempts` polls. /// -/// `项目正在被其他写操作占用:` is the one lock error that means -/// "nothing is broken, the current holder is mid-write" — every other variant -/// (a torn lock file, a denied path) is returned immediately. Callers pick the -/// budget from what a lost race costs them: a one-shot user intent waits out the -/// full window, a poll that will run again shortly waits far less. +/// 能不能等由 `ProjectWriteLockFailure` 的**类型**决定,不解析错误文案:只有可重试的 +/// 取锁失败才在这里等,权限拒绝和坏路径立刻返回。判据曾经是 +/// `项目正在被其他写操作占用:` 这个前缀,那等于把"要不要等"绑在中文文案上—— +/// 改一次文案就悄悄改掉一次重试语义。Callers pick the budget from what a lost race +/// costs them: a one-shot user intent waits out the full window, a poll that will run +/// again shortly waits far less. fn acquire_game_creator_agent_runtime_project_write_lock_within( root: &Path, command_id: &str, max_attempts: usize, ) -> Result { let max_attempts = max_attempts.max(1); + let started_at = std::time::Instant::now(); for attempt in 0..max_attempts { - match acquire_project_write_lock(root, command_id) { - Err(error) - if error.starts_with("项目正在被其他写操作占用:") - && attempt + 1 < max_attempts => - { - std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); + let failure = match acquire_project_write_lock_failure(root, command_id) { + Ok(lock) => return Ok(lock), + Err(failure) if failure.is_retryable() => failure, + Err(failure) => return Err(failure.message()), + }; + if attempt + 1 == max_attempts { + // 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。 + // 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人", + // 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。 + // 单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)根本没有等待: + // 既不写 `wait_exhausted`(waitedMs≈0 会让"耗尽"这个词失去意义,而 hydrate + // 每次状态变化都会撞一次锁,会把它变成噪声),也不做终态改判。 + let waited = max_attempts > 1; + let (projection, message) = failure.exhausted_projection(waited); + if waited { + app_log!( + "project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}", + attempt + 1, + started_at.elapsed().as_millis(), + crate::project::project_write_lock_contention_diagnostic(root) + ); } - result => return result, + return Err(message); } + std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL); } unreachable!("project write lock retry loop always returns") } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index efd6d42fd..518dabcd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -24,7 +24,7 @@ enum AutonomousManifestParentWakeReconciliationOutcome { pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool { let normalized = error.to_ascii_lowercase(); - error.starts_with("项目正在被其他写操作占用:") + error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) || error.contains("另一个程序正在使用此文件") || normalized.contains("sharing violation") || normalized.contains("lock violation") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs index 43dbe909a..765a14a82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs @@ -1495,7 +1495,7 @@ pub(crate) fn hydrate_planning_session_v2( "planning.v2.hydrate", ) { Ok(lock) => lock, - Err(error) if error.starts_with("项目正在被其他写操作占用:") => { + Err(error) if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => { return Ok(None) } Err(error) => return Err(error), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 4ffe94674..003d4464b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -16,6 +16,7 @@ mod resource_dependency_graph; mod resource_editor; mod resource_layout; mod verification; +mod write_lock; pub(crate) use agent_db::*; pub(crate) use asset_canvas::*; @@ -30,3 +31,4 @@ pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; pub(crate) use verification::*; +pub(crate) use write_lock::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index ca30e8bf4..08691c487 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -2,7 +2,7 @@ use super::*; use std::collections::BTreeSet; #[cfg(windows)] -use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024; const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 9c99aaede..380cc2836 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -3,7 +3,7 @@ use super::*; use super::filesystem::validate_portable_project_path_component; #[cfg(windows)] -use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; +use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT; #[cfg(windows)] fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 02f3c83a0..6f067628b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,510 +1,5 @@ use super::*; -#[cfg(windows)] -pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - -static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = - std::sync::atomic::AtomicU64::new(1); -const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; -/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者 -/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它 -/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。 -const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; -/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。 -const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; -const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; - -#[derive(Debug)] -pub(crate) struct ProjectWriteLock { - path: PathBuf, - content: String, - /// In the free-form autonomous lane a single Runtime process may have - /// several specialist actions in flight at once. A file lock is still - /// useful across processes, but making same-process contenders fail turns - /// ordinary parallel work into a dead run (and can deadlock nested tool - /// calls). Such a contender receives an in-process/advisory guard instead - /// of deleting the real holder's lock on drop. - bypassed_same_process: bool, -} - -impl ProjectWriteLock { - pub(crate) fn guards_project_root(&self, root: &Path) -> Result { - let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; - if self.bypassed_same_process { - // The relaxed guard deliberately has no ownership of the durable - // `.agent/project.lock` file. It still binds the observation to - // the validated project root so callers cannot use a guard from a - // different project. - return Ok(self.path == expected_path); - } - Ok(self.path == expected_path - && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) - } -} - -impl Drop for ProjectWriteLock { - fn drop(&mut self) { - if self.bypassed_same_process { - return; - } - if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { - let _ = fs::remove_file(&self.path); - } - } -} - -#[cfg(unix)] -fn project_write_lock_process_is_alive(process_id: u64) -> Option { - // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 - // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 - let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { - return Some(false); - }; - let result = unsafe { libc::kill(process_id, 0) }; - if result == 0 { - return Some(true); - } - match std::io::Error::last_os_error().raw_os_error() { - Some(libc::ESRCH) => Some(false), - Some(libc::EPERM) => Some(true), - _ => None, - } -} - -#[cfg(windows)] -fn project_write_lock_process_is_alive(process_id: u64) -> Option { - use std::ffi::c_void; - - #[link(name = "kernel32")] - unsafe extern "system" { - fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; - fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; - fn CloseHandle(handle: *mut c_void) -> i32; - } - - // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 - // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 - let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { - return Some(false); - }; - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - const STILL_ACTIVE: u32 = 259; - // SAFETY: OpenProcess returns an owned kernel handle or null; it is - // closed below. We only request the query permission needed here. - let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; - if process.is_null() { - // ERROR_INVALID_PARAMETER means the process no longer exists. For - // access-denied/other failures we cannot prove liveness, so keep the - // conservative unknown result and let the normal bounded wait decide. - return match std::io::Error::last_os_error().raw_os_error() { - Some(87) => Some(false), - _ => None, - }; - } - let mut exit_code = 0_u32; - // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. - let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; - // SAFETY: `process` is an owned handle returned by OpenProcess. - unsafe { CloseHandle(process) }; - if result == 0 { - return None; - } - Some(exit_code == STILL_ACTIVE) -} - -#[cfg(not(any(unix, windows)))] -fn project_write_lock_process_is_alive(_process_id: u64) -> Option { - None -} - -/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有 -/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时 -/// 保持原有的保守回收策略。 -#[cfg(windows)] -pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { - use std::ffi::c_void; - - #[repr(C)] - struct FileTime { - low_date_time: u32, - high_date_time: u32, - } - - #[link(name = "kernel32")] - unsafe extern "system" { - fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; - fn GetProcessTimes( - process: *mut c_void, - creation_time: *mut FileTime, - exit_time: *mut FileTime, - kernel_time: *mut FileTime, - user_time: *mut FileTime, - ) -> i32; - fn CloseHandle(handle: *mut c_void) -> i32; - } - - const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; - /// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。 - const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; - // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. - let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; - if process.is_null() { - return None; - } - // SAFETY: every FileTime is plain data filled by GetProcessTimes. - let mut creation = unsafe { std::mem::zeroed::() }; - let mut exit = unsafe { std::mem::zeroed::() }; - let mut kernel = unsafe { std::mem::zeroed::() }; - let mut user = unsafe { std::mem::zeroed::() }; - // SAFETY: `process` is a live handle and all four pointers are writable scalars. - let result = - unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; - // SAFETY: `process` is an owned handle returned by OpenProcess. - unsafe { CloseHandle(process) }; - if result == 0 { - return None; - } - let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time); - file_time - .checked_sub(FILETIME_UNIX_EPOCH_OFFSET) - .map(|unix_100ns| unix_100ns / 10_000_000) -} - -#[cfg(target_os = "linux")] -pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; - // SAFETY: sysconf has no memory safety preconditions and returns -1 on failure. - let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; - if clock_ticks <= 0 { - return None; - } - let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?; - let start_ticks = stat - .rsplit_once(") ")? - .1 - .split_whitespace() - .nth(19)? - .parse::() - .ok()?; - let boot_time = fs::read_to_string("/proc/stat") - .ok()? - .lines() - .find_map(|line| line.strip_prefix("btime "))? - .trim() - .parse::() - .ok()?; - Some(boot_time + start_ticks / clock_ticks as u64) -} - -#[cfg(not(any(windows, target_os = "linux")))] -pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option { - None -} - -/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: -/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 -/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 -#[derive(Debug, Clone)] -pub(crate) struct ProjectWriteLockSnapshot { - content: Vec, - pid: Option, - created_at: Option, - process_started_at: Option, -} - -impl ProjectWriteLockSnapshot { - pub(crate) fn read(path: &Path) -> Option { - let content = fs::read(path).ok()?; - let payload = serde_json::from_slice::(&content).ok(); - let number = |key: &str| { - payload - .as_ref() - .and_then(|payload| payload.get(key)) - .and_then(serde_json::Value::as_u64) - }; - Some(Self { - pid: number("pid"), - created_at: number("createdAt"), - process_started_at: number("processStartedAt"), - content, - }) - } -} - -fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool { - ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) - == Some(u64::from(std::process::id())) -} - -/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” -/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 -/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 -fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { - metadata - .modified() - .ok() - .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_secs()) -} - -/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 -/// 按“不回收”处理,不能退化成 0 或极大值。 -fn project_write_lock_age_seconds( - snapshot: &ProjectWriteLockSnapshot, - modified_at: Option, - now: u64, -) -> Option { - if let Some(created_at) = snapshot.created_at { - return Some(now.saturating_sub(created_at)); - } - modified_at.map(|modified_at| now.saturating_sub(modified_at)) -} - -/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 -/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 -pub(crate) fn project_write_lock_reclaim_decision( - snapshot: &ProjectWriteLockSnapshot, - modified_at: Option, - now: u64, - process_is_alive: impl Fn(u64) -> Option, - process_started_at: impl Fn(u64) -> Option, -) -> bool { - let Some(owner_pid) = snapshot.pid else { - // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 - return project_write_lock_age_seconds(snapshot, modified_at, now) - .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); - }; - match process_is_alive(owner_pid) { - Some(false) => true, - Some(true) => { - // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 - match (snapshot.process_started_at, process_started_at(owner_pid)) { - // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 - (Some(stored), Some(actual)) => stored != actual, - // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; - // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 - (None, Some(actual)) => { - let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { - return false; - }; - actual - > lock_created_at - .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) - } - _ => false, - } - } - // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 - None => project_write_lock_age_seconds(snapshot, modified_at, now) - .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), - } -} - -/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 -fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { - let metadata = fs::symlink_metadata(path).ok()?; - if metadata.file_type().is_symlink() - || windows_metadata_is_reparse_point(&metadata) - || !metadata.is_file() - || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES - { - return None; - } - let snapshot = ProjectWriteLockSnapshot::read(path)?; - project_write_lock_reclaim_decision( - &snapshot, - project_write_lock_file_modified_seconds(&metadata), - unix_timestamp(), - project_write_lock_process_is_alive, - project_write_lock_process_start_time_seconds, - ) - .then_some(snapshot) -} - -/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 -/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 -/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 -pub(crate) fn project_write_lock_reclaim( - path: &Path, - snapshot: &ProjectWriteLockSnapshot, -) -> Result { - match fs::read(path) { - Ok(content) if content == snapshot.content => {} - Ok(_) => return Ok(false), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); - } - } - match fs::remove_file(path) { - Ok(()) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), - } -} - -fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { - if error.kind() == std::io::ErrorKind::AlreadyExists { - return true; - } - #[cfg(windows)] - { - // Windows can report an existing or delete-pending create_new target as - // ACCESS_DENIED instead of ALREADY_EXISTS while another thread drops it. - return error.kind() == std::io::ErrorKind::PermissionDenied - || matches!(error.raw_os_error(), Some(5 | 32 | 33)); - } - #[cfg(not(windows))] - false -} - -#[cfg(all(test, windows))] -#[test] -fn project_write_lock_treats_windows_target_races_as_contention() { - for code in [5, 32, 33] { - assert!( - project_write_lock_open_error_is_contention(&std::io::Error::from_raw_os_error(code)), - "Windows project lock error {code} must enter the bounded contention wait" - ); - } -} - -#[cfg(all(test, windows))] -#[test] -fn project_write_lock_hardens_space_containing_path_in_process() { - let parent = tempfile::tempdir().expect("create spaced lock parent"); - let root = parent - .path() - .join("Genarrative GameAgent") - .join("gameagent-space"); - fs::create_dir_all(&root).expect("create spaced project root"); - let lock = acquire_project_write_lock(&root, "planning.v2.approval") - .expect("acquire project lock under a space-containing path"); - let lock_path = root.join(".agent").join("project.lock"); - assert!(lock_path.is_file(), "project lock must exist while held"); - crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) - .expect("new project lock must already satisfy the private DACL contract"); - drop(lock); - assert!( - !lock_path.exists(), - "project lock must be removed when the guard is dropped" - ); -} - -fn resolve_project_write_lock_path(root: &Path) -> Result { - let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; - let (parent_relative, file_name) = normalized - .rsplit_once('/') - .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; - let parent = resolve_local_project_path(root, parent_relative)?; - // create_new is the authority for the final lock component. On Windows a - // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED - // before the existing bounded contention wait has a chance to run. - Ok(parent.join(file_name)) -} - -pub(crate) fn acquire_project_write_lock( - root: &Path, - command_id: &str, -) -> Result { - validate_project_root(root)?; - let mut path = resolve_project_write_lock_path(root)?; - if let Some(parent) = path.parent() { - ensure_game_creator_private_directory_tree(parent, "项目锁目录")?; - prepare_game_creator_private_path_for_read(parent, true, "项目锁目录")?; - } - // Re-check the parent after creation so skipping metadata only for the final - // create_new target cannot weaken the normal ancestor link/reparse checks. - path = resolve_project_write_lock_path(root)?; - let payload = serde_json::json!({ - "commandId": command_id, - "pid": std::process::id(), - // 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。 - "processStartedAt": project_write_lock_process_start_time_seconds(u64::from( - std::process::id() - )), - "createdAt": unix_timestamp(), - "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - }); - let content = serde_json::to_string_pretty(&payload) - .map_err(|error| format!("生成项目写锁失败:{error}"))?; - let mut retried_after_reclaim = false; - loop { - let mut options = fs::OpenOptions::new(); - options.create_new(true).write(true); - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); - } - match options.open(&path) { - Ok(mut file) => { - if let Err(error) = file.write_all(content.as_bytes()) { - drop(file); - let _ = fs::remove_file(&path); - return Err(format!("写入项目写锁失败:{}: {error}", path.display())); - } - if let Err(error) = file.sync_all() { - drop(file); - let _ = fs::remove_file(&path); - return Err(format!("落盘项目写锁失败:{}: {error}", path.display())); - } - drop(file); - if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") - { - let _ = fs::remove_file(&path); - return Err(error); - } - let actual = match fs::read_to_string(&path) { - Ok(actual) => actual, - Err(error) => { - let _ = fs::remove_file(&path); - return Err(format!("读取项目写锁失败:{}: {error}", path.display())); - } - }; - if actual != content { - let _ = fs::remove_file(&path); - return Err(format!("项目写锁内容校验失败:{}", path.display())); - } - return Ok(ProjectWriteLock { - path, - content: content.clone(), - bypassed_same_process: false, - }); - } - Err(error) if project_write_lock_open_error_is_contention(&error) => { - if !retried_after_reclaim { - if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) { - if project_write_lock_reclaim(&path, &snapshot)? { - retried_after_reclaim = true; - continue; - } - } - } - if crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) - { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. - return Ok(ProjectWriteLock { - path, - content: String::new(), - bypassed_same_process: true, - }); - } - return Err(format!("项目正在被其他写操作占用:{}", path.display())); - } - Err(error) => { - return Err(format!("创建项目写锁失败:{}: {error}", path.display())); - } - } - } -} - pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { @@ -1076,7 +571,9 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { Ok(()) } -fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { +/// 路径是否是指向别处的 reparse point(符号链接、junction 等)。项目锁文件与项目文件 +/// 遍历都要靠它拒绝“名字在项目里、内容在项目外”的对象,因此对 `write_lock` 可见。 +pub(crate) fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { #[cfg(windows)] { use std::os::windows::fs::MetadataExt; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs new file mode 100644 index 000000000..f615b2ddb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -0,0 +1,887 @@ +use super::*; + +#[cfg(windows)] +pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + +static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1); +const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600; +/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者 +/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它 +/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。 +const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; +/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。 +const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; +const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; + +#[derive(Debug)] +pub(crate) struct ProjectWriteLock { + path: PathBuf, + content: String, + /// In the free-form autonomous lane a single Runtime process may have + /// several specialist actions in flight at once. A file lock is still + /// useful across processes, but making same-process contenders fail turns + /// ordinary parallel work into a dead run (and can deadlock nested tool + /// calls). Such a contender receives an in-process/advisory guard instead + /// of deleting the real holder's lock on drop. + bypassed_same_process: bool, +} + +impl ProjectWriteLock { + pub(crate) fn guards_project_root(&self, root: &Path) -> Result { + let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?; + if self.bypassed_same_process { + // The relaxed guard deliberately has no ownership of the durable + // `.agent/project.lock` file. It still binds the observation to + // the validated project root so callers cannot use a guard from a + // different project. + return Ok(self.path == expected_path); + } + Ok(self.path == expected_path + && fs::read_to_string(&self.path).is_ok_and(|content| content == self.content)) + } +} + +impl Drop for ProjectWriteLock { + fn drop(&mut self) { + if self.bypassed_same_process { + return; + } + if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(unix)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 + // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; + let result = unsafe { libc::kill(process_id, 0) }; + if result == 0 { + return Some(true); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => Some(false), + Some(libc::EPERM) => Some(true), + _ => None, + } +} + +#[cfg(windows)] +fn project_write_lock_process_is_alive(process_id: u64) -> Option { + use std::ffi::c_void; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 + // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + const STILL_ACTIVE: u32 = 259; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is + // closed below. We only request the query permission needed here. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + // ERROR_INVALID_PARAMETER means the process no longer exists. For + // access-denied/other failures we cannot prove liveness, so keep the + // conservative unknown result and let the normal bounded wait decide. + return match std::io::Error::last_os_error().raw_os_error() { + Some(87) => Some(false), + _ => None, + }; + } + let mut exit_code = 0_u32; + // SAFETY: `exit_code` is a writable scalar and `process` is a live handle. + let result = unsafe { GetExitCodeProcess(process, &mut exit_code) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + Some(exit_code == STILL_ACTIVE) +} + +#[cfg(not(any(unix, windows)))] +fn project_write_lock_process_is_alive(_process_id: u64) -> Option { + None +} + +/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有 +/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时 +/// 保持原有的保守回收策略。 +#[cfg(windows)] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + use std::ffi::c_void; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void; + fn GetProcessTimes( + process: *mut c_void, + creation_time: *mut FileTime, + exit_time: *mut FileTime, + kernel_time: *mut FileTime, + user_time: *mut FileTime, + ) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + /// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。 + const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000; + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return None; + } + // SAFETY: every FileTime is plain data filled by GetProcessTimes. + let mut creation = unsafe { std::mem::zeroed::() }; + let mut exit = unsafe { std::mem::zeroed::() }; + let mut kernel = unsafe { std::mem::zeroed::() }; + let mut user = unsafe { std::mem::zeroed::() }; + // SAFETY: `process` is a live handle and all four pointers are writable scalars. + let result = + unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; + // SAFETY: `process` is an owned handle returned by OpenProcess. + unsafe { CloseHandle(process) }; + if result == 0 { + return None; + } + let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time); + file_time + .checked_sub(FILETIME_UNIX_EPOCH_OFFSET) + .map(|unix_100ns| unix_100ns / 10_000_000) +} + +#[cfg(target_os = "linux")] +pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option { + let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // SAFETY: sysconf has no memory safety preconditions and returns -1 on failure. + let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if clock_ticks <= 0 { + return None; + } + let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?; + let start_ticks = stat + .rsplit_once(") ")? + .1 + .split_whitespace() + .nth(19)? + .parse::() + .ok()?; + let boot_time = fs::read_to_string("/proc/stat") + .ok()? + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .trim() + .parse::() + .ok()?; + Some(boot_time + start_ticks / clock_ticks as u64) +} + +#[cfg(not(any(windows, target_os = "linux")))] +pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option { + None +} + +/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: +/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 +/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 +#[derive(Debug, Clone)] +pub(crate) struct ProjectWriteLockSnapshot { + content: Vec, + command_id: Option, + pid: Option, + created_at: Option, + process_started_at: Option, +} + +impl ProjectWriteLockSnapshot { + pub(crate) fn read(path: &Path) -> Option { + let content = fs::read(path).ok()?; + let payload = serde_json::from_slice::(&content).ok(); + let number = |key: &str| { + payload + .as_ref() + .and_then(|payload| payload.get(key)) + .and_then(serde_json::Value::as_u64) + }; + Some(Self { + command_id: payload + .as_ref() + .and_then(|payload| payload.get("commandId")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + pid: number("pid"), + created_at: number("createdAt"), + process_started_at: number("processStartedAt"), + content, + }) + } + + /// 持锁方身份的单行描述。Issue #318 的现场只有一句"别人在写",无法回答"到底是谁、 + /// 是不是自己人",所以争用错误和等待日志都要带上这几个字段。 + /// `ownerIsSelf` 用 `pid` 判定:`true` 是同进程另一条写通道,`false` 才是真外部进程。 + pub(crate) fn describe_holder(&self) -> String { + format!( + "commandId={} pid={} createdAt={} ownerIsSelf={}", + self.command_id.as_deref().unwrap_or("unknown"), + self.pid + .map(|pid| pid.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + self.created_at + .map(|created_at| created_at.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + match self.pid { + Some(pid) if pid == u64::from(std::process::id()) => "true", + Some(_) => "false", + None => "unknown", + }, + ) + } +} + +fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool { + ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) + == Some(u64::from(std::process::id())) +} + +/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” +/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 +/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 +fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { + metadata + .modified() + .ok() + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) +} + +/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 +/// 按“不回收”处理,不能退化成 0 或极大值。 +fn project_write_lock_age_seconds( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, +) -> Option { + if let Some(created_at) = snapshot.created_at { + return Some(now.saturating_sub(created_at)); + } + modified_at.map(|modified_at| now.saturating_sub(modified_at)) +} + +/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 +/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 +pub(crate) fn project_write_lock_reclaim_decision( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, + process_is_alive: impl Fn(u64) -> Option, + process_started_at: impl Fn(u64) -> Option, +) -> bool { + let Some(owner_pid) = snapshot.pid else { + // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 + return project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); + }; + match process_is_alive(owner_pid) { + Some(false) => true, + Some(true) => { + // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 + match (snapshot.process_started_at, process_started_at(owner_pid)) { + // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 + (Some(stored), Some(actual)) => stored != actual, + // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; + // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 + (None, Some(actual)) => { + let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { + return false; + }; + actual + > lock_created_at + .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) + } + _ => false, + } + } + // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 + None => project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), + } +} + +/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 +fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) + || !metadata.is_file() + || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES + { + return None; + } + let snapshot = ProjectWriteLockSnapshot::read(path)?; + project_write_lock_reclaim_decision( + &snapshot, + project_write_lock_file_modified_seconds(&metadata), + unix_timestamp(), + project_write_lock_process_is_alive, + project_write_lock_process_start_time_seconds, + ) + .then_some(snapshot) +} + +/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 +/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 +/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 +pub(crate) fn project_write_lock_reclaim( + path: &Path, + snapshot: &ProjectWriteLockSnapshot, +) -> Result { + match fs::read(path) { + Ok(content) if content == snapshot.content => {} + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); + } + } + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), + } +} + +/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、 +/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用 +/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。 +pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:"; + +/// 锁分类判据必须能被两个平台覆盖,所以平台由参数传入而不是藏在 `#[cfg]` 后面: +/// CI 只有 Linux runner,`#[cfg(windows)]` 的用例在 CI 里一次都不会跑,而 Windows 特有的 +/// `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) 分支恰恰是最危险的一段。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProjectWriteLockPlatform { + Windows, + Unix, +} + +pub(crate) const PROJECT_WRITE_LOCK_PLATFORM: ProjectWriteLockPlatform = if cfg!(windows) { + ProjectWriteLockPlatform::Windows +} else { + ProjectWriteLockPlatform::Unix +}; + +/// 一次 `create_new` 失败在**等待契约**上的归类。三类的处置完全不同:可重试、权限拒绝 +/// 必须失败关闭、其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL +/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。 +/// +/// 判据只能是错误码本身。**不要用 `path.exists()` 这种一次 metadata 观察决定"要不要重试"**: +/// 目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 +/// `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false。本机实测 60000 次建锁 / 删锁竞争里 +/// 有 396-538 例命中"5 + 目标不可见";按"目标不存在"判成权限拒绝,等待层就会立刻失败关闭 +/// ——正是 Issue #318 要消灭的"毫秒级直接失败",只是换成了更误导的 ACL 文案。 +/// 终态投影放在等待预算耗尽之后做,见 `ProjectWriteLockFailure::exhausted_projection`。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectWriteLockOpenFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:短暂重叠,可重试。 + Retryable, + /// 明确不是争用:权限 / ACL 拒绝,失败关闭。 + Permission, + /// 其它 I/O 错误,原样上报。 + Other, +} + +fn project_write_lock_open_failure_for( + platform: ProjectWriteLockPlatform, + error: &std::io::Error, +) -> ProjectWriteLockOpenFailure { + if error.kind() == std::io::ErrorKind::AlreadyExists { + return ProjectWriteLockOpenFailure::Retryable; + } + match platform { + ProjectWriteLockPlatform::Windows => { + // 32 / 33 是 sharing / lock violation,只可能在目标被占用时出现,恒定可重试。 + // ACCESS_DENIED(5) 既可能是 delete-pending / 删除拆链窗口,也可能是真实 ACL + // 拒绝,错误码上不可区分,因此同样先按可重试处理,由等待方在预算耗尽后再判定终态。 + if matches!(error.raw_os_error(), Some(5 | 32 | 33)) + || error.kind() == std::io::ErrorKind::PermissionDenied + { + return ProjectWriteLockOpenFailure::Retryable; + } + } + ProjectWriteLockPlatform::Unix => { + // Unix 没有删除挂起:目标存在必然先命中 AlreadyExists,EACCES 就是权限拒绝, + // 可以立刻判定,不必让调用方白等一个等待窗口。 + if error.kind() == std::io::ErrorKind::PermissionDenied { + return ProjectWriteLockOpenFailure::Permission; + } + } + } + ProjectWriteLockOpenFailure::Other +} + +/// 一次取锁失败的完整形状。 +/// +/// 等待层需要它做两件事:按分类决定是否重试,以及在预算耗尽后用**当时的**目标状态做终态 +/// 投影。把"这一次失败"整份传下去,调用方就不必回头解析错误文案。 +#[derive(Debug)] +pub(crate) enum ProjectWriteLockFailure { + /// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。 + /// 保留平台与 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。 + Retryable { + platform: ProjectWriteLockPlatform, + path: PathBuf, + source: std::io::Error, + }, + /// 已经定稿、不可重试的失败文案:权限拒绝、其它 I/O 错误、前置校验失败。 + Terminal(String), +} + +/// 只有 Windows 的 `ACCESS_DENIED(5)` 才可能在等待之后被改判:它在分类阶段与删除拆链 +/// 窗口不可区分。平台必须一起传进来——Linux 上 errno 5 是 `EIO` 而不是 `EACCES`, +/// 只看 `kind()` 会让同一条判据在两个平台上得出不同结论,而这条判据正是要在 CI 上跑。 +fn project_write_lock_permission_is_ambiguous( + platform: ProjectWriteLockPlatform, + source: &std::io::Error, +) -> bool { + platform == ProjectWriteLockPlatform::Windows + && (source.kind() == std::io::ErrorKind::PermissionDenied + || source.raw_os_error() == Some(5)) +} + +impl ProjectWriteLockFailure { + /// 是否允许进入有界等待。判据是失败分类,不是错误文案。 + pub(crate) fn is_retryable(&self) -> bool { + matches!(self, Self::Retryable { .. }) + } + + /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 + /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 + /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 + pub(crate) fn message(&self) -> String { + match self { + Self::Terminal(message) => message.clone(), + Self::Retryable { + platform, + path, + source, + } => project_write_lock_contention_error( + path, + ProjectWriteLockSnapshot::read(path).as_ref(), + project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists(), + ), + } + } + + /// 等待预算耗尽后的终态投影:`(日志分类, 给用户的文案)`。 + /// + /// 删除拆链窗口是微秒级:真的等过预算(`waited`)仍在失败、且目标此刻仍然不存在, + /// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`) + /// 没有等待证据,保持争用语义,不做终态改判。 + pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) { + let Self::Retryable { + platform, + path, + source, + } = self + else { + return ("terminal", self.message()); + }; + if waited && project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists() + { + return ( + "permission_denied", + project_write_lock_permission_error(path, source), + ); + } + ("contention", self.message()) + } +} + +/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份, +/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。 +/// +/// `permission_ambiguous` 为真表示目标此刻不存在、而错误码是 Windows 上无法与权限拒绝 +/// 区分的 `ACCESS_DENIED(5)`:零等待入口没有等待窗口可以证伪,文案必须把两种处置都说 +/// 出来,而不是替调用方猜一个。 +fn project_write_lock_contention_error( + path: &Path, + snapshot: Option<&ProjectWriteLockSnapshot>, + permission_ambiguous: bool, +) -> String { + match snapshot { + Some(snapshot) => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})", + path.display(), + snapshot.describe_holder() + ), + None if permission_ambiguous => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝)", + path.display() + ), + None => format!( + "{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件可能处于删除挂起或尚未写完)", + path.display() + ), + } +} + +fn project_write_lock_permission_error(path: &Path, error: &std::io::Error) -> String { + // 文案刻意不含争用前缀:`..._with_wait`、`provider_recovery.rs` 和前端都按前缀把 + // 错误当成"等一下就好"的瞬时状态,权限拒绝必须失败关闭。 + format!( + "项目写锁路径权限被拒绝,不是写锁争用(请检查项目目录与 .agent 目录的 ACL):{}: {error}", + path.display() + ) +} + +/// 等待预算耗尽时写进 App 日志的持锁方快照。 +pub(crate) fn project_write_lock_contention_diagnostic(root: &Path) -> String { + let Ok(path) = resolve_project_write_lock_path(root) else { + return "持锁方身份不可解析".to_string(); + }; + match ProjectWriteLockSnapshot::read(&path) { + Some(snapshot) => snapshot.describe_holder(), + None => "持锁方身份不可读(锁文件可能处于删除挂起或尚未写完)".to_string(), + } +} + +/// **重试性只由错误码决定,不由一次 metadata 观察决定。** 这条用例把平台作为参数, +/// 因此 CI 的 Linux runner 也会执行 Windows 分支:删除拆链窗口里 `create_new` 报 +/// `ACCESS_DENIED(5)` 而目标已经不可见,用 `exists()` 判"要不要等"会把这批瞬时失败判死。 +#[test] +fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_probe() { + for error in [ + std::io::Error::from_raw_os_error(5), + std::io::Error::from_raw_os_error(32), + std::io::Error::from_raw_os_error(33), + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + std::io::Error::from(std::io::ErrorKind::AlreadyExists), + ] { + assert_eq!( + project_write_lock_open_failure_for(ProjectWriteLockPlatform::Windows, &error), + ProjectWriteLockOpenFailure::Retryable, + "Windows 上 {error:?} 必须进入有界等待" + ); + } + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::AlreadyExists) + ), + ProjectWriteLockOpenFailure::Retryable, + "Unix 上目标存在必须是争用" + ); + // Unix 没有删除挂起,EACCES 就是权限拒绝,可以立刻判定,不必白等一个等待窗口。 + assert_eq!( + project_write_lock_open_failure_for( + ProjectWriteLockPlatform::Unix, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied) + ), + ProjectWriteLockOpenFailure::Permission + ); + for platform in [ + ProjectWriteLockPlatform::Windows, + ProjectWriteLockPlatform::Unix, + ] { + assert_eq!( + project_write_lock_open_failure_for( + platform, + &std::io::Error::from(std::io::ErrorKind::NotFound) + ), + ProjectWriteLockOpenFailure::Other, + "其它 I/O 错误必须原样上报,不进入等待" + ); + } +} + +/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上 +/// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。 +/// +/// 平台连同错误一起构造,用例因此不依赖宿主的 errno 语义:Linux 上 errno 5 是 `EIO` +/// 而不是 `EACCES`,只看 `kind()` 会让同一条判据在 CI 与 Windows 上得出不同结论。 +#[test] +fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() { + let temporary = tempfile::tempdir().expect("create projection root"); + let path = temporary.path().join("project.lock"); + let ambiguous = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Windows, + path: path.clone(), + source: std::io::Error::from_raw_os_error(5), + }; + assert!( + ambiguous.is_retryable(), + "ACCESS_DENIED 必须允许进入有界等待" + ); + + // 目标不存在 + 真的等过预算:改判权限拒绝,文案不得再含争用前缀。 + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "permission_denied"); + assert!( + !message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "预算耗尽且目标缺失时不得再投影成争用:{message}" + ); + + // 同一形状的单次试探没有等待证据:保持争用语义,前缀逐字不变。 + let (projection, message) = ambiguous.exhausted_projection(false); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + + // 目标此刻存在(真实争用或带句柄的删除挂起):永远按争用上报。 + fs::write(&path, b"{}").expect("write a visible lock fixture"); + let (projection, message) = ambiguous.exhausted_projection(true); + assert_eq!(projection, "contention"); + assert!( + message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{message}" + ); + fs::remove_file(&path).expect("remove lock fixture"); + + // 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。 + let occupied = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Windows, + path: path.clone(), + source: std::io::Error::from_raw_os_error(32), + }; + assert_eq!(occupied.exhausted_projection(true).0, "contention"); + + // Unix 侧没有这种不可区分的错误码:EACCES 在分类阶段就是终态,永远不会被改判。 + let unix_denied = ProjectWriteLockFailure::Retryable { + platform: ProjectWriteLockPlatform::Unix, + path: path.clone(), + source: std::io::Error::from_raw_os_error(5), + }; + assert_eq!(unix_denied.exhausted_projection(true).0, "contention"); + + // 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。 + let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error( + &path, + &std::io::Error::from(std::io::ErrorKind::PermissionDenied), + )); + assert!(!denied.is_retryable()); + assert!( + !denied + .message() + .starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX), + "{}", + denied.message() + ); +} + +#[cfg(all(test, windows))] +#[test] +fn project_write_lock_hardens_space_containing_path_in_process() { + let parent = tempfile::tempdir().expect("create spaced lock parent"); + let root = parent + .path() + .join("Genarrative GameAgent") + .join("gameagent-space"); + fs::create_dir_all(&root).expect("create spaced project root"); + let lock = acquire_project_write_lock(&root, "planning.v2.approval") + .expect("acquire project lock under a space-containing path"); + let lock_path = root.join(".agent").join("project.lock"); + assert!(lock_path.is_file(), "project lock must exist while held"); + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) + .expect("new project lock must already satisfy the private DACL contract"); + drop(lock); + assert!( + !lock_path.exists(), + "project lock must be removed when the guard is dropped" + ); +} + +fn resolve_project_write_lock_path(root: &Path) -> Result { + let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; + let (parent_relative, file_name) = normalized + .rsplit_once('/') + .ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?; + let parent = resolve_local_project_path(root, parent_relative)?; + // create_new is the authority for the final lock component. On Windows a + // delete-pending lock can make a metadata preflight fail with ACCESS_DENIED + // before the existing bounded contention wait has a chance to run. + Ok(parent.join(file_name)) +} + +pub(crate) fn acquire_project_write_lock( + root: &Path, + command_id: &str, +) -> Result { + acquire_project_write_lock_failure(root, command_id).map_err(|failure| failure.message()) +} + +/// 与 `acquire_project_write_lock` 同一实现,但把失败分类交给调用方。 +/// +/// 有界等待必须按失败类型决定是否重试:用错误文案前缀做控制流时,改一次文案就等于改一次 +/// 重试语义。前置校验失败没有可重试语义,统一作为终态文案上报。 +pub(crate) fn acquire_project_write_lock_failure( + root: &Path, + command_id: &str, +) -> Result { + validate_project_root(root).map_err(ProjectWriteLockFailure::Terminal)?; + let mut path = + resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; + if let Some(parent) = path.parent() { + ensure_game_creator_private_directory_tree(parent, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + prepare_game_creator_private_path_for_read(parent, true, "项目锁目录") + .map_err(ProjectWriteLockFailure::Terminal)?; + } + // Re-check the parent after creation so skipping metadata only for the final + // create_new target cannot weaken the normal ancestor link/reparse checks. + path = resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?; + let payload = serde_json::json!({ + "commandId": command_id, + "pid": std::process::id(), + // 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。 + "processStartedAt": project_write_lock_process_start_time_seconds(u64::from( + std::process::id() + )), + "createdAt": unix_timestamp(), + "nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成项目写锁失败:{error}")) + .map_err(ProjectWriteLockFailure::Terminal)?; + let mut retried_after_reclaim = false; + loop { + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + match options.open(&path) { + Ok(mut file) => { + if let Err(error) = file.write_all(content.as_bytes()) { + drop(file); + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "写入项目写锁失败:{}: {error}", + path.display() + ))); + } + if let Err(error) = file.sync_all() { + drop(file); + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "落盘项目写锁失败:{}: {error}", + path.display() + ))); + } + drop(file); + if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") + { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(error)); + } + let actual = match fs::read_to_string(&path) { + Ok(actual) => actual, + Err(error) => { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "读取项目写锁失败:{}: {error}", + path.display() + ))); + } + }; + if actual != content { + let _ = fs::remove_file(&path); + return Err(ProjectWriteLockFailure::Terminal(format!( + "项目写锁内容校验失败:{}", + path.display() + ))); + } + return Ok(ProjectWriteLock { + path, + content: content.clone(), + bypassed_same_process: false, + }); + } + Err(error) => { + // 是否可重试只看错误码(平台判据见 `project_write_lock_open_failure_for`): + // 拿 `path.exists()` 当场判死会在删除拆链窗口里把瞬时争用变成永久失败。 + let failure = + project_write_lock_open_failure_for(PROJECT_WRITE_LOCK_PLATFORM, &error); + if failure == ProjectWriteLockOpenFailure::Permission { + // 只有 Unix 的 EACCES 能在这里被明确判定(Windows 的 ACCESS_DENIED + // 归可重试,终态由等待方在预算耗尽后投影)。权限拒绝不会重试,所以在 + // 这里记录:它必须能在 App 日志里和"别人正在写"区分开。 + app_log!( + "project.write_lock.permission_denied commandId={command_id} path={} osError={:?}", + path.display(), + error.raw_os_error() + ); + return Err(ProjectWriteLockFailure::Terminal( + project_write_lock_permission_error(&path, &error), + )); + } + if failure == ProjectWriteLockOpenFailure::Other { + return Err(ProjectWriteLockFailure::Terminal(format!( + "创建项目写锁失败:{}: {error}", + path.display() + ))); + } + if !retried_after_reclaim { + if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) { + if project_write_lock_reclaim(&path, &snapshot) + .map_err(ProjectWriteLockFailure::Terminal)? + { + app_log!( + "project.write_lock.reclaim_stale commandId={command_id} path={} holder={}", + path.display(), + snapshot.describe_holder() + ); + retried_after_reclaim = true; + continue; + } + } + } + if crate::agent::autonomous_game_build_root_run_active_at(root) + && project_write_lock_is_owned_by_current_process(&path) + { + // The autonomous game-build lane intentionally permits + // parallel specialist actions. If the durable lock belongs + // to this very process, contention is an in-process overlap, + // not another application editing the project. Return an + // advisory guard and leave the real lock untouched. + return Ok(ProjectWriteLock { + path, + content: String::new(), + bypassed_same_process: true, + }); + } + // 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次, + // 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。 + return Err(ProjectWriteLockFailure::Retryable { + platform: PROJECT_WRITE_LOCK_PLATFORM, + path: path.clone(), + source: error, + }); + } + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs index 9f7dc6541..f7de9d54e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs @@ -356,3 +356,89 @@ fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() { ); fs::remove_dir_all(root).ok(); } + +/// Issue #318 取证缺口:争用错误必须点名持锁方,现场才能回答"谁在持锁、是不是自己人"。 +/// 另一个活进程持锁时必须报成争用、带出身份,并且不能把它的锁当成残留回收掉。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_contention_names_a_live_external_holder() { + let root = unique_project_path(); + fs::create_dir_all(root.join(".agent")).expect("创建 .agent 目录"); + let holder = spawn_unrelated_live_process(); + let holder_pid = holder.id(); + write_project_lock_fixture( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "external-editor-writer", + "pid": holder_pid, + "createdAt": unix_timestamp(), + "nonce": 7, + })) + .expect("序列化外部持有者锁 fixture"), + ); + let held_content = fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("读回持有者锁"); + + let error = + acquire_project_write_lock(&root, "file.write").expect_err("活的外部进程持锁时必须报争用"); + assert!( + error.starts_with("项目正在被其他写操作占用:"), + "争用必须保持共享前缀:{error}" + ); + assert!( + error.contains("commandId=external-editor-writer"), + "错误必须点名持锁命令:{error}" + ); + assert!( + error.contains(&format!("pid={holder_pid}")), + "错误必须点名持锁进程:{error}" + ); + assert!( + error.contains("ownerIsSelf=false"), + "错误必须说明持锁方不是本进程:{error}" + ); + assert_eq!( + fs::read(root.join(PROJECT_LOCK_RELATIVE_PATH)).expect("复查持有者锁"), + held_content, + "活外部持有者的锁文件不得被回收或改写" + ); + assert!( + project_write_lock_contention_diagnostic(&root).contains(&format!("pid={holder_pid}")), + "等待日志必须带同一份持锁方身份" + ); + + stop_unrelated_live_process(holder); + fs::remove_dir_all(root).ok(); +} + +/// Issue #318 第 3 条:权限拒绝不能再投影成"被其他写操作占用"。 +/// 文案刻意不含争用前缀,有界等待和前端才不会把它当成"等一下就好"的瞬时状态。 +#[cfg(unix)] +#[test] +fn project_write_lock_does_not_project_permission_denial_as_contention() { + use std::os::unix::fs::PermissionsExt; + + let root = unique_project_path(); + let agent_directory = root.join(".agent"); + fs::create_dir_all(&agent_directory).expect("创建 .agent 目录"); + let original = fs::metadata(&agent_directory) + .expect("读取控制目录元数据") + .permissions(); + fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500)) + .expect("去掉控制目录写权限"); + + let outcome = acquire_project_write_lock(&root, "file.write"); + fs::set_permissions(&agent_directory, original).expect("恢复控制目录权限"); + + // CI 容器以 root 运行,0o500 目录照样可以创建文件;此时本用例的前提不成立, + // 直接跳过。分类判据本身另有不依赖 ACL 环境的纯函数用例覆盖。 + let Err(error) = outcome else { + return; + }; + + assert!( + !error.starts_with("项目正在被其他写操作占用:"), + "权限拒绝不得投影成写锁争用:{error}" + ); + + fs::remove_dir_all(root).ok(); +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e0efb6a96..af9b01a65 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -34,7 +34,7 @@ - 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。 - 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。 -- 影响范围:`config.rs` 的新建 harden 与提权命令行、`filesystem.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 +- 影响范围:`config.rs` 的新建 harden 与提权命令行、`project/write_lock.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 - 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。 @@ -8204,3 +8204,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。 - 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。 - 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。 + +## 2026-09-10 Direct 写通道纳入统一项目锁等待窗口并补齐持锁方可诊断 + +- 背景:Issue #318。`agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,却用零等待取锁,任何重叠都在 24-42ms 内被投影成“项目正在被其他写操作占用”;同一形状已在 2026-07-22 由 `file.write / file.patch / file.delete` 用有界等待修过,本项目技术方案的 2026-08-13 一节也已规定这类争用结果“统一投影为争用并进入既有有界等待”。现场取证还缺 `commandId / pid / createdAt / ownerIsSelf`,无法回答“谁在持锁”,加上 `create_new` 把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,排障被引向“残留锁”。 +- 决策:① Direct 写路径改用 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`,与其它写入口同语义,同一轮并行写按同一把锁串行。①′ 这条等待是同步轮询(最多约 10 秒),handler 是 async,因此写路径经 `spawn_blocking` 走阻塞线程池:直接在 handler 里同步等待会占住 tokio worker,争用窗口内并行写多个文件时会波及共享同一 runtime 的只读端点与 UI 命令,破坏 Issue #318 现场“只读工具全部正常”的诊断特征。② 争用错误前缀逐字不变并追加持锁方身份;`create_new` 失败拆成可重试(进入有界等待)/ 权限拒绝(失败关闭,文案不含争用前缀)/ 其它三类,**重试性只看错误码**:Windows 的 `ACCESS_DENIED(5)` 与删除拆链窗口在错误码上不可区分,一律先按可重试处理,等满预算且目标仍不存在时才由 `exhausted_projection` 改判成权限拒绝(单次试探不改判);`sharing violation(32)` 与 `lock violation(33)` 恒定归可重试。③ 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录持锁方快照、等待时长与 `projection=`(contention / permission_denied),Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生,hydrate 的单次试探既不写日志也不改判。④ 重试与否改由类型决定:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装;`PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 同时收口 `provider_recovery.rs` / `planning_session_v2.rs` / `direct_runtime.rs` 三处手写文案。⑤ 平台判据的每一环都要带平台:终态改判也曾只比 `ErrorKind`,而 errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`,CI 直接把它判成 `contention`;现由 `Retryable { platform, path, source }` 携带平台。 +- 为什么不按“目标是否存在”当场分类:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)` 而 `exists()` 已经报 false(本机 6 万次建锁 / 删锁竞争实测 396-538 例命中该组合)。按一次 metadata 观察判成权限拒绝,等待层会立刻失败关闭,等于把 Issue #318 的“毫秒级直接失败”换成更误导的 ACL 文案;这也是对 2026-08-13 已定口径“这些结果统一投影为争用并进入既有有界等待”的回归。 +- 复用既有实现:回收判据沿用 2026-09-09 的 `ProjectWriteLockSnapshot` + `project_write_lock_reclaim_decision` + 字节 CAS 删除,不新增第二套回收机制;本次只给快照补 `commandId` 与 `describe_holder()`,供错误文案和日志使用。 +- 不做什么:不放宽 `.agent/project.lock` 的项目级串行化语义,不引入可重入项目锁,不改“同一调用链禁止二次获取 `.agent/project.lock`”的既有约定,不改 AGC 多进程拓扑,不改 `pendingOperations` 语义,也不改“活持有者始终不回收”的既有判据。其它仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)不在本次范围。 +- 验证方式:`project_lock_recovery` 追加持锁方身份与权限拒绝两条;`direct_tool_bridge` 追加同进程重叠写等待、同轮并行写、有界等待不占 runtime worker(默认 `current_thread` runtime 加心跳任务,同步阻塞会立刻让心跳停摆)、ACL 拒绝不投影成争用四条;`project/write_lock` 追加“重试性只由错误码决定”与“终态改判三条件”两条平台无关用例(平台作参数传入,Linux CI 覆盖 Windows 分支)。Windows 本机定向结果:`project_write_lock` 18 条、`bridge_write_file` 4 条、`parent_wake` 16 条、`waits_across` 3 条全过;CI(`71e9ad313`)四个 job 全绿,其中 Native shell tests 的首轮失败正是第 ⑤ 条平台判据缺陷。 +- 关联文档:`docs/project-memory/shared-memory/pitfalls.md`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、Issue #318。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f6a3b783b..451a00738 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5058,7 +5058,7 @@ - 原因:fixture 用 `0xFFFF_FFF0` 当死 PID;Unix 的 `pid_t` 是有符号 32 位,`i32::try_from` 直接失败,存活判定返回 `None`(无法判定)而不是 `Some(false)`,于是落回 600 秒保守分支,残留锁不再被回收。 - 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 `i32::MAX as u64 - 1`,另加 `u64::MAX` 非法进程号用例。 - 验证:WSL Ubuntu 上 `cargo test --bin genarrative-ai-game-creator-shell project_lock_recovery` 7 条全过;Windows 上把可表示性判据临时回退到 HEAD 后,只有 `project_write_lock_reclaims_unrepresentable_owner_pid` 失败,说明该用例确实覆盖这条分支;Linux CI 的原始失败记录覆盖越界 PID 分支。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。 ## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09) @@ -5066,4 +5066,15 @@ - 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。 - 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。 - 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。 -- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`。 + +## 2026-09-10 Direct 写通道零等待取锁把毫秒级竞争放大成整轮阻断 + +- **现象**:AGC 新建项目后第一轮 Direct 对话里,唯一的项目写入通道 `agc_write_file` 每次都返回 `项目正在被其他写操作占用:<项目根>\.agent\project.lock`;同一轮 15 次写入全部 `status=failed` 且 `durationMs` 只有 24-42ms,而只读工具(`agc_list_project_files`、`agc_list_registered_assets`、`client.session.info`)全部正常,整轮无法写入任何项目文件。 +- **原因**:`.agent/project.lock` 是 `create_new` 存在性锁,Direct 通道却调零等待的 `acquire_project_write_lock`,与 App 自身其它写通道(美术 lane、revision、conversation、预览等)撞车就直接判死;而 `file.write / file.patch / file.delete` 等入口走的是约 10 秒有界等待。**失败耗时本身就是判据**:几十毫秒说明这个入口根本没等,同等争用在其它通道会被等待窗口吸收。此外错误文案不带 `commandId / pid / createdAt / ownerIsSelf`,又把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,现场很容易被误判成“残留锁”。 +- **处理**:Direct 写路径改用统一的有界等待;`create_new` 失败按可重试 / 权限 / 其它分三类并给不同文案;争用错误与等待日志都带持锁方身份,`ownerIsSelf` 区分“自己人”和“别人”。 +- **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner,`#[cfg(windows)]` 用例在 CI 里一次都不跑)。**判据的每一环都要带平台**:只看 `ErrorKind` 的判据在 Linux 上会给出相反结论——errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO`(`Uncategorized`)。所以“等满预算再改判成权限拒绝”这第二个判据也必须连同平台与原始错误码一起传,否则 Windows 侧的行为在 CI 上永远测不到(本批第一次推送就是 CI 抓到 `permission_denied` 被改判成 `contention`:判据只比了 `kind()`)。 +- **补充:同步有界等待不能直接跑在 async handler 里**。这条等待是 2 000 × 5ms 的同步轮询(最多约 10 秒),而 `handle_direct_tool_bridge` 是 async:直接在 handler 里等待会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而 bridge 与只读端点、UI 命令共享同一个 runtime——于是“只读工具全部正常”这条现场诊断特征会在争用窗口内失效,把排障引向错误方向(本次现场正是靠它判断“写锁没释放”的)。做法是把整条写路径挪进 `tokio::task::spawn_blocking`(仓库既有模式),等待语义与错误文案都不变;用例用默认 `current_thread` runtime 加心跳任务锁住这一点:handler 一旦同步阻塞,同一 runtime 上的心跳就完全停摆。**这类“零等待改成有界等待”的改动都要同时问一句:调用方是不是 async,等待窗口会不会占住执行器。** +- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf`:`true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets` 的 `pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。 +- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、有界等待不占 runtime worker、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9313fba40..5842afc88 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1337,3 +1337,16 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 - 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。 - 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。 - 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。 + +## 2026-09-10 Direct 写通道项目锁等待、持锁方可诊断与权限分类 + +- `agc_write_file` 是用户直接触发、失败即整轮无法落盘的项目写入通道,原先却用零等待 `acquire_project_write_lock`:任何重叠都在 24-42ms 内被判成“项目正在被其他写操作占用”,而 `file.write / file.patch / file.delete` 等入口用的是约 10 秒有界等待。现统一为 `acquire_game_creator_agent_runtime_project_write_lock_with_wait`:短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误;同一轮并行写多个文件按同一把锁串行。这是 2026-07-22 同一形状修复在 Direct 通道上的补齐,与 2026-08-13 一节“这些结果统一投影为争用并进入既有有界等待”的口径一致。**失败耗时是判据**:几十毫秒说明该入口没等,不是锁没释放。 +- 这条等待是**同步轮询**(2_000 × 5ms,最多约 10 秒),而 `handle_direct_tool_bridge` 是 async handler:直接在 handler 里跑完整条写路径会占住一个 tokio worker,争用窗口内同一轮并行写多个文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime——Issue #318 现场“只读工具全部正常”这条诊断特征会在争用窗口内失效。因此写路径经 `bridge_write_file_in_blocking_pool` 走 `tokio::task::spawn_blocking`(仓库既有模式,如 `codex_app_server.rs` 的 DirectProject 历史落盘),等待语义与错误文案不变;定向用例用默认 `current_thread` runtime 加心跳任务锁住“等待期间 runtime 仍在推进”。 +- 争用错误必须带持锁方身份才可行动:`项目正在被其他写操作占用:<锁路径>(持锁方 commandId=<命令> pid=<进程> createdAt=<创建时间> ownerIsSelf=<是否本进程>)`。锁文件处于 delete-pending 或尚未写完时读不到身份,也必须显式表达成“不可读”,不得默认成“没有持锁方”。前缀逐字不变:`project_gates.rs`、`provider_recovery.rs`、`planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按它把争用识别成可等待的瞬时状态;这句话已是 `crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX` 单一真源,四个站点不再各自手写中文。 +- `create_new` 的失败必须分三类处置,不能再共用一句文案:可重试(目标已存在、Windows `sharing violation(32)` / `lock violation(33)` / `ACCESS_DENIED(5)`)进入有界等待;明确判定不是争用的权限 / ACL 拒绝(Unix `EACCES`)失败关闭且文案不含争用前缀;其它 I/O 错误原样上报。**重试性只能由错误码决定,不能用 `path.exists()` 这类一次 metadata 观察决定**:目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回 `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false(本机实测 6 万次建锁 / 删锁竞争里 396-538 例命中该组合)。按“目标不存在”当场判成权限拒绝,等待层就会立刻失败关闭——正是本次要消灭的“毫秒级直接失败”,只是换成更误导的 ACL 文案。平台判据以 `project_write_lock_open_failure_for(platform, error)` 保留、平台由参数传入而不是 `#[cfg]`:CI 只有 Linux runner,Windows 分支必须在 Linux 上也能断言。 +- Windows 上真实 ACL 拒绝与删除拆链窗口在错误码上不可区分,所以终态改判放到**等待预算耗尽之后**:`ProjectWriteLockFailure::exhausted_projection(waited)` 只在“真的等过预算 + 目标此刻仍不存在 + 错误码是 `ACCESS_DENIED(5)`”三个条件同时成立时才投影成权限拒绝;单次试探(`max_attempts == 1`,例如 hydrate 的 `try_acquire_...`)没有等待证据,保持争用语义。代价是 Windows 上真实 ACL 拒绝会先等满等待窗口(约 10 秒)才报权限错误;Unix 的 `EACCES` 立即判定、不等待。 +- 重试与否改由**类型**决定,不再解析错误文案:`acquire_project_write_lock_failure` 返回 `ProjectWriteLockFailure::{Retryable, Terminal}`,有界等待按 `is_retryable()` 分流,`acquire_project_write_lock` 只是它的文案包装。零等待入口前缀不变,只在“错误码不可区分且目标此刻不存在”时补一句“可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”,把两种处置都交给调用方,而不是替它猜一个。 +- 复用 2026-09-09 的回收机制,不新增第二套:`ProjectWriteLockSnapshot` 补 `commandId` 与 `describe_holder()`,争用错误、`project.write_lock.reclaim_stale`、`project.write_lock.wait_exhausted` 三处共用同一份身份描述。“活持有者始终不回收”的判据不变。 +- 等待预算耗尽时按 `project.write_lock.wait_exhausted` 记录 `commandId`、尝试次数、等待毫秒数、`projection=`(contention / permission_denied)与持锁方身份,Unix 上明确判定的权限拒绝按 `project.write_lock.permission_denied` 记录;争用不在零等待入口里逐次记账,避免有界等待的上千次重试淹没日志。这条日志正是 Issue #318 现场缺的“谁在持锁、是不是自己人”。**这条日志与终态改判都只在真的等过(`max_attempts > 1`)时发生**:单次试探(hydrate 的 `try_acquire_*`)不写 `wait_exhausted`(`waitedMs≈0` 会让“耗尽”失去意义,而 hydrate 每次状态变化都会撞一次锁,写成日志就是噪声),也不做终态改判。 +- 定向验收覆盖:同进程重叠写等待后成功、同一轮并行写多个文件、有界等待不占 runtime worker(`current_thread` + 心跳任务)、活外部进程持锁(错误带 `ownerIsSelf=false` 且锁文件不被回收)、ACL 拒绝不投影成争用,外加两条平台无关判据用例(重试性只由错误码决定、终态改判三条件)——后两条让 Linux CI 也能盯住 Windows 分支。对应 `project_lock_recovery`、`direct_tool_bridge` 与 `project/write_lock` 定向测试;`tests/project_tools.rs` 既有的 `runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖“带句柄的 delete-pending 必须等到成功”。 +- 仍待收口(后续事项):① 其余仍用零等待取锁的入口(`command.exec / project.verify / memory / conversation / task / checkpoint / 预览 / UI 编辑器 / 资源编辑器 / Tauri 命令`)本批不改,遇到同类争用仍会立刻失败;零等待入口无法区分“拆链窗口 / ACL 拒绝”,因此在前缀不变的前提下补一句“锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”。② 锁策略已按“单一职责”收口到 `project/write_lock.rs`(887 行:取锁、等待分类、持锁方诊断、残留回收),`project/filesystem.rs` 回到项目文件 IO(680 行);仍待收口的是 Direct 锁用例,它们还留在 `direct_tool_bridge.rs`(3135 行,锁用例与桥实现混在一起),后续移到 `tests/project_lock_recovery.rs` 或独立测试文件。③ 行为级 Windows 用例(delete-pending 等)仍只在 Windows 本地执行,CI 没有 Windows runner;关键判据已参数化到 Linux 可覆盖,行为级覆盖仍需本地执行或后续补 runner。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 48a74dd94..5f231d263 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1896,7 +1896,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析 | 现役 pending wire | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs:26-27`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs:3-46,219-270`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs:290-520` | 保持 `game-creator-pending-action.v5`;M1 另建 planning pending,不升级全局 wire | | submit 专用提交/恢复分支 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_action_batch.rs` | **2026-08-14 按 M1B-2 收口**:在普通 dispatch 前处理 Runtime-owned submit;提交后终止策划子 run,并保留 generic v5 standalone pending + v4 batch anchors,不复用 `WaitingForUserInput`,不创建 planning pending。审批等待属于 M1C-1 | | JSON sidecar | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs:44-104,122-250` | 现有 writer 可覆盖;不可变文件必须新增 no-replace helper | -| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs:85-138`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 | +| 项目锁 | `apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs:1467-1505` | 所有 planning mutation 在同一项目锁内重读事实 | | completion blocker | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs` | **M1C-1 隔离 worktree 已补齐**:仅对 exact `project-supervisor-plan` standard 顶层根 Run 生效,读取 GDD lineage、approval pending/receipt、generic submit anchors、terminal observation、decision audit、planning session 与 recovery,且只读不创建 pending;现役 collaboration blocker 对策划子 Agent 仍不适用 | | plan 根 run 子 Agent 创建面 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs`(`observe_agent_runtime_agent_delegate` / `observe_agent_runtime_agent_spawn_isolated`)、`agent/runtime_protocol/run_configuration.rs`(`validate_project_supervisor_plan_root_binding_at`)、`agent/prompt.rs`(`game_creator_project_supervisor_tool_plan_prompt`) | **2026-08-14 `M1A-4` 已落地**:plan 根 run 只能委派 `project-planning`,`agent.spawn_isolated` 一律拒,两条通道共用 typed `kind=plan-root-child-target-unsupported`;plan source 下不拼 `supervisorIntro` 与 `$visualContract`。**已知残留(有意保留,见 decision-log 2026-08-14 `M1A-4` 条)**:`$base` 的 `$isolatedAgentTemplates` 段仍会向 plan 根 run 列出全部专业角色名——那是 `agent.spawn_isolated` 的模板目录,因执行层硬拒而成为死文本;因此**不得**写「plan 根 run 上下文不出现其它 Agent 名」这类验收句 | | plan retry | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs`(`resolve_game_creator_agent_runtime_retry_configuration_at`)、`runtime_driver.rs`(`supervisor_plan_root_identity_holds_at`) | **2026-08-13 `M1A-3` 已落地 source 保源**:`task.source == project-supervisor-plan` 时先走强判据,通过则保留该 source,失败 `kind=plan-root-retry-identity-unsupported`、不降级。gui/cli 仍走 `agent-background-task`。plan-session revision / `gddId` / 按 `gdd-approval` kind 禁 retry 仍属后续包(现役已拒 `waiting-*`) | diff --git a/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md b/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md deleted file mode 100644 index ca6b18e5b..000000000 --- a/local-docs/【修复记录】Issue226跨Origin重定向标记泄漏修复-2026-09-02.md +++ /dev/null @@ -1,84 +0,0 @@ -# Issue #226:跨 origin 重定向标记泄漏复审修复 - -更新时间:2026-09-02 -关联 Issue:`#226 添加客户端特殊标识`;交接 Issue:`#225 添加客户端埋点统计` - -## 1. 修复结论 - -采纳评审意见:AGC 主站 Client 的默认重定向策略不能继续使用 reqwest 的“任意 origin 默认跟随”,否则 `default_headers` 中的 `X-Genarrative-Client: agc` 可能被复制到 OSS 或第三方 origin。 - -本次修复将工厂默认策略调整为: - -- 目标 URL 与原始请求 URL 的完整 origin(scheme + host + effective port)相同:委托 reqwest 默认 redirect policy。 -- origin 不同:`stop`,返回当前 3xx,不发起下一跳请求。 -- 无法取得原始 URL 或 origin 无法确认:按 fail-closed 处理,停止重定向。 - -这不是把 factory 改成全局 `Policy::none()`。同 origin 的 redirect 仍保留原有默认限制、方法和请求体处理;调用点显式设置的 `Policy::none()` 继续有效。 - -另针对评审发现的同名 Header 覆盖缺口,补充了主站请求终结器:`default_headers` 继续负责普通请求的默认注入;所有已审计的 AGC 主站请求在发送前统一调用 `with_agc_main_site_marker`,使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,确保最终值固定为 `agc`。 - -## 2. 修改范围 - -修改 AGC Rust HTTP Client factory、主站请求终结器、已审计主站直发调用点和定向测试: - -- `apps/ai-game-creator-shell/src-tauri/src/http_client.rs` -- 已审计主站请求所在的 `assets.rs`、`commands.rs`、`canvas_generation.rs`、`direct_runtime.rs`、`direct_tool_bridge.rs`、`project/asset_canvas/generation.rs`、`project/resource_editor.rs` -- 本地实施方案、阶段计划和本复审记录 - -不修改: - -- #225 的主站 tracking、数据库和后台代码。 -- OSS/签名下载、Provider、受控搜索、loopback、更新下载等第三方 Client。 -- 各业务调用点的 timeout、connect timeout、认证、幂等或请求体逻辑。 - -## 3. 重定向行为契约 - -| 场景 | 处理 | 标记是否到达下一跳 | -|---|---|---:| -| 同 scheme、host、effective port | 继续按 reqwest 默认策略 | 是,仍是主站 origin | -| scheme、host 或端口任一不同 | 返回当前 3xx,不 follow | 否,不发起请求 | -| 调用点显式 `Policy::none()` | 继续不 follow | 否 | - -使用 `Url::origin()` 比较,不比较 URL 字符串前缀;路径、查询参数和 fragment 不参与 origin 判断。 - -## 4. 同名 Header 覆盖契约 - -`reqwest` 的 `ClientBuilder::default_headers` 只会为请求补充缺失字段,请求级同名 Header 默认优先。因此不能把 `default_headers` 单独当作“不可伪造”的约束。 - -当前实现由两层组成: - -- factory 设置 `default_headers`,覆盖普通未显式设置 Header 的请求; -- `with_agc_main_site_marker(request)` 在主站请求发送前用 `RequestBuilder::headers` 替换同名字段。`external_editor_json_request` 已内置该终结器,直接 `.send()` 的主站路径也显式经过该终结器。 - -这样既保留方案一的 Client factory 形态,又满足“调用方传入同名 Header 时最终仍为 `X-Genarrative-Client: agc`”的冻结契约。 - -## 5. 测试证据 - -新增或调整 `http_client` 定向测试,覆盖: - -- 完整 origin 比较:默认端口等价,scheme/host/非默认端口变化视为跨 origin。 -- 同 origin 302:下一跳实际收到 `X-Genarrative-Client: agc`,最终响应成功。 -- 跨 origin 302:当前响应为 302,外部 listener 没有收到连接。 -- 显式 `Policy::none()`:仍然覆盖 factory 默认策略。 -- 请求级伪造同名 Header:终结器覆盖调用方值,服务端只收到 `agc`。 - -验证命令及结果: - -```text -cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture -8 tests passed -``` - -## 6. 对 #225 的交接 - -本修复不改变 Header 契约: - -```http -X-Genarrative-Client: agc -``` - -主站只会收到客户端实际发出的主站请求;跨 origin 3xx 不会产生第二个第三方请求,因此不会出现客户端把该标记发送到 OSS/第三方 origin 的情况。#225 不需要回改 tracking、数据库或后台设计。 - -## 7. 后续限制 - -`agc_main_site_client_builder()` 仍返回原始 `reqwest::ClientBuilder`,理论上调用方可以再次覆盖 redirect policy,或新增请求时绕过终结器。当前已审计生产调用点均已经过终结器,只有显式 `Policy::none()` 的 redirect 覆盖,没有重新启用任意 origin follow 的调用。若未来需要类型级不可绕过,再单独评估 `AgcMainSiteClient` wrapper,不在本次复审修复中扩大范围。 diff --git a/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md b/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md deleted file mode 100644 index 45311511f..000000000 --- a/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md +++ /dev/null @@ -1,342 +0,0 @@ -# Issue #225:AGC 主站请求标记埋点统计实施方案 - -更新时间:2026-09-02 -关联 Issue: - -- `#225 添加客户端埋点统计`:本文全部实施范围 -- `#226 添加客户端特殊标识`:客户端侧已完成;本文只接收其固定交接契约,不回改客户端 - -当前状态:阶段 0~6 已完成。主站生产代码和定向测试已按阶段提交;阶段 6 仅补充最终门禁、交接与验收文档。未修改 SpacetimeDB schema、OpenAPI 或后台页面。 - -## 1. 一句话交付结果 - -主站 `api-server` 能识别 AGC 发来的 `X-Genarrative-Client: agc`,并在本期新增的 AGC 专用成功路由埋点写入 `tracking_event.metadata_json` 的 `client: "agc"`,同时按真实登录用户或 External API Key 的 `owner_user_id` 归属,后台可以通过现有 tracking 事件查询看到这类请求;既有 route tracking 和手工资产事件保持原有全客户端统计口径,不改变鉴权、计费、幂等和响应语义。 - -## 2. Issue 边界 - -### 2.1 本次只做 #225 - -本次主改动限定在主站后端: - -- `server-rs/crates/api-server/src/app.rs` 的 tracking middleware 入口。 -- `server-rs/crates/api-server/src/tracking.rs` 的标记识别、主体归属、metadata 合并和 route tracking 覆盖。 -- 必要的 `api-server` 定向测试、External v1 路由测试和后台 tracking 读取验证。 -- 方案、验收、交接文档。 - -### 2.2 本次不做 #226 的回改 - -不修改: - -- AGC TS 的 `fetchClientHttp` 标记注入。 -- AGC Rust 主站 Client factory、请求终结器和 redirect policy。 -- AGC 主站/第三方请求边界、timeout、认证、幂等和请求体语义。 - -`#226` 已冻结并合入的客户端契约直接作为本 Issue 的输入。 - -### 2.3 明确不做项 - -- 不新增 `tracking_event` 列、索引、migration 或生成 bindings;第一阶段复用已有 `metadata_json`。 -- 不新增平行的 `agc_client_request` 事件、平行表或平行统计口径。 -- 不改变 `/api/external/v1` 的路由、HTTP 方法、DTO、状态码、鉴权或异步语义,因此不改 OpenAPI 契约。 -- 不把 `generationInputs.source`、User-Agent、请求体中的 owner 字段当作客户端来源或主体。 -- 不记录 access token、External API Key 明文、Cookie、签名 URL、项目绝对路径或其他秘密。 -- 不把 OSS 上传、签名 URL 实际下载、LLM/Codex Provider、受控搜索、loopback、更新下载和任意外部网页请求当成主站业务 tracking。 -- 不默认把失败响应改造成新的 tracking 事实;先保持现有“成功响应才写 route tracking”语义。 -- 不默认新增后台筛选控件;后台先复用现有 tracking 原始事件查询展示 `metadata_json`。 - -## 3. 当前实现基线与缺口 - -### 3.1 现有 tracking 流程 - -当前全局链路位于: - -- `server-rs/crates/api-server/src/app.rs` -- `server-rs/crates/api-server/src/tracking.rs` - -流程为: - -```text -请求进入 - → tracking middleware 保存 method/path - → next.run(request) - → 从 response extensions 读取认证主体 - → 仅对成功响应解析 RouteTrackingSpec - → 生成 TrackingEventDraft.metadata - → 本机 tracking outbox - → SpacetimeDB tracking_event / tracking_daily_stat -``` - -现有 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal` 都会在认证 middleware 成功后写入 response extensions;但 tracking middleware 目前只消费前者。 - -### 3.2 当前缺口 - -- tracking middleware 尚未读取 `X-Genarrative-Client`。 -- route metadata 尚未写入 `client`。 -- External API Key 的 `ExternalApiPrincipal.owner_user_id` 尚未接入 route tracking 归属。 -- `/api/external/v1/*` 没有完整的 route tracking spec。 -- 账号态实际被 AGC 使用的 `/api/editor`、`/api/assets`、`/api/runtime` 路径也有未覆盖项。 - -## 4. 冻结的 #226 交接契约 - -### 4.1 Header 识别 - -```http -X-Genarrative-Client: agc -``` - -规则: - -- Header 名按 HTTP 规则大小写不敏感。 -- 值去除首尾空白后,精确等于小写 `agc` 才认定为 AGC。 -- 缺失、空值、`AGC`、其他未知值均按“未标记”处理。 -- 未标记请求不被拒绝,也不改变业务行为。 -- Header 只用于来源审计和统计,不参与鉴权、权限、计费、幂等或账号归属。 - -### 4.2 metadata 形态 - -第一阶段复用已有 `tracking_event.metadata_json`,在原对象上追加固定键: - -```json -{ - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202, - "operation": "generateExternalEditorImage", - "client": "agc" -} -``` - -约定: - -- JSON key 固定为 `client`,值固定为 `agc`。 -- 有效标记时追加 `client`;未标记时不写 `client`,不写空字符串或 `null`。 -- 已有 `route`、`method`、`status`、`operation` 以及资产类嵌套 metadata 必须保留。 -- `route` 使用主站实际收到的 method/path;External v1 不得只记录客户端账本中的内部映射路径。 - -### 4.3 认证主体归属 - -| 请求类型 | `user_id` | `owner_user_id` | `scope_kind/scope_id` | 说明 | -|---|---|---|---|---| -| 登录账号态 | 沿用 `AuthenticatedAccessToken.claims().user_id()` | 沿用现有行为,通常与 user_id 相同 | 沿用 route spec;User scope 使用真实用户 | Header 不能覆盖真实认证主体 | -| External API Key 态 | 不伪造登录用户,可为空 | `ExternalApiPrincipal.owner_user_id()` | User scope 使用 owner_user_id | 归属 API Key 所属账号 | -| 无认证的公开/站点请求 | 为空 | 为空 | 沿用 Site/公开 route spec | 只在已有公开 route spec 时记录 | - -第一阶段不把 API Key 明文写入 metadata。`key_id` 是安全的内部标识,但只有在后续明确需要按单个 Key 统计或审计时才增加 `externalApiKeyId`,不作为本期客户端交接前提。 - -### 4.4 成功与失败语义 - -本期先保留当前 route tracking 的成功响应语义: - -- 2xx 成功响应按 route spec 写入 tracking。 -- 3xx、4xx、5xx 和 transport failure 不因为 AGC 标记而自动新增 route 事件。 -- 生成提交、异步轮询、重试请求使用同一标记;是否落库仍由现有 route tracking 规则决定。 - -如果产品后续要求统计失败调用,应另立失败事件的 event key、幂等键、容量和报表口径,不在本期隐式扩展。 - -## 5. 推荐实现:扩展现有 route tracking - -本期采用方案一:在现有 route tracking 上追加来源和主体信息,不创建通用 AGC 请求事件。 - -```text -请求 Header - → tracking middleware 在 next.run 前白名单解析 marker - → next.run(request) - → 从 response extensions 读取 AuthenticatedAccessToken / ExternalApiPrincipal - → 按实际 method + path 解析 RouteTrackingSpec - → 计算 user/owner/scope - → 在现有 metadata 对象追加 client=agc - → 复用现有 outbox、tracking_event、tracking_daily_stat - → 现有后台 tracking 查询读取 metadata_json -``` - -### 5.1 为什么不新增通用 AGC 事件 - -- 不会与现有 route event 重复计数。 -- 不会改变 `tracking_daily_stat` 的 event key 和历史统计口径。 -- 仍能看到实际 path、method、status、operation 和 client 的组合。 -- 继续复用现有 outbox、SpacetimeDB procedure 和后台原始事件查询。 -- 新增或确认 AGC 路由时只需补齐 route spec 和测试,不需要引入第二套事件系统。 - -代价是 route coverage 必须显式审计;因此阶段 3 将以 AGC 客户端调用清单和 External v1 router/OpenAPI 交叉核对,禁止“标记已经发送但主站没有 route spec”的漏项。 - -### 5.2 实现边界 - -推荐保持最小抽象: - -- `app.rs` 在消费 request 之前解析 marker,并从 response extensions 取两类主体。 -- `tracking.rs` 提供小型白名单解析函数和主体归属逻辑。 -- `RouteTrackingSpec` 明确区分既有全客户端 route 与本期新增的 AGC-only route;新增 AGC route 只有在 marker 有效时才允许落库。 -- `build_route_tracking_metadata` 只在有效 marker 时追加 `client`,不重写既有字段。 -- `record_route_tracking_event_after_success` 继续负责 route spec、outbox 和失败日志策略。 -- 不把 marker 放进 `RequestContext`,除非阶段 1 证明同一请求的其他统一 tracking 入口确实需要它;避免扩大公共上下文结构。 - -既有 route spec 默认保持全客户端语义;本期新增、仅为 AGC 调用清单补齐的 route spec 使用 AGC-only 策略。已有 `handled_by_existing_event` 的详细资产事件不改记录范围,只在有效 marker 时追加 `client`。 - -## 6. Route 覆盖范围 - -### 6.1 必须覆盖的账号态路径 - -按 AGC 当前扫描清单,至少核对并覆盖: - -- `/api/auth/*`:当前登录用户查询、refresh、登录入口、发送验证码、手机号登录、logout 等实际调用。 -- `/api/profile/*`:dashboard、recharge center、recharge order/confirm、wallet ledger、API Key 管理。 -- `/api/assets/*`:direct-upload-tickets、objects/confirm、read-url、read-bytes 以及 AGC 实际使用的资产操作。 -- `/api/editor/*`:projects、assets/library、assets/folders、项目 resources、图片/编辑/去背景/图集/角色动画/视频/音效/BGM 生成和轮询相关内部路径。 -- `/api/runtime/external-generation/jobs/{operationId}`:账号态生成任务轮询。 - -已有 route spec 的 event key 和 scope 语义保持不变;新增项使用稳定、可读、与路径/operation 对应的 event key。 - -### 6.2 必须覆盖的 External API Key 业务路径 - -按 `server-rs/crates/api-server/src/modules/external_api.rs` 和实际 AGC 使用情况核对: - -- `/api/external/v1/assets/direct-upload-tickets` -- `/api/external/v1/assets/objects/confirm` -- `/api/external/v1/assets/read-url` -- `/api/external/v1/editor/projects` 及项目读取/资源登记相关路径 -- `/api/external/v1/editor/assets/library`、folders、asset CRUD 中实际被 AGC 使用的路径 -- `/api/external/v1/editor/images/generations` -- `/api/external/v1/editor/images/edits` -- `/api/external/v1/editor/images/background-removals` -- `/api/external/v1/editor/icon-spritesheets/generations` -- `/api/external/v1/editor/character-animations/generations` -- `/api/external/v1/editor/videos/generations` -- `/api/external/v1/editor/audios/sound-effects/generations` -- `/api/external/v1/editor/audios/background-music/generations` -- `/api/external/v1/generations/{operationId}` - -External v1 路由的 tracking 必须记录外部实际 path,不把它改写成 `/api/editor`、`/api/assets` 或 `/api/runtime`。 - -### 6.3 明确排除的 External v1 路径 - -以下是公开发现或集成协议入口,不属于普通 AGC 主站业务调用,本期不新增普通 route tracking spec: - -- `GET /api/external/v1/openapi.json` -- `GET /api/external/v1/agent-integration.json` -- `GET /api/external/v1/skill/SKILL.md` -- `GET /api/external/v1/skill.zip` -- `POST /api/external/v1/mcp` - -如果未来 AGC 明确接入远程 MCP/Skill,再单独定义集成流量的事件语义,不把它们隐式混入普通业务统计。 - -## 7. 数据库与后台承接 - -### 7.1 数据库 - -沿用现有链路: - -```text -TrackingEventDraft.metadata - → RuntimeTrackingEventInput.metadata_json - → tracking outbox - → record_tracking_event procedure - → tracking_event.metadata_json -``` - -不增加字段、不改 migration、不重新生成 bindings。现有 JSON object 校验足以容纳 `client` 键;`tracking_daily_stat` 继续按原 event key/scope/day 聚合,不按 client 另建统计表。 - -### 7.2 后台 - -现有后台 tracking 查询已经返回 `metadata_json`,第一阶段只做数据可见性验证: - -- 管理员能在原始事件详情中看到 `client: "agc"`。 -- 原有 event key、scope、user/owner、日期查询不回归。 -- 不新增客户端筛选器、导出列或新的后台路由。 - -如果后续需要高频按 client 筛选,再评估新增结构化列和索引;那将是独立 schema 变更,不应在本期偷偷引入。 - -## 8. 安全与兼容性约束 - -- 来源 Header 是可伪造的审计标签,不能当作安全边界。 -- 主体必须来自已验证的 `AuthenticatedAccessToken` 或 `ExternalApiPrincipal`,不能信任请求体 owner、客户端账本或 Header 中的身份。 -- 不记录 Bearer token、API Key 原文、Cookie、签名 URL 或请求体中的敏感字段。 -- Header 缺失/未知时继续走原有业务和 tracking 逻辑,只是不追加 `client`。 -- metadata 合并必须保留既有资产嵌套字段,不得用新对象覆盖旧 metadata。 -- outbox 入队、SpacetimeDB 写入失败继续只记录 warning,不阻断主业务响应。 -- 不修改 route normalization、dynamic id 归一化、event id 幂等或 daily stat 聚合规则。 - -## 9. 定向测试方案 - -### 9.1 Marker 解析 - -表驱动测试覆盖: - -- `X-Genarrative-Client: agc` → `Some("agc")`。 -- Header 名大小写变化 → 仍识别。 -- 缺失、空值、首尾空白、`AGC`、未知值 → 未标记。 - -### 9.2 Metadata 与主体归属 - -- 有效 marker 的账号态成功路由:metadata 含 `client: "agc"`,user_id/owner_user_id 保持真实用户。 -- 有效 marker 的 External v1 成功路由:metadata 含 `client: "agc"`,owner_user_id 等于 `ExternalApiPrincipal.owner_user_id()`,scope_id 不落到 `anonymous`。 -- 未标记请求:metadata 不含 `client`。 -- 请求体伪造 owner 或 marker 不改变主体归属。 -- metadata 原有 route/method/status/operation 和资产嵌套字段仍存在。 - -### 9.3 AGC-only 记录门禁 - -- 本期新增的账号态和 External v1 AGC route:带有效 marker 的 2xx 响应才记录。 -- 同一批新增 route:缺失、非法或未知 marker 时不记录,也不拒绝业务请求。 -- 既有 route spec 和已有详细资产事件保持原有全客户端记录语义。 -- 4xx/5xx 即使带有效 marker 也不新增成功 route event。 -- `build_router` middleware 集成测试验证 marker、账号认证主体、成功 route tracking 和隔离 outbox 可以串联,且 metadata 保留 `client`、实际 route 和 user/owner。 - -### 9.4 Route 覆盖 - -- 当前 AGC 调用清单中的账号态路径全部能解析到 route spec。 -- 当前 AGC 使用的 External v1 业务路径全部能解析到 route spec。 -- `/api/external/v1` 发现、Skill 和 MCP 路径不被普通业务 spec 误收录。 -- dynamic project/asset/operation ID 仍按现有 normalize 规则归一化。 - -### 9.5 持久化与后台读取 - -- 构造 tracking event input 后,`metadata_json` 是合法 JSON object 且含 `client`。 -- outbox 入队/回退直写路径不丢失 `client`。 -- tracking_event 读回及后台 tracking API 解析不丢失 metadata。 -- 现有 daily stat、event id 幂等和失败不阻断语义保持不变。 - -### 9.6 状态码语义 - -至少保留一组回归: - -- 2xx 成功响应写入 route tracking。 -- 4xx/5xx 不因为 marker 自动新增成功 route event。 -- 认证失败不伪造 ExternalApiPrincipal 或用户归属。 -- 轻量 middleware 成功链路测试通过;不依赖真实 SpacetimeDB,不纳入完整环境型 E2E。 - -## 10. 实施顺序 - -1. 阶段 0:确认基线、交接契约、路径边界和不做项。 -2. 阶段 1:加入 marker 白名单解析,并将解析结果传入现有 route tracking。 -3. 阶段 2:接入 `AuthenticatedAccessToken` / `ExternalApiPrincipal`,完成 owner/user/scope 归属和 metadata 合并。 -4. 阶段 3:按 AGC 调用清单补齐账号态与 External v1 业务 route spec,明确排除发现/MCP。 -5. 阶段 4:验证 outbox、SpacetimeDB `tracking_event` 和现有后台原始查询,无 schema 变更。 -6. 阶段 5:完成 marker、主体、route 覆盖、状态码、安全和持久化定向测试。 -7. 阶段 6:执行最终门禁,更新交接材料并把结果回传 Issue #225;不要求 #226 回改。 - -## 11. 完成判据 - -只有同时满足以下条件,#225 才算完成: - -- 主站精确识别 `X-Genarrative-Client: agc`,未知/缺失值不拒绝请求。 -- 有效标记进入现有 route tracking metadata,写入 `client: "agc"`。 -- 已有 route、method、status、operation、资产 metadata 和 event key 语义不丢失。 -- 登录账号态按真实用户归属,External API Key 态按 `owner_user_id` 归属。 -- 当前 AGC 实际使用的账号态和 External v1 业务路径均有 tracking spec。 -- 本期新增的 AGC route spec 未携带有效 marker 时不产生 route tracking event;既有 route spec 和手工资产事件保持原有全客户端统计语义。 -- External v1 discovery/Skill/MCP、OSS、签名下载、Provider、搜索、loopback、更新下载不被误纳入普通 AGC 业务统计。 -- outbox、SpacetimeDB 写入、daily stat、幂等和失败不阻断语义无回归。 -- 后台现有 tracking 查询可以看到 `metadata_json.client`,无需新增 schema 或页面。 -- 定向测试和必要的格式/编码/空白检查通过。 - -## 12. 评审与提交节奏建议 - -建议按阶段分组提交或至少在一个 PR 中保持以下逻辑顺序: - -1. 阶段 0 文档与契约冻结。 -2. marker 解析、metadata 合并和账号态主体接入。 -3. External API Key 主体接入与 External v1 route spec。 -4. 持久化/后台读取验证和定向测试。 -5. 最终文档、交接评论和门禁记录。 - -每个阶段先通过自身验收,再进入下一阶段;不需要等待 #226 再次修改,也不需要先新增数据库字段。 diff --git a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md b/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md deleted file mode 100644 index 3cdbc576d..000000000 --- a/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md +++ /dev/null @@ -1,470 +0,0 @@ -# Issue #226:AGC 客户端主站请求统一标记实施方案 - -更新时间:2026-09-02
-关联 Issue: - -- `#226 添加客户端特殊标识`:本方案实际实施范围 -- `#225 添加客户端埋点统计`:主站接收、落库和后台统计,本文只冻结交接契约,不在本次实施 - -状态:阶段 6 已完成;已补齐跨 origin 重定向安全复审修复
-本方案包含 #226 客户端代码修改,不包含 #225 主站接收、落库和后台统计代码 - -## 1. 一句话交付结果 - -让 AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带 `X-Genarrative-Client: agc`,同时保持同源重定向及现有认证、幂等、timeout、connect timeout 和上传/下载安全边界;跨 origin 重定向不继续请求,第三方请求不携带该标记。 - -## 2. Issue 边界 - -### 2.1 本次只做 #226 - -本次只修改 AGC 客户端: - -- Web/TS 统一请求出口。 -- Tauri/Rust 主站专用 HTTP Client 构造方式。 -- 主站请求与第三方请求的 Client 边界。 -- 客户端侧定向测试。 - -### 2.2 本次不做 #225 - -本次不修改: - -- `server-rs` 请求上下文。 -- 主站 `tracking.rs`、tracking middleware 或 route tracking 规格。 -- SpacetimeDB `tracking_event` schema、migration、bindings 或索引。 -- 后台埋点查询、筛选和报表。 -- External v1 OpenAPI、请求 DTO 和响应 DTO。 - -### 2.3 不恢复或扩展其他旧标记 - -AGC 当前已有部分 body 内来源值: - -- `ai-game-creator-client` -- `game-creator-account-binding` -- `game-creator-resource-editor` - -本次不删除、不改写这些现有业务字段,也不把它们扩展成统一调用标记。它们继续服务各自的生成/资源登记语义;统一来源以 HTTP Header 为准。 - -## 3. 冻结的客户端标记契约 - -### 3.1 Header - -```http -X-Genarrative-Client: agc -``` - -约定: - -- Header 名按 HTTP 规则大小写不敏感;客户端发送时固定使用上面的拼写。 -- 值固定为小写 `agc`。 -- 客户端应覆盖调用方传入的同名 Header,避免业务层伪造其他值。 -- 不携带 token、用户 ID、API Key、版本号或项目 ID。 -- 不将该 Header 用作鉴权、权限、计费或账号归属证明。 - -### 3.2 发送范围 - -只要请求目标是当前选定的 Genarrative 主站 origin,并且属于主站业务/API 请求,就携带该 Header。 - -包括: - -- 登录、刷新、登出和用户查询。 -- 账户、钱包和充值接口。 -- 项目、画布、素材库和素材读取接口。 -- 上传凭证、对象确认和项目资源登记接口。 -- 图片、图集、去背景、角色动画、视频、音效和背景音乐生成接口。 -- 生成任务异步轮询接口。 -- 登录账号态映射后的 `/api/editor`、`/api/assets`、`/api/runtime` 请求。 -- 开发者 API Key 态的 `/api/external/v1` 请求。 -- 创建本机开发者 API Key 的 `/api/profile/api-keys` 请求。 - -不包括: - -- 更新清单和更新包下载。 -- OSS/对象存储 multipart 上传。 -- `read-url` 返回的签名地址下载。 -- LLM Provider 请求。 -- AGC 受控搜索。 -- loopback 工具桥。 -- 浏览器试玩页面和任意外部网页请求。 - -### 3.3 主站 origin - -主站 origin 必须来自当前选定的 `serverBaseUrl` / `apiBaseUrl`,包括: - -- 发布地址。 -- 开发地址。 -- 用户配置的合法自定义地址。 - -不能只根据硬编码域名判断,也不能因为 URL 路径包含 `/api` 就认定它是主站。 - -## 4. 方案一的落地结构 - -本次采用“统一 Client 工厂 + `default_headers` + origin-safe redirect policy”方案,不引入 origin-aware RequestBuilder 包装器。 - -总体结构: - -```text -TS 主站请求 - → fetchClientHttp - → 解析当前选定主站目标 - → 注入 X-Genarrative-Client: agc - → fetch / Tauri HTTP plugin - -Rust 主站请求 - → 主站 Client Builder factory - → default_headers 注入 X-Genarrative-Client: agc - → 请求发送前由主站请求终结器覆盖同名 Header - → 默认只跟随同 origin 重定向,跨 origin 返回当前 3xx - → 保留调用方原有 Client 配置 - -Rust 第三方请求 - → 独立 Client - → 不携带主站标记 -``` - -## 5. TS 实施方案 - -### 5.1 统一入口 - -文件: - -- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) - -在 `fetchClientHttp` 中: - -1. 按当前已有逻辑解析最终目标 URL。 -2. 保持现有的服务器地址校验和 transport 选择。 -3. 使用 `Headers` 合并 `init.headers`。 -4. 设置 `X-Genarrative-Client: agc`。 -5. 按现有逻辑调用浏览器 `fetch` 或 Tauri HTTP plugin。 - -### 5.2 需要保持的 Header - -不得覆盖或删除: - -- `Authorization` -- `Content-Type` -- `x-genarrative-response-envelope` -- 调用方已有的 `X-Request-ID` - -### 5.3 TS 侧不应修改的请求 - -[appUpdate.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/appUpdate.ts) 中直接访问更新清单的请求不经过 `fetchClientHttp`,继续保持现状,不纳入主站业务标记。 - -## 6. Rust 实施方案 - -### 6.1 主站 Client Builder factory - -在 AGC Tauri/Rust 侧增加一个小型、无业务语义的主站 Client Builder 入口。推荐职责只有: - -- 创建 `reqwest::ClientBuilder`。 -- 设置 `X-Genarrative-Client: agc` 默认 Header。 -- 设置只允许同 origin 的默认重定向策略;同源时委托 reqwest 默认策略,跨 origin 时停止。 -- 不负责 token、API Key、幂等键、请求体、重试或错误解析。 - -由于 `reqwest::ClientBuilder::default_headers` 只补充请求中缺失的字段,主站请求在发送前还必须经过 `with_agc_main_site_marker(request)`。该终结器使用 `RequestBuilder::headers` 替换调用方可能传入的同名 Header,保证最终值固定为 `agc`;`external_editor_json_request` 已内置该步骤,直接 `.send()` 的主站路径显式调用该终结器。 - -示意结构: - -```rust -fn agc_main_site_client_builder() -> reqwest::ClientBuilder { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::HeaderName::from_static("x-genarrative-client"), - reqwest::header::HeaderValue::from_static("agc"), - ); - let default_policy = reqwest::redirect::Policy::default(); - let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { - let same_origin = attempt - .previous() - .first() - .map(|initial| initial.origin() == attempt.url().origin()) - .unwrap_or(false); - if same_origin { - default_policy.redirect(attempt) - } else { - attempt.stop() - } - }); - reqwest::Client::builder() - .default_headers(headers) - .redirect(redirect_policy) -} -``` - -实际实现可放在 AGC Rust 已有的客户端公共模块中;本 Issue 只增加轻量请求终结器,不新增完整 `AgcMainSiteClient` RequestBuilder 包装器,不提前实现方案二。 - -### 6.2 保留现有 Client 配置 - -主站请求原有 builder 配置必须在 factory 返回的 builder 上继续设置: - -```rust -let client = agc_main_site_client_builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(60)) - .redirect(reqwest::redirect::Policy::none()) - .build()?; -``` - -必须保留的语义包括: - -- connect timeout。 -- request timeout。 -- 调用方显式设置的 redirect policy;未显式设置时保留同源默认重定向、默认限制和方法处理,跨 origin 重定向由 factory 停止。 -- `no_proxy` 或其他已有网络策略(仅属于原请求的情况下保留)。 -- Bearer access token/API Key 的设置方式。 -- `Idempotency-Key` 的设置方式和原值。 -- Content-Type、multipart、JSON body 和响应解析方式。 - -`reqwest::Client::new()` 不能配置 `default_headers`。如果它被用于生产主站请求,需要改为从 builder 创建;不能为了少改一行而遗漏标记。 - -### 6.3 只替换主站请求的创建点 - -潜在涉及的生产文件: - -- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) -- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) -- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) -- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) -- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) -- [asset_canvas/generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) - -替换原则: - -- 主站 API Client 的 `Client::builder()` 改用 AGC 主站 builder。 -- 生产主站 API 中使用 `Client::new()` 的位置改用 AGC 主站 builder,并保留原 timeout 等配置。 -- 只访问主站的专用 Client 仍使用 factory 的默认 Header,并在最终 `.send()` 前经过 `with_agc_main_site_marker`;不能依赖 `default_headers` 覆盖请求级同名 Header。 -- 一个 Client 如果同时访问主站和第三方地址,不得直接改成主站 Client;应拆分用途或保持第三方 Client 独立。 - -### 6.4 不应替换的 Client - -以下请求明确保持独立 Client,不加主站标记: - -- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) 的 Provider 请求。 -- `build_external_asset_download_client` 创建的签名素材下载 Client。 -- OSS 表单直传 Client。 -- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) 的受控搜索 Client。 -- loopback 客户端工具桥 Client。 -- [main.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/main.rs) 的更新下载请求。 - -### 6.5 阶段 3 实施记录 - -- 已将生产代码中明确用于主站 API 的 Client 创建点切换为 `crate::http_client::agc_main_site_client_builder()`,覆盖 `assets.rs`、`commands.rs`、`canvas_generation.rs`、`direct_runtime.rs`、`direct_tool_bridge.rs`、`asset_canvas/generation.rs` 和 `resource_editor.rs`。 -- 主站项目、素材库、素材目录、上传凭证、对象确认、资源登记、图片生成/编辑/抠图、视频/动画/音频生成以及 generation 查询请求复用带 `X-Genarrative-Client: agc` 的 Client。 -- `assets/read-url` 换签请求使用主站 factory;签名 URL 媒体下载仍使用独立的 `build_external_asset_download_client`,不会携带主站标记。 -- OSS multipart 上传、Provider、受控搜索、loopback 工具桥和更新下载没有切换到主站 factory。 -- 保留原有 timeout、connect timeout、`no_proxy`、Bearer/API Key、Idempotency-Key、请求体和响应处理;同源重定向保持默认语义,跨 origin 重定向由 factory 阻断;未修改 `ExternalEditorBindingAccess` 的路由映射语义。 -- 阶段 3 请求捕获验证:`sync_canvas_project_assets_downloads_external_resources`(1 test passed)确认账号态项目请求和 `assets/read-url` 带标记,签名媒体下载不带标记。 -- 阶段 3 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`(2 tests passed);`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_downloads_external_resources -- --nocapture`(1 test passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check`、`npm run check:encoding` 和 `git diff --check` 均通过。 - -### 6.6 阶段 4 实施记录 - -- 保持第三方请求的独立 Client 边界:`build_external_asset_download_client`、Codex Provider 代理、受控搜索、loopback 工具桥和更新下载均未接入主站 factory。 -- 为搜索、loopback 和更新下载提取独立的 Client 构造点,保留原有 `no_proxy`、timeout 和 redirect 配置,并明确验证这些 Client 不携带 `X-Genarrative-Client`。 -- 新增 OSS/签名传输 Client、受控搜索 Client、loopback 工具桥 Client 和更新下载 Client 的本地 TCP 负向测试;Provider 代理转发测试增加无标记断言。 -- 阶段 4 验证结果:`omits_agc_marker` 负向测试 4 个通过;Provider 代理测试 1 个通过;阶段 3 的画板同步请求捕获测试继续作为签名 URL 媒体下载无标记证据。 -- 未修改主站 API、`ExternalEditorBindingAccess` 路由映射、认证语义、OSS/Provider/搜索/loopback/更新的网络安全策略或 #225 代码。 - -### 6.7 阶段 5 实施记录 - -- 账号态回归使用真实平台会话 fixture,验证 `/api/editor`、`/api/assets` 和 `/api/runtime` 实际路径均携带 `X-Genarrative-Client: agc`。 -- 账号态生成请求继续携带 Bearer、UUID Idempotency-Key 和原有 `generationInputs.source = ai-game-creator-client`;排队、运行中和完成状态仍按原语义轮询,提交只发生一次。 -- External Key 态回归清除平台会话,使用 `tnr_sk_phase5_fixture` 和本地自定义 `apiBaseUrl`,验证 `/api/external/v1/editor/projects/*` 与 `/api/external/v1/assets/read-url` 携带相同标记和 API Key,签名媒体下载不带标记。 -- External v1 endpoint、账号态路由映射、请求体、认证、幂等、异步轮询和结果未知语义均未改变。 -- 阶段 5 验证:`background_agent_runtime_can_generate_platform_art_asset`、`sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker` 和 `generation_submit_response_loss_is_not_retried` 均通过;Rust fmt、编码检查和 `git diff --check` 通过。 - -### 6.8 同名 Header 覆盖复审修复记录(2026-09-02) - -- 评审确认 `reqwest` 的 `default_headers` 只补充缺失字段,请求级同名 Header 会优先,不能单独满足“标记值固定为 `agc`”的契约。 -- 新增 `crate::http_client::with_agc_main_site_marker`,在主站请求发送前用 `RequestBuilder::headers` 替换 `X-Genarrative-Client`;`external_editor_json_request` 内置该终结器,直接发送的主站请求也显式接入。 -- 已补齐账号态、External Key 态、生成/轮询、资源编辑、素材读取、对象确认和抠图等主站直发路径;OSS/签名下载、Provider、搜索、loopback 和更新下载仍保持独立 Client。 -- 新增回归测试验证调用方传入伪造同名 Header 时最终只发送 `X-Genarrative-Client: agc`;`http_client` 定向测试共 8 个通过。 - -## 7. 调用语义和现有路由边界 - -本次不改变 `ExternalEditorBindingAccess` 的职责: - -- 继续负责平台账号态/开发者 Key 态的凭据访问。 -- 继续负责 `/api/external/v1` 到 `/api/editor`、`/api/assets`、`/api/runtime` 的路径映射。 -- 继续负责冻结会话和账号切换校验。 - -统一标记只附加在 HTTP Client 层,不改变: - -- External v1 语义 endpoint。 -- 账号态实际 endpoint。 -- 请求体中的 `generationInputs`。 -- operationId、Idempotency-Key 或本地账本。 - -主站后续记录时必须使用实际到达的 HTTP path;不能仅使用客户端账本中保存的 External v1 endpoint。 - -## 8. #225 的交接契约(本次不实现,但现在冻结) - -以下内容是为了让 `#225` 可以直接按固定契约接收,不需要反向修改 `#226` 的设计。 - -### 8.1 主站接收规则 - -主站读取: - -```http -X-Genarrative-Client: agc -``` - -规则: - -- Header 名大小写不敏感。 -- 精确值 `agc` 视为 AGC 客户端。 -- 缺失、空值或未知值视为“未标记”,不拒绝请求,也不改变业务行为。 -- Header 不参与权限和计费。 -- 不从请求体中的 `generationInputs.source` 推导统一客户端标记。 - -### 8.2 tracking metadata 形态 - -为避免 #225 重新设计字段,建议固定写入现有 tracking metadata JSON: - -```json -{ - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202, - "operation": "generateExternalEditorImage", - "client": "agc" -} -``` - -约定: - -- JSON key 固定为 `client`。 -- AGC 值固定为 `agc`。 -- 缺失时不写 `client`,不写空字符串。 -- 第一阶段复用 `metadata_json`,不要求 `tracking_event` 新增列。 - -### 8.3 认证主体 - -主站记录标记时仍以真实认证主体为准: - -- 登录账号态:记录现有 `AuthenticatedAccessToken` 解析出的用户维度。 -- External API Key 态:记录 `ExternalApiPrincipal` 的 `owner_user_id`,必要时保留 `key_id` 维度。 -- 不记录 access token 或 API Key 明文。 - -### 8.4 路由覆盖 - -`#225` 应同时考虑: - -- `/api/auth/*` -- `/api/profile/*` -- `/api/assets/*` -- `/api/editor/*` -- `/api/runtime/*` -- `/api/external/v1/*` - -特别是 `/api/external/v1/*` 当前没有完整 route tracking spec,不能只依赖已有内部 `/api/editor` 路由规格。 - -### 8.5 成功与失败 - -`#226` 会给成功、失败、重试和轮询请求使用同一个来源标记。`#225` 是否继续只记录成功响应,或扩展为记录失败请求,应由 #225 按埋点目标决定,但不能要求 #226 为失败请求换另一种标记。 - -建议 #225 至少保留: - -- 实际 method/path。 -- response status。 -- request_id。 -- client=`agc`。 -- user/owner/key principal 的安全维度。 - -## 9. 定向测试方案 - -### 9.1 主站请求带标记 - -TS 层: - -- mock `fetchClientHttp` 的最终 transport。 -- 验证认证、素材和钱包请求均出现 `X-Genarrative-Client: agc`。 -- 验证已有 Authorization、envelope Header 和自定义 request ID 仍存在。 - -Rust 层: - -- 使用主站 mock server 接收项目查询、素材读取、生成提交和任务轮询。 -- 验证账号态映射路径携带标记。 -- 验证开发者 API Key 态 External v1 路径携带标记。 -- 验证长 timeout/短 timeout 的不同 Client profile 都携带标记。 - -### 9.2 第三方请求不带标记 - -使用 mock 或请求捕获断言以下请求没有 `X-Genarrative-Client`: - -- OSS multipart 上传。 -- 签名 URL 下载。 -- LLM Provider。 -- 受控网页搜索。 -- loopback 工具桥。 -- 更新清单/更新包下载。 - -### 9.3 语义保持 - -定向测试还应确认: - -- 原有 Authorization 仍被发送。 -- 原有 Idempotency-Key 仍被发送且值不变。 -- 请求体和 Content-Type 不变。 -- 同源 redirect policy 的默认跟随、限制和方法处理保持不变;跨 origin 3xx 不发起下一跳。 -- timeout 和 connect timeout 不被统一 factory 覆盖成单一值。 -- 第三方请求没有因共享 Client 改造而意外继承主站 Header。 - -## 10. 实施顺序 - -1. 固定 Header 常量和文档契约。 -2. 在 TS `fetchClientHttp` 加统一注入。 -3. 增加 Rust 主站 Client Builder factory。 -4. 按用途盘点并只替换主站 Client 创建点。 -5. 检查所有 `Client::new()` 主站生产调用是否已迁移到 builder。 -6. 检查 OSS、签名下载、Provider、搜索、loopback、更新下载仍使用独立 Client。 -7. 增加主站正向测试和第三方负向测试。 -8. 运行定向测试、类型检查、Rust check/test、`npm run check:encoding` 和 `git diff --check`。 -9. 将本方案中的第 8 节交给 `#225`,主站按该契约接收,不要求客户端二次改设计。 - -## 11. 完成判据 - -只有同时满足以下条件,`#226` 才算完成: - -- TS 主站业务请求统一带 `X-Genarrative-Client: agc`。 -- Rust 主站业务请求统一带同一 Header。 -- 账号态和开发者 API Key 态均覆盖。 -- 生成提交和异步轮询均覆盖。 -- 主站专用 Client 保留原有网络和业务语义。 -- OSS、签名 URL、Provider、搜索、loopback 和更新下载不带标记。 -- 客户端测试覆盖正向和负向边界。 -- 不修改 `#225` 所需的主站数据设计;`#225` 可直接读取 Header 并按本文约定写入 tracking metadata。 - -## 12. 阶段 6 实施记录与交接状态 - -更新时间:`2026-09-02` - -阶段 6 已完成,`#226` 客户端侧可以交付评审;`#225` 不需要因客户端实现回改设计。固定交接材料如下: - -- 请求 Header:`X-Genarrative-Client: agc`。 -- tracking metadata:复用现有 `metadata_json`,写入 `client: "agc"`;缺失时不写空字符串。 -- 主站记录实际收到的 method/path;不能只记录客户端账本中的 External v1 endpoint。 -- 登录账号态按真实用户维度记录;External API Key 态按 `ExternalApiPrincipal.owner_user_id` 记录,必要时保留 `key_id` 维度。 -- Header 只用于来源审计/统计,不参与鉴权、权限、计费或账号归属;不记录 token、API Key 明文或签名 URL。 -- `#225` 应同时覆盖账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 和 API Key 态 `/api/external/v1/*`。 -- Header 缺失、空值或未知值按未标记处理,不拒绝业务请求;客户端不需要为失败、重试或轮询请求更换标记。 - -最终门禁结果: - -| 门禁 | 结果 | -|---|---| -| TS `clientHttp` 定向测试 | 通过,14 tests passed | -| AGC TS 类型检查(含 skill-pack/config 检查) | 通过 | -| Rust `http_client` factory 测试 | 通过,7 tests passed;覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()` | -| Rust OSS/签名 URL/Provider/搜索/loopback/更新下载负向测试 | 通过,`omits_agc_marker` 4 tests passed;loopback 认证边界 1 test passed | -| Rust 账号态主站 mock 捕获 | 通过,1 test passed | -| Rust External Key 态主站 mock 捕获 | 通过,1 test passed | -| Rust 签名 URL 下载边界 | 通过,1 test passed | -| Rust 提交响应丢失幂等回归 | 通过,1 test passed | -| Rust fmt、编码、diff 空白检查 | 通过 | - -本阶段未执行真实发布环境线上 smoke;账号态和 External Key 态证据来自本地 mock/custom `apiBaseUrl` fixture。真实发布环境 smoke 属于发布前或 `#225` 联调门禁,不改变本次 `#226` 客户端契约。跨 origin 重定向复审已通过本地双 listener 和链式重定向测试确认不会发起第三方下一跳。 - -可直接粘贴到 `#225` 的交接评论: - -> `#226` 客户端侧已完成并冻结交接契约:AGC 主站业务请求统一发送 `X-Genarrative-Client: agc`。账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External API Key 态 `/api/external/v1/*` 均覆盖;OSS/签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。主站可按实际 method/path 读取 Header,并在现有 tracking `metadata_json` 中写入 `client: "agc"`;Header 缺失/空值/未知值按未标记处理且不拒绝请求。登录态按真实用户维度记录,External Key 态按 `owner_user_id`(必要时 `key_id`)记录,不记录 token/API Key 明文或签名 URL。客户端正向、负向、账号态、External Key 态和幂等回归均已通过,`#225` 不需要让 `#226` 回改设计。` diff --git a/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md b/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md deleted file mode 100644 index 2acdebf32..000000000 --- a/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md +++ /dev/null @@ -1,398 +0,0 @@ -# Issue #225:AGC 主站请求标记埋点分阶段实施与验收计划 - -更新时间:2026-09-02 -关联 Issue: - -- `#225 添加客户端埋点统计`:本计划全部实施范围 -- `#226 添加客户端特殊标识`:客户端已完成;仅作为固定交接输入 - -当前状态:阶段 0、阶段 1、阶段 2、阶段 3、阶段 4、阶段 5、阶段 6 已完成。阶段 1~5 的实现已提交:`2296f79fd`、`2a23ba657`、`01159a269`、`c75c61521`、`c5170669e`;日登录埋点构造器回归修复为 `0a06b4988`。阶段 6 仅补充最终验收、交接和门禁文档,不新增生产代码。 - -## 1. 交付目标 - -主站识别有效的: - -```http -X-Genarrative-Client: agc -``` - -并在已有 route tracking 的 `tracking_event.metadata_json` 中记录: - -```json -{ - "client": "agc" -} -``` - -同时按真实认证主体归属:登录账号态使用 `AuthenticatedAccessToken` 的用户,External API Key 态使用 `ExternalApiPrincipal.owner_user_id()`;不新增平行事件体系,不改鉴权、计费、幂等和 API 契约。 - -## 2. 固定不做项 - -本计划不修改: - -- AGC 客户端 Header 注入和 Rust Client factory。 -- `tracking_event` schema、migration、bindings 或索引。 -- `/api/external/v1` OpenAPI、DTO、HTTP 方法、状态码和鉴权。 -- 失败请求的全新 tracking 事实。 -- 公开 discovery、Skill、MCP、OSS、签名下载、Provider、搜索、loopback 和更新下载的普通 AGC 业务统计。 -- 后台新筛选器或报表页面;第一阶段只复用原始 metadata 查询。 - -## 3. 阶段总览 - -```text -阶段 0 现状基线与交接契约冻结(已完成) - ↓ -阶段 1 Marker 解析与 tracking 入口接入 - ↓ -阶段 2 认证主体归属与 metadata 合并 - ↓ -阶段 3 账号态 / External v1 route coverage - ↓ -阶段 4 tracking_event、outbox 与后台读取验证 - ↓ -阶段 5 定向测试、安全边界与语义回归 - ↓ -阶段 6 最终门禁与 #226 交接收口 -``` - -每阶段先通过本阶段验收,再进入下一阶段。阶段 1~6 均只改主站或相关测试/文档,不要求 #226 回改。 - -## 4. 阶段 0:现状基线与交接契约冻结 - -### 4.1 工作内容 - -- 确认当前分支、HEAD、工作树和 `origin/master` 关系。 -- 核对 `app.rs` tracking middleware、`tracking.rs` route spec、outbox 和 `tracking_event` 写入链路。 -- 核对 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal` 的 response extension 传播。 -- 读取 AGC 调用清单和 External v1 router,冻结正向/排除路径。 -- 固定 Header、metadata、未知值行为、主体归属和成功响应语义。 - -### 4.2 阶段边界 - -- 不修改生产代码。 -- 不新增 schema 或后台代码。 -- 不把 Issue226 的客户端实现重新打开。 - -### 4.3 验收标准 - -- [x] 当前基线和代码缺口已记录。 -- [x] `X-Genarrative-Client: agc` 的识别规则已固定。 -- [x] `metadata_json.client = "agc"` 的落库形态已固定。 -- [x] 账号态与 External API Key 态的主体归属已固定。 -- [x] External v1 discovery/Skill/MCP 与第三方边界已列明。 -- [x] 未修改主站生产代码、schema、OpenAPI 或后台。 - -### 4.4 阶段产物 - -- [【阶段验收】Issue225阶段0现状基线与边界冻结-2026-09-02.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue225阶段0现状基线与边界冻结-2026-09-02.md) -- 本实施方案。 -- 本分阶段验收计划。 - -## 5. 阶段 1:Marker 解析与 tracking 入口接入 - -### 5.1 工作内容 - -修改范围限定在 `api-server` tracking 入口及定向测试: - -- 在 `record_api_tracking_after_success` 的 `next.run(request)` 之前读取请求 Header。 -- 增加白名单解析:首尾空白可清理,值必须精确为小写 `agc`。 -- 将解析结果传入现有 `record_route_tracking_event_after_success`。 -- 不把原始未知值、完整 Header、token 或请求体写入日志/metadata。 - -### 5.2 阶段验收 - -定向单元测试必须证明: - -- 有效 `agc` 能被识别。 -- Header 名大小写变化仍能识别。 -- 缺失、空值、`AGC`、未知值均按未标记处理。 -- 未标记请求仍走原有 route tracking 逻辑,不被拒绝。 -- 认证、响应状态和现有 request context 行为不变。 - -### 5.3 阶段完成判据 - -- [x] marker 只在统一 tracking 入口解析一次。 -- [x] 业务 handler 无需逐个读取 Header。 -- [x] 尚未接入主体归属和 External v1 route 扩展之外,不发生无关改动。 - -### 5.4 阶段 1 实现与验证记录 - -- `app.rs` 在 `next.run(request)` 前调用统一 marker 解析函数,并把解析结果传入 route tracking。 -- `tracking.rs` 只接受 Header 名 `X-Genarrative-Client`(HTTP 名称大小写不敏感)和值 `trim` 后精确等于小写 `agc` 的请求。 -- 解析结果以内部 `TrackingClientMarker::Agc` 保存到 `TrackingEventDraft`,本阶段不改变 metadata 内容;metadata 合并由阶段 2 完成。 -- 未标记、未知值和无效 Header 值均得到 `None`,不会写入日志,也不会拒绝请求。 -- 定向测试:`tracking::tests::tracking_client_marker_accepts_only_trimmed_lowercase_agc` 通过。 -- `cargo fmt --manifest-path server-rs/crates/api-server/Cargo.toml`、`git diff --check` 通过。 -- 当前未运行完整 `api-server` 测试套件;现有编译过程已通过,完整测试按阶段 5/CI 统一执行。 - -## 6. 阶段 2:认证主体归属与 metadata 合并 - -### 6.1 工作内容 - -- 从最终 response extensions 读取 `AuthenticatedAccessToken`。 -- 同时读取 `ExternalApiPrincipal`。 -- 账号态沿用现有 user_id/owner_user_id 语义。 -- External API Key 态设置 `owner_user_id = principal.owner_user_id()`,User scope 的 scope_id 使用 owner,避免回退到 `anonymous`。 -- 在 `build_route_tracking_metadata` 原有 JSON object 上追加 `client: "agc"`,保留原字段和资产嵌套 metadata。 -- 第一阶段不写 API Key 明文;不新增 `externalApiKeyId`,除非评审明确要求单 Key 统计。 - -### 6.2 阶段验收 - -- 账号态成功请求的 user_id 与现有 access token 用户一致。 -- External v1 成功请求的 owner_user_id 与 `ExternalApiPrincipal.owner_user_id()` 一致。 -- Header 不能覆盖、伪造或替换认证主体。 -- 有效 marker 时 metadata 含 `client: "agc"`;无效/缺失时不含该键。 -- `route`、`method`、`status`、`operation` 和已有嵌套字段均保留。 -- token、API Key 明文和签名 URL 不进入 metadata 或日志。 - -### 6.3 阶段完成判据 - -- [x] route tracking draft 在两类主体下都能生成正确的结构化归属。 -- [x] metadata 合并逻辑有独立测试,不能只依赖端到端偶然覆盖。 -- [x] 不改变 event id、outbox 和 daily stat 逻辑。 - -### 6.4 阶段 2 实现与验证记录 - -- `app.rs` 从最终 response extensions 同时读取 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal`。 -- `tracking.rs` 优先使用已验证的 External API Key owner;External API Key 请求不伪造 `user_id`,User scope 的 `scope_id` 使用 `owner_user_id`。 -- 账号态继续使用 access token claims 的 user id,同时写入 `user_id` 和 `owner_user_id`,保持原有语义。 -- `build_route_tracking_metadata` 只在有效 marker 时追加 `client: "agc"`,保留 route、method、status、operation 和 asset 嵌套 metadata;无 marker 不写 client。 -- 定向测试覆盖账号态主体、External API Key owner 优先级、User scope owner 和 metadata 字段保留。 -- 当前未修改 External v1 route spec、schema、OpenAPI、outbox 或后台页面。 - -## 7. 阶段 3:账号态与 External v1 Route Coverage - -### 7.1 工作内容 - -以 `local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md` 和 `modules/external_api.rs` 为输入,补齐显式 route spec: - -- 账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/external-generation/jobs/{id}` 的实际 AGC 路径。 -- External API Key 态 `/api/external/v1/assets/*`、`/editor/*`、`/generations/{id}` 的实际业务路径。 -- 生成提交、轮询、项目读取/资源登记、素材库、上传凭证、对象确认、换签读取等当前已确认调用。 -- 每个新增 spec 固定 event key、module key、scope kind 和动态路径归一化方式。 - -本阶段新增的 route spec 统一使用 AGC-only 记录策略:只有带有效 marker 的 2xx 请求才写入;既有 route spec 保持原有全客户端语义,上传票据/对象确认等 `handled_by_existing_event` 路径继续由手工资产事件负责。 - -同时明确不加入: - -- `/api/external/v1/openapi.json`。 -- `/api/external/v1/agent-integration.json`。 -- `/api/external/v1/skill/SKILL.md`、`skill.zip`。 -- `/api/external/v1/mcp`。 - -### 7.2 阶段验收 - -- AGC 当前实际调用清单中的每个 method + path 都能解析到 spec。 -- External v1 记录实际外部 path,不被映射为内部 `/api/editor` 等路径。 -- 发现/MCP 路径不会误进入普通业务 route tracking。 -- project/asset/operation 动态 ID 仍按现有规则归一化。 -- 原有 route spec 的 event key、scope 和统计口径不变。 -- 新增 AGC route 在未携带有效 marker 时不产生 route tracking event,且不影响业务响应。 - -### 7.3 阶段完成判据 - -- [x] 有一张可审计的“AGC 调用清单 → route spec → event key”矩阵。 -- [x] 新增路径均有 resolver 测试;未确认的 OpenAPI 潜在路径单独记录,不混入已实现范围。 -- [x] 没有用 catch-all `agc_client_request` 取代显式 route tracking。 - -### 7.4 阶段 3 实现与验证记录 - -- `tracking.rs` 补齐当前 AGC 实际使用的账号态 `/api/auth`、`/api/profile`、`/api/assets`、`/api/editor`、`/api/runtime/external-generation/jobs/{id}` 路径。 -- `tracking.rs` 补齐当前 AGC 实际使用的 External v1 资产、项目、素材库、生成提交和任务轮询路径;External v1 使用独立的实际外部 path 进入 metadata,不映射回账号态内部路径。 -- 上传票据和对象确认已有详细资产事件,route spec 以 `handled_by_existing_event` 标记为复用现有事件,统一 route tracking 不再重复写入;tracking middleware 将有效 marker 放入 request extensions,由共享资产 handler 将 `client: "agc"` 合并到既有资产 metadata,并补齐实际 route/method/status/operation。 -- 共享资产事件沿用真实主体:账号态写入 user/owner;External API Key 态只写 `owner_user_id`,不伪造登录 `user_id`,User scope 使用 owner。 -- 扩展动态路径归一化的静态段白名单,使 project、generation、asset 和 external v1 路径按既有 `{id}` 规则正确匹配;未引入 catch-all AGC 事件。 -- discovery、agent-integration、Skill、skill.zip 和 MCP 路径没有业务 route spec。 - -已执行定向验证: - -```text -cargo fmt --manifest-path server-rs/crates/api-server/Cargo.toml -- --check -cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::asset_tracking_metadata_receives_only_valid_agc_marker -- --nocapture -git diff --check -``` - -结果:tracking 定向测试 13 个通过,资产 marker/route metadata 与 External owner 归属测试 2 个通过,api-server 编译检查通过。 - -## 8. 阶段 4:tracking_event、outbox 与后台读取验证 - -### 8.1 工作内容 - -- 验证 `TrackingEventDraft.metadata` 经 `build_tracking_event_input` 后仍是合法 JSON object。 -- 验证本机 tracking outbox 入队和 SpacetimeDB 回退直写都保留 `client`。 -- 验证既有 `tracking_event` 行和 `tracking_daily_stat` 聚合未改变。 -- 验证现有后台 tracking API 返回 `metadata_json` 中的 `client`。 -- 不新增 schema/migration/bindings,不修改后台页面。 - -### 8.2 阶段验收 - -- AGC 成功事件可在 tracking_event 读回 `client: "agc"`。 -- unmarked 事件不会被补写 `client`。 -- owner_user_id、user_id、scope_id、event_key 和 occurred_at 不丢失。 -- 后台原始事件查询和已有 event key 查询不回归。 -- `npm run check:spacetime-schema` 不因本阶段产生 schema 变更;若未改 schema,可记录为无需运行。 - -### 8.3 阶段完成判据 - -- 至少有一条账号态和一条 External Key 态从 tracking draft 到后台读取的完整证据。 -- 已确认第一阶段不需要新增数据库字段。 - -### 8.4 阶段 4 实现与验证记录 - -- `api-server` tracking 测试分别构造账号态和 External API Key 态输入,验证 `TrackingEventDraft → build_tracking_event_input` 后 metadata 仍是合法 JSON object,`client: "agc"`、route、status、event key、module、scope 和 user/owner 字段均保留;未标记输入不补写 `client`。 -- tracking outbox 测试使用 External v1 AGC 事件完成 NDJSON 入队和读取 round-trip,验证 `metadata_json.client`、实际 External v1 route、`owner_user_id` 和“不伪造 `user_id`”均保留。 -- `spacetime-client` active mapper 测试验证送往生成绑定的 `RuntimeTrackingEventInput` 不丢失 `client`、External owner、scope、event key 和 module;module-runtime 的统一输入校验测试验证 metadata 必须是 JSON object,标记对象可正常通过。由于本地未启动 SpacetimeDB,未把确定性 input/mapper 验证扩大解释为真实远端库 E2E。 -- 后台 tracking SQL response parser 测试分别覆盖账号态与 External API Key 态,并增加 draft/input → SQL row → parser 组合回归,验证管理员现有原始事件读取能保留 `metadata_json.client`、实际 route 和 user/owner 归属。 -- 未修改 tracking schema、migration、bindings、outbox 失败回退策略、daily stat 聚合逻辑或后台页面;SpacetimeDB 的实际入库仍复用既有 `record_tracking_event` / `record_tracking_events` procedure,未新增字段。 - -已执行定向验证: - -```text -cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check -cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking_outbox::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::parse_admin_tracking_events_sql_response_ -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture -``` - -结果:module-runtime 2 个、api-server tracking 15 个、tracking outbox 8 个、后台 readback parser 5 个、spacetime-client mapper 1 个定向测试全部通过;格式检查通过。未运行完整工作区测试和 `npm run check:spacetime-schema`,因为本阶段未修改 schema。 - -## 9. 阶段 5:定向测试、安全边界与语义回归 - -### 9.1 工作内容 - -补齐并运行与改动直接相关的测试: - -- marker 解析表驱动测试。 -- route metadata 合并测试。 -- 账号态主体归属测试。 -- ExternalApiPrincipal owner 归属测试。 -- External v1 route resolver 覆盖测试。 -- discovery/MCP 排除测试。 -- 2xx 记录、4xx/5xx 不新增成功 route 事件测试。 -- `build_router` 轻量 middleware 成功链路测试:真实 HTTP request 经认证、tracking middleware 后写入隔离 outbox,并断言 `client`、route 和主体归属。 -- outbox/SpacetimeDB input 和后台 tracking readback 测试。 - -CI 已覆盖且与本改动无直接关系的全量测试可交给 CI;本阶段仍必须运行能直接证明本 Issue 契约的 targeted tests。 - -### 9.2 阶段验收 - -- 有效 marker 正向场景全部通过。 -- 缺失/空值/未知 marker 负向场景全部通过。 -- 登录账号态和 External Key 态主体归属均通过。 -- 未发生鉴权、状态码、幂等、请求体或异步轮询回归。 -- 测试输出不包含 token、API Key 明文、Cookie、签名 URL 或本地私密路径。 - -### 9.3 阶段完成判据 - -- 测试矩阵覆盖“标记/未标记 × 账号态/External Key 态 × 已覆盖/排除路由”。 -- 所有失败都能定位到 marker、主体、route、持久化或后台读取中的具体层。 - -### 9.4 阶段 5 实现与验证记录 - -- `tracking.rs` 抽出 `should_record_route_tracking` 判定,明确新增 AGC-only route 只有在带有效 marker 的 2xx 响应下才进入统一成功 route tracking;既有全客户端 route 保持原语义,4xx/5xx 和上传确认等复用事件均不新增重复成功事件。 -- 新增 2xx、4xx/5xx 状态矩阵测试,覆盖 `OK / CREATED / ACCEPTED / NO_CONTENT` 与认证、权限、客户端、服务端失败状态。 -- 新增 AGC-only route 正反向门禁测试:有效 marker 记录,缺失或无效 marker 不记录;既有 route 和手工资产事件策略保持不变。 -- 新增 `app::tests::agc_marker_flows_through_auth_tracking_middleware_to_outbox`,不启动真实 SpacetimeDB,仅验证 middleware 到 outbox 的最小闭环。 -- 新增 `app.rs` 路由回归:带有效 `X-Genarrative-Client: agc` 的未认证业务请求仍返回 `401`,marker 不绕过鉴权,也不改变失败状态。 -- 新增 route metadata 安全边界测试,确认统一 route metadata 只包含既有 route/method/status/operation 和可选 `client`/资产字段,不出现 authorization、token、API Key、Cookie、签名 URL 或 request body 字段。 -- 阶段 4 的账号态、External API Key、outbox、后台 readback 和 mapper 测试全部复跑;SpacetimeDB 既有 event-id 幂等回归也通过。未修改鉴权、请求体、异步轮询、event id、daily stat 或失败回退实现。 - -已执行定向验证: - -```text -cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check -cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::asset_tracking_metadata_receives_only_valid_agc_marker -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::external_asset_tracking_keeps_owner_without_forging_user -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking_outbox::tests:: -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::parse_admin_tracking_events_sql_response_ -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::tracking_inputs_round_trip_to_admin_readback_for_both_subjects -- --nocapture -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture -``` - -结果:module-runtime 2 个、spacetime-client 1 个、api-server tracking 18 个、app 鉴权回归 1 个、资产 marker/owner 2 个、tracking outbox 8 个、后台 readback 5 个、spacetime-module 幂等 1 个定向测试全部通过;格式检查通过。未运行与本 Issue 无直接关系的完整工作区测试,按约定交给 CI。 - -## 10. 阶段 6:最终门禁与 #226 交接收口 - -### 10.1 工作内容 - -- 汇总阶段 1~5 的测试和 readback 证据。 -- 更新本方案、验收记录和 Issue #225 评论草案。 -- 复核 #226 交接契约不需要客户端回改。 -- 检查 diff、编码、格式、敏感信息和无关文件。 - -### 10.2 最终门禁 - -按实际改动范围运行: - -- `cargo fmt --manifest-path server-rs/Cargo.toml -- --check`。 -- `cargo test --locked -p api-server` 的 tracking/External v1 定向测试。 -- 必要时 `cargo check --locked -p api-server`。 -- `npm run check:encoding`。 -- `git diff --check`。 -- `git status --short`,确认没有构建产物、日志、凭据或无关文件。 - -未修改 schema 时不运行 schema 生成;若实现阶段意外需要 schema,必须停在阶段 4 重新确认迁移范围,不能顺手修改。 - -### 10.3 阶段完成判据 - -- 主站可以在现有 tracking_event metadata 中看到 `client: "agc"`。 -- 两类认证主体归属正确。 -- 当前 AGC 实际业务路径无漏记,发现/MCP/第三方边界无误记。 -- 后台现有查询可读,无需新 schema 或新页面。 -- 所有必要 targeted tests、格式/编码/空白检查通过。 -- 向 #225 交付固定 Header、metadata、主体归属、路由覆盖和验证证据;不要求 #226 回改。 - -### 10.4 阶段 6 实际执行与结果 - -阶段 6 以当前分支 `feat/agc_call_rec`、`HEAD=5aa38d9f3` 为基线;该提交已合并最新 `origin/master`(`8932f0b27`)。本阶段未修改 `server-rs`、SpacetimeDB schema、OpenAPI 或后台页面,只更新验收和交接文档。 - -最终门禁结果: - -| 验收项 | 命令/证据 | 结果 | -|---|---|---| -| api-server 编译 | `cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server` | 通过;仅有仓库既有 dead-code warning | -| api-server tracking/资产/后台/outbox 定向测试 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking -- --nocapture` | 通过,43 tests passed | -| marker 不绕过鉴权回归 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture` | 通过,1 test passed | -| module-runtime tracking input | `cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture` | 通过,2 tests passed | -| spacetime-client mapper | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture` | 通过,1 test passed | -| SpacetimeDB event-id 幂等 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture` | 通过,1 test passed | -| Rust 格式 | `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` | 通过 | -| 中文编码 | `npm run check:encoding` | 通过,5662 个文件 | -| Diff 空白与分支基线 | `git diff --check`、`git merge-base --is-ancestor origin/master HEAD` | 通过 | - -上述测试合计 48 个与本 Issue 直接相关的测试通过。完整工作区测试按用户约定交由 CI;本阶段未启动真实 SpacetimeDB,也未执行发布环境线上写入,因此最终证据是本地确定性 input/mapper、outbox、后台 parser 和 route tracking 回归,不把本地测试表述为线上 E2E。 - -### 10.5 阶段 6 交接结论 - -- 主站只对白名单值 `X-Genarrative-Client: agc` 追加 `metadata_json.client = "agc"`;缺失、空值、`AGC` 和未知值按未标记处理,不拒绝请求。 -- 本期新增的 AGC route spec 只有在带有效 marker 的 2xx 响应下才记录;既有 route spec 与手工资产事件保持原有全客户端统计语义。 -- 账号态沿用真实 access token 用户;External API Key 态使用 `ExternalApiPrincipal.owner_user_id()`,不伪造 `user_id`。 -- 当前 AGC 实际账号态和 External v1 业务路径均有显式 route spec;动态 ID 继续归一化,External v1 保留实际外部 path。 -- OSS、签名下载、Provider、受控搜索、loopback、更新下载、公开 discovery/Skill/MCP 不进入普通 AGC 业务 route tracking。 -- 最终记录仍落在现有 SpacetimeDB `tracking_event.metadata_json`,经过本机 `server-rs/.data/tracking-outbox/` 临时缓冲后由既有 tracking procedure 写入;不新增 schema、索引、后台页面或独立事件体系。 -- `#226` 的客户端标记注入和 origin-safe redirect 契约已满足本 Issue 输入要求,不需要回改 #226 设计。 -- 另有一个独立于 #225 的客户端后续项:自定义 origin 使用显式默认端口或大写主机名时,`clientHttp.ts` 的字符串与 `URL.origin` 比较可能误判为跨 origin;它只会拒绝合法请求,不会泄漏 marker,应单独在 #226 跟踪。 - -阶段 6 详细验收记录与可直接粘贴到 Issue #225 的交付评论见: - -- [【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md) - -## 11. 建议提交分组 - -建议按以下逻辑分组提交,便于逐阶段验收: - -1. 阶段 1:marker 解析与 tracking 入口。 -2. 阶段 2:主体归属与 metadata 合并。 -3. 阶段 3:route spec 与 External v1 覆盖。 -4. 阶段 4~5:持久化/后台验证、定向测试和安全回归。 -5. 阶段 6:验收记录、Issue 交接评论和最终门禁。 - -如仓库要求单提交,也应在 PR 描述中按上述五组列出,确保每组都有独立验收标准。 diff --git a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md b/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md deleted file mode 100644 index e0a9eb589..000000000 --- a/local-docs/【实施计划】Issue226-AGC客户端主站请求标记分阶段验收-2026-09-01.md +++ /dev/null @@ -1,408 +0,0 @@ -# Issue #226:AGC 客户端主站请求标记分阶段实施与验收计划 - -更新时间:2026-09-02
-关联 Issue: - -- `#226 添加客户端特殊标识`:本计划全部实施范围 -- `#225 添加客户端埋点统计`:只接收交接契约,不在本计划实现 - -当前状态:阶段 6 已完成;跨 origin 重定向复审修复已完成;阶段 0 的证据与验收记录见 -[【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md)。 - -## 1. 交付目标 - -AGC 客户端发往当前选定 Genarrative 主站 origin 的业务 HTTP 请求统一携带: - -```http -X-Genarrative-Client: agc -``` - -同时保持同源重定向及现有认证、幂等、timeout、connect timeout、`no_proxy`、请求体和响应处理语义;跨 origin 3xx 不继续请求;OSS、签名下载、Provider、搜索、loopback 和更新下载请求不携带该标记。 - -## 2. 明确不做项 - -本计划不修改: - -- 主站 `server-rs`。 -- `/api/external/v1` OpenAPI 或 DTO。 -- SpacetimeDB schema、migration、bindings。 -- 主站 tracking middleware、route tracking 或后台查询。 -- `#225` 的数据库字段和管理页实现。 -- 现有 `generationInputs.source` 等业务 body 字段。 - -## 3. 阶段总览 - -```text -阶段 0 现状基线与契约冻结 - ↓ -阶段 1 TS 统一出口 - ↓ -阶段 2 Rust 主站 Client Builder factory - ↓ -阶段 3 主站 Client 创建点迁移 - ↓ -阶段 4 第三方请求隔离与负向验证 - ↓ -阶段 5 账号态/External 态和请求语义回归 - ↓ -阶段 6 #225 交接与最终门禁 -``` - -每个阶段都应先完成本阶段验收,再进入下一阶段;阶段之间不依赖 `#225` 已经实现。 - -## 4. 阶段 0:现状基线与契约冻结 - -### 4.1 工作内容 - -- 确认当前工作树无其他相关未提交修改。 -- 复核 AGC TS 主站统一出口:`fetchClientHttp`。 -- 复核 Rust 主站请求和第三方请求的 Client 创建点。 -- 固定 Header 名和值:`X-Genarrative-Client: agc`。 -- 固定主站 origin 判定依据:当前选定的 `serverBaseUrl` / `apiBaseUrl`。 -- 固定第三方排除列表。 -- 固定 #225 接收时使用的 metadata 约定:`metadata.client = "agc"`。 - -### 4.2 不应发生的改动 - -- 不修改生产代码。 -- 不修改主站代码或 OpenAPI。 -- 不新增数据库字段。 - -### 4.3 验收标准 - -- 已有调用清单和实施方案文档可定位到实际源码。 -- 正向范围和排除范围没有未决歧义。 -- Header 契约、metadata 交接字段和未知值行为已写入 Issue/方案文档。 -- `git status --short` 只反映预期的文档或无相关修改。 - -### 4.4 阶段产物 - -- 本方案文档。 -- Issue #226 的实现范围评论草案(正式评论待外部 Issue 操作确认)。 -- 交给 #225 的固定 Header/metadata 交接说明。 -- 阶段 0 基线与验收记录。 - -## 5. 阶段 1:TS 统一出口注入 - -### 5.1 工作内容 - -修改范围限定在: - -- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) -- 对应的 `clientHttp` 定向测试或现有测试 fixture - -在 `fetchClientHttp` 中: - -1. 保持现有目标 URL 解析和服务器地址校验。 -2. 使用 `Headers` 合并原有 `RequestInit.headers`。 -3. 注入 `X-Genarrative-Client: agc`。 -4. 保持浏览器 `fetch` 与 Tauri HTTP plugin 两条 transport 行为不变。 - -认证和业务函数不逐个添加 Header: - -- `clientAuth.ts` 继续调用 `fetchClientHttp`。 -- `clientApi.ts` 继续调用 `fetchClientHttp`。 - -更新清单请求 [appUpdate.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/appUpdate.ts) 不迁移到该统一出口,也不纳入主站业务标记。 - -### 5.2 阶段验收 - -TS 定向测试必须证明: - -- `/api/auth/*` 请求带 `X-Genarrative-Client: agc`。 -- `/api/profile/*` 请求带 `X-Genarrative-Client: agc`。 -- `/api/editor/*` 请求带 `X-Genarrative-Client: agc`。 -- `/api/assets/*` 请求带 `X-Genarrative-Client: agc`。 -- 原 `Authorization` 保留。 -- 原 envelope Header 保留。 -- 原 `X-Request-ID` 保留。 -- 调用方传入其他同名值时,最终值仍为 `agc`。 -- 更新清单请求不因本阶段被改为携带主站业务标记。 - -### 5.3 阶段完成判据 - -- TS 层所有已确认的主站业务请求经过 `fetchClientHttp` 并可观察到标记。 -- 没有在业务 API 文件中出现重复 Header 注入。 -- 相关 TS 定向测试通过。 - -### 5.4 阶段 1 实施记录 - -- `clientHttp.ts` 新增固定 Header 常量,并在 `fetchClientHttp` 内通过 `Headers` 合并并覆盖同名调用方 Header。 -- Web `fetch` 与 Tauri HTTP plugin 都接收同一份带标记的 `RequestInit`;原始 `RequestInit.headers` 不被修改。 -- `clientHttp.test.ts` 已覆盖 `/api/auth/*`、`/api/profile/*`、`/api/editor/*`、`/api/assets/*`,并分别验证 Web/Tauri transport、Authorization、envelope Header、`X-Request-ID` 和同名 Header 覆盖。 -- 阶段 1 验证命令:`npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts`(14 tests passed);`npm run ai-game-creator-shell:typecheck` 通过。 -- `appUpdate.ts` 未接入 `fetchClientHttp`,更新下载边界保持不变。 - -## 6. 阶段 2:Rust 主站 Client Builder factory - -### 6.1 工作内容 - -在 AGC Tauri/Rust 侧增加小型主站 Client Builder 入口,职责限定为: - -- 创建 `reqwest::ClientBuilder`。 -- 通过 `default_headers` 注入 `X-Genarrative-Client: agc`。 -- 不负责认证、幂等、重试、请求体、错误解析或账本。 - -建议抽象为 builder 工厂而不是完整 RequestBuilder facade,以保持当前方案一的低复杂度: - -```rust -fn agc_main_site_client_builder() -> reqwest::ClientBuilder; -``` - -工厂返回的 builder 允许调用方继续设置原有配置;未显式覆盖时,factory 默认只跟随同 origin 重定向: - -- connect timeout -- request timeout -- 显式 redirect policy(覆盖 factory 默认策略) -- 原有 `no_proxy` 或其他网络策略 - -### 6.2 阶段验收 - -增加最小单元测试或 mock 请求测试,证明: - -- 从 factory build 出的 Client 默认带 `X-Genarrative-Client: agc`。 -- 请求级 Authorization 和 Idempotency-Key 仍可正常设置。 -- factory 不改变 timeout 或 proxy 配置;默认 redirect 只跟随同 origin,调用方显式 `Policy::none()` 仍可覆盖。 -- 不新增完整 `AgcMainSiteClient` 类型。 -- 不把第三方下载或 Provider Client 接入该 factory。 - -### 6.3 阶段完成判据 - -- 主站 Client 的统一构造入口已经存在。 -- factory 的职责没有扩展到业务编排。 -- 现有测试或新增测试能锁定 Header 默认值。 - -### 6.4 阶段 2 实施记录 - -- 新增 `src-tauri/src/http_client.rs`,提供 `agc_main_site_client_builder()`。 -- factory 仅通过 `default_headers` 注入 `x-genarrative-client: agc`,不接管认证、幂等、请求体、重试或错误解析。 -- 调用方仍可在返回的 builder 上继续配置 connect timeout、request timeout、显式 redirect policy 和 `no_proxy`;未显式配置时跨 origin 重定向不会被跟随。 -- 新增两个本地 TCP fixture 单元测试,验证真实发送请求带标记、请求级 Authorization/Idempotency-Key 保留,且 factory 不自动注入认证信息。 -- 阶段 2 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture`(2 tests passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 通过。 -- 阶段 2 未迁移任何业务 Client 创建点;迁移属于阶段 3。 - -## 7. 阶段 3:主站 Client 创建点迁移 - -### 7.1 工作内容 - -只替换生产代码中用于主站 API 的 Client 创建方式: - -- 主站用途的 `reqwest::Client::builder()` 改用 AGC 主站 builder factory。 -- 主站用途的 `reqwest::Client::new()` 改为 builder 创建,以便设置默认 Header。 -- 保留每个调用原有 timeout、connect timeout、认证和幂等设置;同源 redirect 语义保留,跨 origin redirect 统一阻断。 - -重点检查文件: - -- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) -- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) -- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) -- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) -- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) -- [asset_canvas/generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) - -### 7.2 主站请求覆盖清单 - -迁移后应覆盖: - - -- `/api/profile/api-keys` -- `/api/editor/projects` -- `/api/editor/projects/{projectId}` -- `/api/editor/assets/library` -- `/api/editor/assets/folders` -- `/api/assets/direct-upload-tickets` -- `/api/assets/objects/confirm` -- `/api/assets/read-url` -- `/api/editor/projects/{projectId}/resources` -- `/api/editor/images/generations` -- `/api/editor/images/edits` -- `/api/editor/images/background-removals` -- `/api/editor/icon-spritesheets/generations` -- `/api/editor/character-animations/generations` -- `/api/editor/videos/generations` -- `/api/editor/audios/sound-effects/generations` -- `/api/editor/audios/background-music/generations` -- `/api/runtime/external-generation/jobs/{operationId}` -- 开发者 API Key 态对应的 `/api/external/v1/*` 路径 - -### 7.3 阶段验收 - -代码审查和请求捕获必须证明: - -- 上述主站请求均使用带默认 Header 的 Client。 -- 登录账号态路径带标记。 -- 开发者 API Key 态 External v1 路径带标记。 -- 生成提交和任务轮询都带标记。 -- 请求体、Bearer/API Key、Idempotency-Key、timeout 和同源 redirect 处理未变化;跨 origin redirect 不发起第二跳。 -- 没有为了加标记而修改 `ExternalEditorBindingAccess` 的路由映射语义。 - -### 7.4 阶段完成判据 - -- 主站生产请求的 Client 创建点完成迁移。 -- 没有把所有 Rust Client 粗暴替换成主站 Client。 -- 旧的第三方 Client 边界仍然清晰。 - -### 7.5 阶段 3 实施记录 - -- 已迁移 7 个生产 Rust 文件中的主站 Client 创建点:`assets.rs`、`commands.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`、`agent/direct_tool_bridge.rs`、`project/asset_canvas/generation.rs` 和 `project/resource_editor.rs`。 -- 迁移覆盖账号态实际 `/api/editor`、`/api/assets`、`/api/runtime` 路径,以及开发者 Key 态 `/api/external/v1` 路径;`assets/read-url` 的主站换签请求也通过 factory 创建。 -- 签名 URL 下载和 OSS multipart 上传仍由独立安全下载 Client 负责;Provider、搜索、loopback 和更新下载保持裸 Client。 -- 未修改 `ExternalEditorBindingAccess` 路由映射、凭据、请求体、operationId、Idempotency-Key 或本地账本语义。 -- 请求捕获测试已验证账号态画板同步的项目请求和换签请求带 `X-Genarrative-Client: agc`,签名媒体下载不带该 Header。 -- 阶段 3 验证:`http_client` 定向测试 2 个通过;`sync_canvas_project_assets_downloads_external_resources` 通过;Rust fmt、编码检查和 `git diff --check` 通过。 - -## 8. 阶段 4:第三方请求隔离与负向验证 - -### 8.1 工作内容 - -逐项确认以下 Client 不使用主站 factory: - -- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) -- `build_external_asset_download_client`。 -- OSS multipart 上传 Client。 -- 受控搜索 Client。 -- loopback 工具桥 Client。 -- 更新清单/更新包下载 Client。 - -### 8.2 阶段验收 - -为每类请求配置 mock 或请求捕获,断言: - -- OSS multipart 上传没有 `X-Genarrative-Client`。 -- 签名 URL 下载没有 `X-Genarrative-Client`。 -- LLM Provider 没有 `X-Genarrative-Client`。 -- 搜索请求没有 `X-Genarrative-Client`。 -- loopback 工具桥没有 `X-Genarrative-Client`。 -- 更新下载没有 `X-Genarrative-Client`。 - -同时断言这些请求原有的 `no_proxy`、redirect、超时和安全校验仍生效。 - -### 8.3 阶段完成判据 - -- 所有明确排除项均通过负向测试。 -- 没有出现“为了复用 factory,把第三方请求也带上 Header”的情况。 -- 负向测试失败时能定位到具体请求类别,而不是只报告一个总失败。 - -### 8.4 阶段 4 实施记录 - -- `build_external_asset_download_client` 的 OSS/签名传输 Client 保持独立;新增 `external_asset_transfer_client_omits_agc_marker` 本地 TCP 测试。 -- 受控搜索 Client 提取为独立构造点,新增 `controlled_search_client_omits_agc_marker` 测试。 -- loopback 工具桥 Client 提取为独立构造点,新增 `loopback_tool_bridge_client_omits_agc_marker` 测试。 -- 更新下载保留独立裸 Client,新增 `update_download_client_omits_agc_marker` 测试。 -- Codex Provider 代理转发测试增加 `x-genarrative-client` 缺失断言;阶段 3 的画板同步测试继续确认签名媒体下载无标记。 -- 阶段 4 验证命令:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture`(4 tests passed);`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer -- --nocapture`(1 test passed);`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` 和 `git diff --check` 通过。 - -## 9. 阶段 5:账号态、External 态和语义回归 - -### 9.1 工作内容 - -覆盖以下身份和地址组合: - -| 场景 | 认证 | 路径形态 | -|---|---|---| -| 平台账号态 | access token | `/api/editor`、`/api/assets`、`/api/runtime` | -| 开发者 Key 态 | `tnr_sk_...` | `/api/external/v1` | -| 开发环境 | 当前开发主站 origin | 主站实际路径 | -| 发布环境 | 当前发布主站 origin | 主站实际路径 | -| 自定义服务器 | 合法自定义 `apiBaseUrl` | 自定义主站实际路径 | - -### 9.2 阶段验收 - -- 账号态和开发者 Key 态都收到相同 Header 值 `agc`。 -- External v1 语义 endpoint 不因加 Header 改变。 -- 账号态路由映射不因加 Header 改变。 -- 自定义合法主站地址仍能携带标记。 -- 非主站地址不会被主站 Client 使用。 -- 认证失败、幂等重试、异步轮询和结果未知语义不变。 -- 现有 body 内 `generationInputs.source` 值保持原样。 - -### 9.3 阶段完成判据 - -- 关键请求矩阵全部有正向或负向证据。 -- 没有发生 token、API Key、Cookie、签名 URL 或项目绝对路径泄漏到日志/测试输出。 -- 没有因为 Header 增加而改变业务状态码或重试策略。 - -### 9.4 阶段 5 实施记录 - -- 账号态生成回归:`background_agent_runtime_can_generate_platform_art_asset` 捕获并检查 `/api/editor`、`/api/assets`、`/api/runtime` 请求,确认统一标记、Bearer、UUID Idempotency-Key、单次提交和三阶段轮询;同时锁定 `generationInputs.source = ai-game-creator-client`。 -- External Key 态与自定义服务器回归:`sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker` 在无平台会话下使用 `tnr_sk_phase5_fixture` 和自定义本地 `apiBaseUrl`,确认 External v1 项目/换签请求带标记,签名媒体下载不带标记。 -- 结果未知语义回归:`generation_submit_response_loss_is_not_retried` 通过,确认提交响应丢失时保留原 Idempotency-Key 且不重复提交。 -- 账号态画板同步测试继续覆盖主站项目请求、`assets/read-url` 和签名下载的正负边界。 -- 阶段 5 定向测试均通过;阶段 6 只剩 #225 交接材料复核与最终门禁,不在本阶段修改主站代码。 - -## 10. 阶段 6:#225 交接与最终门禁 - -### 10.1 交给 #225 的固定契约 - -`#225` 可以直接按以下约定实现,不需要要求 #226 回改客户端设计: - -```text -请求 Header:X-Genarrative-Client: agc -tracking metadata:client = "agc" -``` - -主站处理规则: - -- Header 名大小写不敏感。 -- 值为 `agc` 时识别为 AGC。 -- 缺失、空值或未知值按未标记处理,不拒绝业务请求。 -- 记录实际收到的 method/path,而不是客户端账本中的 External v1 endpoint。 -- 登录态使用真实登录用户维度。 -- External API Key 态使用 `ExternalApiPrincipal.owner_user_id`,必要时保留 `key_id`。 -- 不记录 token、API Key 明文或签名 URL。 -- External v1 不能因为当前没有 route spec 而漏记。 - -### 10.2 #226 最终门禁 - -完成 #226 前,不要求 #225 已经合并;但必须把以下材料交给 #225: - -- Header 名和值。 -- 主站/第三方请求边界。 -- 账号态和 External Key 态路径说明。 -- 正向测试结果摘要。 -- 负向测试结果摘要。 -- `metadata.client = "agc"` 的落库约定。 - -### 10.3 最终验证命令 - -按改动范围运行: - -- AGC TS 相关定向测试。 -- AGC Rust 相关定向测试或 `cargo test` 子集。 -- 必要的 mock HTTP 请求捕获测试。 -- `npm run check:encoding`。 -- `git diff --check`。 -- 检查 `git status --short`,确保没有构建产物、日志、凭据和无关文件。 - -### 10.4 阶段 6 实施记录 - -阶段 6 已完成,跨 origin 重定向复审修复已收口;交给 `#225` 的固定材料已在 -[【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【实施方案】Issue226-AGC客户端主站请求统一标记-2026-09-01.md) -第 12 节集中确认:Header 为 `X-Genarrative-Client: agc`,tracking metadata 使用 `client: "agc"`,主站按实际 method/path 和真实认证主体记录;缺失/空值/未知 Header 不拒绝请求。该交接不要求 `#226` 再回改客户端,也不要求本阶段实现 `#225` 的主站 tracking、数据库或后台代码。 - -最终门禁已通过: - -- TS `clientHttp` 定向测试:14 tests passed。 -- AGC TS 类型检查:通过。 -- Rust `http_client` factory:7 tests passed,覆盖同源跟随、跨 origin 阻断、链式重定向和显式 `Policy::none()`。 -- Rust `omits_agc_marker`:4 tests passed;loopback 认证边界:1 test passed。 -- 账号态主站 mock 捕获:1 test passed。 -- External Key 态主站 mock 捕获:1 test passed。 -- 签名 URL 下载边界:1 test passed。 -- 提交响应丢失幂等回归:1 test passed。 -- Rust fmt、`npm run check:encoding`、`git diff --check`:通过。 - -发布环境说明:本阶段没有执行真实发布环境线上 smoke;账号态/External Key 态使用本地 mock 和自定义 `apiBaseUrl` fixture 验证。真实发布环境 smoke 留给发布前或 `#225` 联调门禁,不影响 `#226` 客户端实现完成判定。 - -## 11. 建议提交/评审节奏 - -为方便阶段验收,建议在同一个 `#226` PR 内按以下逻辑组织提交或至少按阶段分组: - -1. 契约常量、TS 统一出口和 TS 测试。 -2. Rust 主站 Client Builder factory。 -3. Rust 主站 Client 创建点迁移。 -4. 第三方请求隔离和负向测试。 -5. 账号态/External 态回归、文档和最终门禁。 - -如果仓库提交策略要求单提交,也应在 PR 描述中按上述五组列出,确保每组都能单独 review 和验收。 diff --git a/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md b/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md deleted file mode 100644 index 5cbe08b61..000000000 --- a/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md +++ /dev/null @@ -1,228 +0,0 @@ -# AGC 客户端主站调用与可标记点清单 - -更新时间:2026-09-01
-范围:`apps/ai-game-creator-shell` 对 Genarrative 主站的调用扫描
-状态:只读盘点,未修改客户端、主站、OpenAPI 或数据库代码 - -## 1. 使用说明 - -本清单把调用分成三类: - -1. **已在 AGC 源码中发现的实际主站调用**:后续应纳入统一客户端标记范围。 -2. **主站 External v1 契约存在、但本次没有在 AGC 非测试源码中发现直接调用的接口**:作为潜在漏项保留,落地前需再次确认调用方。 -3. **明确不是主站的请求**:不应计入 AGC 主站调用统计,也不应加主站调用标记。 - -同一 AGC 能力可能有两套真实路径: - -- 登录账号态:使用平台账号 access token,External 语义路径会映射到站内 `/api/editor`、`/api/assets`、`/api/runtime`。 -- 开发者 API Key 态:使用 `tnr_sk_...`,保留 `/api/external/v1/...` 路径。 - -因此记录时应以主站实际收到的 `method + path + marker + request_id` 为准,不要只依赖客户端账本中的 External v1 endpoint。 - -## 2. 建议的统一标记 - -推荐先使用一个稳定请求头: - -```http -X-Genarrative-Client: agc -``` - -可继续复用已有 `X-Request-ID` 作为单次请求关联 ID。若后续需要区分一次客户端请求与重试,可另加每请求唯一的 `X-Genarrative-Client-Request-Id`,但不属于本次必须项。 - -标记只用于来源审计和统计,不得用于鉴权、权限、计费或账号归属判断。主站仍应以登录 access token 或 External API Key 的真实认证主体为准。 - -## 3. 已发现的实际调用:Web/TS 层 - -统一网络出口: - -- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) -- `fetchClientHttp` 根据环境使用浏览器 `fetch` 或 Tauri HTTP 插件。 -- 这一层适合统一注入标记,但不能覆盖 Tauri/Rust 的 `reqwest` 直连。 - -### 3.1 认证调用 - -统一封装:`clientAuth.ts` 的 `requestAuthJson`。 - -| 编号 | 方法 | 路径 | 用途 | 认证 | 标记建议 | 代码位置 | -|---|---|---|---|---|---|---| -| TS-A01 | GET | `/api/auth/me` | 获取当前用户 | Bearer,可选 | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:169) | -| TS-A02 | POST | `/api/auth/refresh` | 刷新 access token | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:183) | -| TS-A03 | POST | `/api/auth/entry` | 手机号+密码登录 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:210) | -| TS-A04 | POST | `/api/auth/phone/send-code` | 发送登录验证码 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:232) | -| TS-A05 | POST | `/api/auth/phone/login` | 手机号+验证码登录 | 无 Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:253) | -| TS-A06 | POST | `/api/auth/logout` | 退出登录;失败时可能刷新后重试 | Bearer | 应加 | [clientAuth.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.ts:275) | - -### 3.2 素材、账户和钱包调用 - -统一封装:`clientApi.ts` 的 `requestClientApi` / `requestClientApiBytes`。 - -| 编号 | 方法 | 路径 | 用途 | 标记建议 | 代码位置 | -|---|---|---|---|---|---| -| TS-B01 | GET | `/api/editor/assets/library` | 读取平台素材库 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:164) | -| TS-B02 | GET | `/api/assets/read-url?objectKey=...` | 获取素材签名读取地址 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:176) | -| TS-B03 | GET | `/api/assets/read-bytes?objectKey=...` | 读取素材二进制 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:190) | -| TS-B04 | GET | `/api/profile/dashboard` | 读取余额/账户概览 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:198) | -| TS-B05 | GET | `/api/profile/recharge-center` | 读取充值中心 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:206) | -| TS-B06 | POST | `/api/profile/recharge/orders` | 创建充值订单 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:214) | -| TS-B07 | POST | `/api/profile/recharge/orders/{orderId}/wechat/confirm` | 确认微信支付订单 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:229) | -| TS-B08 | GET | `/api/profile/wallet-ledger` | 读取钱包流水 | 应加 | [clientApi.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientApi.ts:237) | - -## 4. 已发现的实际调用:Tauri/Rust 层 - -Rust 层直接使用 `reqwest`,不会经过 TS 的 `fetchClientHttp`,必须单独覆盖。 - -### 4.1 开发者 Key 与项目/素材上下文 - -核心路由分流在: - -- [external_editor_bindings.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs:88) - -| 编号 | External v1 语义路径 | 账号态实际路径 | 用途 | 标记建议 | 代码位置 | -|---|---|---|---|---|---| -| RS-C01 | `POST /api/profile/api-keys` | 同路径 | 创建本机 AGC 开发者 API Key | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:384) | -| RS-C02 | `GET /api/external/v1/editor/projects` | `GET /api/editor/projects` | 列出/查找画布项目 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1119) | -| RS-C03 | `POST /api/external/v1/editor/projects` | `POST /api/editor/projects` | 创建画布项目 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1151) | -| RS-C04 | `GET /api/external/v1/editor/projects/{projectId}` | `GET /api/editor/projects/{projectId}` | 读取项目、恢复或同步 | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:717) | -| RS-C05 | `GET /api/external/v1/editor/assets/library` | `GET /api/editor/assets/library` | 读取账户素材库 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1196) | -| RS-C06 | `POST /api/external/v1/editor/assets/folders` | `POST /api/editor/assets/folders` | 创建素材文件夹 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1224) | -| RS-C07 | `POST /api/external/v1/assets/direct-upload-tickets` | `POST /api/assets/direct-upload-tickets` | 获取对象存储直传凭证 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1532) | -| RS-C08 | `POST /api/external/v1/assets/objects/confirm` | `POST /api/assets/objects/confirm` | 确认已上传对象 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1629) | -| RS-C09 | `GET /api/external/v1/assets/read-url` | `GET /api/assets/read-url` | 换签读取素材 | 应加 | [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs:1245) | -| RS-C10 | `POST /api/external/v1/editor/projects/{projectId}/resources` | `POST /api/editor/projects/{projectId}/resources` | 登记项目画布资源 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:1659) | - -### 4.2 生成、去背景和异步轮询 - -| 编号 | 方法 | External v1 语义路径 | 账号态实际路径 | 用途 | 标记建议 | 代码位置 | -|---|---|---|---|---|---|---| -| RS-G01 | POST | `/api/external/v1/editor/images/generations` | `/api/editor/images/generations` | 图片生成 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:941) | -| RS-G02 | POST | `/api/external/v1/editor/images/edits` | `/api/editor/images/edits` | 图片编辑/重绘 | 应加 | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2827) | -| RS-G03 | POST | `/api/external/v1/editor/images/background-removals` | `/api/editor/images/background-removals` | 图片去背景 | 应加 | [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs:1818) | -| RS-G04 | POST | `/api/external/v1/editor/icon-spritesheets/generations` | `/api/editor/icon-spritesheets/generations` | 图标图集/切片生成 | 应加 | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:6290) | -| RS-G05 | POST | `/api/external/v1/editor/character-animations/generations` | `/api/editor/character-animations/generations` | 角色动画生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2344) | -| RS-G06 | POST | `/api/external/v1/editor/videos/generations` | `/api/editor/videos/generations` | 视频生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2373) | -| RS-G07 | POST | `/api/external/v1/editor/audios/sound-effects/generations` | `/api/editor/audios/sound-effects/generations` | 音效生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2391) | -| RS-G08 | POST | `/api/external/v1/editor/audios/background-music/generations` | `/api/editor/audios/background-music/generations` | 背景音乐生成 | 应加 | [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2408) | -| RS-G09 | GET | `/api/external/v1/generations/{operationId}` | `/api/runtime/external-generation/jobs/{operationId}` | External Key/账号态生成任务轮询 | 应加 | [external_editor_bindings.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs:100) | - -资源编辑器统一提交和轮询还位于: - -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2498) -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs:2614) - -### 4.3 Direct Runtime 恢复与账户素材导入 - -| 编号 | 方法 | 路径 | 用途 | 标记建议 | 代码位置 | -|---|---|---|---|---|---| -| RS-R01 | GET | `/api/external/v1/editor/projects/{projectId}` 或 `/api/editor/projects/{projectId}` | Direct Runtime 只读恢复项目资源 | 应加 | [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs:2487) | -| RS-R02 | GET | `/api/external/v1/assets/read-url` 或 `/api/assets/read-url` | 账户素材导入前换签 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3833) | -| RS-R03 | GET | `/api/external/v1/editor/assets/library` 或 `/api/editor/assets/library` | 查询当前账号素材并按 assetId 选择 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3283) | -| RS-R04 | GET | `/api/external/v1/editor/projects/{projectId}` 或 `/api/editor/projects/{projectId}` | 读取项目画布资源清单 | 应加 | [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs:3327) | - -## 5. 已知的现有 body 内来源字段 - -这些字段不是统一 Header,且不会被全局 HTTP tracking middleware 自动读取,只能作为业务请求内部来源信息: - -| 来源值 | 出现位置 | 覆盖范围 | 说明 | -|---|---|---|---| -| `ai-game-creator-client` | [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs:951) | 部分图片生成请求 | 账号态图片生成 helper 会写入 `generationInputs.source` | -| `game-creator-account-binding` | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2311) | 项目资源登记 | 表示 AGC 账户绑定上下文 | -| `game-creator-resource-editor` | [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs:2796) | 资源编辑生成输入 | 表示 AGC 资源编辑链路 | - -这些 body 字段不能替代统一请求标记,因为它们不覆盖登录、列表、素材读取、钱包、上传凭证、对象确认和任务轮询等请求;并且主站 tracking 当前不会解析生成请求 body 中的 `generationInputs.source`。 - -## 6. External v1 契约中存在、但本次未发现 AGC 直接调用的潜在接口 - -这些接口在 [genarrative-external-v1.openapi.json](C:/projects/narrative/Genarrative/docs/openapi/genarrative-external-v1.openapi.json) 中存在。它们应保留在后续核对清单中,但本次不把它们误报为 AGC 已实际调用: - -### 6.1 项目管理 - -- `GET /api/external/v1/editor/projects/recent` -- `DELETE /api/external/v1/editor/projects/{projectId}` -- `PATCH /api/external/v1/editor/projects/{projectId}/metadata` -- `PATCH /api/external/v1/editor/projects/{projectId}/canvas` - -### 6.2 素材文件夹和素材记录管理 - -- `PATCH /api/external/v1/editor/assets/folders/{folderId}` -- `DELETE /api/external/v1/editor/assets/folders/{folderId}` -- `POST /api/external/v1/editor/assets` -- `PATCH /api/external/v1/editor/assets/{assetId}` -- `DELETE /api/external/v1/editor/assets/{assetId}` - -### 6.3 UI 设计图素材提取 - -- `POST /api/external/v1/editor/ui-designs/assets/extractions` - -当前 AGC Prompt 明确要求不要误用这个接口,而是通过图片生成链路生成 UI 原型,因此它目前属于契约潜在接口,不属于已确认实际调用。 - -### 6.4 External API 发现和协议接口 - -这些是公开契约/集成发现能力,不属于普通 AGC 主站业务调用;若未来 AGC 直接接入 MCP 或远程 Skill,再单独纳入: - -- `GET /api/external/v1/openapi.json` -- `GET /api/external/v1/agent-integration.json` -- `GET /api/external/v1/skill/SKILL.md` -- `GET /api/external/v1/skill.zip` -- `POST /api/external/v1/mcp` - -## 7. 明确排除的请求 - -以下请求不是 AGC 调用主站,不应进入主站调用标记统计: - -- LLM Provider:`POST {provider}/responses`,见 [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs)。 -- OSS/对象存储上传:主站返回直传票据后,客户端向票据 host 发出的 multipart POST。 -- 签名素材下载:`read-url` 返回临时地址后,对签名地址发出的二进制 GET。 -- AGC 受控网页搜索:见 [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs:2201)。 -- 本地 Tauri IPC、文件读写、浏览器试玩页面请求。 - -## 8. 主站承接标记的现状 - -### 8.1 请求上下文 - -主站请求上下文位于: - -- [request_context.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/request_context.rs) - -当前已有 `request_id`、operation 和计时信息,但没有 client/source/marker 字段。 - -### 8.2 全局 tracking - -主站全局 middleware 和 route tracking 位于: - -- [app.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/app.rs) -- [tracking.rs](C:/projects/narrative/Genarrative/server-rs/crates/api-server/src/tracking.rs) - -当前特点: - -- 主要在成功响应后写 route tracking event。 -- `/api/auth`、`/api/profile`、`/api/assets`、`/api/editor`、`/api/runtime` 已有部分 route spec。 -- `/api/external/v1/*` 当前没有 route spec,所以 External API Key 模式的 AGC 调用不会自动进入现有 route tracking。 -- External API 鉴权中间件已有 `ExternalApiPrincipal`,可提供 `owner_user_id` 和 `key_id`,但现有成功 route tracking 只显式读取登录态 `AuthenticatedAccessToken`,需要单独确认 External principal 的接入方式。 - -### 8.3 数据库和后台 - -现有 SpacetimeDB `tracking_event` 已有 `metadata_json`,适合第一阶段记录: - -```json -{ - "client": "agc", - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202 -} -``` - -后台当前 tracking 查询支持 event、user、scope、日期等结构化条件,不能直接按 `metadata_json.client` 高效筛选。若后续需要高频按客户端筛选,再考虑新增独立 `client_marker` 字段和索引;这会涉及 schema、migration、绑定和相关门禁。 - -## 9. 后续实现前的核对顺序 - -本文件只作为调用盘点,不代表已经授权改造。真正落地前建议按以下顺序重新核对: - -1. 以本清单的“实际调用”表为准,逐个确认非测试源码仍有调用。 -2. 先覆盖 TS `fetchClientHttp`。 -3. 再覆盖 Rust 的公共 External Editor 请求构造点和剩余直接 `reqwest` 调用。 -4. 确认账号态映射路径与开发者 Key External v1 路径都能携带同一标记。 -5. 主站解析 Header 时使用白名单值,未知值按未标记处理,不影响业务请求。 -6. route tracking 同时补充 External v1 路径和 External API principal 维度。 -7. 第一阶段优先写 `metadata_json`,暂不急于改 SpacetimeDB schema。 -8. 验证成功、失败、重试、异步轮询、401、上传凭证和钱包请求是否都能按预期区分。 diff --git a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md b/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md deleted file mode 100644 index 7c938c0ab..000000000 --- a/local-docs/【技术方案】AGC主站请求标记统一注入两种方案-2026-09-01.md +++ /dev/null @@ -1,414 +0,0 @@ -# AGC 主站请求标记统一注入两种方案 - -更新时间:2026-09-01
-关联 Issue:`#226 添加客户端特殊标识`、`#225 添加客户端埋点统计`
-状态:方案说明,未实施代码修改 - -## 1. 背景 - -AGC 客户端同时存在两套 HTTP 出口: - -1. Web/TS 层,通过 `fetchClientHttp` 使用浏览器 `fetch` 或 Tauri HTTP 插件。 -2. Tauri/Rust 层,通过多个 `reqwest::Client` 直接调用主站、对象存储、签名下载地址、LLM Provider 和本地工具桥。 - -目标是让 AGC 发往主站的请求统一携带来源标记,而不是在每个业务 API 调用点重复写 Header。 - -推荐标记: - -```http -X-Genarrative-Client: agc -``` - -该标记只用于来源审计、埋点和统计,不参与鉴权、权限、计费或账号归属判断。 - -## 2. 目标与不做项 - -### 2.1 目标 - -- AGC 发往当前选定主站 origin 的请求统一携带客户端标记。 -- 同时覆盖登录账号态和开发者 API Key 态。 -- 同时覆盖普通业务请求、生成提交和异步任务轮询。 -- 避免在每个 `/api/...` 调用点重复设置 Header。 -- 不把标记发送给 OSS、签名资源地址、LLM Provider、网页搜索或本地工具桥。 -- 保持现有 access token、API Key、幂等键和请求体语义不变。 - -### 2.2 不做项 - -- 本方案不定义主站数据库字段和后台页面实现,主站接收与落库属于 `#225`。 -- 不使用客户端标记替代真实认证主体。 -- 不修改 External v1 请求 DTO 或 OpenAPI 请求体。 -- 不通过 DNS、系统代理或网络抓包层注入 Header。 -- 不给进程内所有 `reqwest` 请求无差别设置默认 Header。 - -## 3. 两种方案的共同部分 - -无论 Rust 层选择哪种方案,Web/TS 层都可以直接在统一出口注入。 - -### 3.1 Web/TS 统一出口 - -文件: - -- [clientHttp.ts](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src/services/clientHttp.ts) - -处理方式: - -1. `fetchClientHttp` 解析最终请求地址。 -2. 确认目标 origin 等于当前选定的 `serverBaseUrl`。 -3. 在传给浏览器 `fetch` 或 Tauri HTTP 插件前设置: - -```http -X-Genarrative-Client: agc -``` - -示意代码: - -```ts -const headers = new Headers(init.headers); -headers.set('X-Genarrative-Client', 'agc'); - -return transport(target.url, { - ...init, - headers, -}); -``` - -需要保留调用方原有 Header,尤其是: - -- `Authorization` -- `Content-Type` -- `x-genarrative-response-envelope` -- `X-Request-ID`,如果调用方已提供 - -### 3.2 主站 origin 的定义 - -不能只写死以下两个地址: - -```text -https://dev.genarrative.world -https://www.genarrative.world -``` - -AGC 还支持自定义服务器,判断依据应是当前请求绑定的权威 `serverBaseUrl` / `apiBaseUrl`,并比较规范化后的 origin: - -```text -scheme + host + effective port -``` - -路径、query 和 fragment 不属于 origin 判断条件。 - -### 3.3 必须排除的请求 - -以下请求不能携带主站客户端标记: - -- `direct-upload-tickets` 返回的对象存储 multipart 上传地址。 -- `read-url` 返回的临时签名下载地址。 -- LLM Provider 的 `/responses` 等请求。 -- AGC 受控网页搜索请求。 -- 本地 loopback 工具桥请求。 -- 游戏试玩页面、外部网页或用户输入的任意 URL。 - -### 3.4 CORS - -浏览器环境向不同 origin 的主站发送自定义 Header 时可能触发 CORS 预检。主站及自定义部署需要允许: - -```http -Access-Control-Allow-Headers: X-Genarrative-Client -``` - -Tauri HTTP 插件和 Rust `reqwest` 不受浏览器 CORS 限制,但仍应遵守相同的目标 origin 边界。 - -## 4. 方案一:专用主站 HTTP Client 工厂 - -### 4.1 核心思路 - -为 Rust 层建立一个只允许服务于主站 API 的 `reqwest::Client` 构造函数,并通过 `default_headers` 自动加入客户端标记;请求发送前再由轻量终结器覆盖同名 Header,确保调用方不能伪造标记值。 - -第三方上传、签名下载、Provider 和搜索继续使用现有独立 Client,不带标记。 - -示意代码: - -```rust -fn build_agc_main_site_client(timeout: Duration) -> Result { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - reqwest::header::HeaderName::from_static("x-genarrative-client"), - reqwest::header::HeaderValue::from_static("agc"), - ); - - let default_policy = reqwest::redirect::Policy::default(); - let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { - let same_origin = attempt - .previous() - .first() - .map(|initial| initial.origin() == attempt.url().origin()) - .unwrap_or(false); - if same_origin { - default_policy.redirect(attempt) - } else { - attempt.stop() - } - }); - - reqwest::Client::builder() - .default_headers(headers) - .connect_timeout(Duration::from_secs(10)) - .timeout(timeout) - .redirect(redirect_policy) - .build() - .map_err(|error| format!("创建 AGC 主站 HTTP 客户端失败:{error}")) -} -``` - -调用代码仍然保持普通 `reqwest` 写法: - -```rust -let client = build_agc_main_site_client(Duration::from_secs(60))?; - -let response = with_agc_main_site_marker( - client - .get(format!("{api_base_url}{route}")) - .bearer_auth(token), -) - .send() - .await?; -``` - -业务请求不再逐个调用 `.header("X-Genarrative-Client", "agc")`;由统一终结器在发送前覆盖调用方同名值。 - -### 4.2 预计改动范围 - -主要改动是把主站用途的 `reqwest::Client::new()` / `Client::builder()` 替换为统一工厂,而不是修改每个 API endpoint。 - -潜在涉及文件: - -- [assets.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs) -- [commands.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/commands.rs) -- [canvas_generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs) -- [direct_runtime.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs) -- [direct_tool_bridge.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs) -- [generation.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs) -- [resource_editor.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs) - -不应迁移到主站 Client 的文件或请求: - -- [codex_provider_proxy.rs](C:/projects/narrative/Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs) -- `build_external_asset_download_client` 创建的签名资源下载 Client。 -- OSS multipart 上传 Client。 -- 受控搜索和 loopback 工具桥 Client。 - -### 4.3 优点 - -- 实现简单,符合当前仓库优先简单设计的原则。 -- 业务请求代码基本不变。 -- Header 由 factory 默认注入,终结器在发送前统一覆盖同名值。 -- 不需要新增 HTTP middleware 框架或复杂泛型包装。 -- 便于为普通请求和长时间生成提交提供不同 timeout,但共享同一标记配置。 -- 可以通过单元测试检查 Client 发出的请求自动带标记。 - -### 4.4 缺点和风险 - -- `default_headers` 对该 Client 发出的所有请求生效,不会再次判断目标域名。 -- `default_headers` 不能覆盖请求级同名 Header;新增的主站直发路径若绕过请求终结器,仍可能发送错误值。 -- 如果后续有人误用主站 Client 请求 OSS、签名 URL 或 Provider,标记会被带到第三方。 -- 当前存在多个不同 timeout 的 Client,需要提供少量参数或几个明确的工厂函数。 -- 仍需要替换现有主站 Client 的创建点,无法做到完全零调用点改动。 - -### 4.5 风险控制 - -保持控制简单,不引入复杂网络状态机: - -- 主站 Client 只在持有权威 `apiBaseUrl` 的模块中创建。 -- 主站 Client 不传给通用下载函数。 -- 签名下载继续由 `build_external_asset_download_client` 单独创建。 -- OSS 上传继续使用单独 Client。 -- 工厂名称明确包含 `main_site`,避免误用。 -- 所有 AGC 主站请求的最终 `.send()` 前统一经过 `with_agc_main_site_marker`;新增直发路径必须同步接入该终结器。 -- 定向测试断言主站 mock 收到标记、第三方 mock 未收到标记。 - -## 5. 方案二:按目标 origin 的请求包装器 - -### 5.1 核心思路 - -不依赖 Client 的默认 Header,而是统一通过一个请求构造入口创建主站 RequestBuilder。 - -包装器在构造请求时: - -1. 解析目标 URL。 -2. 解析当前权威 `apiBaseUrl`。 -3. 比较两者 origin。 -4. 只有同源时加入 AGC 标记。 -5. 非同源时拒绝,或明确返回不带标记的普通请求。 - -更安全的做法是主站包装器直接拒绝非同源 URL。 - -示意代码: - -```rust -fn main_site_request( - client: &reqwest::Client, - method: reqwest::Method, - api_base_url: &str, - route: &str, -) -> Result { - let base = url::Url::parse(api_base_url) - .map_err(|_| "AGC 主站地址无效".to_string())?; - let target = base - .join(route) - .map_err(|_| "AGC 主站请求路径无效".to_string())?; - - if target.origin() != base.origin() { - return Err("AGC 主站请求越出当前服务器 origin".to_string()); - } - - Ok(client - .request(method, target) - .header("X-Genarrative-Client", "agc")) -} -``` - -调用示意: - -```rust -let response = main_site_request( - &client, - reqwest::Method::GET, - access.api_base_url(), - &route, -)? -.bearer_auth(access.bearer_token()) -.send() -.await?; -``` - -### 5.2 预计改动范围 - -所有主站 `.get(...)`、`.post(...)`、`.patch(...)`、`.delete(...)` 构造点需要切换为包装器,或统一封装为一个 `AgcMainSiteHttp` 类型。 - -例如: - -```rust -struct AgcMainSiteHttp { - client: reqwest::Client, - base_url: url::Url, -} - -impl AgcMainSiteHttp { - fn get(&self, route: &str) -> Result; - fn post(&self, route: &str) -> Result; -} -``` - -下载、OSS 和 Provider 请求继续直接使用普通 `reqwest::Client`。 - -### 5.3 优点 - -- 每次请求都会校验真实目标 origin。 -- 即使包装器被误用于第三方 URL,也可以失败关闭,不会泄漏标记。 -- 主站 URL 拼接和 origin 校验有单一权威实现。 -- 适合未来出现更多动态 URL、多个部署 origin 或更严格的请求来源策略。 -- 可以在同一个入口继续注入 request ID、客户端版本等非敏感请求元信息。 - -### 5.4 缺点和风险 - -- 需要修改更多请求构造点。 -- 容易把简单的 Header 注入扩大成新的 HTTP 抽象层。 -- 现有代码已经有 `ExternalEditorBindingAccess`、路由映射、冻结会话校验等概念,再新增完整 HTTP facade 会增加概念数量。 -- 如果包装器同时承接鉴权、重试、错误解析、幂等和下载,很容易过度设计。 -- 对只需要固定来源标记的当前需求,复杂度高于方案一。 - -### 5.5 风险控制 - -- 包装器只负责 URL 构造、origin 校验和固定 Header,不负责业务错误解析。 -- 不在包装器中自动重试 POST。 -- 不把 token、API Key 或幂等键保存到长生命周期对象。 -- `ExternalEditorBindingAccess` 继续负责账号态/开发者 Key 路由映射和冻结会话校验。 -- 业务层继续明确设置 Bearer、Idempotency-Key 和请求体。 - -## 6. 两种方案对比 - -| 对比项 | 方案一:专用主站 Client 工厂 | 方案二:origin 请求包装器 | -|---|---|---| -| Header 注入位置 | `reqwest::Client::default_headers` | 每次构造 RequestBuilder 时 | -| 业务调用点改动 | 较少,主要替换 Client 创建点 | 较多,需要替换请求构造点 | -| 第三方泄漏防护 | 依赖 Client 使用边界 | 每次请求显式 origin 校验 | -| 实现复杂度 | 低 | 中 | -| 新增概念数量 | 少 | 较多 | -| 支持不同 timeout | 通过工厂参数或少量变体 | 共用 Client 或包装器配置 | -| 对动态 URL 的安全性 | 一般 | 高 | -| 当前需求适配度 | 高 | 中 | -| 后续扩展请求元信息 | 可以,但仍是 Client 级别 | 更灵活,可按请求控制 | -| 误用后的行为 | 可能把标记发给非主站 | 可拒绝非同源请求 | - -## 7. 推荐选择 - -当前推荐 **方案一:专用主站 HTTP Client 工厂**。 - -理由: - -- 当前需求只是给主站请求增加稳定来源标记。 -- 主站、签名下载、OSS 和 Provider 已有相对明确的客户端边界。 -- 不需要为一个固定 Header 引入新的 HTTP facade。 -- 改动集中在 Client 创建点,业务请求路径和错误语义变化较小。 -- 更符合仓库“优先简单、避免过度设计”的约束。 - -方案一需要明确遵守:主站 Client 不能用于签名下载、OSS 上传和 Provider 请求。如果实施时发现多个模块无法可靠维持这条边界,或存在大量由外部数据生成的动态目标 URL,再改选方案二。 - -不建议一开始同时实现两种方案。二选一即可,避免形成“Client 默认 Header + RequestBuilder 再加一次 Header”的重复机制。 - -## 8. 建议的 PR 边界 - -### 8.1 `#226 添加客户端特殊标识` - -负责: - -- TS `fetchClientHttp` 统一注入标记。 -- Rust 按选定方案统一注入标记。 -- 主站请求携带标记。 -- 第三方请求不携带标记。 -- 必要的 CORS Header 配置验证。 - -不负责: - -- tracking event 落库。 -- External v1 route tracking。 -- 后台筛选和统计展示。 - -### 8.2 `#225 添加客户端埋点统计` - -负责: - -- 主站读取并校验客户端标记。 -- 结合登录用户或 `ExternalApiPrincipal` 记录认证主体。 -- 将标记写入 `tracking_event.metadata_json` 或后续确定的结构化字段。 -- 补齐 `/api/external/v1/*` tracking。 -- 后台查询、筛选或统计。 - -## 9. 验收清单 - -### 9.1 正向请求 - -- TS 登录请求带 `X-Genarrative-Client: agc`。 -- TS 素材、钱包请求带标记。 -- Rust 账号态 `/api/editor/*` 请求带标记。 -- Rust 账号态 `/api/assets/*` 请求带标记。 -- Rust 账号态 `/api/runtime/external-generation/jobs/*` 轮询带标记。 -- Rust API Key 态 `/api/external/v1/*` 请求带标记。 -- `/api/profile/api-keys` 请求带标记。 -- 自定义主站 origin 的请求带标记。 - -### 9.2 排除请求 - -- OSS multipart 上传不带标记。 -- 签名 URL 下载不带标记。 -- LLM Provider 请求不带标记。 -- AGC 受控搜索不带标记。 -- 本地工具桥请求不带标记。 - -### 9.3 兼容性 - -- 原 Authorization 不变。 -- 原 Idempotency-Key 不变。 -- 原请求 body 不变。 -- 原 timeout 不变;同源 redirect policy 语义不变,跨 origin redirect 由主站 Client factory 阻断。 -- 浏览器跨域预检允许 `X-Genarrative-Client`。 -- 未识别 marker 时主站业务请求不受影响。 diff --git a/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md b/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md deleted file mode 100644 index a2e7d1003..000000000 --- a/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md +++ /dev/null @@ -1,105 +0,0 @@ -# Issue225 阶段 5:定向测试与安全边界 - -更新时间:2026-09-02 -关联分支:`feat/agc_call_rec` - -## 1. 阶段交付结果 - -本阶段补齐 Issue #225 直接相关的状态码、鉴权、安全 metadata 和幂等回归,确认 `X-Genarrative-Client: agc` 只是来源审计标签,不改变认证、权限、计费、请求体、异步轮询或成功事件语义。 - -## 2. 新增回归 - -### 2.1 成功状态语义 - -`tracking.rs` 新增 `should_record_route_tracking` 判定测试: - -- `200 / 201 / 202 / 204` 的显式业务 route 可以进入成功 tracking; -- `400 / 401 / 403 / 404 / 500 / 502` 即使带 AGC marker,也不会新增成功 route event; -- 已由详细资产事件处理的上传票据和对象确认不重复生成统一 route event。 - -### 2.2 AGC-only 路由门禁 - -本期新增、仅为 AGC 调用清单补齐的账号态和 External v1 route spec 均要求有效 `X-Genarrative-Client: agc`: - -- 带有效 marker 的 2xx 响应才写入 route tracking; -- 缺失、非法、未知或大小写不符合契约的 marker 不写入这些新增 route event,也不拒绝业务请求; -- 既有 route spec 和已有详细资产事件保持原有全客户端记录语义。 - -### 2.3 鉴权边界 - -`app.rs` 通过真实 router 发起未认证的: - -```text -GET /api/editor/projects -X-Genarrative-Client: agc -``` - -响应仍为 `401 Unauthorized`。marker 不会获得权限,也不会改变失败状态。 - -### 2.4 metadata 安全边界 - -route metadata 测试确认不会写入: - -```text -authorization -accessToken -token -apiKey -cookie -signature -signedUrl -requestBody -``` - -有效 marker 只产生固定的 `client: "agc"`,并保留既有 route/method/status/operation 与资产嵌套字段。 - -### 2.5 既有链路复回归 - -阶段 4 的以下路径在阶段 5 再次顺序执行: - -- `TrackingEventDraft → RuntimeTrackingEventInput`; -- tracking outbox NDJSON round-trip; -- `spacetime-client` mapper; -- module-runtime metadata object 校验; -- 后台 tracking SQL response parser; -- 账号态 / External API Key owner 归属; -- SpacetimeDB tracking event-id 幂等回归。 - -## 3. 验收结果 - -- [x] marker 有效值、缺失值、空值、大小写和未知值测试通过。 -- [x] 账号态 `user_id = owner_user_id` 归属测试通过。 -- [x] External API Key 态只写真实 `owner_user_id`,不伪造 `user_id`,scope 使用 owner。 -- [x] 当前账号态和 External v1 业务 route coverage 测试通过。 -- [x] discovery、Skill、MCP 排除测试通过。 -- [x] 2xx 成功记录和 4xx/5xx 不新增成功 route event 测试通过。 -- [x] AGC marker 不绕过鉴权、不改变失败状态。 -- [x] route metadata 不包含 token、API Key、Cookie、签名 URL 或请求体字段。 -- [x] outbox、mapper、runtime input 和后台 readback 回归通过。 -- [x] event-id 幂等回归通过。 - -## 4. 定向验证结果 - -| 测试范围 | 通过数 | -|---|---:| -| `module-runtime` tracking input | 2 | -| `spacetime-client` tracking mapper | 1 | -| `api-server` tracking | 18 | -| `api-server` app 鉴权回归 | 1 | -| `api-server` 资产 marker / owner | 2 | -| `api-server` tracking outbox | 8 | -| `api-server` 后台 readback | 5 | -| `spacetime-module` event-id 幂等 | 1 | -| 合计 | 38 | - -另外通过: - -- `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` -- `git diff --check` -- `npm run check:encoding` - -未运行与本 Issue 无直接关系的完整工作区测试,按约定交给 CI。没有启动真实 SpacetimeDB,因此没有把确定性 procedure input/mapper 和 admin parser 回归扩大解释为远端数据库 E2E。 - -## 5. 后续阶段 - -阶段 6 汇总阶段 1~5 的证据,复核 #226 交接契约、执行最终门禁并准备 Issue #225 交付说明。阶段 5 代码已提交为 `c5170669e`;日登录埋点构造器回归修复补充提交为 `0a06b4988`。 diff --git a/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md b/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md deleted file mode 100644 index a36a13acd..000000000 --- a/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md +++ /dev/null @@ -1,148 +0,0 @@ -# Issue225 阶段6:最终门禁与 #226 交接收口 - -更新时间:`2026-09-02` -实施范围:`#225 添加客户端埋点统计` -交接输入:`#226 添加客户端特殊标识` - -执行结论:阶段 6 通过,Issue #225 的主站接收、tracking metadata、主体归属、路由覆盖、持久化/后台读取验证和安全语义回归已收口。本阶段没有新增生产功能,没有修改 #226 客户端实现,不需要 #226 回改设计。 - -## 1. 阶段边界 - -本阶段只完成: - -1. 汇总阶段 1~5 的实现提交、定向测试和 readback 证据。 -2. 复核 `#226` 交接的 Header、origin、认证和第三方边界。 -3. 执行与本 Issue 直接相关的最终编译、测试、格式、编码和空白门禁。 -4. 更新实施方案、分阶段计划和 Issue #225 交付评论草案。 - -本阶段明确不做: - -- 不修改 `AGC` 客户端的 `fetchClientHttp`、Rust Client factory、请求终结器或 redirect policy。 -- 不新增 `tracking_event` 列、索引、migration、生成 bindings 或新的统计表。 -- 不修改 `/api/external/v1` 的 OpenAPI、DTO、HTTP 方法、状态码、鉴权或异步语义。 -- 不新增后台筛选器、报表页面或真实发布环境线上写入。 - -## 2. 当前仓库基线 - -阶段 6 开始时仓库状态: - -| 项目 | 结果 | -|---|---| -| 分支 | `feat/agc_call_rec` | -| HEAD | `5aa38d9f3`(已合并最新 `origin/master`) | -| `origin/master` | `8932f0b27` | -| 工作树 | 开始阶段 6 时干净;本记录及计划/方案更新属于本阶段待提交文档变更 | - -阶段 1~5 的实现提交: - -| 阶段 | 提交 | -|---|---| -| 阶段 1:marker 解析与 tracking 入口 | `2296f79fd` | -| 阶段 2:主体归属与 metadata | `2a23ba657` | -| 阶段 3:路由覆盖 | `01159a269` | -| 阶段 4:持久化与后台读取 | `c75c61521` | -| 阶段 5:安全与语义回归 | `c5170669e` | -| 日登录埋点构造器回归修复 | `0a06b4988` | - -## 3. 最终交付行为 - -### 3.1 Marker 与 metadata - -主站识别: - -```http -X-Genarrative-Client: agc -``` - -规则: - -- Header 名按 HTTP 规则大小写不敏感。 -- Header 值去除首尾空白后,必须精确等于小写 `agc`。 -- 缺失、空值、`AGC` 或未知值按未标记处理,不拒绝请求。 -- 有效标记只追加到已有成功 route tracking 的 `tracking_event.metadata_json`: - -```json -{ - "route": "/api/editor/projects", - "method": "GET", - "status": 200, - "operation": "listEditorProjects", - "client": "agc" -} -``` - -不写入 Header 原文、token、API Key、Cookie、签名 URL、请求体或项目绝对路径。 - -本期新增的 AGC route spec 只有在请求带有效 marker 时才记录;既有 route spec 与手工资产事件沿用原有全客户端记录策略。 - -阶段 6 后续补充了一条轻量 middleware 成功链路回归:真实 `build_router` 请求经过账号认证和 tracking middleware 后写入隔离 outbox,并验证 `client`、实际 route、user/owner 归属;该测试不启动真实 SpacetimeDB,不改变完整环境型 E2E 仍作为发布前 smoke 的边界。 - -### 3.2 主体归属 - -- 登录账号态:使用已验证 access token 的真实用户,保留既有 `user_id`、`owner_user_id` 和 scope 语义。 -- External API Key 态:使用 `ExternalApiPrincipal.owner_user_id()`;不伪造登录 `user_id`,User scope 的 `scope_id` 使用 owner。 -- Header 只是来源审计标签,不能绕过认证、权限、计费或替换主体。 - -### 3.3 路由覆盖与排除 - -已覆盖的实际业务路径: - -- 账号态:`/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 -- External API Key 态:`/api/external/v1/*` 的当前资产、项目、素材库、生成提交和任务轮询业务路径。 -- 动态 project/generation/asset ID 继续按显式静态段规则归一化;External v1 metadata 保留实际外部 path。 -- 上传票据和对象确认沿用已有详细资产事件,不重复生成普通 route event,但会保留有效 AGC metadata。 - -明确排除: - -- OSS multipart 上传、签名 URL/OSS 媒体下载。 -- LLM/Codex Provider、AGC 受控搜索、loopback 工具桥。 -- 更新清单、更新包下载和任意外部网页请求。 -- `/api/external/v1/openapi.json`、`agent-integration.json`、Skill 文档/压缩包和 MCP 入口。 - -### 3.4 落库与读取位置 - -AGC 调用记录最终落在主站 SpacetimeDB 的 `tracking_event` 表,标识位于 `tracking_event.metadata_json.client`。普通 route tracking 默认先进入 api-server 本机: - -```text -server-rs/.data/tracking-outbox/active.ndjson -server-rs/.data/tracking-outbox/sealed-*.ndjson -``` - -worker 使用既有 `record_tracking_events_and_return` 批量 procedure 写入 SpacetimeDB;outbox 不可用时沿用同步 `record_tracking_event_and_return` 回退。后台 `GET /admin/api/tracking/events` 读取同一 `tracking_event` 的 `metadata_json`,当前没有单独的 `client=agc` 查询参数,需要从返回 JSON 中识别 `"client": "agc"`。 - -## 4. 最终门禁证据 - -| 验收项 | 命令 | 结果 | -|---|---|---| -| api-server 编译 | `cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server` | 通过;仅仓库既有 warning | -| api-server tracking/资产/后台/outbox 定向测试 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking -- --nocapture` | 43 passed,0 failed | -| marker 不绕过鉴权回归 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture` | 1 passed,0 failed | -| module-runtime tracking input | `cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture` | 2 passed,0 failed | -| spacetime-client mapper | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture` | 1 passed,0 failed | -| SpacetimeDB event-id 幂等 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture` | 1 passed,0 failed | -| Rust 格式 | `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` | 通过 | -| 中文编码 | `npm run check:encoding` | 通过,5662 个文件 | -| Diff 空白/分支基线 | `git diff --check`、`git merge-base --is-ancestor origin/master HEAD` | 通过 | - -与本 Issue 直接相关的本轮定向测试合计 48 个通过。完整工作区测试不在本地重复运行,按约定交给 CI;本地未启动真实 SpacetimeDB,因此没有把 input/mapper/readback parser 测试表述为线上 E2E。 - -## 5. #226 交接复核 - -`#226` 已提供并冻结以下输入: - -- 发往当前 Genarrative 主站 origin 的业务请求带 `X-Genarrative-Client: agc`。 -- OSS、签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。 -- 同源重定向继续允许;跨 origin 重定向被阻断,避免标记泄漏到第三方 origin。 -- 请求级同名 Header 不能伪造最终值,主站业务请求最终仍为 `agc`。 - -主站 #225 已按上述契约消费 Header;不存在要求 #226 重新设计或回改的接口缺口。 - -补充记录一个不属于 #225 的客户端后续项:`clientHttp.ts` 当前把自定义服务器地址的规范化字符串直接与 `URL.origin` 比较;显式默认端口(例如 `https://example.com:443`)或主机名大小写可能导致合法自定义 origin 被误判为跨 origin。该问题表现为请求被客户端拒绝,不会造成 AGC 标记泄漏,也不影响当前 release/dev 默认地址;应作为 #226 的独立客户端修复跟踪,不能在 #225 中偷偷回改客户端设计。 - -## 6. 可直接粘贴到 Issue #225 的交付评论 - -> `#225` 主站侧已完成并收口:统一识别 `X-Genarrative-Client: agc`,本期新增的 AGC-only 成功 route 只有在带有效 marker 时才写入 `tracking_event.metadata_json.client = "agc"`;既有 route tracking 和手工资产事件保持原有全客户端统计语义。账号态按已验证 access token 的真实用户归属,External API Key 态按 `ExternalApiPrincipal.owner_user_id()` 归属,不伪造 `user_id`,不记录 token/API Key/Cookie/签名 URL。当前 AGC 实际使用的账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External v1 业务路径均有显式 route spec;动态 ID 继续归一化,External v1 保留实际外部 path。OSS/签名下载、Provider、受控搜索、loopback、更新下载、公开 discovery/Skill/MCP 不进入普通 AGC 业务统计。记录最终复用既有 outbox、SpacetimeDB procedure、daily stat、event-id 幂等和后台 `GET /admin/api/tracking/events` 读取,不新增 schema、页面或独立事件体系。阶段 1~5 的定向实现和回归已提交,阶段 6 最终门禁通过:api-server tracking/资产/后台/outbox 43 个、鉴权回归 1 个、module-runtime 2 个、spacetime-client 1 个、spacetime-module 1 个定向测试全部通过,Rust 编译/格式、编码和 diff 检查通过;完整工作区测试按 CI 执行。`#226` 客户端标记、origin-safe redirect 和第三方边界契约无需回改。` - -## 7. 后续发布前事项 - -本 Issue 代码和本地确定性验证已完成;发布或联调时由主站环境补做一次真实链路 smoke:使用已部署的 AGC 客户端请求主站业务接口,确认 Header 被接收、`tracking_event.metadata_json.client` 可由后台原始查询读回。该 smoke 是环境验证,不改变本 PR 的设计或代码范围。 diff --git a/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md b/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md deleted file mode 100644 index 533a5f401..000000000 --- a/local-docs/【阶段验收】Issue226阶段0现状基线与契约冻结-2026-09-01.md +++ /dev/null @@ -1,184 +0,0 @@ -# Issue #226 阶段 0:现状基线与契约冻结验收记录 - -更新时间:2026-09-01
-关联 Issue:`#226 添加客户端特殊标识`;交接 Issue:`#225 添加客户端埋点统计`
-执行结论:阶段 0 本地基线与契约冻结通过;未修改生产代码、主站、OpenAPI 或数据库。
-远程 Issue 评论未直接提交,本文第 7 节提供可直接粘贴的评论草案。 - -## 1. 本阶段边界 - -阶段 0 只完成四件事: - -1. 记录当前工作树和源码基线。 -2. 核对 TS 统一请求出口,以及 Rust 主站/第三方 Client 创建边界。 -3. 冻结客户端 Header、主站 origin 和正负向范围。 -4. 冻结交给 `#225` 的 tracking metadata 约定。 - -本阶段明确不做: - -- 不改 `apps/ai-game-creator-shell` 生产实现。 -- 不改 `server-rs`、OpenAPI、SpacetimeDB schema、migration、bindings 或后台。 -- 不把现有 body 内 `generationInputs.source` 等字段升级成统一请求标记。 -- 不把所有 `reqwest::Client` 粗暴替换成主站 Client。 - -## 2. 当前仓库基线 - -| 项目 | 结果 | -|---|---| -| 工作目录 | `C:\projects\narrative\Genarrative` | -| 当前分支 | `feat/agc_call_header` | -| 当前 HEAD | `b14e42d9b` — `AGC支持安装扩展skill、MCP (#233)` | -| 阶段开始前工作树 | `git status --short` 为空 | -| 编码检查 | `npm run check:encoding` 通过,检查 5647 个文件 | -| Diff 空白检查 | `git diff --check` 通过 | -| 阶段 0 代码改动 | 无 | - -说明:本记录和相关方案/清单属于预期本地文档变更。当前仓库通过 `.git/info/exclude` 忽略整个 `local-docs/`,因此这些材料不会出现在 `git status` 或 `git diff` 中;生产代码工作树仍保持干净。 - -## 3. 源码边界核对 - -### 3.1 TS 统一出口已确认 - -AGC Web/TS 侧的统一网络入口是: - -- `apps/ai-game-creator-shell/src/services/clientHttp.ts:163` 的 `fetchClientHttp`。 -- `clientAuth.ts` 通过 `requestAuthJson` 调用该入口。 -- `clientApi.ts` 通过 `requestClientApi` / `requestClientApiBytes` 调用该入口。 - -因此阶段 1 只需要在 `fetchClientHttp` 统一注入 Header,即可覆盖认证、素材、账户和钱包等 TS 请求;更新下载 `clientHttp` 之外的独立路径仍需保持排除。 - -### 3.2 Rust 不是统一 Client - -Rust 侧已确认存在多个独立 `reqwest::Client::new()` / `Client::builder()` 创建点,不能依赖 TS 入口覆盖。主站相关的生产创建点主要分布在: - -- `assets.rs:377`、`:712`:开发者 Key、项目同步/恢复。 -- `commands.rs:3283`、`:3833`:账户素材库和素材下载前换签。 -- `agent/generation/canvas_generation.rs:2404`、`:2408`:External Editor 生成轮询/提交。 -- `project/asset_canvas/generation.rs:3729`、`:3733`:画布生成轮询/提交。 -- `project/resource_editor.rs:3101`:资源编辑生成/轮询公共入口。 -- `agent/direct_runtime.rs:2487`:Direct Runtime 只读恢复。 -- `agent/direct_tool_bridge.rs:1813`:受控去背景调用前的主站上下文准备。 - -公共路由与凭据映射抽象已确认存在于: - -- `project/external_editor_bindings.rs:40` 的 `ExternalEditorBindingAccess`。 -- `ExternalEditorBindingAccess::api_route(...)` 负责账号态 `/api/editor`、`/api/assets`、`/api/runtime` 与 API Key 态 `/api/external/v1` 的路径映射。 - -### 3.3 明确的第三方/非主站边界 - -以下调用保留原有 Client,不进入主站标记范围: - -- OSS/对象存储 multipart 上传。 -- `read-url` 返回的签名 URL 二进制下载。 -- LLM Provider `/responses`:`agent/codex_provider_proxy.rs:207`。 -- AGC 受控网页搜索:`agent/direct_tool_bridge.rs:2191`,带有独立 `no_proxy` 语义。 -- loopback 工具桥、本地 IPC、试玩页面请求。 -- 更新清单和更新包下载:`main.rs:120`。 - -完整的实际调用表、潜在 External v1 接口和排除项以 -[【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md](C:/projects/narrative/Genarrative/local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md) -为准。 - -## 4. 冻结的客户端标记契约 - -### 4.1 Header - -```http -X-Genarrative-Client: agc -``` - -固定规则: - -- Header 名按 HTTP 规则大小写不敏感;客户端发送时固定使用 `X-Genarrative-Client`。 -- 值固定为小写 `agc`。 -- 统一出口/主站 Client 应覆盖调用方传入的同名 Header,避免业务层伪造其他值。 -- 不携带 token、API Key、用户 ID、项目 ID 或版本号。 -- 只用于来源审计和统计,不参与鉴权、权限、计费或账号归属判断。 - -### 4.2 Origin 与发送范围 - -主站判断依据固定为当前选定并已规范化的 `serverBaseUrl` / `apiBaseUrl`,比较完整 origin(scheme + host + port),而不是只看路径或字符串前缀。 - -应带标记的请求包括: - -- `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 -- API Key 态 `/api/external/v1/*`。 -- 项目、画布、素材库、上传凭证、对象确认、资源登记、图片/图集/去背景/角色动画/视频/音频生成和异步轮询。 -- `/api/profile/api-keys`。 - -不得带标记的请求包括:OSS、签名下载、Provider、受控搜索、loopback、更新下载及任意外部网页请求。 - -### 4.3 保留的请求语义 - -后续实现必须保留每个调用点已有的: - -- `timeout`、`connect_timeout`、redirect policy 和 `no_proxy`。 -- Authorization/Bearer、External API Key、Idempotency-Key 和既有业务 Header。 -- Content-Type、请求体、响应状态处理、重试和异步轮询语义。 - -## 5. 冻结给 #225 的交接契约 - -主站接收同名 Header: - -```http -X-Genarrative-Client: agc -``` - -主站规则: - -- 精确值 `agc` 识别为 AGC。 -- Header 缺失、空值或未知值按“未标记”处理,不拒绝请求,不改变业务行为。 -- 不从 `generationInputs.source` 推导统一客户端标记。 -- 认证主体仍以真实账号态或 `ExternalApiPrincipal` 为准,不记录 token/API Key 明文。 - -第一阶段建议复用现有 `tracking_event.metadata_json`,固定 JSON key/value: - -```json -{ - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202, - "operation": "generateExternalEditorImage", - "client": "agc" -} -``` - -交接要求: - -- 记录主站实际收到的 method/path,不只记录客户端账本中的 External v1 endpoint。 -- `/api/external/v1/*` 也要纳入 tracking 覆盖,不能只依赖内部 `/api/editor` 路由规格。 -- 缺失标记时不写 `client` 空字符串;未知值按未标记处理。 -- 成功、失败、重试和轮询请求沿用同一 Header;是否记录失败请求由 `#225` 的埋点目标决定,不要求 `#226` 回改标记设计。 - -## 6. 阶段 0 验收结果 - -| 验收项 | 结果 | 证据 | -|---|---|---| -| 当前工作树基线已确认 | 通过 | 本文第 2 节;阶段开始前 `git status --short` 为空 | -| TS 统一出口已定位 | 通过 | `clientHttp.ts:163`、`clientAuth.ts`、`clientApi.ts` | -| Rust 主站/第三方 Client 边界已定位 | 通过 | 本文第 3 节;完整清单见扫描文档 | -| 正向范围无未决歧义 | 通过 | 本文第 4.2 节 | -| 排除范围无未决歧义 | 通过 | 本文第 3.3、4.2 节 | -| Header、metadata、未知值行为已冻结 | 通过 | 本文第 4、5 节;实施方案第 3、8 节 | -| 未修改生产代码/主站/契约/数据库 | 通过 | `git diff --check`;本阶段仅新增/更新被本地忽略的 `local-docs` | - -阶段 0 可退出,允许进入阶段 1。阶段 1 的入口条件是直接按本文契约实现 TS `fetchClientHttp`,无需等待 `#225`。 - -## 7. Issue #226 实现范围评论草案 - -> 本次 #226 只做 AGC 客户端侧请求标记,不修改 #225 的主站 tracking、数据库或后台实现。 -> -> 冻结 Header:`X-Genarrative-Client: agc`。TS 在 `fetchClientHttp` 统一注入;Rust 增加主站专用 Client builder factory,通过 `default_headers` 注入,并只替换主站请求的 Client 创建点。账号态 `/api/editor`、`/api/assets`、`/api/runtime` 与 API Key 态 `/api/external/v1` 都发送同一标记。 -> -> 保留各调用点现有 timeout、connect timeout、redirect、no_proxy、Authorization/Bearer、API Key、Idempotency-Key、请求体和响应语义。OSS 上传、签名 URL 下载、LLM Provider、受控搜索、loopback、更新下载和外部网页请求不得带标记。 -> -> #225 接收约定:精确值 `agc` 识别为 AGC;缺失/空值/未知值按未标记处理且不拒绝请求。第一阶段复用 `tracking_event.metadata_json`,写入 `client: "agc"`,并按主站实际 method/path 记录;不要求 #226 为 #225 回改设计。 - -正式提交评论前,应将本文草案与 #226 当前讨论串核对一次;本阶段未直接执行远程 Issue 写操作。 - -## 8. 阶段 1 入口与风险提示 - -- 阶段 1 只改 TS `fetchClientHttp` 及其定向测试,先验证 Header 合并、同名 Header 覆盖和两条 transport 行为。 -- 阶段 2/3 再处理 Rust factory 与生产创建点;测试 fixture 中的裸 Client 不应被机械替换。 -- `default_headers` 只放稳定来源标记;Authorization、Idempotency-Key 和每请求动态 Header 继续保留在调用点。 -- 后续定向测试必须同时覆盖账号态和 API Key 态,以及主站正向和第三方负向请求。 diff --git a/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md b/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md deleted file mode 100644 index 1f879b1b8..000000000 --- a/local-docs/【阶段验收】Issue226阶段6交接与最终门禁-2026-09-02.md +++ /dev/null @@ -1,119 +0,0 @@ -# Issue #226 阶段 6:#225 交接与最终门禁验收记录 - -更新时间:`2026-09-02` -实施范围:`#226 添加客户端特殊标识` -交接范围:`#225 添加客户端埋点统计` -执行结论:阶段 6 通过;#226 客户端实现、跨 origin 重定向安全复审、边界回归、交接材料和最终门禁已收口。未修改 #225 主站代码、数据库、OpenAPI 或后台实现。 - -## 1. 阶段边界 - -本阶段只完成: - -1. 复核并固定交给 `#225` 的 Header、tracking metadata、认证主体和路径边界。 -2. 汇总阶段 1~5 的正向、负向和语义回归证据。 -3. 执行最终定向测试、编码/格式/空白检查和工作树检查。 -4. 更新实施方案与分阶段计划的完成状态。 - -本阶段明确不做: - -- 不修改 `server-rs`、主站 tracking middleware、route tracking 或后台。 -- 不修改 SpacetimeDB `tracking_event` schema、migration、bindings 或索引。 -- 不修改 External v1 OpenAPI、DTO 或请求响应语义。 -- 不执行真实发布环境线上写入或埋点验证。 - -## 2. 当前仓库状态 - -阶段 6 开始时: - -| 项目 | 结果 | -|---|---| -| 分支 | `feat/agc_call_header` | -| HEAD | `8059bbcb5`(已合并最新 `origin/master`) | -| 工作树 | 干净 | -| `origin/master` | `4a2f5be6c`,同步 AGC 更新下载域名门禁 | - -阶段 0~5 的提交保持不变,本阶段只补充交接/验收文档。 - -## 3. 给 #225 的固定交接契约 - -### 3.1 客户端请求标记 - -```http -X-Genarrative-Client: agc -``` - -- Header 名大小写不敏感;值精确为小写 `agc` 时识别为 AGC。 -- 缺失、空值或未知值按未标记处理,不拒绝请求,也不改变业务行为。 -- Header 只用于来源审计和统计,不参与鉴权、权限、计费或账号归属。 -- 不记录 access token、API Key 明文、签名 URL、项目绝对路径、用户隐私或客户端版本号。 - -### 3.2 tracking metadata - -第一阶段复用现有 tracking `metadata_json`,固定 JSON key/value: - -```json -{ - "route": "/api/editor/images/generations", - "method": "POST", - "status": 202, - "operation": "generateExternalEditorImage", - "client": "agc" -} -``` - -约定: - -- `client` key 固定;AGC 值固定为 `agc`。 -- Header 缺失时不写 `client` 空字符串。 -- 记录主站实际收到的 method/path,不只记录客户端账本中的 External v1 endpoint。 -- `/api/external/v1/*` 不能因为缺少完整 route spec 而漏记。 - -### 3.3 认证主体 - -- 登录账号态:沿用主站现有 access token 解析出的用户维度。 -- External API Key 态:使用 `ExternalApiPrincipal.owner_user_id`,必要时保留 `key_id` 维度。 -- Header 与认证主体独立处理;不能用 Header 代替认证,也不能从 `generationInputs.source` 推导客户端标记。 - -### 3.4 路由和边界 - -应识别的主站请求: - -- 账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 -- External API Key 态 `/api/external/v1/*`。 -- 生成提交、异步轮询、项目/素材/资源登记和 `/api/assets/read-url` 换签。 - -明确不应识别为 AGC 主站业务请求: - -- OSS multipart 上传。 -- 签名 URL/OSS 媒体下载。 -- LLM/Codex Provider。 -- AGC 受控搜索。 -- loopback 工具桥。 -- 更新清单、更新包下载和任意外部网页请求。 - -## 4. 最终门禁结果 - -| 验收项 | 命令/证据 | 结果 | -|---|---|---| -| TS 统一出口正向测试 | `npm exec vitest run apps/ai-game-creator-shell/tests/clientHttp.test.ts` | 通过,14 tests passed | -| AGC TS 类型和配置检查 | `npm run ai-game-creator-shell:typecheck` | 通过;skill-pack/config 检查通过 | -| Rust 主站 Client factory、请求终结器与 origin-safe redirect policy | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml http_client -- --nocapture` | 通过,8 tests passed;同源跟随、跨 origin 阻断、链式重定向、显式 `Policy::none()` 和请求级同名 Header 覆盖均覆盖 | -| 第三方请求负向矩阵 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml omits_agc_marker -- --nocapture` | 通过,4 tests passed | -| loopback 认证/请求边界 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer -- --nocapture` | 通过,1 test passed | -| 账号态主站请求捕获 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml background_agent_runtime_can_generate_platform_art_asset -- --nocapture` | 通过,1 test passed | -| External Key 态和自定义 origin | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_with_developer_key_uses_external_route_and_marker -- --nocapture` | 通过,1 test passed | -| `read-url` 与签名下载边界 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml sync_canvas_project_assets_downloads_external_resources -- --nocapture` | 通过,1 test passed | -| 结果未知/幂等语义 | `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml generation_submit_response_loss_is_not_retried -- --nocapture` | 通过,1 test passed | -| Rust 格式 | `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` | 通过 | -| 中文编码 | `npm run check:encoding` | 通过,5653 个文件 | -| Diff 空白 | `git diff --check` | 通过 | - -Rust 测试输出包含仓库既有的 unused/dead-code warning;本任务相关测试均无 error 或 failure。 - -## 5. 发布环境限制与后续交接 - -本阶段没有执行真实发布环境线上 smoke。账号态和 External Key 态的路径、Header、认证/幂等语义来自本地 mock/custom `apiBaseUrl` fixture;OSS/签名 URL/Provider/搜索/loopback/更新下载边界来自本地请求捕获;跨 origin 重定向来自双 listener 和链式重定向 fixture。发布前或 `#225` 联调时,应由主站侧补做真实环境 Header 接收、tracking metadata 写入和后台查询验证,不要求客户端回改本次设计。 - -## 6. 可直接粘贴到 #225 的评论 - -> `#226` 客户端侧已完成并冻结交接契约:AGC 主站业务请求统一发送 `X-Genarrative-Client: agc`。账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External API Key 态 `/api/external/v1/*` 均覆盖;OSS/签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。主站可按实际 method/path 读取 Header,并在现有 tracking `metadata_json` 中写入 `client: "agc"`;Header 缺失/空值/未知值按未标记处理且不拒绝请求。登录态按真实用户维度记录,External Key 态按 `owner_user_id`(必要时 `key_id`)记录,不记录 token/API Key 明文或签名 URL。客户端正向、负向、账号态、External Key 态和幂等回归均已通过,`#225` 不需要让 `#226` 回改设计。`