From 4182979a19e4a02d7d2a1b4230311b0a472f8024 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:51:19 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=87=AD=E6=8D=AE=E5=88=86=E7=A6=BB?= =?UTF-8?q?=EF=BC=8C=E7=BB=AD=E6=9C=9F=E4=B8=8D=E5=86=8D=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=E5=9C=A8=E9=80=94=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原生会话快照拆成身份代次与写入 revision,安装与清除按 revision 排序、按身份归属判定 - 冻结平台会话校验改为只比身份,同账号 access token 轮换不再让在途生成、编辑、上传、确认和下载失败 - 同一身份代次禁止更换登录主体或服务 origin,换号、退出和 origin 变化继续失败关闭 - Runner attach 新增 platform_auth_revision,平台会话安装与校验按 revision 排序、按身份归属判定 - GUI owner 替换与清除改为计数器只增不减,同一主体重装保持身份代次,避免旧 epoch 迟到写入复活 - renderer 拆分平台原生身份代次与写入 revision,同账号续期只更新凭据,不推进身份代次 - 刷新失败结果新增权威失效判定,只有服务端明确 401 与 403 才清除本地会话,网络错误和 5xx 保留会话 - 收敛刷新判据,重复的刷新工具函数合并为一个对外判定,去掉一次性包装 - 补齐平台会话身份判据、续期与迟到写入的 Rust 与前端回归用例 --- .../src-tauri/src/agent/direct_runtime/mod.rs | 9 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 11 +- .../src/agent/generation/canvas_generation.rs | 53 +- .../generation/external_generation_state.rs | 3 +- .../src-tauri/src/commands.rs | 45 +- .../src-tauri/src/main.rs | 2 +- .../src-tauri/src/platform_session.rs | 555 ++++++++++++------ .../src/project/external_editor_bindings.rs | 9 +- .../src-tauri/src/project/resource_editor.rs | 66 +-- .../src-tauri/src/runner/client.rs | 54 +- .../src-tauri/src/runner/dispatch.rs | 33 +- .../src-tauri/src/runner/protocol.rs | 4 + .../src-tauri/src/runner/tests.rs | 26 +- .../src/app/AuthenticatedClient.tsx | 4 +- .../src/services/clientAuth.ts | 34 +- .../src/services/platformSession.ts | 264 ++++++--- .../tests/appSurface/auth.suite.ts | 187 +++++- .../tests/clientApi.test.ts | 49 +- 18 files changed, 974 insertions(+), 434 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 43781b495..caa8e4449 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -3053,14 +3053,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( )?; let _platform_session_lease = access .frozen_platform_session() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &format!("{:x}", Sha256::digest(session.access_token.as_bytes())), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; // The network phase deliberately runs without the project write lock. Capture rollback state // only after acquiring the lock and revalidating the source identity, otherwise a failure can diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 1005ed813..615fd3ead 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -49,7 +49,7 @@ struct ExternalMcpHttpState { root: PathBuf, token: String, session_user_id: String, - session_generation: u64, + session_identity_generation: u64, } pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { @@ -1241,7 +1241,8 @@ fn external_mcp_session_id(root: &Path) -> String { material.push('\0'); material.push_str(&session.user_id); material.push('\0'); - material.push_str(&session.generation.to_string()); + // 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。 + material.push_str(&session.identity_generation.to_string()); } format!("mcp-{:x}", Sha256::digest(material.as_bytes())) } @@ -1759,7 +1760,9 @@ async fn handle_external_mcp_http_request( let Some(session) = current_platform_session() else { return Err(StatusCode::UNAUTHORIZED); }; - if session.user_id != state.session_user_id || session.generation != state.session_generation { + if session.user_id != state.session_user_id + || session.identity_generation != state.session_identity_generation + { return Err(StatusCode::UNAUTHORIZED); } let response = EXTERNAL_MCP_BRIDGE_URL @@ -1794,7 +1797,7 @@ pub(crate) async fn start_external_mcp_loopback( root, token: token.clone(), session_user_id: session.user_id, - session_generation: session.generation, + session_identity_generation: session.identity_generation, }; let app = Router::new() .route(&route, post(handle_external_mcp_http_request)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 8275aa26c..f545cc279 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -11,6 +11,10 @@ use super::external_generation_state::{ retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState, }; use super::*; +use crate::platform_session::{ + acquire_platform_session_identity_lease, validate_platform_session_identity, + PlatformSessionIdentity, +}; use reqwest::multipart::{Form, Part}; const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); @@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice { #[derive(Clone)] struct PreparedPlatformSessionFence { - user_id: String, - api_base_url: String, - generation: u64, - access_token_sha256: String, + identity: PlatformSessionIdentity, } impl PreparedPlatformSessionFence { @@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence { access .frozen_platform_session() .map(|session| PreparedPlatformSessionFence { - user_id: session.user_id.clone(), - api_base_url: session.api_base_url.clone(), - generation: session.generation, - access_token_sha256: format!( - "{:x}", - Sha256::digest(session.access_token.as_bytes()) - ), + identity: session.identity(), }) } fn validate(&self) -> Result<(), String> { - let matches = current_platform_session().is_some_and(|session| { - session.user_id == self.user_id - && session.api_base_url == self.api_base_url - && session.generation == self.generation - && format!("{:x}", Sha256::digest(session.access_token.as_bytes())) - == self.access_token_sha256 - }); - if matches { - Ok(()) - } else { - Err( - "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" - .to_string(), - ) - } + // 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。 + validate_platform_session_identity(&self.identity) } fn acquire_lease(&self) -> Result { - acquire_validated_platform_session_fingerprint( - &self.user_id, - &self.api_base_url, - self.generation, - &self.access_token_sha256, - ) + acquire_platform_session_identity_lease(&self.identity) } } @@ -10636,7 +10613,7 @@ mod canvas_generation_tests { } drop(owner_a_access); drop(frozen_owner_a); - install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2) + install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2) .expect("switch to owner B"); let error = match request_platform_art_asset_with_runtime_options_at( @@ -10761,8 +10738,14 @@ mod canvas_generation_tests { .recv_timeout(Duration::from_secs(3)) .expect("wait for accepted response"); std::thread::sleep(Duration::from_millis(50)); - install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2) - .expect("switch platform account after accepted response"); + install_platform_session( + "post-202-user-b", + "post-202-token-b", + &switch_base_url, + 2, + 2, + ) + .expect("switch platform account after accepted response"); }); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 03b0dcac1..0a8974342 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -1431,12 +1431,13 @@ mod external_generation_state_tests { base_url, ); let frozen_a = current_platform_session().expect("freeze owner A"); - validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch"); + validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch"); replace_platform_session_for_gui_owner( "fingerprint-owner-b", "fingerprint-token-b", base_url, 2, + 2, ) .expect("switch global session to owner B"); let current_b = current_platform_session().expect("owner B is current after switch"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fcf26b2cb..a62bfd568 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1939,8 +1939,9 @@ pub(crate) async fn polish_local_project_prompt( } #[tauri::command] -pub(crate) fn read_platform_account_session_generation() -> u64 { - current_platform_session_generation() +pub(crate) fn read_platform_account_session_state( +) -> crate::platform_session::PlatformSessionWriteState { + crate::platform_session::current_platform_session_write_state() } #[tauri::command] @@ -1948,28 +1949,45 @@ pub(crate) async fn install_platform_account_session( user_id: String, access_token: String, api_base_url: String, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { tokio::task::spawn_blocking(move || { - validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + validate_platform_session_input( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; install_external_agent_runner_platform_session( &user_id, &access_token, &api_base_url, - generation, + identity_generation, + revision, )?; - install_platform_session(&user_id, &access_token, &api_base_url, generation) + install_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + ) }) .await .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } #[tauri::command] -pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> { +pub(crate) async fn clear_platform_account_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { tokio::task::spawn_blocking(move || { shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(generation)?; - clear_platform_session(generation); + clear_external_agent_runner_platform_session(identity_generation, revision)?; + clear_platform_session(identity_generation, revision); Ok(()) }) .await @@ -4247,14 +4265,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( access.validate_frozen_session()?; let _platform_session_lease = frozen_session .as_ref() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &format!("{:x}", Sha256::digest(session.access_token.as_bytes())), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; access.validate_frozen_session()?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index dc26656c2..e36c5d902 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2725,7 +2725,7 @@ fn main() { confirm_resume_game_creator_agent_runtime_tasks, schedule_game_creator_agent_ready_tasks, check_game_creator_llm_config, - read_platform_account_session_generation, + read_platform_account_session_state, install_platform_account_session, clear_platform_account_session, read_game_creator_app_config, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 0e24b3b53..28be4dd3c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,5 +1,4 @@ use serde::Deserialize; -use sha2::{Digest, Sha256}; use std::fs::{self, OpenOptions}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -12,12 +11,41 @@ pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_ const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; +/// 平台会话快照 = 身份(登录主体 + 服务 origin)+ 凭据(当前 access token)。 +/// +/// `identity_generation` 只在登录主体、服务 origin 或登出状态变化时推进;同一身份的 +/// access token 轮换(长回合保活、401 续期、同账号重新登录)必须保持它不变。 +/// `revision` 只用于 native 写入顺序判定,防止迟到 install / clear 复活旧状态, +/// 不表达身份归属。 #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PlatformSessionSnapshot { pub(crate) user_id: String, pub(crate) access_token: String, pub(crate) api_base_url: String, - pub(crate) generation: u64, + pub(crate) identity_generation: u64, + pub(crate) revision: u64, +} + +/// 冻结会话的身份判据。 +/// +/// 只包含登录主体、服务 origin 和身份代次,不包含 token 字节:同一身份的凭据轮换 +/// 不得让在途生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin +/// 变化必须让它失配。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlatformSessionIdentity { + pub(crate) user_id: String, + pub(crate) api_base_url: String, + pub(crate) identity_generation: u64, +} + +impl PlatformSessionSnapshot { + pub(crate) fn identity(&self) -> PlatformSessionIdentity { + PlatformSessionIdentity { + user_id: self.user_id.clone(), + api_base_url: self.api_base_url.clone(), + identity_generation: self.identity_generation, + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -231,26 +259,31 @@ pub(crate) fn load_platform_session_fixture_from_env_for_build( let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; let bytes = read_fixture_file(&fixture_path)?; let fixture = parse_platform_session_fixture(&bytes)?; + // fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达 + // “从零安装一次确定的会话”,不表达同一身份的凭据续期。 let snapshot = validated_platform_session_snapshot( &fixture.user_id, &fixture.access_token, &fixture.api_base_url, fixture.generation, + fixture.generation, )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - // A fresh CLI/Runner normally starts at generation zero. Replacing the + // A fresh CLI/Runner normally starts at revision zero. Replacing the // state here also makes a Debug GUI fixture deterministic without relaxing - // the normal account-switch generation rules. - current.generation = snapshot.generation; + // the normal account-switch rules. + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); Ok(()) } #[derive(Default)] struct PlatformSessionState { - generation: u64, + revision: u64, + identity_generation: u64, snapshot: Option, } @@ -265,37 +298,60 @@ fn install_platform_session_in( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) { - if generation < current.generation { + if revision < current.revision { return; } - if generation == current.generation { + if revision == current.revision { if current.snapshot.as_ref().is_some_and(|snapshot| { snapshot.user_id == user_id && snapshot.access_token == access_token && snapshot.api_base_url == api_base_url + && snapshot.identity_generation == identity_generation }) { return; } - // Equal-generation retries may only repeat the exact committed snapshot. In - // particular, a late install cannot revive a generation that was cleared. + // 同一 revision 只允许逐字段重复已提交的会话。尤其地:迟到写入不能复活已清除的 + // 会话,也不能在同一个 revision 上偷偷换掉主体或 token。 return; } - current.generation = generation; + if identity_generation < current.identity_generation { + return; + } + if current.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.identity_generation == identity_generation + && (snapshot.user_id != user_id || snapshot.api_base_url != api_base_url) + }) { + // 同一个身份代次不允许更换登录主体或服务 origin:换号必须先推进身份代次, + // 否则旧账号的在途 operation 可能拿到新账号的凭据。 + return; + } + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = Some(PlatformSessionSnapshot { user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url: api_base_url.to_string(), - generation, + identity_generation, + revision, }); } -fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) { - if generation < current.generation { +fn clear_platform_session_in( + current: &mut PlatformSessionState, + identity_generation: u64, + revision: u64, +) { + if revision <= current.revision { return; } - current.generation = generation; + if identity_generation < current.identity_generation { + return; + } + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = None; } @@ -303,10 +359,16 @@ pub(crate) fn install_platform_session( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -315,7 +377,8 @@ pub(crate) fn install_platform_session( &snapshot.user_id, &snapshot.access_token, &snapshot.api_base_url, - snapshot.generation, + snapshot.identity_generation, + snapshot.revision, ); Ok(()) } @@ -324,7 +387,8 @@ fn validated_platform_session_snapshot( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result { if editor_api_mode() == EditorApiMode::ExternalDeveloper { return Err("独立外部开发发行版不接受陶泥儿网站登录态".to_string()); @@ -342,7 +406,8 @@ fn validated_platform_session_snapshot( user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url, - generation, + identity_generation, + revision, }) } @@ -350,24 +415,63 @@ pub(crate) fn validate_platform_session_input( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ()) + validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + ) + .map(|_| ()) } pub(crate) fn replace_platform_session_for_gui_owner( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - current.generation = snapshot.generation; - current.snapshot = Some(snapshot); + // 新的 GUI authority epoch 可以替换任意旧会话,但两个计数器只能前进:把写入下限 + // 重置到请求值会让旧 epoch 的迟到写入重新变成“更新”。同一主体的重装(续期后的 + // Runner 重挂、同一 epoch 的重新对账)保持身份代次,只更新凭据。 + let same_identity = current.snapshot.as_ref().is_some_and(|current_snapshot| { + current_snapshot.user_id == snapshot.user_id + && current_snapshot.api_base_url == snapshot.api_base_url + }); + let next_identity_generation = if same_identity { + current + .identity_generation + .max(snapshot.identity_generation) + } else { + current + .identity_generation + .saturating_add(1) + .max(snapshot.identity_generation) + }; + let next_revision = current.revision.saturating_add(1).max(snapshot.revision); + current.revision = next_revision; + current.identity_generation = next_identity_generation; + current.snapshot = Some(PlatformSessionSnapshot { + user_id: snapshot.user_id, + access_token: snapshot.access_token, + api_base_url: snapshot.api_base_url, + identity_generation: next_identity_generation, + revision: next_revision, + }); Ok(()) } @@ -375,10 +479,16 @@ pub(crate) fn install_platform_session_checked( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -387,12 +497,13 @@ pub(crate) fn install_platform_session_checked( &snapshot.user_id, &snapshot.access_token, &snapshot.api_base_url, - snapshot.generation, + snapshot.identity_generation, + snapshot.revision, ); if current.snapshot.as_ref() == Some(&snapshot) { Ok(()) } else { - Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string()) + Err("authentication-required: 平台登录态写入已过期或主体冲突".to_string()) } } @@ -422,30 +533,41 @@ fn normalize_platform_api_base_url(value: &str) -> Result { Ok(value.to_string()) } -pub(crate) fn clear_platform_session(generation: u64) { +pub(crate) fn clear_platform_session(identity_generation: u64, revision: u64) { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - clear_platform_session_in(&mut current, generation); + clear_platform_session_in(&mut current, identity_generation, revision); } -pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) { +pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, revision: u64) { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - current.generation = generation; + // 与 replace 同一口径:清除也只能前进,不能让新 epoch 把下限归零。 + current.revision = current.revision.saturating_add(1).max(revision); + current.identity_generation = current + .identity_generation + .saturating_add(1) + .max(identity_generation); current.snapshot = None; } -pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> { +pub(crate) fn clear_platform_session_checked( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - clear_platform_session_in(&mut current, generation); - if current.generation == generation && current.snapshot.is_none() { + clear_platform_session_in(&mut current, identity_generation, revision); + if current.revision >= revision + && current.identity_generation >= identity_generation + && current.snapshot.is_none() + { Ok(()) } else { - Err("authentication-required: 平台登出 generation 已过期".to_string()) + Err("authentication-required: 平台登出写入已过期".to_string()) } } @@ -457,17 +579,42 @@ pub(crate) fn current_platform_session() -> Option { .clone() } -pub(crate) fn current_platform_session_generation() -> u64 { - platform_session() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .generation +/// native 写入顺序 revision。渲染层用它作为只增不减的下限,避免新 WebView 的本地计数 +/// 复位后写出比现存会话更旧的 install / clear。 +/// 原生写入下限,供渲染层reserve新的身份代次与 revision。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlatformSessionWriteState { + pub(crate) identity_generation: u64, + pub(crate) revision: u64, } -pub(crate) fn validate_platform_session_snapshot( +pub(crate) fn current_platform_session_write_state() -> PlatformSessionWriteState { + let current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + PlatformSessionWriteState { + identity_generation: current.identity_generation, + revision: current.revision, + } +} + +/// 冻结会话校验:只比较身份,不比较 token 字节。 +pub(crate) fn validate_frozen_platform_session( expected: &PlatformSessionSnapshot, ) -> Result<(), String> { - if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) { + validate_platform_session_identity(&expected.identity()) +} + +pub(crate) fn validate_platform_session_identity( + expected: &PlatformSessionIdentity, +) -> Result<(), String> { + let matches = current_platform_session() + .as_ref() + .map(PlatformSessionSnapshot::identity) + .as_ref() + == Some(expected); + if matches { Ok(()) } else { Err( @@ -477,19 +624,11 @@ pub(crate) fn validate_platform_session_snapshot( } } -pub(crate) fn with_validated_platform_session_fingerprint( - expected_user_id: &str, - expected_api_base_url: &str, - expected_generation: u64, - expected_access_token_sha256: &str, +pub(crate) fn with_validated_platform_session_identity( + expected: &PlatformSessionIdentity, action: impl FnOnce() -> Result, ) -> Result { - let lease = acquire_validated_platform_session_fingerprint( - expected_user_id, - expected_api_base_url, - expected_generation, - expected_access_token_sha256, - )?; + let lease = acquire_platform_session_identity_lease(expected)?; let result = action(); drop(lease); result @@ -499,22 +638,20 @@ pub(crate) struct ValidatedPlatformSessionLease { _guard: std::sync::MutexGuard<'static, PlatformSessionState>, } -pub(crate) fn acquire_validated_platform_session_fingerprint( - expected_user_id: &str, - expected_api_base_url: &str, - expected_generation: u64, - expected_access_token_sha256: &str, +/// 取得身份租约:持锁期间换号 / 退出无法落地,调用方可以安全地用当前凭据完成一次 +/// 本地提交。凭据续期不改变身份,因此不会被这个租约挡住。 +pub(crate) fn acquire_platform_session_identity_lease( + expected: &PlatformSessionIdentity, ) -> Result { let current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let matches = current.snapshot.as_ref().is_some_and(|snapshot| { - snapshot.user_id == expected_user_id - && snapshot.api_base_url == expected_api_base_url - && snapshot.generation == expected_generation - && format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes())) - == expected_access_token_sha256 - }); + let matches = current + .snapshot + .as_ref() + .map(PlatformSessionSnapshot::identity) + .as_ref() + == Some(expected); if !matches { return Err( "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" @@ -524,13 +661,6 @@ pub(crate) fn acquire_validated_platform_session_fingerprint( Ok(ValidatedPlatformSessionLease { _guard: current }) } -fn platform_session_snapshot_matches( - current: Option<&PlatformSessionSnapshot>, - expected: &PlatformSessionSnapshot, -) -> bool { - current == Some(expected) -} - pub(crate) fn platform_session_is_available() -> bool { current_platform_session().is_some() } @@ -581,12 +711,14 @@ pub(crate) fn install_test_platform_session( .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::mem::take(&mut *current); *current = PlatformSessionState { - generation: 1, + revision: 1, + identity_generation: 1, snapshot: Some(PlatformSessionSnapshot { user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url: api_base_url.to_string(), - generation: 1, + identity_generation: 1, + revision: 1, }), }; drop(current); @@ -621,73 +753,42 @@ pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard { mod tests { use super::*; + const TEST_ORIGIN: &str = "https://dev.genarrative.world"; + #[test] - fn cleared_generation_rejects_late_install_and_older_clear() { + fn cleared_revision_rejects_late_install_and_older_clear() { let mut state = PlatformSessionState::default(); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 1, 1); + clear_platform_session_in(&mut state, 2, 2); + install_platform_session_in(&mut state, "user-a", "late-token-a", TEST_ORIGIN, 1, 1); install_platform_session_in( &mut state, "user-a", - "token-a", - "https://dev.genarrative.world", - 1, - ); - clear_platform_session_in(&mut state, 2); - install_platform_session_in( - &mut state, - "user-a", - "late-token-a", - "https://dev.genarrative.world", - 1, - ); - install_platform_session_in( - &mut state, - "user-a", - "same-generation-token", - "https://dev.genarrative.world", + "same-revision-token", + TEST_ORIGIN, + 2, 2, ); assert!(state.snapshot.is_none()); - assert_eq!(state.generation, 2); + assert_eq!(state.revision, 2); - install_platform_session_in( - &mut state, - "user-b", - "token-b", - "https://dev.genarrative.world", - 3, - ); - clear_platform_session_in(&mut state, 2); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 3, 3); + clear_platform_session_in(&mut state, 2, 2); assert_eq!( state.snapshot.as_ref().map(|value| value.user_id.as_str()), Some("user-b") ); - assert_eq!(state.generation, 3); + assert_eq!(state.revision, 3); + assert_eq!(state.identity_generation, 3); } #[test] - fn equal_generation_only_accepts_the_exact_idempotent_snapshot() { + fn equal_revision_only_accepts_the_exact_idempotent_snapshot() { let mut state = PlatformSessionState::default(); - install_platform_session_in( - &mut state, - "user-a", - "token-a", - "https://dev.genarrative.world", - 4, - ); - install_platform_session_in( - &mut state, - "user-a", - "token-a", - "https://dev.genarrative.world", - 4, - ); - install_platform_session_in( - &mut state, - "user-b", - "token-b", - "https://dev.genarrative.world", - 4, - ); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-a", "token-b", TEST_ORIGIN, 4, 4); assert_eq!( state.snapshot.as_ref().map(|value| value.user_id.as_str()), Some("user-a") @@ -702,18 +803,122 @@ mod tests { } #[test] - fn current_generation_preserves_the_floor_after_session_clear() { + fn same_identity_credential_refresh_keeps_identity_and_frozen_session() { + let _session = install_test_platform_session("refresh-user", "token-a", TEST_ORIGIN); + let frozen = current_platform_session().expect("frozen platform session"); + let identity = frozen.identity(); + + install_platform_session("refresh-user", "token-b", TEST_ORIGIN, 1, 2) + .expect("refresh credential for the same identity"); + + assert_eq!( + current_platform_session().map(|session| session.access_token), + Some("token-b".to_string()) + ); + assert_eq!( + current_platform_session_write_state().identity_generation, + 1 + ); + validate_frozen_platform_session(&frozen) + .expect("same-identity token rotation must keep the frozen session valid"); + validate_platform_session_identity(&identity) + .expect("same-identity token rotation must keep the identity valid"); + } + + #[test] + fn identity_change_invalidates_frozen_session_and_needs_a_new_identity_generation() { + let _session = install_test_platform_session("identity-user-a", "token-a", TEST_ORIGIN); + let frozen = current_platform_session().expect("frozen platform session"); + install_platform_session("identity-user-a", "token-b", TEST_ORIGIN, 1, 2) + .expect("credential refresh for the same identity"); + + // 同身份代次不允许换主体:否则旧账号在途请求会拿到新账号凭据。 + install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 1, 3) + .expect("conflicting subject at the same identity generation is ignored"); + assert_eq!( + current_platform_session().map(|session| session.user_id), + Some("identity-user-a".to_string()) + ); + validate_frozen_platform_session(&frozen) + .expect("ignored conflicting write must not disturb the frozen session"); + + install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 2, 4) + .expect("account switch advances the identity generation"); + assert!(validate_frozen_platform_session(&frozen).is_err()); + assert!(current_platform_session().is_some()); + } + + #[test] + fn gui_owner_replacement_keeps_counters_monotonic_and_same_subject_identity() { + let _session = clear_test_platform_session(); + replace_platform_session_for_gui_owner("gui-owner-a", "token-a", TEST_ORIGIN, 5, 5) + .expect("install gui owner A"); + let installed = current_platform_session().expect("gui owner A session"); + assert_eq!(installed.identity_generation, 5); + assert_eq!(installed.revision, 5); + + // 同一主体只换凭据:身份代次保持,写入 revision 前进。 + replace_platform_session_for_gui_owner("gui-owner-a", "token-a2", TEST_ORIGIN, 5, 6) + .expect("refresh gui owner A credential"); + let refreshed = current_platform_session().expect("gui owner A refreshed session"); + assert_eq!(refreshed.identity_generation, 5); + assert_eq!(refreshed.revision, 6); + + // 迟到的旧 epoch 写入不能把写入下限拉回去。 + replace_platform_session_for_gui_owner("gui-owner-a", "token-a", TEST_ORIGIN, 5, 4) + .expect("stale gui owner write"); + let after_stale = + current_platform_session().expect("gui owner A session after stale write"); + assert_eq!( + after_stale.identity_generation, + refreshed.identity_generation + ); + assert!(after_stale.revision > refreshed.revision); + + // 换主体必须推进身份代次,使旧身份的在途 operation 失效。 + replace_platform_session_for_gui_owner("gui-owner-b", "token-b", TEST_ORIGIN, 5, 5) + .expect("switch gui owner"); + let switched = current_platform_session().expect("gui owner B session"); + assert_eq!(switched.user_id, "gui-owner-b"); + assert!(switched.identity_generation > after_stale.identity_generation); + assert!(validate_frozen_platform_session(&after_stale).is_err()); + + // 清除同样只能前进,不能把下限归零。 + clear_platform_session_for_gui_owner(0, 0); + let cleared = current_platform_session_write_state(); + assert!(cleared.revision > switched.revision); + assert!(cleared.identity_generation > switched.identity_generation); + assert!(current_platform_session().is_none()); + } + + #[test] + fn older_identity_generation_cannot_restore_a_replaced_subject() { + let mut state = PlatformSessionState::default(); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 5); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 6, 6); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 7); + assert_eq!( + state.snapshot.as_ref().map(|value| value.user_id.as_str()), + Some("user-b") + ); + } + + #[test] + fn current_revision_preserves_the_floor_after_session_clear() { let _session = clear_test_platform_session(); install_platform_session( - "generation-floor-user", - "generation-floor-token", - "https://dev.genarrative.world", + "revision-floor-user", + "revision-floor-token", + TEST_ORIGIN, + 41, 41, ) - .expect("install session generation floor"); - clear_platform_session(42); + .expect("install session revision floor"); + clear_platform_session(42, 42); - assert_eq!(current_platform_session_generation(), 42); + let state = current_platform_session_write_state(); + assert_eq!(state.revision, 42); + assert_eq!(state.identity_generation, 42); assert!(current_platform_session().is_none()); } @@ -752,64 +957,40 @@ mod tests { } #[test] - fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() { - let expected = PlatformSessionSnapshot { - user_id: "user-a".to_string(), - access_token: "token-a".to_string(), - api_base_url: "https://dev.genarrative.world".to_string(), - generation: 4, - }; - assert!(platform_session_snapshot_matches( - Some(&expected), - &expected - )); + fn frozen_platform_session_rejects_logout_and_account_switch_but_allows_token_rotation() { + let _session = install_test_platform_session("frozen-user-a", "token-a", TEST_ORIGIN); + let identity = current_platform_session() + .expect("frozen platform session") + .identity(); + validate_platform_session_identity(&identity).expect("matching identity is valid"); - for current in [ - None, - Some(PlatformSessionSnapshot { - user_id: "user-b".to_string(), - ..expected.clone() - }), - Some(PlatformSessionSnapshot { - access_token: "token-b".to_string(), - generation: 5, - ..expected.clone() - }), - ] { - assert!(!platform_session_snapshot_matches( - current.as_ref(), - &expected - )); - } + install_platform_session("frozen-user-a", "token-b", TEST_ORIGIN, 1, 2) + .expect("same-identity credential rotation"); + validate_platform_session_identity(&identity) + .expect("token rotation must not invalidate the frozen identity"); + + install_platform_session("frozen-user-b", "token-c", TEST_ORIGIN, 2, 3) + .expect("account switch"); + assert!(validate_platform_session_identity(&identity).is_err()); + + clear_platform_session(3, 4); + assert!(validate_platform_session_identity(&identity).is_err()); } #[test] fn validated_session_lease_linearizes_local_commit_with_account_switch() { - let _session = install_test_platform_session( - "lease-user-a", - "lease-token-a", - "https://dev.genarrative.world", - ); - let expected = current_platform_session().expect("current lease session"); - let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes())); - let lease = acquire_validated_platform_session_fingerprint( - &expected.user_id, - &expected.api_base_url, - expected.generation, - &token_sha256, - ) - .expect("acquire validated session lease"); + let _session = install_test_platform_session("lease-user-a", "lease-token-a", TEST_ORIGIN); + let expected = current_platform_session() + .expect("current lease session") + .identity(); + let lease = acquire_platform_session_identity_lease(&expected) + .expect("acquire validated session lease"); let (started_sender, started_receiver) = std::sync::mpsc::channel(); let (finished_sender, finished_receiver) = std::sync::mpsc::channel(); let switcher = std::thread::spawn(move || { started_sender.send(()).expect("signal account switch"); - install_platform_session( - "lease-user-b", - "lease-token-b", - "https://dev.genarrative.world", - 2, - ) - .expect("switch account after lease release"); + install_platform_session("lease-user-b", "lease-token-b", TEST_ORIGIN, 2, 2) + .expect("switch account after lease release"); finished_sender.send(()).expect("signal switched account"); }); started_receiver diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 430fd6e9a..36025909e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -110,11 +110,13 @@ impl<'a> ExternalEditorBindingAccess<'a> { } /// Call before and after every awaited remote action and immediately before installing a - /// binding. Developer-key mode has no process-global account generation to compare. + /// binding. 只比较身份:同一账号的 access token 轮换(长回合保活、401 续期)不得让 + /// 在途的生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin 变化仍然 + /// 失败关闭。Developer-key 模式没有进程级身份代次可比对。 pub(crate) fn validate_frozen_session(&self) -> Result<(), String> { validate_external_editor_binding_access_shape(self)?; if let Some(session) = self.frozen_platform_session { - validate_platform_session_snapshot(session)?; + validate_frozen_platform_session(session)?; } Ok(()) } @@ -1152,7 +1154,8 @@ mod tests { user_id: user_id.to_string(), access_token: token.to_string(), api_base_url: "https://dev.genarrative.world".to_string(), - generation, + identity_generation: generation, + revision: generation, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index b1f292976..77183a884 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -4389,14 +4389,7 @@ fn with_frozen_resource_edit_platform_session( let Some(platform_session) = platform_session else { return action(); }; - let access_token_sha256 = sha256_hex(platform_session.access_token.as_bytes()); - with_validated_platform_session_fingerprint( - &platform_session.user_id, - &platform_session.api_base_url, - platform_session.generation, - &access_token_sha256, - action, - ) + with_validated_platform_session_identity(&platform_session.identity(), action) } fn commit_resource_edit_asset_with_frozen_platform_session( @@ -4774,14 +4767,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at( let current_platform_session = current_platform_session(); let _platform_session_lease = current_platform_session .as_ref() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &sha256_hex(session.access_token.as_bytes()), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; let entries = match fs::read_dir(&directory) { @@ -5084,12 +5070,8 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at( Ok(()) }; if let Some(session) = platform_session { - let access_token_sha256 = sha256_hex(session.access_token.as_bytes()); - crate::platform_session::with_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &access_token_sha256, + crate::platform_session::with_validated_platform_session_identity( + &session.identity(), archive, )?; } else { @@ -5500,7 +5482,8 @@ mod tests { user_id: "gui-owner".to_string(), access_token: "gui-token".to_string(), api_base_url: "https://dev.genarrative.world".to_string(), - generation: 7, + identity_generation: 7, + revision: 7, }; let developer_credentials = ( "https://dev.genarrative.world".to_string(), @@ -5911,6 +5894,7 @@ mod tests { "source-binding-token-b", api_base_url, *generation, + *generation, ) .expect("switch account after source registration"); } @@ -6925,7 +6909,7 @@ mod tests { listener, upload_url, false, - Some((base_url.clone(), frozen_session.generation + 1)), + Some((base_url.clone(), frozen_session.identity_generation + 1)), done_receiver, ); let client = reqwest::Client::new(); @@ -6952,7 +6936,8 @@ mod tests { "source-binding-owner-a", "source-binding-token-a", &base_url, - frozen_session.generation + 2, + frozen_session.identity_generation + 2, + frozen_session.identity_generation + 2, ) .expect("switch back to source binding owner A"); let resumed_session = current_platform_session().expect("resumed source binding owner A"); @@ -7018,7 +7003,7 @@ mod tests { install_test_platform_session("submission-owner-a", "submission-token-a", &base_url); let frozen_session = current_platform_session().expect("frozen owner A session"); let switch_base_url = base_url.clone(); - let switch_generation = frozen_session.generation + 1; + let switch_generation = frozen_session.identity_generation + 1; let server = std::thread::spawn(move || { let mut stream = accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0); @@ -7032,6 +7017,7 @@ mod tests { "submission-token-b", &switch_base_url, switch_generation, + switch_generation, ) .expect("switch to owner B before returning accepted response"); write_json( @@ -7447,8 +7433,14 @@ mod tests { ledger.access_scheme = None; initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a)) .expect("write resource ledger for owner A"); - replace_platform_session_for_gui_owner("resource-owner-b", "resource-token-b", base_url, 2) - .expect("switch global resource session to owner B"); + replace_platform_session_for_gui_owner( + "resource-owner-b", + "resource-token-b", + base_url, + 2, + 2, + ) + .expect("switch global resource session to owner B"); let error = prepare_resource_edit_service_identity( root, @@ -7509,7 +7501,8 @@ mod tests { user_id: "resource-identity-owner-b".to_string(), access_token: "resource-identity-token-b".to_string(), api_base_url: owner_a.api_base_url.clone(), - generation: owner_a.generation + 1, + identity_generation: owner_a.identity_generation + 1, + revision: owner_a.revision + 1, }; let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string()); @@ -7535,7 +7528,8 @@ mod tests { &owner_b.user_id, &owner_b.access_token, &owner_b.api_base_url, - owner_b.generation, + owner_b.identity_generation, + owner_b.revision, ) .expect("switch to resource non-owner B"); @@ -7639,7 +7633,8 @@ mod tests { "resource-lease-owner-b", "resource-lease-token-b", api_base_url, - frozen_a.generation + 1, + frozen_a.identity_generation + 1, + frozen_a.revision + 1, ) .expect("switch resource lease owner"); switched_sender.send(()).expect("signal resource switch"); @@ -8259,7 +8254,8 @@ mod tests { "archive-owner-b", "archive-token-b", api_base_url, - owner_a.generation + 1, + owner_a.identity_generation + 1, + owner_a.revision + 1, ) .expect("switch to owner B"); let error = archive_failed_local_project_resource_edit_at( @@ -8388,7 +8384,8 @@ mod tests { "pending-owner-b", "pending-token-b", api_base_url, - owner_a.generation + 1, + owner_a.identity_generation + 1, + owner_a.revision + 1, ) .expect("switch to pending owner B"); let owner_b = current_platform_session().expect("pending owner B session"); @@ -9347,7 +9344,7 @@ mod tests { let (attempted_sender, attempted_receiver) = mpsc::channel(); let (completed_sender, completed_receiver) = mpsc::channel(); let switch_api_base_url = api_base_url.to_string(); - let switch_generation = frozen_session.generation + 1; + let switch_generation = frozen_session.identity_generation + 1; let switch_thread = std::thread::spawn(move || { begin_switch_receiver .recv() @@ -9360,6 +9357,7 @@ mod tests { "commit-token-b", &switch_api_base_url, switch_generation, + switch_generation, ) .expect("switch to commit owner B"); completed_sender diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 6c6f9fdbf..6be20226a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -987,7 +987,10 @@ pub(crate) fn attach_external_agent_runner_gui_owner( platform_api_base_url: platform_session .as_ref() .map(|session| session.api_base_url.clone()), - platform_auth_generation: platform_session.map(|session| session.generation), + platform_auth_generation: platform_session + .as_ref() + .map(|session| session.identity_generation), + platform_auth_revision: platform_session.map(|session| session.revision), ..ExternalAgentRunnerRequestParams::default() }, )?; @@ -998,7 +1001,8 @@ pub(crate) fn install_external_agent_runner_platform_session( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?; @@ -1008,7 +1012,8 @@ pub(crate) fn install_external_agent_runner_platform_session( remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), Some((user_id, access_token, api_base_url)), - generation, + identity_generation, + revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { @@ -1017,7 +1022,8 @@ pub(crate) fn install_external_agent_runner_platform_session( &config_dir, &endpoint, Some((user_id, access_token, api_base_url)), - generation, + identity_generation, + revision, ) }) }, @@ -1025,7 +1031,10 @@ pub(crate) fn install_external_agent_runner_platform_session( ) } -pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> { +pub(crate) fn clear_external_agent_runner_platform_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { let Some(config_dir) = external_agent_runner_config_dir() else { return Ok(()); }; @@ -1035,7 +1044,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), None, - generation, + identity_generation, + revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { @@ -1044,7 +1054,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R &config_dir, &endpoint, None, - generation, + identity_generation, + revision, ) }) }, @@ -1057,7 +1068,8 @@ fn validate_external_agent_runner_platform_session_attachment( config_dir: &Path, endpoint: &ExternalAgentRunnerEndpoint, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { let state = lock_unpoisoned(state); let registration = state.registration.as_ref().ok_or_else(|| { @@ -1070,7 +1082,8 @@ fn validate_external_agent_runner_platform_session_attachment( || registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()) || registration.params.gui_owner_epoch.is_none() || registration.params.gui_owner_session_revision != Some(registration.generation) - || registration.params.platform_auth_generation != Some(generation) + || registration.params.platform_auth_generation != Some(identity_generation) + || registration.params.platform_auth_revision != Some(revision) || registration.params.platform_user_id.as_deref() != expected_user_id || registration.params.platform_access_token.as_deref() != expected_access_token || registration.params.platform_api_base_url.as_deref() != expected_api_base_url @@ -1101,12 +1114,14 @@ pub(super) fn synchronize_external_agent_runner_platform_session_with( pub(super) fn remember_external_agent_runner_platform_session( state: &Mutex, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { remember_external_agent_runner_platform_session_with( state, session, - generation, + identity_generation, + revision, write_external_agent_runner_gui_owner_claim_atomic, ) } @@ -1114,21 +1129,26 @@ pub(super) fn remember_external_agent_runner_platform_session( pub(super) fn remember_external_agent_runner_platform_session_with( state: &Mutex, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); let Some(registration) = state.registration.as_ref() else { return Ok(()); }; - let current_generation = registration.params.platform_auth_generation.unwrap_or(0); - if generation < current_generation { + // 写入顺序只认 revision;身份代次只表达主体归属,同一账号续期会推进 revision + // 但保持 identity generation 不变。 + let current_revision = registration.params.platform_auth_revision.unwrap_or(0); + if revision < current_revision { return Ok(()); } - if generation == current_generation { + if revision == current_revision { match session { Some((user_id, access_token, api_base_url)) if registration.params.platform_user_id.as_deref() == Some(user_id) + && registration.params.platform_auth_generation + == Some(identity_generation) && registration.params.platform_access_token.as_deref() == Some(access_token) && registration.params.platform_api_base_url.as_deref() @@ -1168,7 +1188,8 @@ pub(super) fn remember_external_agent_runner_platform_session_with( session.map(|(_, access_token, _)| access_token.to_string()); registration.params.platform_api_base_url = session.map(|(_, _, api_base_url)| api_base_url.to_string()); - registration.params.platform_auth_generation = Some(generation); + registration.params.platform_auth_generation = Some(identity_generation); + registration.params.platform_auth_revision = Some(revision); registration.params.gui_owner_session_revision = Some(registration_generation); Ok(()) } @@ -1472,6 +1493,7 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( platform_access_token: None, platform_api_base_url: None, platform_auth_generation: None, + platform_auth_revision: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index b254c88f9..ad79e98e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -133,37 +133,46 @@ fn apply_external_agent_runner_gui_owner_attachment( params.platform_access_token.as_deref(), params.platform_api_base_url.as_deref(), params.platform_auth_generation, + params.platform_auth_revision, ) { - (Some(user_id), Some(access_token), Some(api_base_url), Some(generation)) => { + ( + Some(user_id), + Some(access_token), + Some(api_base_url), + Some(identity_generation), + Some(revision), + ) => { if replace_claim { crate::replace_platform_session_for_gui_owner( user_id, access_token, api_base_url, - generation, + identity_generation, + revision, ) } else { crate::install_platform_session_checked( user_id, access_token, api_base_url, - generation, + identity_generation, + revision, ) } } - (None, None, None, Some(generation)) => { + (None, None, None, Some(identity_generation), Some(revision)) => { if replace_claim { - crate::clear_platform_session_for_gui_owner(generation); + crate::clear_platform_session_for_gui_owner(identity_generation, revision); Ok(()) } else { - crate::clear_platform_session_checked(generation) + crate::clear_platform_session_checked(identity_generation, revision) } } - (None, None, None, None) if replace_claim => { - crate::clear_platform_session_for_gui_owner(0); + (None, None, None, None, None) if replace_claim => { + crate::clear_platform_session_for_gui_owner(0, 0); Ok(()) } - (None, None, None, None) => Ok(()), + (None, None, None, None, None) => Ok(()), _ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()), }; result?; @@ -171,7 +180,7 @@ fn apply_external_agent_runner_gui_owner_attachment( Ok(claim) => claim, Err(error) => { *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); return Err(format!( "Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}" )); @@ -181,7 +190,7 @@ fn apply_external_agent_runner_gui_owner_attachment( || committed_claim.session_revision != requested_revision { *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string()); } if let Some(event_sink) = event_sink { @@ -207,7 +216,7 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( return Ok(()); } *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); match durable_claim { Ok(_) => Err( "authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离" diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 4f0e3d555..7b9dc2838 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -280,6 +280,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) platform_api_base_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) platform_auth_generation: Option, + /// 原生写入 revision:只用于 install / clear 的顺序判定。同一身份的凭据轮换会推进 + /// revision,但不推进 `platform_auth_generation`(身份代次)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_auth_revision: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 20a6dfc0d..d64138712 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -668,14 +668,16 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { &state, Some(("user-a", "token-a", "https://dev.genarrative.world")), 4, + 4, ) .expect("remember owner A session"); - remember_external_agent_runner_platform_session(&state, None, 5) + remember_external_agent_runner_platform_session(&state, None, 5, 5) .expect("remember logged-out session"); remember_external_agent_runner_platform_session( &state, Some(("user-a", "late-token-a", "https://dev.genarrative.world")), 4, + 4, ) .expect("ignore stale owner A session"); remember_external_agent_runner_platform_session( @@ -686,12 +688,14 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { "https://dev.genarrative.world", )), 5, + 5, ) .expect("ignore conflicting same-generation session"); remember_external_agent_runner_platform_session( &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 6, + 6, ) .expect("remember latest owner B session"); @@ -732,6 +736,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { platform_access_token: Some("token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -752,7 +757,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { ) .expect("attach owner A"); - remember_external_agent_runner_platform_session(&state, None, 2) + remember_external_agent_runner_platform_session(&state, None, 2, 2) .expect("remember logged-out session"); attach_registered_external_agent_runner_gui_owner_if_needed_with( &state, @@ -781,6 +786,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { platform_access_token: Some("token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -800,6 +806,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 2, + 2, ) .expect("remember owner B while owner A attach is in flight"); Ok(()) @@ -844,6 +851,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() { gui_owner_epoch: Some(owner.owner_epoch().to_string()), gui_owner_session_revision: Some(0), platform_auth_generation: Some(2), + platform_auth_revision: Some(2), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -875,6 +883,7 @@ fn gui_owner_partial_platform_session_payload_fails_without_mutation() { platform_user_id: Some("runner-owner-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(2), + platform_auth_revision: Some(2), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -908,6 +917,7 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(10), + platform_auth_revision: Some(10), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -925,12 +935,14 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("new GUI epoch replaces higher-generation old owner"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-b".to_string(), 1)) ); @@ -943,6 +955,7 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(11), + platform_auth_revision: Some(11), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -979,6 +992,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(8), + platform_auth_revision: Some(8), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1002,6 +1016,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ platform_access_token: Some("runner-token-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1009,7 +1024,8 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ validate_external_agent_runner_gui_owner_claim_current(&state) .expect("reattached owner B claim is current"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-b".to_string(), 1)) ); } @@ -1053,6 +1069,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1069,6 +1086,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { "https://dev.genarrative.world", )), 2, + 2, |_, _, _| Err("injected durable claim write failure".to_string()), ) }, diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 2c9fe3834..fdef7af6f 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -404,7 +404,9 @@ export function AuthenticatedClient({ ); return; } - if (result.status === 'failed') { + // 仅服务端明确否认当前身份时才登出。网络错误、5xx 和网关错误属于刷新暂时 + // 不可用,必须保留既有会话与 access token。 + if (result.status === 'failed' && result.authoritative) { clearStoredAuthAccessToken(); setAuthUser(null); setAuthStatus('unauthenticated'); diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 65d0650da..1869351e7 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -125,7 +125,13 @@ class ClientAuthRequestError extends Error { } } -function isClientAuthUnauthorizedError(error: unknown) { +/** + * 服务端明确否认当前身份(401/403)才算权威失效。 + * + * 网络错误、5xx、网关错误和响应契约异常都属于"刷新暂时不可用":调用方必须保留既有 + * 会话与 access token,不能把一次瞬时失败放大成登出。 + */ +export function isClientAuthAuthorityFailure(error: unknown) { return ( error instanceof ClientAuthRequestError && (error.status === 401 || error.status === 403) @@ -133,7 +139,7 @@ function isClientAuthUnauthorizedError(error: unknown) { } export function isClientAuthRecoverableCheckError(error: unknown) { - return !isClientAuthUnauthorizedError(error); + return !isClientAuthAuthorityFailure(error); } export function getClientAuthErrorMessage(error: unknown, fallback: string) { @@ -255,12 +261,24 @@ export async function refreshClientAuthAccessToken( apiBaseUrl, transitionClientOperation(operation, 'network'), ); - const refreshPromise = requestAuthJson( - '/api/auth/refresh', - { method: 'POST' }, - '刷新登录状态失败', - { skipAuth: true, apiBaseUrl }, - ) + const performRefresh = () => + requestAuthJson( + '/api/auth/refresh', + { method: 'POST' }, + '刷新登录状态失败', + { skipAuth: true, apiBaseUrl }, + ); + const refreshWithConvergenceRetry = async () => { + try { + return await performRefresh(); + } catch (error) { + if (!isClientAuthAuthorityFailure(error)) throw error; + // 并发轮换收敛:另一个窗口 / 实例可能刚刚轮换过 refresh cookie,用当前 cookie + // 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算登录态权威失效。 + return await performRefresh(); + } + }; + const refreshPromise = refreshWithConvergenceRetry() .then((response) => { clientAuthRefreshOperations.set( apiBaseUrl, diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index 8f40a9ef6..a28535b03 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -3,6 +3,7 @@ import { resolveTauriInvoke } from '../app/tauri'; import { getCurrentClientAuthUser, getStoredAuthAccessToken, + isClientAuthAuthorityFailure, refreshClientAuthAccessToken, } from './clientAuth'; import { getClientServerBaseUrl } from './clientHttp'; @@ -14,6 +15,14 @@ import { const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; +function readStoredAccessTokenOrThrow() { + const accessToken = getStoredAuthAccessToken(); + if (!accessToken) { + throw new Error('陶泥儿登录凭据缺失,请重新登录'); + } + return accessToken; +} + type CommittedPlatformSession = { user: AuthUser; accessToken: string; @@ -21,10 +30,25 @@ type CommittedPlatformSession = { generation: number; }; +/** 原生写入:身份代次表达主体归属,revision 只表达写入顺序。 */ +type PlatformNativeSessionWrite = { + identityGeneration: number; + revision: number; +}; + export type PlatformSessionRefreshResult = | { status: 'refreshed'; user: AuthUser; generation: number } | { status: 'stale' } - | { status: 'failed'; error: unknown }; + | { + status: 'failed'; + error: unknown; + /** + * 只有服务端明确否认当前身份(401/403,且收敛重试后仍失败)才为 true。 + * 网络错误、5xx、网关错误和响应契约异常必须保留既有会话与 access token, + * 调用方不得据此把用户登出。 + */ + authoritative: boolean; + }; type PlatformSessionRefreshListener = ( result: PlatformSessionRefreshResult, @@ -33,8 +57,14 @@ type PlatformSessionRefreshListener = ( type PlatformSessionGenerationListener = (generation: number) => void; let platformAuthGeneration = 0; -let platformNativeGeneration = 0; -let platformNativeGenerationFloorPromise: Promise | null = null; +/** 原生写入 revision:每次安装 / 清除都推进,用于拒绝迟到写入。 */ +let platformNativeRevision = 0; +/** 原生身份代次:只在登录、切号、登出或新 authority epoch 推进,续期保持不变。 */ +let platformNativeIdentityGeneration = 0; +let platformNativeSessionFloorPromise: Promise<{ + identityGeneration: number; + revision: number; +}> | null = null; let committedPlatformSession: CommittedPlatformSession | null = null; let desiredPlatformSession: CommittedPlatformSession | null = null; let platformSessionRefreshPromise: Promise | null = @@ -95,7 +125,7 @@ function notifyPlatformSessionGeneration() { async function installNativePlatformSession( session: CommittedPlatformSession, - generation: number, + write: PlatformNativeSessionWrite, ) { const invoke = resolveTauriInvoke(); if (!invoke) return; @@ -103,14 +133,18 @@ async function installNativePlatformSession( userId: session.user.id, accessToken: session.accessToken, apiBaseUrl: session.apiBaseUrl, - generation, + identityGeneration: write.identityGeneration, + revision: write.revision, }); } -async function clearNativePlatformSession(generation: number) { +async function clearNativePlatformSession(write: PlatformNativeSessionWrite) { const invoke = resolveTauriInvoke(); if (!invoke) return; - await invoke('clear_platform_account_session', { generation }); + await invoke('clear_platform_account_session', { + identityGeneration: write.identityGeneration, + revision: write.revision, + }); } function waitForNativeMutationAbandonment( @@ -147,37 +181,60 @@ function enqueuePlatformSessionNativeMutation( async function readNativePlatformSessionGenerationFloor() { const invoke = resolveTauriInvoke(); - if (!invoke) return 0; - const floor = await invoke( - 'read_platform_account_session_generation', - ); + if (!invoke) return { identityGeneration: 0, revision: 0 }; + const state = await invoke<{ + identityGeneration?: unknown; + revision?: unknown; + } | null>('read_platform_account_session_state'); // Browser/unit-test adapters commonly expose a no-op invoke that returns null // for native-only read commands. They have no surviving Rust generation floor. - if (floor === null) return 0; - if (!Number.isSafeInteger(floor) || floor < 0) { - throw new Error('本地运行时登录态 generation 无效,请重启客户端后重试'); + if (state === null || state === undefined) { + return { identityGeneration: 0, revision: 0 }; } - return floor; + const identityGeneration = Number(state.identityGeneration ?? 0); + const revision = Number(state.revision ?? 0); + if ( + !Number.isSafeInteger(identityGeneration) || + identityGeneration < 0 || + !Number.isSafeInteger(revision) || + revision < 0 + ) { + throw new Error('本地运行时登录态写入下限无效,请重启客户端后重试'); + } + return { identityGeneration, revision }; } -async function reserveNativePlatformSessionGeneration() { - platformNativeGenerationFloorPromise ??= +async function reserveNativePlatformSessionWrite(options: { + identityChange: boolean; +}): Promise { + platformNativeSessionFloorPromise ??= readNativePlatformSessionGenerationFloor(); - let nativeGenerationFloor: number; + let floor: { identityGeneration: number; revision: number }; try { - nativeGenerationFloor = await platformNativeGenerationFloorPromise; + floor = await platformNativeSessionFloorPromise; } catch (error) { // 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程 // 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。 - platformNativeGenerationFloorPromise = null; + platformNativeSessionFloorPromise = null; throw error; } - platformNativeGeneration = Math.max( - platformNativeGeneration + 1, + platformNativeRevision = Math.max( + platformNativeRevision + 1, platformAuthGeneration, - nativeGenerationFloor + 1, + floor.revision + 1, ); - return platformNativeGeneration; + // 同一账号的凭据续期必须复用当前身份代次;只有登录、切号、登出或新 authority epoch + // 才允许推进它,否则在途生成 operation 会被自己的续期判成"旧账号请求"。 + platformNativeIdentityGeneration = options.identityChange + ? Math.max( + platformNativeIdentityGeneration + 1, + floor.identityGeneration + 1, + ) + : Math.max(platformNativeIdentityGeneration, floor.identityGeneration); + return { + identityGeneration: platformNativeIdentityGeneration, + revision: platformNativeRevision, + }; } async function reconcileNativePlatformSessionToCurrentAuthority() { @@ -186,17 +243,20 @@ async function reconcileNativePlatformSessionToCurrentAuthority() { const authoritativeSession = desiredPlatformSession ? { ...desiredPlatformSession } : null; - const reconciliationGeneration = - await reserveNativePlatformSessionGeneration(); + // 只有权威会话与上一次已提交会话不是同一身份时才推进身份代次:同账号续期后的对账 + // 仍然算同一身份,不得让在途 operation 失效。 + const identityChange = + !committedPlatformSession || + !authoritativeSession || + committedPlatformSession.user.id !== authoritativeSession.user.id || + committedPlatformSession.apiBaseUrl !== authoritativeSession.apiBaseUrl; + const write = await reserveNativePlatformSessionWrite({ identityChange }); restoreCurrentRendererAccessToken(); try { if (authoritativeSession) { - await installNativePlatformSession( - authoritativeSession, - reconciliationGeneration, - ); + await installNativePlatformSession(authoritativeSession, write); } else { - await clearNativePlatformSession(reconciliationGeneration); + await clearNativePlatformSession(write); } } catch (error) { if (platformAuthGeneration === authoritativeGeneration) { @@ -229,6 +289,39 @@ function resolvePlatformApiBaseUrl() { return getClientServerBaseUrl(); } +async function commitNativePlatformSession( + candidate: CommittedPlatformSession, + authorityGeneration: number, + options: { identityChange: boolean }, +): Promise { + const write = await reserveNativePlatformSessionWrite({ + identityChange: options.identityChange, + }); + try { + await installNativePlatformSession(candidate, write); + } catch (error) { + if (platformAuthGeneration === authorityGeneration) { + desiredPlatformSession = committedPlatformSession + ? { ...committedPlatformSession } + : null; + } + await reconcileNativePlatformSessionToCurrentAuthority(); + if (platformAuthGeneration !== authorityGeneration) return null; + throw error; + } + if (platformAuthGeneration !== authorityGeneration) { + await reconcileNativePlatformSessionToCurrentAuthority(); + return null; + } + committedPlatformSession = candidate; + desiredPlatformSession = { ...candidate }; + restoreCommittedAccessToken(); + if (options.identityChange) { + notifyPlatformSessionGeneration(); + } + return candidate; +} + async function commitPlatformSession( user: AuthUser, accessToken: string, @@ -251,28 +344,50 @@ async function commitPlatformSession( desiredPlatformSession = { ...candidate }; notifyPlatformSessionGeneration(); restoreCommittedAccessToken(); - const nativeGeneration = await reserveNativePlatformSessionGeneration(); - try { - await installNativePlatformSession(candidate, nativeGeneration); - } catch (error) { - if (platformAuthGeneration === candidate.generation) { - desiredPlatformSession = committedPlatformSession - ? { ...committedPlatformSession } - : null; - } - await reconcileNativePlatformSessionToCurrentAuthority(); - if (platformAuthGeneration !== candidate.generation) return null; - throw error; - } - if (platformAuthGeneration !== candidate.generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); + return commitNativePlatformSession(candidate, candidate.generation, { + identityChange: true, + }); +} + +/** + * 同一身份的凭据续期:只替换 access token 与 native 写入 revision,保持身份代次不变, + * 因此在途生成、编辑、上传、确认和下载 operation 不会被自己的续期判成旧账号请求。 + */ +async function commitPlatformCredentialRefresh( + user: AuthUser, + accessToken: string, + apiBaseUrl: string, + expectedGeneration: number, +): Promise { + if (platformAuthGeneration !== expectedGeneration) { + restoreCurrentRendererAccessToken(); return null; } - committedPlatformSession = candidate; + const current = committedPlatformSession; + if (!current) { + restoreCurrentRendererAccessToken(); + return null; + } + if (current.user.id !== user.id || current.apiBaseUrl !== apiBaseUrl) { + // 身份已经变化:按换号路径重新提交,不能复用旧身份代次。 + return commitPlatformSession( + user, + accessToken, + apiBaseUrl, + expectedGeneration, + ); + } + const candidate: CommittedPlatformSession = { + user, + accessToken, + apiBaseUrl, + generation: current.generation, + }; desiredPlatformSession = { ...candidate }; - restoreCommittedAccessToken(); - notifyPlatformSessionGeneration(); - return candidate; + restoreCurrentRendererAccessToken(); + return commitNativePlatformSession(candidate, expectedGeneration, { + identityChange: false, + }); } export function currentPlatformSessionGeneration() { @@ -283,6 +398,11 @@ export function currentPlatformSessionApiBaseUrl() { return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); } +/** 仅供测试断言:同一账号续期不得推进这个身份代次。 */ +export function currentPlatformNativeIdentityGenerationForTests() { + return platformNativeIdentityGeneration; +} + export function beginPlatformSessionTransition() { platformAuthGeneration += 1; desiredPlatformSession = committedPlatformSession @@ -304,10 +424,7 @@ export async function commitAuthenticatedPlatformSession( expectedGeneration: number, apiBaseUrl = resolvePlatformApiBaseUrl(), ) { - const accessToken = getStoredAuthAccessToken(); - if (!accessToken) { - throw new Error('陶泥儿登录凭据缺失,请重新登录'); - } + const accessToken = readStoredAccessTokenOrThrow(); const operation = createClientOperation( 'auth-transition', { userId: user.id }, @@ -386,16 +503,21 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } - const committedGeneration = await commitAuthenticatedPlatformSession( - user, - expectedGeneration, - apiBaseUrl, + // 同一账号的续期只更新凭据:身份代次保持不变,因此在途生成 operation 不会被 + // 自己的续期判成旧账号请求。 + const committed = await enqueuePlatformSessionNativeMutation(() => + commitPlatformCredentialRefresh( + user, + readStoredAccessTokenOrThrow(), + apiBaseUrl, + expectedGeneration, + ), ); - if (committedGeneration === null) return { status: 'stale' }; + if (committed === null) return { status: 'stale' }; return { status: 'refreshed', user, - generation: committedGeneration, + generation: committed.generation, }; } catch (error) { if (platformAuthGeneration !== expectedGeneration) { @@ -411,9 +533,12 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } + // 只有服务端明确否认当前身份才算权威失效。网络错误、5xx、网关错误和响应契约 + // 异常必须保留既有会话与 access token,否则一次后台保活抖动就会把用户登出。 + const authoritative = isClientAuthAuthorityFailure(error); if ( - !currentOwnerUserId || - currentOwnerUserId === expectedSessionUserId + authoritative && + (!currentOwnerUserId || currentOwnerUserId === expectedSessionUserId) ) { const clearGeneration = beginPlatformSessionClearTransition(); try { @@ -421,8 +546,10 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { } catch (clearError) { failure = clearError; } + return { status: 'failed', error: failure, authoritative: true }; } - return { status: 'failed', error: failure }; + restoreCurrentRendererAccessToken(); + return { status: 'failed', error: failure, authoritative: false }; } })().then((result) => { notifyPlatformSessionRefresh(result); @@ -474,9 +601,11 @@ export async function clearCommittedPlatformSession(generation: number) { return; } desiredPlatformSession = null; - const nativeGeneration = await reserveNativePlatformSessionGeneration(); + const write = await reserveNativePlatformSessionWrite({ + identityChange: true, + }); try { - await clearNativePlatformSession(nativeGeneration); + await clearNativePlatformSession(write); } catch { if (platformAuthGeneration === generation) { desiredPlatformSession = null; @@ -516,8 +645,9 @@ export async function clearCommittedPlatformSession(generation: number) { export function resetPlatformSessionStateForTests() { platformAuthGeneration = 0; - platformNativeGeneration = 0; - platformNativeGenerationFloorPromise = null; + platformNativeRevision = 0; + platformNativeIdentityGeneration = 0; + platformNativeSessionFloorPromise = null; committedPlatformSession = null; desiredPlatformSession = null; platformSessionRefreshPromise = null; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 586a4e0a7..68a9c30c4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -11,6 +11,7 @@ import { beginPlatformSessionTransition, clearCommittedPlatformSession, commitAuthenticatedPlatformSession, + currentPlatformNativeIdentityGenerationForTests, currentPlatformSessionGeneration, requestPlatformSessionRefresh, resetPlatformSessionStateForTests, @@ -214,78 +215,95 @@ export function registerAuthTests() { ); }); - it('reserves install and clear generations above the native floor after renderer state resets', async () => { - let nativeGenerationFloor = 57; + it('reserves install and clear writes above the native floor after renderer state resets', async () => { + let nativeFloor = { identityGeneration: 57, revision: 57 }; const mutations: Array<{ command: string; - generation: number; + identityGeneration: number; + revision: number; }> = []; const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'read_platform_account_session_generation') { - return nativeGenerationFloor; + if (command === 'read_platform_account_session_state') { + return nativeFloor; } if ( command === 'install_platform_account_session' || command === 'clear_platform_account_session' ) { - const generation = (payload as { generation?: number } | undefined) - ?.generation; - if (generation === undefined) { - throw new Error('missing native session generation'); + const write = payload as + | { identityGeneration?: number; revision?: number } + | undefined; + if ( + write?.identityGeneration === undefined || + write?.revision === undefined + ) { + throw new Error('missing native session write identity'); } - mutations.push({ command, generation }); - nativeGenerationFloor = generation; + mutations.push({ + command, + identityGeneration: write.identityGeneration, + revision: write.revision, + }); + nativeFloor = { + identityGeneration: write.identityGeneration, + revision: write.revision, + }; } return null; }); window.__TAURI__ = { core: { invoke } }; resetPlatformSessionStateForTests(); - const installFloor = nativeGenerationFloor; + const installFloor = nativeFloor.revision; + const installIdentityFloor = nativeFloor.identityGeneration; const loginGeneration = beginPlatformSessionTransition(); window.localStorage.setItem( 'genarrative.auth.access-token.v1', 'renderer-reload-token', ); await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration); - expect(mutations[0]).toEqual({ - command: 'install_platform_account_session', - generation: expect.any(Number), - }); - expect(mutations[0]?.generation).toBeGreaterThan(installFloor); + expect(mutations[0]?.command).toBe('install_platform_account_session'); + expect(mutations[0]?.identityGeneration).toBeGreaterThan( + installIdentityFloor, + ); + expect(mutations[0]?.revision).toBeGreaterThan(installFloor); resetPlatformSessionStateForTests(); - const clearFloor = nativeGenerationFloor; + const clearFloor = nativeFloor.revision; + const clearIdentityFloor = nativeFloor.identityGeneration; const logoutGeneration = beginPlatformSessionClearTransition(); await clearCommittedPlatformSession(logoutGeneration); - expect(mutations[1]).toEqual({ - command: 'clear_platform_account_session', - generation: expect.any(Number), - }); - expect(mutations[1]?.generation).toBeGreaterThan(clearFloor); + expect(mutations[1]?.command).toBe('clear_platform_account_session'); + expect(mutations[1]?.revision).toBeGreaterThan(clearFloor); + expect(mutations[1]?.identityGeneration).toBeGreaterThan( + clearIdentityFloor, + ); expect( invoke.mock.calls.filter( - ([command]) => command === 'read_platform_account_session_generation', + ([command]) => command === 'read_platform_account_session_state', ), ).toHaveLength(2); }); - it('retries the native session generation floor read after a transient failure', async () => { + it('retries the native session write floor read after a transient failure', async () => { let floorReads = 0; const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'read_platform_account_session_generation') { + if (command === 'read_platform_account_session_state') { floorReads += 1; if (floorReads === 1) { throw new Error('runner not ready'); } - return 12; + return { identityGeneration: 12, revision: 12 }; } if ( command === 'install_platform_account_session' || command === 'clear_platform_account_session' ) { expect(payload).toEqual( - expect.objectContaining({ generation: expect.any(Number) }), + expect.objectContaining({ + identityGeneration: expect.any(Number), + revision: expect.any(Number), + }), ); } return null; @@ -570,7 +588,8 @@ export function registerAuthTests() { expect(invoke).toHaveBeenLastCalledWith( 'clear_platform_account_session', expect.objectContaining({ - generation: expect.any(Number), + identityGeneration: expect.any(Number), + revision: expect.any(Number), }), ); expect( @@ -732,9 +751,17 @@ export function registerAuthTests() { expect.objectContaining({ userId: 'user-b', accessToken: 'account-b-token', - generation: currentPlatformSessionGeneration(), + identityGeneration: expect.any(Number), + revision: expect.any(Number), }), ); + // 换号必须推进身份代次:旧账号在途 operation 不能拿到新账号凭据。 + const lastInstall = invoke.mock.calls.at(-1)?.[1] as + | { identityGeneration?: number } + | undefined; + expect(lastInstall?.identityGeneration).toBe( + currentPlatformNativeIdentityGenerationForTests(), + ); }); it('treats a late old-account refresh failure as stale after switching accounts', async () => { @@ -822,6 +849,103 @@ export function registerAuthTests() { }); }); + it('keeps the identity generation stable when the same account renews its credential', async () => { + const installs: Array<{ identityGeneration?: number; revision?: number }> = + []; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + if (command === 'install_platform_account_session') { + installs.push( + payload as { identityGeneration?: number; revision?: number }, + ); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const generation = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'expired-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, generation); + const identityGenerationAfterLogin = + currentPlatformNativeIdentityGenerationForTests(); + const sessionGenerationAfterLogin = currentPlatformSessionGeneration(); + + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response(JSON.stringify({ token: 'renewed-token' }), { + status: 200, + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await requestPlatformSessionRefresh(testAuthUser.id); + expect(result).toEqual( + expect.objectContaining({ status: 'refreshed', user: testAuthUser }), + ); + // 续期只换凭据:身份代次与平台会话代次都不推进,在途生成 operation 不会被判成 + // 旧账号请求;native 写入仍然用更高的 revision 拒绝迟到写入。 + expect(currentPlatformNativeIdentityGenerationForTests()).toBe( + identityGenerationAfterLogin, + ); + expect(currentPlatformSessionGeneration()).toBe( + sessionGenerationAfterLogin, + ); + expect(installs).toHaveLength(2); + expect(installs[1]?.identityGeneration).toBe( + installs[0]?.identityGeneration, + ); + expect(installs[1]?.revision).toBeGreaterThan(installs[0]?.revision ?? 0); + }); + + it('keeps the session when a refresh fails for a transient reason', async () => { + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + const generation = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'still-valid-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, generation); + const sessionGeneration = currentPlatformSessionGeneration(); + + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 503 }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await requestPlatformSessionRefresh(testAuthUser.id); + // 刷新暂时不可用不等于登录态权威失效:保留会话与 access token,只让本次动作失败。 + expect(result).toMatchObject({ status: 'failed', authoritative: false }); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('still-valid-token'); + expect(currentPlatformSessionGeneration()).toBe(sessionGeneration); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'clear_platform_account_session', + ), + ).toHaveLength(0); + }); + it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => { setClientServerSelection({ preset: 'dev', customBaseUrl: '' }); const invoke = vi.fn(async () => null); @@ -1452,7 +1576,8 @@ export function registerAuthTests() { fetchSpy.mock.calls.filter( ([input]) => String(input) === '/api/auth/refresh', ), - ).toHaveLength(1); + // 401 刷新先用当前 cookie 收敛重试一次,重试仍被拒绝才算权威失效。 + ).toHaveLength(2); }); it('fails the renderer closed when native session clear is rejected during logout', async () => { diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 91d2a90c6..34639803c 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -8,6 +8,7 @@ import { } from '../src/services/clientApi'; import { getClientAuthRefreshOperation, + getStoredAuthAccessToken, refreshClientAuthAccessToken, } from '../src/services/clientAuth'; import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp'; @@ -142,15 +143,53 @@ it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token expect(fetch).toHaveBeenCalledTimes(6); }); -it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => { +it.each([401])( + '续期被明确拒绝时保留原 HTTP %s,且不重发业务请求', + async (status) => { + let refreshCalls = 0; + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input) => { + if (String(input) === '/api/auth/refresh') { + refreshCalls += 1; + return json({}, 401); + } + return json({}, status); + }); + await expect( + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + ).rejects.toMatchObject({ status }); + // 401 刷新先用当前 cookie 收敛重试一次;重试仍被拒绝才算登录态权威失效, + // 而且不能把一次卡片级失败放大成全局登出。 + expect(refreshCalls).toBe(2); + expect(fetch).toHaveBeenCalledTimes(3); + expect(getStoredAuthAccessToken()).toBe(''); + }, +); + +it('续期 401 后用当前 cookie 收敛重试并继续业务请求', async () => { + let refreshCalls = 0; const fetch = vi .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(json({}, status)) - .mockResolvedValueOnce(json({}, 401)); + .mockImplementation(async (input, init) => { + if (String(input) === '/api/auth/refresh') { + refreshCalls += 1; + return refreshCalls === 1 + ? json({}, 401) + : json({ token: 'rotated-token' }); + } + if (String(input) === '/api/auth/me') return json({ user }); + const token = new Headers(init?.headers).get('Authorization'); + if (token === 'Bearer expired-token') return json({}, 401); + expect(token).toBe('Bearer rotated-token'); + return json(catalog); + }); + await expect( requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), - ).rejects.toMatchObject({ status }); - expect(fetch).toHaveBeenCalledTimes(2); + ).resolves.toEqual(catalog); + expect(refreshCalls).toBe(2); + expect(getStoredAuthAccessToken()).toBe('rotated-token'); }); it('跳过鉴权的请求不触发续期', async () => { From 479120d368c675b6c09072c84d75685c15d8b503 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:51:36 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E8=BD=AE=E6=8D=A2=E7=AB=9E=E4=BA=89=E8=A2=AB=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E6=88=90=E7=99=BB=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refresh 接口的轮换失败不再下发清空 refresh cookie 的响应,避免删掉并发请求刚写入的有效 cookie - 缺少 refresh cookie 的失败保持无副作用,cookie 失效只由吊销、过期或身份变更决定 - 网站前端在刷新返回 401 时先用当前 cookie 收敛重试一次,重试仍被拒绝才判权威失效 - 网站前端只在当前鉴权代次未变化时重试刷新,避免旧账号结论污染新代次 - 更新 api-server 与前端断言,覆盖轮换竞争与收敛重试两条路径 --- server-rs/crates/api-server/src/app.rs | 9 ++-- .../crates/api-server/src/refresh_session.rs | 31 ++++---------- src/services/apiClient.test.ts | 42 +++++++++++++++++-- src/services/apiClient.ts | 19 ++++++++- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index a3a761b3f..d4d081884 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -4933,17 +4933,20 @@ mod tests { .expect("stale refresh request should succeed"); assert_eq!(stale_refresh_response.status(), StatusCode::UNAUTHORIZED); + // 轮换竞争或重放都不能清空浏览器当前的 refresh cookie:清 cookie 会删掉并发 + // 请求刚刚写入的有效 cookie,把一次竞争放大成登出。cookie 的失效只由会话吊销、 + // 过期或身份变更决定,客户端在收敛重试后仍收到 401 时才清本地会话。 assert!( stale_refresh_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.contains("Max-Age=0")) + .is_none_or(|value| !value.contains("Max-Age=0")) ); } #[tokio::test] - async fn refresh_session_rejects_missing_cookie_and_clears_cookie() { + async fn refresh_session_rejects_missing_cookie_without_touching_cookies() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app @@ -4963,7 +4966,7 @@ mod tests { .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.contains("Max-Age=0")) + .is_none_or(|value| !value.contains("Max-Age=0")) ); } diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index c6c783f67..a1bff0077 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -12,8 +12,7 @@ use crate::{ api_response::json_success_body, auth::RefreshSessionToken, auth_session::{ - attach_set_cookie_header, build_clear_refresh_session_cookie_header, - build_refresh_session_cookie_header, map_refresh_session_error, + attach_set_cookie_header, build_refresh_session_cookie_header, map_refresh_session_error, record_daily_login_tracking_event_after_auth_success, sign_access_token_for_user, }, http_error::AppError, @@ -30,10 +29,9 @@ pub async fn refresh_session( .map(|token| token.0.token().to_string()) .unwrap_or_default(); if raw_refresh_token.trim().is_empty() { - return Err(map_refresh_error_with_clear_cookie( - &state, - RefreshSessionError::MissingToken, - )); + // 缺少 cookie 时同样不下发清 cookie 响应:这里没有可清的对象,失败关闭由 + // 客户端"收敛重试后仍 401 才登出"的判据负责。 + return Err(map_refresh_session_error(RefreshSessionError::MissingToken)); } let refresh_token_hash = hash_refresh_session_token(&raw_refresh_token); let next_refresh_token = platform_auth::create_refresh_session_token(); @@ -57,13 +55,11 @@ pub async fn refresh_session( OffsetDateTime::now_utc(), ) { Ok(rotated) => rotated, - Err(RefreshSessionError::SessionNotFound) => { - return Err(map_refresh_error_with_clear_cookie( - &state, - RefreshSessionError::SessionNotFound, - )); - } - Err(error) => return Err(map_refresh_error_with_clear_cookie(&state, error)), + // 轮换失败不下发清空 refresh cookie 的响应。并发或乱序刷新时,清 cookie 会删掉 + // 另一个请求刚刚写入的有效 refresh cookie,把一次轮换竞争放大成登出;refresh + // cookie 的失效只由会话吊销、过期或身份变更语义决定。客户端在收敛重试后仍然 + // 收到 401 / 403 时才清本地会话。 + Err(error) => return Err(map_refresh_session_error(error)), }; let access_token = sign_access_token_for_user( &state, @@ -103,12 +99,3 @@ pub async fn refresh_session( ), )) } - -fn map_refresh_error_with_clear_cookie(state: &AppState, error: RefreshSessionError) -> AppError { - let response_error = map_refresh_session_error(error); - if let Ok(set_cookie) = build_clear_refresh_session_cookie_header(state) { - return response_error.with_header("set-cookie", set_cookie); - } - - response_error -} diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index b04406338..fcd08330e 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -322,6 +322,7 @@ describe('apiClient', () => { it('emits auth change events when refresh fails on protected requests', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 401 })); @@ -330,7 +331,12 @@ describe('apiClient', () => { }); expect(response.status).toBe(401); - expect(fetchMock).toHaveBeenCalledTimes(2); + // 业务 401 + refresh 401 收敛重试 + refresh 仍 401:只有两次刷新都被明确拒绝, + // 才判定登录态权威失效并广播一次全局鉴权变化。 + expect( + fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'), + ).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(dispatchEventMock).toHaveBeenCalledTimes(1); expect(getStoredAccessToken()).toBe(''); }); @@ -405,7 +411,9 @@ describe('apiClient', () => { it('keeps local token when explicit refresh opts out of clearing on failure', async () => { setStoredAccessToken('usable-local-token', { emit: false }); - fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce(createResponseMock({ status: 401 })); await expect( refreshStoredAccessToken({ clearOnFailure: false }), @@ -456,7 +464,9 @@ describe('apiClient', () => { it('clears local token when refresh confirms the session is unauthorized', async () => { setStoredAccessToken('expired-local-token', { emit: false }); - fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce(createResponseMock({ status: 401 })); await expect(refreshStoredAccessToken()).rejects.toMatchObject({ status: 401, @@ -466,6 +476,32 @@ describe('apiClient', () => { expect(getStoredAccessToken()).toBe(''); }); + it('retries refresh once with the current cookie after a rotation race', async () => { + setStoredAccessToken('expired-local-token', { emit: false }); + fetchMock + // 并发轮换竞争:这一次 refresh 拿到的是被另一个客户端轮换过的旧 cookie。 + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + // 收敛重试使用浏览器当前 cookie,拿到轮换后的新 token。 + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'converged-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + + await expect(refreshStoredAccessToken()).resolves.toBe('converged-token'); + expect( + fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'), + ).toHaveLength(2); + expect(getStoredAccessToken()).toBe('converged-token'); + expect(dispatchEventMock).not.toHaveBeenCalled(); + }); + it('does not clear auth when protected request refresh fails transiently', async () => { setStoredAccessToken('expired-token-during-restart', { emit: false }); fetchMock diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 880e52921..f594ca662 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -775,7 +775,7 @@ async function refreshAccessToken() { return refreshAccessTokenAttempt.promise; } - const promise = (async () => { + const performRefresh = async () => { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', @@ -802,6 +802,23 @@ async function refreshAccessToken() { publishRefreshedAccessToken(nextToken, authStateSnapshot); return nextToken; + }; + const promise = (async () => { + try { + return await performRefresh(); + } catch (error) { + const authoritative = + error instanceof ApiClientError && + (error.status === 401 || error.status === 403); + // 登录态已经变化(换号 / 退出 / 另一个 refresh 已发布新 token)时不要重试: + // 这次 refresh 的归属已经过期,重试只会把旧账号的结论带到新代次上。 + if (!authoritative || !isCurrentAuthState(authStateSnapshot)) { + throw error; + } + // 并发轮换收敛:另一个标签页 / 客户端可能刚刚轮换过 refresh cookie,用当前 + // cookie 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算权威失效。 + return await performRefresh(); + } })(); const attempt: RefreshAccessTokenAttempt = { ...authStateSnapshot, From e08171fd2e58e09425b59cca4534362b83507b8c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:51:47 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E8=A1=A5=E8=AE=B0=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E5=88=86=E7=A6=BB=E7=9A=84=E8=A7=84=E8=8C=83=E4=B8=8E=E8=AE=B0?= =?UTF-8?q?=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范新增平台会话身份与凭据分离条款,并修正长回合保活的口径 - 共享记忆补充并发生图登录态冲突的排障口径与验证方式 - 决策记录补充身份代次、凭据轮换、刷新失败语义的长期决策 - 新增本次里程碑与实施计划,记录证据与未验证项 --- ...施计划】平台会话身份与凭据分离-2026-09-16.md | 47 ++++++++++++++++ ...程碑】平台会话身份与凭据分离-2026-09-16.md | 54 +++++++++++++++++++ .../shared-memory/decision-log.md | 7 +++ docs/project-memory/shared-memory/pitfalls.md | 7 +++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 15 +++++- 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 docs/project-memory/plans/【实施计划】平台会话身份与凭据分离-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md diff --git a/docs/project-memory/plans/【实施计划】平台会话身份与凭据分离-2026-09-16.md b/docs/project-memory/plans/【实施计划】平台会话身份与凭据分离-2026-09-16.md new file mode 100644 index 000000000..2fe7234dd --- /dev/null +++ b/docs/project-memory/plans/【实施计划】平台会话身份与凭据分离-2026-09-16.md @@ -0,0 +1,47 @@ +# 【实施计划】平台会话身份与凭据分离 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md` | +| Status | done(待验收复核) | +| Owner | Codex | + +## 修改边界 + +- 允许修改:`apps/ai-game-creator-shell/src-tauri/src/platform_session.rs`、`commands.rs`、`runner/`(attach 参数与安装路径)、`project/external_editor_bindings.rs`、`project/resource_editor.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_tools_mcp.rs`、`agent/direct_runtime/`、`assets.rs` 中与身份判据相关的调用点。 +- 允许修改:`apps/ai-game-creator-shell/src/services/platformSession.ts`、`clientAuth.ts`、`clientApi.ts`、`src/App.tsx` 及对应测试。 +- 允许修改:`src/services/apiClient.ts` 刷新收敛重试与对应测试。 +- 允许修改:`server-rs/crates/api-server/src/refresh_session.rs`、`auth_session.rs` 与对应测试。 +- 明确不修改:`/api/external/v1` 路由与 OpenAPI、`refresh_session` schema / 迁移 / 绑定、生成账本与计费、Provider 与 MCP 工具边界。 + +## 实现顺序 + +1. 原生会话状态拆分:`PlatformSessionSnapshot` 增加身份代次,`generation` 更名并明确为写入 revision;安装 / 清除 / 冻结校验按新判据重写,补单元用例。 +2. 逐个修正原生调用点(binding、生成 operation、MCP 会话、diagnostics、Runner attach),由编译器定位所有旧判据读取点。 +3. renderer:身份代次与 native revision 分离,续期走凭据更新路径;刷新失败只在权威失效时清会话;补前端用例。 +4. 刷新收敛:AGC 与网站前端在刷新 401 时用当前 cookie 重试一次。 +5. `api-server`:轮换失败不再下发清 cookie 响应,补定向用例。 +6. 回写主规范与共享记忆,删除临时计划。 + +## 验证命令 + +1. `npm run ai-game-creator-shell:check:rust`(或定向 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml platform_session`) +2. `npm --prefix apps/ai-game-creator-shell test -- src` 对应前端定向用例 +3. `npm run test -- src/services/apiClient.test.ts` +4. `cargo test -p api-server refresh_session --manifest-path server-rs/Cargo.toml` +5. `npm run typecheck`、`npm run check:encoding`、`git diff --check` + +## 风险与回滚点 + +- 风险:身份代次判据改宽后,若换号路径仍复用旧代次,旧账号请求可能带着新账号 token 发出。缓解:换号、退出和 origin 变化必须先推进身份代次;Rust 侧对同代次不同主体的写入失败关闭,并用用例覆盖。 +- 风险:revision 与身份代次混用会让迟到写入复活旧会话。缓解:写入顺序只认 revision,身份只认身份代次,两者分别有用例。 +- 回滚点:先回滚 renderer 的续期路径与原生判据(第 1-3 步),再回滚服务端响应语义(第 5 步);`refresh_session` schema 不在本次改动内,无需数据回滚。 + +## 执行结果 + +1. `platform_session.rs`:`PlatformSessionSnapshot` 拆成 `identity_generation` + `revision`,新增 `PlatformSessionIdentity`、身份租约与 `validate_frozen_platform_session`;安装 / 清除按"revision 排序 + 身份归属"双判据实现。 +2. 原生调用点:`external_editor_bindings.rs`、`resource_editor.rs`、`canvas_generation.rs`、`direct_tools_mcp.rs`、`direct_runtime`、`assets.rs`、`commands.rs` 全部改按身份判定;`commands.rs` 的 IPC 改为 `install_platform_account_session(userId, accessToken, apiBaseUrl, identityGeneration, revision)`、`clear_platform_account_session(identityGeneration, revision)`、`read_platform_account_session_state()`。 +3. Runner:attach 参数新增 `platform_auth_revision`,`platform_auth_generation` 只表达身份代次;`remember_*` / `validate_*` 按 revision 排序、按身份归属判定。 +4. renderer:`platformSession.ts` 拆分 `platformNativeIdentityGeneration` 与 `platformNativeRevision`,续期走 `commitPlatformCredentialRefresh`(不推进身份代次);刷新失败结果新增 `authoritative`,`AuthenticatedClient` 只在权威失效时登出。 +5. 刷新收敛:AGC `clientAuth.refreshClientAuthAccessToken` 与网站 `apiClient.refreshAccessToken` 在 401 后用当前 cookie 再试一次;网站侧额外要求本代次仍是当前代次。 +6. `api-server`:`refresh_session` 失败不再下发清 cookie 响应(`map_refresh_error_with_clear_cookie` 删除),并更新两条 `app::tests` 断言。 diff --git a/docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md b/docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md new file mode 100644 index 000000000..0c80b4934 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md @@ -0,0 +1,54 @@ +# 【里程碑】平台会话身份与凭据分离 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented(待验收复核) | +| Date | 2026-09-16 | +| Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(`2026-09-16 平台会话身份与凭据分离`) | + +## 目标 + +同一账号在长回合内发生 access token 续期时,在途的生成、编辑、上传、确认和下载 operation 必须继续可用;只有登录主体、服务 origin 或登出状态真正变化时才允许中止它们。刷新失败与并发刷新不得把仍然有效的会话判成未登录。 + +## 范围 + +- AGC renderer 的平台会话状态机:身份代次与 native 写入 revision 的分离,续期不再推进身份代次。 +- 原生 Rust `platform_session` 会话状态:身份判据、token 判据、写入顺序判据的分离。 +- 冻结平台会话的校验语义(外部编辑器 binding、生成 operation、MCP 工具会话)。 +- 独立 Runner 的平台会话安装 / 清除与 attach 参数。 +- AGC 与网站前端的刷新失败语义、并发刷新收敛重试。 +- `api-server` `/api/auth/refresh` 轮换失败时的响应语义。 + +## 不在范围内 + +- 不改 `refresh_session` 的持久化 schema,不引入服务端轮换宽限表。 +- 不改 `/api/external/v1` 路由、OpenAPI 或共享 DTO。 +- 不改生成、计费、账本幂等键与恢复语义本身。 +- 不改 Provider、MCP 工具白名单、审批或项目锁。 + +## 依赖与前置条件 + +- 现有 native revision 与 durable GUI owner claim 语义保持不变。 +- 现有 `authentication-required` 错误分类保持不变,只收窄其触发条件。 + +## 验收标准 + +- [x] 同一账号续期后,续期前建立的在途冻结会话校验通过;换号、退出和 origin 变化后校验失败关闭。 +- [x] 同一账号续期不推进 renderer 身份代次,也不使 `AuthenticatedClient` / Direct 配置判据把会话判成 stale。 +- [x] 迟到或更旧的凭据写入被 revision 拒绝,不能覆盖更新的 token,也不能复活已清除的会话。 +- [x] 瞬时刷新失败(网络错误、5xx、网关错误、响应契约异常)不清除本地会话与 access token。 +- [x] 刷新返回 401 时先用当前 cookie 收敛重试一次;重试成功继续使用新凭据,重试仍 401 才清会话。 +- [x] `/api/auth/refresh` 轮换失败不下发清空 refresh cookie 的响应。 + +## 证据要求 + +- 自动化:AGC Rust 定向用例(身份判据、revision CAS、冻结会话)、AGC 前端用例(代次与刷新失败语义)、`api-server` 定向用例(轮换失败响应)、网站 `apiClient` 用例(收敛重试)。 +- 运行时:AGC shell Rust 定向测试与前端测试;`api-server` 定向 `cargo test`。 +- 边界:换号 / 退出失败关闭、迟到写入拒绝、并发刷新竞争、token 不出现在错误文本与持久化中。 + +## 证据与未验证项 + +- 已获得:AGC `platform_session::tests` 12/12、`runner::tests` 平台会话相关 5/5、`assets::tests` 平台路由 2/2;AGC `appSurface` 前端套件 468 项全通过;网站 `src/services/apiClient.test.ts` 33/33;`cargo test -p api-server refresh_session` 3/3。 +- 未验证:依赖项目夹具的 AGC Rust 用例(`project::resource_editor::tests`、`project::external_editor_bindings::tests` 等)在本机全部以同一条环境错误失败——测试临时目录属主是 `BUILTIN\Administrators`,而进程用户是 `kdletters\kdletters`,严格 ACL 校验在夹具初始化阶段失败关闭,与本次改动无关;需要非提权或属主正确的运行环境才能补齐。 +- 未验证:真实 Provider 的并发生图 + 长回合保活端到端 smoke 未执行(需要有效平台登录态与付费生成)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d778bcade..080d7d929 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8765,6 +8765,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。 - 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。 - 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。 + +## 2026-09-16 平台会话身份与凭据分离 + +- 背景:长回合保活和 401 续期每次签发新 access token,并推进 native generation 重新安装原生会话;冻结平台会话同时比对身份与 token 字节,于是同账号的正常续期被等价成换号,并发在途的生成 / 编辑 / 上传 / 确认 / 下载 operation 全部被判为“旧账号请求”失败。更彻底的一层是原生会话、Runner attach 与 renderer 代次都把“写入顺序”和“身份归属”压在同一个计数器上。 +- 决策:平台会话拆成身份(`userId + api origin + identity generation`)与凭据(当前 access token)。identity generation 只在登录、切号、登出或新的 GUI authority epoch 推进;同账号续期只更新凭据并推进只用于拒绝迟到写入的 revision。冻结会话校验、MCP 会话身份与 Runner attach 统一按身份判定,换号 / 退出仍然失败关闭。刷新失败只在服务端明确 401/403 且一次收敛重试后仍失败时清会话;`/api/auth/refresh` 的轮换失败不再下发清空 refresh cookie 的响应。 +- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-16 平台会话身份与凭据分离”;开发期计划见 `docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md` 与对应实施计划。 +- 验证:AGC `platform_session::tests`、`runner::tests`、`assets::tests` 定向通过;AGC `appSurface` 前端套件 468 项通过;网站 `src/services/apiClient.test.ts` 33 项通过;`cargo test -p api-server refresh_session` 通过。项目夹具类 Rust 用例受本机临时目录属主为 `BUILTIN\Administrators` 的环境限制,未计入本次证据。 ## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板 - 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e8d132d9f..0b72f24d7 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5619,3 +5619,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`git config --show-origin --get user.name` 出现 `file:.git/config Git Hooks Test`、`git rev-parse --show-toplevel` 指向 `%TEMP%\genarrative-pre-push-*\repo` 都是被污染的确定性证据;被 `core.worktree` 劫持期间执行的 `git pull` 会把检出写进临时目录,真实工作树整体落后(本次 93 个文件),配置修好后用 `git checkout HEAD -- .` 回填。 - Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 - 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。 + +## 2026-09-16 并发生图遇到“登录态冲突”:身份代次与凭据轮换混用 + +- **现象**:DirectProject 长回合里并发派发的生图 / 素材生成请求中途报 `authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试`,或平台工具返回 `HTTP 401 invalid-token`;账号并没有切换,重新登录后短时间内可复现。 +- **原因**:客户端把两件事压成了一个判据。原生冻结会话(`PlatformSessionSnapshot`)同时承担“身份归属”和“access token 字节比对”,而长回合保活与 401 续期每次都会签发新 token 并推进 native generation 重新安装会话;于是同账号的正常续期被等价成换号,续期窗口内所有在途生成、编辑、上传、确认和下载 operation 全部失配。保活定时器(回合 busy 时每 5 分钟一次)会稳定落在生图窗口内,所以并发越多越必现。另一层在服务端:`/api/auth/refresh` 是严格一次性轮换,且失败时下发清空 refresh cookie 的响应;两个窗口 / 实例并发续期时,输的一方会把赢家刚写入的有效 cookie 删掉。 +- **处理(现行口径)**:平台会话统一拆成**身份**(`userId + api origin + identity generation`)与**凭据**(当前 access token)。identity generation 只在登录、切号、登出和新的 GUI authority epoch 推进;同账号续期只更新凭据并推进写入 revision(revision 只用于拒绝迟到写入)。冻结会话校验只比身份,比 token 字节的判据已被取代。刷新失败语义收紧为:只有服务端明确返回 401/403 且经一次收敛重试后仍失败,才清本地会话;网络错误、5xx、网关错误和响应契约异常必须保留会话与 access token。`/api/auth/refresh` 的轮换失败不再下发清 cookie 响应。 +- **验证**:AGC `platform_session::tests` 覆盖“同身份 token 轮换后冻结会话仍有效”“换号 / 退出后失效”“迟到 install 被 revision 拒绝”;`appSurface` 前端用例覆盖“续期不推进身份代次且 native 写入 revision 递增”“瞬时刷新失败不清会话”;网站 `src/services/apiClient.test.ts` 覆盖“刷新 401 后收敛重试成功”与“两次都被拒绝才判权威失效”;`api-server` `refresh_session_*` 覆盖“轮换失败不下发清 cookie”。定位同类问题先看冻结会话判据里有没有 token 字节,再看服务端失败响应有没有 `Max-Age=0`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 06089fbd6..54b13f529 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,9 +32,22 @@ DirectProject 的 AGC 工具、构建、验证和浏览器试玩错误,若不属于鉴权、权限、余额、项目身份、历史损坏、传输断开、取消或付费操作状态不确定等安全终止边界,必须作为脱敏错误上下文回传同一 LLM 会话,由 LLM 读取当前项目、修改真实文件并重跑失败阶段。客户端最多连续反馈三次;每次保留 stage、工具 / 命令、错误正文和已有证据,不得静默吞错、伪造成功或用占位产物跳过阶段。达到三次仍失败后,才向用户投影终态错误和诊断引用。 +## 2026-09-16 平台会话身份与凭据分离(并发续期不再中断在途生成) + +平台会话在 AGC renderer、原生 Rust 层和独立 Runner 中统一拆成两个互不替代的概念:**身份**(`userId + api origin + identity generation`)与**凭据**(当前 access token)。身份代次只表达登录主体或服务 origin 的更换;凭据轮换必须保持身份代次不变。 + +- 同一身份内的 access token 轮换 —— 长回合保活、`401` 续期、同一账号重新登录 —— 只更新凭据,不推进身份代次,也不得让任何在途生成、编辑、上传、确认或下载 operation 的冻结会话失效。此前把 token 字节和 native generation 一起当作身份判据,使一次正常续期被等价成换号,在途生图被判为 `authentication-required: 陶泥儿登录态已变化`;本条款取代该判据。 +- 冻结会话的校验判据只包含身份,不含 token 字节。换号、退出或 origin 变化仍然失败关闭:旧身份的 operation 后续一切 POST、poll、下载和结果安装都必须停止,只保留对账证据,不得改绑或重放。 +- 原生 install / clear 继续使用单调 revision 作为写入顺序判据,防止迟到写入复活旧状态;身份代次不参与写入排序,只参与身份归属判定。同身份凭据更新必须携带不小于当前 revision 的新 revision 且保持身份代次不变,才能替换 token。 +- `authentication-required` 只表达"当前 operation 对应的身份已不是权威身份"。同一身份的凭据轮换不得产生该错误。 +- 刷新失败语义:只有服务端明确返回 `401` / `403`,且收敛重试后仍然失败,才允许清除本地会话与 access token。网络错误、`5xx`、网关错误和响应契约异常必须保留既有会话与 access token,只让发起刷新的那个动作失败。 +- 并发刷新收敛:同一 origin 的 refresh 单飞;一次刷新返回 `401` 后允许用当前 refresh cookie 收敛重试一次,重试成功则继续使用新凭据,重试仍为 `401` 才判定登录态权威失效。 +- 服务端 `/api/auth/refresh` 的轮换失败不再下发清空 refresh cookie 的响应。refresh cookie 的失效只由会话吊销、过期或身份变更语义决定,不由一次轮换竞争决定;客户端以"收敛重试后仍 401"作为登出判据。 +- 证据要求:Rust 定向用例覆盖"同身份 token 轮换后冻结会话与在途 operation 仍有效""换号 / 退出后冻结会话失效""迟到 install 被 revision 拒绝";AGC 前端用例覆盖"续期不推进身份代次""瞬时刷新失败不清会话""收敛重试成功不登出";`api-server` 用例覆盖"刷新轮换失败不下发清 cookie"。 + ## 2026-09-15 DirectProject 长回合平台会话保活 -DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。 +DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新复用单飞请求、身份判据和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。2026-09-16 起,同一账号的保活续期只更新凭据、不推进身份代次,因此不得中断在途生成 operation。 完成门禁同时允许已登记的普通平台图片作为运行时素材。此前只把 canonical art-spec、背景、图集和图集切片加入来源白名单;`agc_generate_image` 生成的 `assets/neon-*.png` 即使已经登记并被源码引用,也会被判成“未引用平台图片”,触发同一回合的重复修复。浏览器预览把本地图片 URL 改写成 UUID 路径时,验收按每个视口的已渲染本地图片数量与源码引用数量做有界匹配;仍要求两个视口都有对应观察,空视口继续进入修复。 From b48293fb1f6fee9703d4514d2bad8f68a47d4831 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:08:26 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=20epoch=20=E9=87=8D=E8=A3=85=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=EF=BC=8C=E9=81=BF=E5=85=8D=E5=8E=9F=E7=94=9F=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8E=E6=B8=B2=E6=9F=93=E5=B1=82=E6=BC=82=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GUI owner 重装与清除改为按 authority 快照重定基准,保持原生计数与渲染层认知一致 - 注释写明 epoch 交接的授权前提,以及写入单调性由渲染层 reserve 与 durable session revision 保证 - 同主体换凭据的重装保持身份代次,在途 operation 的冻结会话继续有效 - 补充 epoch 重装与换主体失效的回归用例,替换原先错误假设原生计数器单调的用例 --- .../src-tauri/src/platform_session.rs | 79 ++++++------------- 1 file changed, 25 insertions(+), 54 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 28be4dd3c..41fe89783 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -445,33 +445,14 @@ pub(crate) fn replace_platform_session_for_gui_owner( let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - // 新的 GUI authority epoch 可以替换任意旧会话,但两个计数器只能前进:把写入下限 - // 重置到请求值会让旧 epoch 的迟到写入重新变成“更新”。同一主体的重装(续期后的 - // Runner 重挂、同一 epoch 的重新对账)保持身份代次,只更新凭据。 - let same_identity = current.snapshot.as_ref().is_some_and(|current_snapshot| { - current_snapshot.user_id == snapshot.user_id - && current_snapshot.api_base_url == snapshot.api_base_url - }); - let next_identity_generation = if same_identity { - current - .identity_generation - .max(snapshot.identity_generation) - } else { - current - .identity_generation - .saturating_add(1) - .max(snapshot.identity_generation) - }; - let next_revision = current.revision.saturating_add(1).max(snapshot.revision); - current.revision = next_revision; - current.identity_generation = next_identity_generation; - current.snapshot = Some(PlatformSessionSnapshot { - user_id: snapshot.user_id, - access_token: snapshot.access_token, - api_base_url: snapshot.api_base_url, - identity_generation: next_identity_generation, - revision: next_revision, - }); + // 这条路径是 GUI authority epoch 的重定性入口:只有 durable claim 的 epoch + session + // revision 与登记完全一致时才会走到这里,新 epoch 可以替换旧进程留下的任意计数器。 + // 因此按调用方快照重定基准,让原生计数与渲染层认知严格一致;Runner 同 epoch 的幂等 + // 重挂仍走 install_platform_session_checked 的精确相等校验。写入的持续单调性由渲染层 + // reserve(max(本地 + 1, 原生下限 + 1))和 durable session revision 保证。 + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; + current.snapshot = Some(snapshot); Ok(()) } @@ -544,12 +525,9 @@ pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, rev let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - // 与 replace 同一口径:清除也只能前进,不能让新 epoch 把下限归零。 - current.revision = current.revision.saturating_add(1).max(revision); - current.identity_generation = current - .identity_generation - .saturating_add(1) - .max(identity_generation); + // 与 replace 同一口径:epoch 交接按调用方快照重定基准,避免原生计数与渲染层认知漂移。 + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = None; } @@ -849,7 +827,7 @@ mod tests { } #[test] - fn gui_owner_replacement_keeps_counters_monotonic_and_same_subject_identity() { + fn gui_owner_replacement_rebases_to_the_authority_and_only_subject_change_fences() { let _session = clear_test_platform_session(); replace_platform_session_for_gui_owner("gui-owner-a", "token-a", TEST_ORIGIN, 5, 5) .expect("install gui owner A"); @@ -857,37 +835,30 @@ mod tests { assert_eq!(installed.identity_generation, 5); assert_eq!(installed.revision, 5); - // 同一主体只换凭据:身份代次保持,写入 revision 前进。 + // 同一主体只换凭据(续期后 Runner 重挂走的就是这条 replace 路径):身份代次保持、 + // 写入 revision 前进,在途 operation 的冻结会话仍然有效。 replace_platform_session_for_gui_owner("gui-owner-a", "token-a2", TEST_ORIGIN, 5, 6) .expect("refresh gui owner A credential"); let refreshed = current_platform_session().expect("gui owner A refreshed session"); assert_eq!(refreshed.identity_generation, 5); assert_eq!(refreshed.revision, 6); + validate_frozen_platform_session(&installed) + .expect("same-subject credential replacement keeps the frozen session valid"); - // 迟到的旧 epoch 写入不能把写入下限拉回去。 - replace_platform_session_for_gui_owner("gui-owner-a", "token-a", TEST_ORIGIN, 5, 4) - .expect("stale gui owner write"); - let after_stale = - current_platform_session().expect("gui owner A session after stale write"); - assert_eq!( - after_stale.identity_generation, - refreshed.identity_generation - ); - assert!(after_stale.revision > refreshed.revision); - - // 换主体必须推进身份代次,使旧身份的在途 operation 失效。 - replace_platform_session_for_gui_owner("gui-owner-b", "token-b", TEST_ORIGIN, 5, 5) + // 换主体必须推进身份代次,旧身份的在途 operation 失败关闭。 + replace_platform_session_for_gui_owner("gui-owner-b", "token-b", TEST_ORIGIN, 6, 7) .expect("switch gui owner"); let switched = current_platform_session().expect("gui owner B session"); assert_eq!(switched.user_id, "gui-owner-b"); - assert!(switched.identity_generation > after_stale.identity_generation); - assert!(validate_frozen_platform_session(&after_stale).is_err()); + assert_eq!(switched.identity_generation, 6); + assert!(validate_frozen_platform_session(&installed).is_err()); + assert!(validate_frozen_platform_session(&refreshed).is_err()); - // 清除同样只能前进,不能把下限归零。 - clear_platform_session_for_gui_owner(0, 0); + // epoch 交接后的清除同样按调用方快照重定基准,让原生计数与渲染层认知一致。 + clear_platform_session_for_gui_owner(7, 8); let cleared = current_platform_session_write_state(); - assert!(cleared.revision > switched.revision); - assert!(cleared.identity_generation > switched.identity_generation); + assert_eq!(cleared.revision, 8); + assert_eq!(cleared.identity_generation, 7); assert!(current_platform_session().is_none()); }