平台会话身份与凭据分离,续期不再中断在途生成

- 原生会话快照拆成身份代次与写入 revision,安装与清除按 revision 排序、按身份归属判定
- 冻结平台会话校验改为只比身份,同账号 access token 轮换不再让在途生成、编辑、上传、确认和下载失败
- 同一身份代次禁止更换登录主体或服务 origin,换号、退出和 origin 变化继续失败关闭
- Runner attach 新增 platform_auth_revision,平台会话安装与校验按 revision 排序、按身份归属判定
- GUI owner 替换与清除改为计数器只增不减,同一主体重装保持身份代次,避免旧 epoch 迟到写入复活
- renderer 拆分平台原生身份代次与写入 revision,同账号续期只更新凭据,不推进身份代次
- 刷新失败结果新增权威失效判定,只有服务端明确 401 与 403 才清除本地会话,网络错误和 5xx 保留会话
- 收敛刷新判据,重复的刷新工具函数合并为一个对外判定,去掉一次性包装
- 补齐平台会话身份判据、续期与迟到写入的 Rust 与前端回归用例
This commit is contained in:
kdletters
2026-09-16 16:51:19 +08:00
parent 262deaf9b7
commit 4182979a19
18 changed files with 974 additions and 434 deletions
@@ -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
@@ -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))
@@ -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<ValidatedPlatformSessionLease, String> {
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(),
@@ -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");
@@ -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()?;
@@ -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,
File diff suppressed because it is too large Load Diff
@@ -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,
}
}
@@ -4389,14 +4389,7 @@ fn with_frozen_resource_edit_platform_session<T>(
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
@@ -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<ExternalAgentRunnerGuiOwnerAttachmentState>,
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
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) => {
@@ -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 已变化,平台登录态已隔离"
@@ -280,6 +280,10 @@ pub(super) struct ExternalAgentRunnerRequestParams {
pub(super) platform_api_base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_auth_generation: Option<u64>,
/// 原生写入 revision:只用于 install / clear 的顺序判定。同一身份的凭据轮换会推进
/// revision,但不推进 `platform_auth_generation`(身份代次)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_auth_revision: Option<u64>,
}
#[derive(Deserialize, Serialize)]
@@ -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()),
)
},
@@ -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');
@@ -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<AuthRefreshResponse>(
'/api/auth/refresh',
{ method: 'POST' },
'刷新登录状态失败',
{ skipAuth: true, apiBaseUrl },
)
const performRefresh = () =>
requestAuthJson<AuthRefreshResponse>(
'/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,
@@ -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<number> | 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<PlatformSessionRefreshResult> | 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<T>(
async function readNativePlatformSessionGenerationFloor() {
const invoke = resolveTauriInvoke();
if (!invoke) return 0;
const floor = await invoke<number | null>(
'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<PlatformNativeSessionWrite> {
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<CommittedPlatformSession | null> {
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<CommittedPlatformSession | null> {
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;
@@ -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 () => {
@@ -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 () => {