合入平台会话身份与凭据分离
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m15s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m58s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust crates (push) Successful in 3m1s
Project CI / Frontend tests (push) Failing after 4m24s
Project CI / Repository checks (push) Successful in 4m1s
Project CI / Native shell tests (push) Successful in 7m45s
Project CI / AI game creator shell web tests (push) Failing after 3m4s
Project CI / Backend tests (push) Successful in 8m20s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m15s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m58s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust crates (push) Successful in 3m1s
Project CI / Frontend tests (push) Failing after 4m24s
Project CI / Repository checks (push) Successful in 4m1s
Project CI / Native shell tests (push) Successful in 7m45s
Project CI / AI game creator shell web tests (push) Failing after 3m4s
Project CI / Backend tests (push) Successful in 8m20s
平台会话快照拆分身份代次与写入 revision,凭据续期轮换不再中断在途生成 AGC Runner 请求参数新增 platform_auth_revision,并按 revision 做单调写入判定 解决与多窗口共享 Runner 的冲突,保留 claim adopt/publish 与界面参与锁语义 同步刷新轮换竞争、appSurface 鉴权用例与客户端 API 测试 补录平台会话身份与凭据分离的实施计划、共享记忆与决策记录
This commit is contained in:
@@ -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(),
|
||||
|
||||
+2
-1
@@ -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()?;
|
||||
|
||||
@@ -2733,7 +2733,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
|
||||
|
||||
@@ -1107,7 +1107,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()
|
||||
},
|
||||
)?;
|
||||
@@ -1118,7 +1121,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())?;
|
||||
@@ -1128,7 +1132,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| {
|
||||
@@ -1137,7 +1142,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,
|
||||
)
|
||||
})
|
||||
},
|
||||
@@ -1145,7 +1151,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(());
|
||||
};
|
||||
@@ -1155,7 +1164,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| {
|
||||
@@ -1164,7 +1174,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R
|
||||
&config_dir,
|
||||
&endpoint,
|
||||
None,
|
||||
generation,
|
||||
identity_generation,
|
||||
revision,
|
||||
)
|
||||
})
|
||||
},
|
||||
@@ -1177,7 +1188,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(|| {
|
||||
@@ -1190,7 +1202,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
|
||||
@@ -1221,12 +1234,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,
|
||||
publish_external_agent_runner_gui_owner_claim,
|
||||
)
|
||||
}
|
||||
@@ -1234,21 +1249,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,
|
||||
publish_claim: impl FnOnce(&Path, u64) -> Result<ExternalAgentRunnerGuiOwnerClaim, 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()
|
||||
@@ -1290,7 +1310,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);
|
||||
if let Some(claim) = published_claim {
|
||||
registration.params.gui_owner_epoch = Some(claim.owner_epoch);
|
||||
registration.params.gui_owner_session_revision = Some(claim.session_revision);
|
||||
@@ -1640,6 +1661,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) => {
|
||||
|
||||
@@ -140,37 +140,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 epoch_changed => {
|
||||
crate::clear_platform_session_for_gui_owner(0);
|
||||
(None, None, None, None, None) if epoch_changed => {
|
||||
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?;
|
||||
@@ -178,7 +187,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}"
|
||||
));
|
||||
@@ -188,7 +197,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 {
|
||||
@@ -214,7 +223,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)]
|
||||
|
||||
@@ -691,14 +691,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(
|
||||
@@ -709,12 +711,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");
|
||||
|
||||
@@ -756,6 +760,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()
|
||||
},
|
||||
)
|
||||
@@ -776,7 +781,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,
|
||||
@@ -806,6 +811,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()
|
||||
},
|
||||
)
|
||||
@@ -825,6 +831,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(())
|
||||
@@ -868,6 +875,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() {
|
||||
gui_owner_epoch: Some(owner.owner_epoch.clone()),
|
||||
gui_owner_session_revision: Some(0),
|
||||
platform_auth_generation: Some(2),
|
||||
platform_auth_revision: Some(2),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
)
|
||||
@@ -898,6 +906,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()
|
||||
},
|
||||
)
|
||||
@@ -930,6 +939,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()
|
||||
},
|
||||
)
|
||||
@@ -946,12 +956,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))
|
||||
);
|
||||
|
||||
@@ -964,6 +976,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()
|
||||
},
|
||||
)
|
||||
@@ -999,6 +1012,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()
|
||||
},
|
||||
)
|
||||
@@ -1022,6 +1036,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()
|
||||
},
|
||||
)
|
||||
@@ -1029,7 +1044,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))
|
||||
);
|
||||
}
|
||||
@@ -1073,6 +1089,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()
|
||||
},
|
||||
)
|
||||
@@ -1089,6 +1106,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()),
|
||||
)
|
||||
},
|
||||
@@ -1423,12 +1441,14 @@ fn second_window_attach_with_same_claim_keeps_runner_platform_session() {
|
||||
platform_access_token: Some("runner-token-a".to_string()),
|
||||
platform_api_base_url: Some("https://dev.genarrative.world".to_string()),
|
||||
platform_auth_generation: Some(7),
|
||||
platform_auth_revision: Some(7),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
)
|
||||
.expect("first window installs its session");
|
||||
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-a".to_string(), 7))
|
||||
);
|
||||
|
||||
@@ -1443,7 +1463,8 @@ fn second_window_attach_with_same_claim_keeps_runner_platform_session() {
|
||||
)
|
||||
.expect("second window attaches with the same claim");
|
||||
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-a".to_string(), 7)),
|
||||
"同一 claim 的第二个窗口不得清空平台登录态"
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 【实施计划】平台会话身份与凭据分离
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Milestone | `docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md` |
|
||||
| Status | done(待验收复核) |
|
||||
| Owner | Codex |
|
||||
|
||||
## 修改边界
|
||||
|
||||
- 允许修改:`apps/ai-game-creator-shell/src-tauri/src/platform_session.rs`、`commands.rs`、`runner/`(attach 参数与安装路径)、`project/external_editor_bindings.rs`、`project/resource_editor.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_tools_mcp.rs`、`agent/direct_runtime/`、`assets.rs` 中与身份判据相关的调用点。
|
||||
- 允许修改:`apps/ai-game-creator-shell/src/services/platformSession.ts`、`clientAuth.ts`、`clientApi.ts`、`src/App.tsx` 及对应测试。
|
||||
- 允许修改:`src/services/apiClient.ts` 刷新收敛重试与对应测试。
|
||||
- 允许修改:`server-rs/crates/api-server/src/refresh_session.rs`、`auth_session.rs` 与对应测试。
|
||||
- 明确不修改:`/api/external/v1` 路由与 OpenAPI、`refresh_session` schema / 迁移 / 绑定、生成账本与计费、Provider 与 MCP 工具边界。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 原生会话状态拆分:`PlatformSessionSnapshot` 增加身份代次,`generation` 更名并明确为写入 revision;安装 / 清除 / 冻结校验按新判据重写,补单元用例。
|
||||
2. 逐个修正原生调用点(binding、生成 operation、MCP 会话、diagnostics、Runner attach),由编译器定位所有旧判据读取点。
|
||||
3. renderer:身份代次与 native revision 分离,续期走凭据更新路径;刷新失败只在权威失效时清会话;补前端用例。
|
||||
4. 刷新收敛:AGC 与网站前端在刷新 401 时用当前 cookie 重试一次。
|
||||
5. `api-server`:轮换失败不再下发清 cookie 响应,补定向用例。
|
||||
6. 回写主规范与共享记忆,删除临时计划。
|
||||
|
||||
## 验证命令
|
||||
|
||||
1. `npm run ai-game-creator-shell:check:rust`(或定向 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml platform_session`)
|
||||
2. `npm --prefix apps/ai-game-creator-shell test -- src` 对应前端定向用例
|
||||
3. `npm run test -- src/services/apiClient.test.ts`
|
||||
4. `cargo test -p api-server refresh_session --manifest-path server-rs/Cargo.toml`
|
||||
5. `npm run typecheck`、`npm run check:encoding`、`git diff --check`
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 风险:身份代次判据改宽后,若换号路径仍复用旧代次,旧账号请求可能带着新账号 token 发出。缓解:换号、退出和 origin 变化必须先推进身份代次;Rust 侧对同代次不同主体的写入失败关闭,并用用例覆盖。
|
||||
- 风险:revision 与身份代次混用会让迟到写入复活旧会话。缓解:写入顺序只认 revision,身份只认身份代次,两者分别有用例。
|
||||
- 回滚点:先回滚 renderer 的续期路径与原生判据(第 1-3 步),再回滚服务端响应语义(第 5 步);`refresh_session` schema 不在本次改动内,无需数据回滚。
|
||||
|
||||
## 执行结果
|
||||
|
||||
1. `platform_session.rs`:`PlatformSessionSnapshot` 拆成 `identity_generation` + `revision`,新增 `PlatformSessionIdentity`、身份租约与 `validate_frozen_platform_session`;安装 / 清除按"revision 排序 + 身份归属"双判据实现。
|
||||
2. 原生调用点:`external_editor_bindings.rs`、`resource_editor.rs`、`canvas_generation.rs`、`direct_tools_mcp.rs`、`direct_runtime`、`assets.rs`、`commands.rs` 全部改按身份判定;`commands.rs` 的 IPC 改为 `install_platform_account_session(userId, accessToken, apiBaseUrl, identityGeneration, revision)`、`clear_platform_account_session(identityGeneration, revision)`、`read_platform_account_session_state()`。
|
||||
3. Runner:attach 参数新增 `platform_auth_revision`,`platform_auth_generation` 只表达身份代次;`remember_*` / `validate_*` 按 revision 排序、按身份归属判定。
|
||||
4. renderer:`platformSession.ts` 拆分 `platformNativeIdentityGeneration` 与 `platformNativeRevision`,续期走 `commitPlatformCredentialRefresh`(不推进身份代次);刷新失败结果新增 `authoritative`,`AuthenticatedClient` 只在权威失效时登出。
|
||||
5. 刷新收敛:AGC `clientAuth.refreshClientAuthAccessToken` 与网站 `apiClient.refreshAccessToken` 在 401 后用当前 cookie 再试一次;网站侧额外要求本代次仍是当前代次。
|
||||
6. `api-server`:`refresh_session` 失败不再下发清 cookie 响应(`map_refresh_error_with_clear_cookie` 删除),并更新两条 `app::tests` 断言。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 【里程碑】平台会话身份与凭据分离
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | implemented(待验收复核) |
|
||||
| Date | 2026-09-16 |
|
||||
| Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(`2026-09-16 平台会话身份与凭据分离`) |
|
||||
|
||||
## 目标
|
||||
|
||||
同一账号在长回合内发生 access token 续期时,在途的生成、编辑、上传、确认和下载 operation 必须继续可用;只有登录主体、服务 origin 或登出状态真正变化时才允许中止它们。刷新失败与并发刷新不得把仍然有效的会话判成未登录。
|
||||
|
||||
## 范围
|
||||
|
||||
- AGC renderer 的平台会话状态机:身份代次与 native 写入 revision 的分离,续期不再推进身份代次。
|
||||
- 原生 Rust `platform_session` 会话状态:身份判据、token 判据、写入顺序判据的分离。
|
||||
- 冻结平台会话的校验语义(外部编辑器 binding、生成 operation、MCP 工具会话)。
|
||||
- 独立 Runner 的平台会话安装 / 清除与 attach 参数。
|
||||
- AGC 与网站前端的刷新失败语义、并发刷新收敛重试。
|
||||
- `api-server` `/api/auth/refresh` 轮换失败时的响应语义。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 不改 `refresh_session` 的持久化 schema,不引入服务端轮换宽限表。
|
||||
- 不改 `/api/external/v1` 路由、OpenAPI 或共享 DTO。
|
||||
- 不改生成、计费、账本幂等键与恢复语义本身。
|
||||
- 不改 Provider、MCP 工具白名单、审批或项目锁。
|
||||
|
||||
## 依赖与前置条件
|
||||
|
||||
- 现有 native revision 与 durable GUI owner claim 语义保持不变。
|
||||
- 现有 `authentication-required` 错误分类保持不变,只收窄其触发条件。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 同一账号续期后,续期前建立的在途冻结会话校验通过;换号、退出和 origin 变化后校验失败关闭。
|
||||
- [x] 同一账号续期不推进 renderer 身份代次,也不使 `AuthenticatedClient` / Direct 配置判据把会话判成 stale。
|
||||
- [x] 迟到或更旧的凭据写入被 revision 拒绝,不能覆盖更新的 token,也不能复活已清除的会话。
|
||||
- [x] 瞬时刷新失败(网络错误、5xx、网关错误、响应契约异常)不清除本地会话与 access token。
|
||||
- [x] 刷新返回 401 时先用当前 cookie 收敛重试一次;重试成功继续使用新凭据,重试仍 401 才清会话。
|
||||
- [x] `/api/auth/refresh` 轮换失败不下发清空 refresh cookie 的响应。
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 自动化:AGC Rust 定向用例(身份判据、revision CAS、冻结会话)、AGC 前端用例(代次与刷新失败语义)、`api-server` 定向用例(轮换失败响应)、网站 `apiClient` 用例(收敛重试)。
|
||||
- 运行时:AGC shell Rust 定向测试与前端测试;`api-server` 定向 `cargo test`。
|
||||
- 边界:换号 / 退出失败关闭、迟到写入拒绝、并发刷新竞争、token 不出现在错误文本与持久化中。
|
||||
|
||||
## 证据与未验证项
|
||||
|
||||
- 已获得:AGC `platform_session::tests` 12/12、`runner::tests` 平台会话相关 5/5、`assets::tests` 平台路由 2/2;AGC `appSurface` 前端套件 468 项全通过;网站 `src/services/apiClient.test.ts` 33/33;`cargo test -p api-server refresh_session` 3/3。
|
||||
- 未验证:依赖项目夹具的 AGC Rust 用例(`project::resource_editor::tests`、`project::external_editor_bindings::tests` 等)在本机全部以同一条环境错误失败——测试临时目录属主是 `BUILTIN\Administrators`,而进程用户是 `kdletters\kdletters`,严格 ACL 校验在夹具初始化阶段失败关闭,与本次改动无关;需要非提权或属主正确的运行环境才能补齐。
|
||||
- 未验证:真实 Provider 的并发生图 + 长回合保活端到端 smoke 未执行(需要有效平台登录态与付费生成)。
|
||||
@@ -8777,6 +8777,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/<eventId>.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。
|
||||
- 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。
|
||||
- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。
|
||||
|
||||
## 2026-09-16 平台会话身份与凭据分离
|
||||
|
||||
- 背景:长回合保活和 401 续期每次签发新 access token,并推进 native generation 重新安装原生会话;冻结平台会话同时比对身份与 token 字节,于是同账号的正常续期被等价成换号,并发在途的生成 / 编辑 / 上传 / 确认 / 下载 operation 全部被判为“旧账号请求”失败。更彻底的一层是原生会话、Runner attach 与 renderer 代次都把“写入顺序”和“身份归属”压在同一个计数器上。
|
||||
- 决策:平台会话拆成身份(`userId + api origin + identity generation`)与凭据(当前 access token)。identity generation 只在登录、切号、登出或新的 GUI authority epoch 推进;同账号续期只更新凭据并推进只用于拒绝迟到写入的 revision。冻结会话校验、MCP 会话身份与 Runner attach 统一按身份判定,换号 / 退出仍然失败关闭。刷新失败只在服务端明确 401/403 且一次收敛重试后仍失败时清会话;`/api/auth/refresh` 的轮换失败不再下发清空 refresh cookie 的响应。
|
||||
- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-16 平台会话身份与凭据分离”;开发期计划见 `docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md` 与对应实施计划。
|
||||
- 验证:AGC `platform_session::tests`、`runner::tests`、`assets::tests` 定向通过;AGC `appSurface` 前端套件 468 项通过;网站 `src/services/apiClient.test.ts` 33 项通过;`cargo test -p api-server refresh_session` 通过。项目夹具类 Rust 用例受本机临时目录属主为 `BUILTIN\Administrators` 的环境限制,未计入本次证据。
|
||||
## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板
|
||||
|
||||
- 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。
|
||||
|
||||
@@ -5625,3 +5625,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- 验证:`git config --show-origin --get user.name` 出现 `file:.git/config Git Hooks Test`、`git rev-parse --show-toplevel` 指向 `%TEMP%\genarrative-pre-push-*\repo` 都是被污染的确定性证据;被 `core.worktree` 劫持期间执行的 `git pull` 会把检出写进临时目录,真实工作树整体落后(本次 93 个文件),配置修好后用 `git checkout HEAD -- .` 回填。
|
||||
- Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。
|
||||
- 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。
|
||||
|
||||
## 2026-09-16 并发生图遇到“登录态冲突”:身份代次与凭据轮换混用
|
||||
|
||||
- **现象**:DirectProject 长回合里并发派发的生图 / 素材生成请求中途报 `authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试`,或平台工具返回 `HTTP 401 invalid-token`;账号并没有切换,重新登录后短时间内可复现。
|
||||
- **原因**:客户端把两件事压成了一个判据。原生冻结会话(`PlatformSessionSnapshot`)同时承担“身份归属”和“access token 字节比对”,而长回合保活与 401 续期每次都会签发新 token 并推进 native generation 重新安装会话;于是同账号的正常续期被等价成换号,续期窗口内所有在途生成、编辑、上传、确认和下载 operation 全部失配。保活定时器(回合 busy 时每 5 分钟一次)会稳定落在生图窗口内,所以并发越多越必现。另一层在服务端:`/api/auth/refresh` 是严格一次性轮换,且失败时下发清空 refresh cookie 的响应;两个窗口 / 实例并发续期时,输的一方会把赢家刚写入的有效 cookie 删掉。
|
||||
- **处理(现行口径)**:平台会话统一拆成**身份**(`userId + api origin + identity generation`)与**凭据**(当前 access token)。identity generation 只在登录、切号、登出和新的 GUI authority epoch 推进;同账号续期只更新凭据并推进写入 revision(revision 只用于拒绝迟到写入)。冻结会话校验只比身份,比 token 字节的判据已被取代。刷新失败语义收紧为:只有服务端明确返回 401/403 且经一次收敛重试后仍失败,才清本地会话;网络错误、5xx、网关错误和响应契约异常必须保留会话与 access token。`/api/auth/refresh` 的轮换失败不再下发清 cookie 响应。
|
||||
- **验证**:AGC `platform_session::tests` 覆盖“同身份 token 轮换后冻结会话仍有效”“换号 / 退出后失效”“迟到 install 被 revision 拒绝”;`appSurface` 前端用例覆盖“续期不推进身份代次且 native 写入 revision 递增”“瞬时刷新失败不清会话”;网站 `src/services/apiClient.test.ts` 覆盖“刷新 401 后收敛重试成功”与“两次都被拒绝才判权威失效”;`api-server` `refresh_session_*` 覆盖“轮换失败不下发清 cookie”。定位同类问题先看冻结会话判据里有没有 token 字节,再看服务端失败响应有没有 `Max-Age=0`。
|
||||
|
||||
@@ -32,9 +32,22 @@
|
||||
|
||||
DirectProject 的 AGC 工具、构建、验证和浏览器试玩错误,若不属于鉴权、权限、余额、项目身份、历史损坏、传输断开、取消或付费操作状态不确定等安全终止边界,必须作为脱敏错误上下文回传同一 LLM 会话,由 LLM 读取当前项目、修改真实文件并重跑失败阶段。客户端最多连续反馈三次;每次保留 stage、工具 / 命令、错误正文和已有证据,不得静默吞错、伪造成功或用占位产物跳过阶段。达到三次仍失败后,才向用户投影终态错误和诊断引用。
|
||||
|
||||
## 2026-09-16 平台会话身份与凭据分离(并发续期不再中断在途生成)
|
||||
|
||||
平台会话在 AGC renderer、原生 Rust 层和独立 Runner 中统一拆成两个互不替代的概念:**身份**(`userId + api origin + identity generation`)与**凭据**(当前 access token)。身份代次只表达登录主体或服务 origin 的更换;凭据轮换必须保持身份代次不变。
|
||||
|
||||
- 同一身份内的 access token 轮换 —— 长回合保活、`401` 续期、同一账号重新登录 —— 只更新凭据,不推进身份代次,也不得让任何在途生成、编辑、上传、确认或下载 operation 的冻结会话失效。此前把 token 字节和 native generation 一起当作身份判据,使一次正常续期被等价成换号,在途生图被判为 `authentication-required: 陶泥儿登录态已变化`;本条款取代该判据。
|
||||
- 冻结会话的校验判据只包含身份,不含 token 字节。换号、退出或 origin 变化仍然失败关闭:旧身份的 operation 后续一切 POST、poll、下载和结果安装都必须停止,只保留对账证据,不得改绑或重放。
|
||||
- 原生 install / clear 继续使用单调 revision 作为写入顺序判据,防止迟到写入复活旧状态;身份代次不参与写入排序,只参与身份归属判定。同身份凭据更新必须携带不小于当前 revision 的新 revision 且保持身份代次不变,才能替换 token。
|
||||
- `authentication-required` 只表达"当前 operation 对应的身份已不是权威身份"。同一身份的凭据轮换不得产生该错误。
|
||||
- 刷新失败语义:只有服务端明确返回 `401` / `403`,且收敛重试后仍然失败,才允许清除本地会话与 access token。网络错误、`5xx`、网关错误和响应契约异常必须保留既有会话与 access token,只让发起刷新的那个动作失败。
|
||||
- 并发刷新收敛:同一 origin 的 refresh 单飞;一次刷新返回 `401` 后允许用当前 refresh cookie 收敛重试一次,重试成功则继续使用新凭据,重试仍为 `401` 才判定登录态权威失效。
|
||||
- 服务端 `/api/auth/refresh` 的轮换失败不再下发清空 refresh cookie 的响应。refresh cookie 的失效只由会话吊销、过期或身份变更语义决定,不由一次轮换竞争决定;客户端以"收敛重试后仍 401"作为登出判据。
|
||||
- 证据要求:Rust 定向用例覆盖"同身份 token 轮换后冻结会话与在途 operation 仍有效""换号 / 退出后冻结会话失效""迟到 install 被 revision 拒绝";AGC 前端用例覆盖"续期不推进身份代次""瞬时刷新失败不清会话""收敛重试成功不登出";`api-server` 用例覆盖"刷新轮换失败不下发清 cookie"。
|
||||
|
||||
## 2026-09-15 DirectProject 长回合平台会话保活
|
||||
|
||||
DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。
|
||||
DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新复用单飞请求、身份判据和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。2026-09-16 起,同一账号的保活续期只更新凭据、不推进身份代次,因此不得中断在途生成 operation。
|
||||
|
||||
完成门禁同时允许已登记的普通平台图片作为运行时素材。此前只把 canonical art-spec、背景、图集和图集切片加入来源白名单;`agc_generate_image` 生成的 `assets/neon-*.png` 即使已经登记并被源码引用,也会被判成“未引用平台图片”,触发同一回合的重复修复。浏览器预览把本地图片 URL 改写成 UUID 路径时,验收按每个视口的已渲染本地图片数量与源码引用数量做有界匹配;仍要求两个视口都有对应观察,空视口继续进入修复。
|
||||
|
||||
|
||||
@@ -4933,17 +4933,20 @@ mod tests {
|
||||
.expect("stale refresh request should succeed");
|
||||
|
||||
assert_eq!(stale_refresh_response.status(), StatusCode::UNAUTHORIZED);
|
||||
// 轮换竞争或重放都不能清空浏览器当前的 refresh cookie:清 cookie 会删掉并发
|
||||
// 请求刚刚写入的有效 cookie,把一次竞争放大成登出。cookie 的失效只由会话吊销、
|
||||
// 过期或身份变更决定,客户端在收敛重试后仍收到 401 时才清本地会话。
|
||||
assert!(
|
||||
stale_refresh_response
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
.is_none_or(|value| !value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_session_rejects_missing_cookie_and_clears_cookie() {
|
||||
async fn refresh_session_rejects_missing_cookie_without_touching_cookies() {
|
||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
||||
|
||||
let response = app
|
||||
@@ -4963,7 +4966,7 @@ mod tests {
|
||||
.headers()
|
||||
.get("set-cookie")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains("Max-Age=0"))
|
||||
.is_none_or(|value| !value.contains("Max-Age=0"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::RefreshSessionToken,
|
||||
auth_session::{
|
||||
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
|
||||
build_refresh_session_cookie_header, map_refresh_session_error,
|
||||
attach_set_cookie_header, build_refresh_session_cookie_header, map_refresh_session_error,
|
||||
record_daily_login_tracking_event_after_auth_success, sign_access_token_for_user,
|
||||
},
|
||||
http_error::AppError,
|
||||
@@ -30,10 +29,9 @@ pub async fn refresh_session(
|
||||
.map(|token| token.0.token().to_string())
|
||||
.unwrap_or_default();
|
||||
if raw_refresh_token.trim().is_empty() {
|
||||
return Err(map_refresh_error_with_clear_cookie(
|
||||
&state,
|
||||
RefreshSessionError::MissingToken,
|
||||
));
|
||||
// 缺少 cookie 时同样不下发清 cookie 响应:这里没有可清的对象,失败关闭由
|
||||
// 客户端"收敛重试后仍 401 才登出"的判据负责。
|
||||
return Err(map_refresh_session_error(RefreshSessionError::MissingToken));
|
||||
}
|
||||
let refresh_token_hash = hash_refresh_session_token(&raw_refresh_token);
|
||||
let next_refresh_token = platform_auth::create_refresh_session_token();
|
||||
@@ -57,13 +55,11 @@ pub async fn refresh_session(
|
||||
OffsetDateTime::now_utc(),
|
||||
) {
|
||||
Ok(rotated) => rotated,
|
||||
Err(RefreshSessionError::SessionNotFound) => {
|
||||
return Err(map_refresh_error_with_clear_cookie(
|
||||
&state,
|
||||
RefreshSessionError::SessionNotFound,
|
||||
));
|
||||
}
|
||||
Err(error) => return Err(map_refresh_error_with_clear_cookie(&state, error)),
|
||||
// 轮换失败不下发清空 refresh cookie 的响应。并发或乱序刷新时,清 cookie 会删掉
|
||||
// 另一个请求刚刚写入的有效 refresh cookie,把一次轮换竞争放大成登出;refresh
|
||||
// cookie 的失效只由会话吊销、过期或身份变更语义决定。客户端在收敛重试后仍然
|
||||
// 收到 401 / 403 时才清本地会话。
|
||||
Err(error) => return Err(map_refresh_session_error(error)),
|
||||
};
|
||||
let access_token = sign_access_token_for_user(
|
||||
&state,
|
||||
@@ -103,12 +99,3 @@ pub async fn refresh_session(
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_refresh_error_with_clear_cookie(state: &AppState, error: RefreshSessionError) -> AppError {
|
||||
let response_error = map_refresh_session_error(error);
|
||||
if let Ok(set_cookie) = build_clear_refresh_session_cookie_header(state) {
|
||||
return response_error.with_header("set-cookie", set_cookie);
|
||||
}
|
||||
|
||||
response_error
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user