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] =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=BC=9A=E8=AF=9D=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E4=B8=8E=E5=87=AD=E6=8D=AE=E5=88=86=E7=A6=BB=EF=BC=8C?= =?UTF-8?q?=E7=BB=AD=E6=9C=9F=E4=B8=8D=E5=86=8D=E4=B8=AD=E6=96=AD=E5=9C=A8?= =?UTF-8?q?=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 () => {