Compare commits

...

1 Commits

Author SHA1 Message Date
kdletters 81a35e0bff 让AGC登录后直接使用平台编辑器API
普通客户端使用陶泥儿登录态访问站内编辑器、素材与生成状态路由
隔离standalone高级模式的External URL与Developer API Key配置
同步GUI与Runner的平台会话、可信服务origin和鉴权代际
绑定生成账本owner并阻断登出或换号后的旧请求落盘
修正本地图片上传确认、项目资源登记和sourceReferenceId链路
区分401登录刷新与403权限不足并保留原幂等恢复语义
补充后端客户端幂等命名空间、稳定结果投影和图集请求头
更新AGC界面测试、Rust契约测试及项目技术文档
2026-08-17 10:43:08 +08:00
35 changed files with 2792 additions and 425 deletions
@@ -16,9 +16,5 @@
"retryBackoffMs": 500
},
"agentLlm": {},
"editorApi": {
"baseUrl": "http://127.0.0.1:8082",
"apiKey": ""
},
"mcpServers": {}
}
@@ -1207,9 +1207,9 @@ for (const [agentId, agentConfig] of Object.entries(
}
}
if (defaultAppConfig.editorApi?.apiKey !== '') {
if (defaultAppConfig.editorApi !== undefined) {
throw new Error(
'AI game creator shell default editorApi.apiKey must stay empty',
'AI game creator shell ordinary default config must not contain editorApi',
);
}
@@ -133,6 +133,9 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr
}
pub(crate) fn editor_api_key_is_configured() -> bool {
if editor_api_mode() == EditorApiMode::PlatformAccount {
return platform_session_is_available();
}
load_game_creator_app_config()
.ok()
.and_then(|config| trim_config_string(&config.editor_api.api_key))
@@ -383,6 +386,19 @@ pub(crate) fn classify_external_generation_initial_response(
match status {
reqwest::StatusCode::OK => {
let generated = external_editor_response_data(payload);
if let Some(queue_state) = generated
.get("queueState")
.filter(|queue_state| queue_state.is_object())
{
if let Some(operation_id) = json_string_field(queue_state, "operationId") {
return Ok(ExternalGenerationInitialResponse::AsyncSubmission(
serde_json::json!({
"operationId": operation_id,
"pollAfterMs": 2_000,
}),
));
}
}
if !external_generation_result_has_download_reference(generated) {
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果"
@@ -467,9 +483,10 @@ pub(crate) async fn wait_for_external_generation_result(
let submission = external_editor_response_data(submission_payload);
let operation_id = json_string_field(submission, "operationId")
.ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?;
let operation_id_path =
url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::<String>();
let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id_path}");
let status_url = format!(
"{api_base_url}{}",
resolve_platform_generation_status_route(&operation_id)
);
let started_at = tokio::time::Instant::now();
let mut poll_after_ms = external_generation_poll_after_ms(submission_payload);
@@ -504,7 +521,7 @@ pub(crate) async fn wait_for_external_generation_result(
));
}
};
let generation = external_editor_response_data(&payload);
let generation = platform_generation_status_data(&payload);
match json_string_field(generation, "status").as_deref() {
Some("completed") => {
let result = generation
@@ -556,12 +573,32 @@ pub(crate) async fn submit_external_generation_request(
idempotency_key: &str,
request_body_json: &str,
) -> Result<reqwest::Response, String> {
let mut request_body = serde_json::from_str::<serde_json::Value>(request_body_json)
.map_err(|error| format!("解析平台图片生成请求失败:{error}"))?;
if editor_api_mode() == EditorApiMode::PlatformAccount {
let inputs = request_body.as_object_mut().and_then(|body| {
body.entry("generationInputs")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
});
if let Some(inputs) = inputs {
inputs.insert(
"source".to_string(),
serde_json::Value::String("ai-game-creator-client".to_string()),
);
}
}
let request_body_json = serde_json::to_string(&request_body)
.map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?;
client
.post(format!("{api_base_url}{endpoint}"))
.post(format!(
"{api_base_url}{}",
resolve_platform_editor_api_route(endpoint)
))
.bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(request_body_json.to_string())
.body(request_body_json)
.send()
.await
.map_err(|error| {
@@ -652,7 +689,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
};
let projects_payload = external_editor_json_request(
client
.get(format!("{api_base_url}/api/external/v1/editor/projects"))
.get(format!(
"{api_base_url}{}",
resolve_platform_editor_api_route("/api/external/v1/editor/projects")
))
.bearer_auth(api_key),
"读取外部画布项目",
)
@@ -670,7 +710,10 @@ pub(crate) async fn prepare_external_canvas_generation_context(
None => {
let payload = external_editor_json_request(
client
.post(format!("{api_base_url}/api/external/v1/editor/projects"))
.post(format!(
"{api_base_url}{}",
resolve_platform_editor_api_route("/api/external/v1/editor/projects")
))
.bearer_auth(api_key)
.json(&serde_json::json!({ "title": canvas_name })),
"创建外部画布项目",
@@ -686,7 +729,8 @@ pub(crate) async fn prepare_external_canvas_generation_context(
let library_payload = external_editor_json_request(
client
.get(format!(
"{api_base_url}/api/external/v1/editor/assets/library"
"{api_base_url}{}",
resolve_platform_editor_api_route("/api/external/v1/editor/assets/library")
))
.bearer_auth(api_key),
"读取外部素材库",
@@ -707,7 +751,8 @@ pub(crate) async fn prepare_external_canvas_generation_context(
let payload = external_editor_json_request(
client
.post(format!(
"{api_base_url}/api/external/v1/editor/assets/folders"
"{api_base_url}{}",
resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders")
))
.bearer_auth(api_key)
.json(&serde_json::json!({ "label": canvas_name })),
@@ -134,7 +134,11 @@ fn request_body_json_and_sha256(
pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String {
let normalized_base_url = api_base_url.trim().trim_end_matches('/');
format!("{:x}", Sha256::digest(normalized_base_url.as_bytes()))
let account_identity = platform_session_service_identity().unwrap_or_default();
format!(
"{:x}",
Sha256::digest(format!("{normalized_base_url}\n{account_identity}").as_bytes())
)
}
pub(crate) fn platform_art_generation_external_service_origin(
@@ -247,9 +247,12 @@ pub(crate) async fn sync_canvas_project_assets_at(
let api_key = resolve_canvas_sync_api_key(api_key)?;
let client = reqwest::Client::new();
let project_url = format!(
"{}/api/external/v1/editor/projects/{}",
"{}{}",
api_base_url,
percent_encode_query_component(canvas_project_id)
resolve_platform_editor_api_route(&format!(
"/api/external/v1/editor/projects/{}",
percent_encode_query_component(canvas_project_id)
))
);
let project_response = client
.get(project_url)
@@ -615,7 +618,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit(
api_key,
resource,
max_bytes,
"/api/external/v1/assets/read-url",
&resolve_platform_editor_api_route("/api/external/v1/assets/read-url"),
)
.await
}
@@ -776,6 +779,11 @@ pub(crate) async fn resolve_external_asset_signed_url(
pub(crate) fn resolve_canvas_sync_api_base_url(
api_base_url: Option<String>,
) -> Result<String, String> {
if editor_api_mode() == EditorApiMode::PlatformAccount {
return current_platform_session()
.map(|session| session.api_base_url)
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string());
}
let config = load_game_creator_app_config()?;
let value = trim_optional_string(api_base_url)
.or_else(|| trim_config_string(&config.editor_api.base_url))
@@ -789,17 +797,113 @@ pub(crate) fn resolve_canvas_sync_api_base_url(
}
pub(crate) fn resolve_canvas_sync_api_key(api_key: Option<String>) -> Result<String, String> {
if editor_api_mode() == EditorApiMode::PlatformAccount {
return current_platform_session()
.map(|session| session.access_token)
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string());
}
let config = load_game_creator_app_config()?;
trim_optional_string(api_key)
.or_else(|| trim_config_string(&config.editor_api.api_key))
.ok_or_else(|| {
format!(
"画板同步需要{} 的 editorApi.apiKey 中设置 API Key",
"{} 的 editorApi.apiKey 中设置开发者 API Key",
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
)
})
}
pub(crate) fn resolve_canvas_sync_api_credentials(
api_base_url: Option<String>,
api_key: Option<String>,
) -> Result<(String, String, Option<PlatformSessionSnapshot>), String> {
if editor_api_mode() == EditorApiMode::PlatformAccount {
let session = current_platform_session()
.ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?;
return Ok((
session.api_base_url.clone(),
session.access_token.clone(),
Some(session),
));
}
Ok((
resolve_canvas_sync_api_base_url(api_base_url)?,
resolve_canvas_sync_api_key(api_key)?,
None,
))
}
pub(crate) fn resolve_platform_editor_api_route(external_route: &str) -> String {
resolve_platform_editor_api_route_for_mode(
external_route,
editor_api_mode() == EditorApiMode::PlatformAccount,
)
}
fn resolve_platform_editor_api_route_for_mode(
external_route: &str,
platform_session_available: bool,
) -> String {
if !platform_session_available {
return external_route.to_string();
}
if let Some(suffix) = external_route.strip_prefix("/api/external/v1/editor/") {
return format!("/api/editor/{suffix}");
}
if let Some(suffix) = external_route.strip_prefix("/api/external/v1/assets/") {
return format!("/api/assets/{suffix}");
}
external_route.to_string()
}
pub(crate) fn resolve_platform_generation_status_route(operation_id: &str) -> String {
resolve_platform_generation_status_route_for_mode(
operation_id,
editor_api_mode() == EditorApiMode::PlatformAccount,
)
}
pub(crate) fn editor_api_authentication_error() -> String {
match editor_api_mode() {
EditorApiMode::PlatformAccount => {
"authentication-required: 陶泥儿登录已失效,请重新登录".to_string()
}
EditorApiMode::ExternalDeveloper => {
"authentication-required: External Editor API Key 无效或权限不足".to_string()
}
}
}
pub(crate) fn editor_api_authorization_error() -> String {
match editor_api_mode() {
EditorApiMode::PlatformAccount => {
"permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string()
}
EditorApiMode::ExternalDeveloper => {
"permission-denied: External Editor API Key 权限不足".to_string()
}
}
}
fn resolve_platform_generation_status_route_for_mode(
operation_id: &str,
platform_session_available: bool,
) -> String {
let operation_id = percent_encode_query_component(operation_id);
if platform_session_available {
format!("/api/runtime/external-generation/jobs/{operation_id}")
} else {
format!("/api/external/v1/generations/{operation_id}")
}
}
pub(crate) fn platform_generation_status_data(payload: &serde_json::Value) -> &serde_json::Value {
let data = payload.get("data").unwrap_or(payload);
data.get("job")
.filter(|job| job.is_object())
.unwrap_or(data)
}
pub(crate) fn json_string_field(value: &serde_json::Value, field: &str) -> Option<String> {
value
.get(field)
@@ -1110,6 +1214,51 @@ mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn platform_session_maps_external_shaped_calls_to_first_party_routes() {
assert_eq!(
resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", true,),
"/api/editor/projects"
);
assert_eq!(
resolve_platform_editor_api_route_for_mode("/api/external/v1/assets/read-url", true,),
"/api/assets/read-url"
);
assert_eq!(
resolve_platform_generation_status_route_for_mode("operation/a b", true),
"/api/runtime/external-generation/jobs/operation%2Fa%20b"
);
assert_eq!(
resolve_platform_generation_status_route_for_mode("operation/a b", false),
"/api/external/v1/generations/operation%2Fa%20b"
);
let payload = serde_json::json!({
"data": {
"job": {
"operationId": "operation-1",
"status": "completed",
"result": { "objectKey": "generated/result.png" },
}
}
});
assert_eq!(
platform_generation_status_data(&payload)["result"]["objectKey"],
"generated/result.png"
);
}
#[test]
fn advanced_external_mode_keeps_external_routes_without_platform_session() {
assert_eq!(
resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", false,),
"/api/external/v1/editor/projects"
);
assert_eq!(
resolve_platform_generation_status_route_for_mode("operation-1", false),
"/api/external/v1/generations/operation-1"
);
}
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
@@ -1270,6 +1270,30 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
check_game_creator_llm_config_from_config()
}
#[tauri::command]
pub(crate) fn install_platform_account_session(
user_id: String,
access_token: String,
api_base_url: String,
generation: u64,
) -> Result<(), String> {
install_platform_session(&user_id, &access_token, &api_base_url, generation)?;
let _ = install_external_agent_runner_platform_session(
&user_id,
&access_token,
&api_base_url,
generation,
);
Ok(())
}
#[tauri::command]
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
clear_platform_session(generation);
let _ = clear_external_agent_runner_platform_session(generation);
Ok(())
}
#[tauri::command]
pub(crate) fn read_game_creator_app_config() -> Result<GameCreatorAppConfigView, String> {
game_creator_app_config_view(load_game_creator_app_config()?)
@@ -1281,8 +1305,7 @@ pub(crate) fn write_game_creator_app_config(
) -> Result<GameCreatorAppConfigView, String> {
let config = normalize_game_creator_app_config(config)?;
let path = writable_game_creator_config_path()?;
let content = serde_json::to_string_pretty(&config)
.map_err(|error| format!("序列化客户端配置失败:{error}"))?;
let content = serialize_game_creator_app_config_for_renderer_write(&config)?;
write_game_creator_config_atomically(&path, &format!("{content}\n"))?;
game_creator_app_config_view(load_game_creator_app_config()?)
}
@@ -1328,14 +1328,31 @@ pub(crate) fn load_game_creator_app_config() -> Result<GameCreatorAppConfig, Str
}
pub(crate) fn game_creator_app_config_view(
config: GameCreatorAppConfig,
mut config: GameCreatorAppConfig,
) -> Result<GameCreatorAppConfigView, String> {
if editor_api_mode() == EditorApiMode::PlatformAccount {
config.editor_api = GameCreatorEditorApiConfig::default();
}
Ok(GameCreatorAppConfigView {
path: writable_game_creator_config_path()?.display().to_string(),
config,
})
}
pub(crate) fn serialize_game_creator_app_config_for_renderer_write(
config: &GameCreatorAppConfig,
) -> Result<String, String> {
let mut value =
serde_json::to_value(config).map_err(|error| format!("序列化客户端配置失败:{error}"))?;
if editor_api_mode() == EditorApiMode::PlatformAccount {
value
.as_object_mut()
.ok_or_else(|| "客户端配置必须是 JSON object".to_string())?
.remove("editorApi");
}
serde_json::to_string_pretty(&value).map_err(|error| format!("序列化客户端配置失败:{error}"))
}
pub(crate) fn writable_game_creator_config_path() -> Result<PathBuf, String> {
if let Some(config_dir) = game_creator_runtime_config_dir() {
return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME));
@@ -67,6 +67,7 @@ mod image_inspect;
mod isolated_agent;
mod mcp;
mod patchset;
mod platform_session;
mod preview;
mod process_session;
mod process_session_bridge;
@@ -101,6 +102,7 @@ use image_inspect::*;
use isolated_agent::*;
use mcp::*;
use patchset::*;
use platform_session::*;
use preview::*;
use process_session::*;
use project::*;
@@ -1239,7 +1241,7 @@ fn default_game_creator_llm_auto_compact_token_limit() -> u64 {
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
}
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082";
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world";
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
@@ -2214,6 +2216,8 @@ fn main() {
confirm_resume_game_creator_agent_runtime_tasks,
schedule_game_creator_agent_ready_tasks,
check_game_creator_llm_config,
install_platform_account_session,
clear_platform_account_session,
read_game_creator_app_config,
write_game_creator_app_config,
read_game_creator_mcp_catalog,
@@ -0,0 +1,320 @@
use std::sync::{Mutex, OnceLock};
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PlatformSessionSnapshot {
pub(crate) user_id: String,
pub(crate) access_token: String,
pub(crate) api_base_url: String,
pub(crate) generation: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EditorApiMode {
PlatformAccount,
ExternalDeveloper,
}
pub(crate) fn editor_api_mode_for_build(
debug_assertions: bool,
game_chat_release_feature: bool,
) -> EditorApiMode {
if !debug_assertions && game_chat_release_feature {
EditorApiMode::ExternalDeveloper
} else {
EditorApiMode::PlatformAccount
}
}
pub(crate) fn editor_api_mode() -> EditorApiMode {
editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release"))
}
#[derive(Default)]
struct PlatformSessionState {
generation: u64,
snapshot: Option<PlatformSessionSnapshot>,
}
static PLATFORM_SESSION: OnceLock<Mutex<PlatformSessionState>> = OnceLock::new();
fn platform_session() -> &'static Mutex<PlatformSessionState> {
PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default()))
}
fn install_platform_session_in(
current: &mut PlatformSessionState,
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) {
if generation < current.generation {
return;
}
if generation == current.generation {
if current.snapshot.as_ref().is_some_and(|snapshot| {
snapshot.user_id == user_id
&& snapshot.access_token == access_token
&& snapshot.api_base_url == api_base_url
}) {
return;
}
// Equal-generation retries may only repeat the exact committed snapshot. In
// particular, a late install cannot revive a generation that was cleared.
return;
}
current.generation = generation;
current.snapshot = Some(PlatformSessionSnapshot {
user_id: user_id.to_string(),
access_token: access_token.to_string(),
api_base_url: api_base_url.to_string(),
generation,
});
}
fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) {
if generation < current.generation {
return;
}
current.generation = generation;
current.snapshot = None;
}
pub(crate) fn install_platform_session(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
if editor_api_mode() == EditorApiMode::ExternalDeveloper {
return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string());
}
let user_id = user_id.trim();
let access_token = access_token.trim();
let api_base_url = normalize_platform_api_base_url(api_base_url)?;
if user_id.is_empty() || user_id.len() > 256 {
return Err("陶泥儿登录用户身份无效".to_string());
}
if access_token.is_empty() || access_token.len() > 16 * 1024 {
return Err("陶泥儿登录凭据无效".to_string());
}
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
install_platform_session_in(
&mut current,
user_id,
access_token,
&api_base_url,
generation,
);
Ok(())
}
fn normalize_platform_api_base_url(value: &str) -> Result<String, String> {
let value = value.trim().trim_end_matches('/');
let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?;
if !matches!(parsed.scheme(), "http" | "https")
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| parsed.path() != "/"
{
return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string());
}
let host = parsed
.host_str()
.ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?;
let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world");
let loopback = host == "localhost"
|| host == "127.0.0.1"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") {
return Err("陶泥儿服务地址不在受信任白名单内".to_string());
}
Ok(value.to_string())
}
pub(crate) fn clear_platform_session(generation: u64) {
let mut current = platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_platform_session_in(&mut current, generation);
}
pub(crate) fn current_platform_session() -> Option<PlatformSessionSnapshot> {
platform_session()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.snapshot
.clone()
}
pub(crate) fn validate_platform_session_snapshot(
expected: &PlatformSessionSnapshot,
) -> Result<(), String> {
if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) {
Ok(())
} else {
Err(
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
.to_string(),
)
}
}
fn platform_session_snapshot_matches(
current: Option<&PlatformSessionSnapshot>,
expected: &PlatformSessionSnapshot,
) -> bool {
current == Some(expected)
}
pub(crate) fn platform_session_is_available() -> bool {
current_platform_session().is_some()
}
pub(crate) fn platform_session_service_identity() -> Option<String> {
current_platform_session()
.map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cleared_generation_rejects_late_install_and_older_clear() {
let mut state = PlatformSessionState::default();
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
1,
);
clear_platform_session_in(&mut state, 2);
install_platform_session_in(
&mut state,
"user-a",
"late-token-a",
"https://dev.genarrative.world",
1,
);
install_platform_session_in(
&mut state,
"user-a",
"same-generation-token",
"https://dev.genarrative.world",
2,
);
assert!(state.snapshot.is_none());
assert_eq!(state.generation, 2);
install_platform_session_in(
&mut state,
"user-b",
"token-b",
"https://dev.genarrative.world",
3,
);
clear_platform_session_in(&mut state, 2);
assert_eq!(
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
Some("user-b")
);
assert_eq!(state.generation, 3);
}
#[test]
fn equal_generation_only_accepts_the_exact_idempotent_snapshot() {
let mut state = PlatformSessionState::default();
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
4,
);
install_platform_session_in(
&mut state,
"user-a",
"token-a",
"https://dev.genarrative.world",
4,
);
install_platform_session_in(
&mut state,
"user-b",
"token-b",
"https://dev.genarrative.world",
4,
);
assert_eq!(
state.snapshot.as_ref().map(|value| value.user_id.as_str()),
Some("user-a")
);
assert_eq!(
state
.snapshot
.as_ref()
.map(|value| value.access_token.as_str()),
Some("token-a")
);
}
#[test]
fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() {
assert_eq!(
editor_api_mode_for_build(true, false),
EditorApiMode::PlatformAccount
);
assert_eq!(
editor_api_mode_for_build(true, true),
EditorApiMode::PlatformAccount
);
assert_eq!(
editor_api_mode_for_build(false, false),
EditorApiMode::PlatformAccount
);
assert_eq!(
editor_api_mode_for_build(false, true),
EditorApiMode::ExternalDeveloper
);
}
#[test]
fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() {
let expected = PlatformSessionSnapshot {
user_id: "user-a".to_string(),
access_token: "token-a".to_string(),
api_base_url: "https://dev.genarrative.world".to_string(),
generation: 4,
};
assert!(platform_session_snapshot_matches(
Some(&expected),
&expected
));
for current in [
None,
Some(PlatformSessionSnapshot {
user_id: "user-b".to_string(),
..expected.clone()
}),
Some(PlatformSessionSnapshot {
access_token: "token-b".to_string(),
generation: 5,
..expected.clone()
}),
] {
assert!(!platform_session_snapshot_matches(
current.as_ref(),
&expected
));
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -9,9 +9,10 @@ mod state;
#[allow(unused_imports)]
pub(crate) use client::{
attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal,
compact_external_agent_runner_context, configure_external_agent_runner,
configure_external_agent_runner_read_only, continue_external_agent_runner_action,
ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui,
clear_external_agent_runner_platform_session, compact_external_agent_runner_context,
configure_external_agent_runner, configure_external_agent_runner_read_only,
continue_external_agent_runner_action, ensure_external_agent_runner_started,
ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session,
interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner,
pause_external_agent_runner, read_external_agent_runner_mcp_catalog,
read_external_agent_runner_status,
@@ -106,7 +106,7 @@ fn redact_url_queries(line: &str) -> String {
.join(" ")
}
fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
pub(super) fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
let lowercase = line.to_ascii_lowercase();
if [
"authorization",
@@ -123,6 +123,9 @@ fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String {
"set-cookie",
"secret",
"access_token",
"accesstoken",
"platform_access_token",
"platformaccesstoken",
"refresh_token",
"\"token\"",
"'token'",
@@ -993,18 +996,125 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?;
let platform_session = crate::current_platform_session();
register_external_agent_runner_gui_owner_attachment(
external_agent_runner_gui_owner_attachment_state(),
&config_dir,
ExternalAgentRunnerRequestParams {
event_sink_port: Some(event_sink.port),
event_sink_token: Some(event_sink.token.clone()),
platform_user_id: platform_session
.as_ref()
.map(|session| session.user_id.clone()),
platform_access_token: platform_session
.as_ref()
.map(|session| session.access_token.clone()),
platform_api_base_url: platform_session
.as_ref()
.map(|session| session.api_base_url.clone()),
platform_auth_generation: platform_session.map(|session| session.generation),
..ExternalAgentRunnerRequestParams::default()
},
);
ensure_external_agent_runner(&config_dir).map(|_| ())
}
pub(crate) fn install_external_agent_runner_platform_session(
user_id: &str,
access_token: &str,
api_base_url: &str,
generation: u64,
) -> Result<(), String> {
remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(),
Some((user_id, access_token, api_base_url)),
generation,
);
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?;
let endpoint = {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
ensure_external_agent_runner(&config_dir)?
};
send_external_agent_runner_request(
&endpoint,
"platform.session.install",
ExternalAgentRunnerRequestParams {
platform_user_id: Some(user_id.to_string()),
platform_access_token: Some(access_token.to_string()),
platform_api_base_url: Some(api_base_url.to_string()),
platform_auth_generation: Some(generation),
..ExternalAgentRunnerRequestParams::default()
},
)
.map(|_| ())
}
pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> {
remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(),
None,
generation,
);
let Some(config_dir) = external_agent_runner_config_dir() else {
return Ok(());
};
let endpoint_path = external_agent_runner_endpoint_path(&config_dir);
let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) else {
return Ok(());
};
send_external_agent_runner_request(
&endpoint,
"platform.session.clear",
ExternalAgentRunnerRequestParams {
platform_auth_generation: Some(generation),
..ExternalAgentRunnerRequestParams::default()
},
)
.map(|_| ())
}
pub(super) fn remember_external_agent_runner_platform_session(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
session: Option<(&str, &str, &str)>,
generation: u64,
) {
let mut state = lock_unpoisoned(state);
let Some(registration) = state.registration.as_mut() else {
return;
};
let current_generation = registration.params.platform_auth_generation.unwrap_or(0);
if generation < current_generation {
return;
}
if generation == current_generation {
match session {
Some((user_id, access_token, api_base_url))
if registration.params.platform_user_id.as_deref() == Some(user_id)
&& registration.params.platform_access_token.as_deref()
== Some(access_token)
&& registration.params.platform_api_base_url.as_deref()
== Some(api_base_url) =>
{
return;
}
Some(_) => return,
None if registration.params.platform_user_id.is_none()
&& registration.params.platform_access_token.is_none() =>
{
return;
}
None => {}
}
}
registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string());
registration.params.platform_access_token =
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);
}
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
result: &Value,
) -> Result<(), String> {
@@ -1295,6 +1405,10 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
steer_id: steer_id.map(str::to_string),
event_sink_port: None,
event_sink_token: None,
platform_user_id: None,
platform_access_token: None,
platform_api_base_url: None,
platform_auth_generation: None,
};
match stable_identity {
Some(stable_identity) => {
@@ -620,6 +620,30 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
redact_runner_secret(&error, &token),
);
}
if let (
Some(user_id),
Some(access_token),
Some(api_base_url),
Some(generation),
) = (
request.params.platform_user_id.as_deref(),
request.params.platform_access_token.as_deref(),
request.params.platform_api_base_url.as_deref(),
request.params.platform_auth_generation,
) {
if let Err(error) = crate::install_platform_session(
user_id,
access_token,
api_base_url,
generation,
) {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-invalid",
error,
);
}
}
state.gui_owner_attached.store(true, Ordering::Release);
ExternalAgentRunnerResponse::success(
&request.request_id,
@@ -638,6 +662,41 @@ pub(super) fn dispatch_external_agent_runner_runtime_request(
),
}
}
"platform.session.install" => {
let result = request
.params
.platform_user_id
.as_deref()
.zip(request.params.platform_access_token.as_deref())
.zip(request.params.platform_api_base_url.as_deref())
.zip(request.params.platform_auth_generation)
.ok_or_else(|| "平台登录态同步参数不完整".to_string())
.and_then(|(((user_id, access_token), api_base_url), generation)| {
crate::install_platform_session(user_id, access_token, api_base_url, generation)
});
match result {
Ok(()) => ExternalAgentRunnerResponse::success(
&request.request_id,
json!({ "installed": true }),
),
Err(error) => ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-invalid",
error,
),
}
}
"platform.session.clear" => {
let Some(generation) = request.params.platform_auth_generation else {
return ExternalAgentRunnerResponse::failure(
&request.request_id,
"platform-session-invalid",
"平台登录态清除缺少 authGeneration",
);
};
crate::clear_platform_session(generation);
ExternalAgentRunnerResponse::success(&request.request_id, json!({ "cleared": true }))
}
"runner.shutdown" | "shutdown" => {
let provider_requests_interrupted =
request_external_agent_runner_forced_shutdown(state);
@@ -865,6 +924,8 @@ pub(super) fn handle_external_agent_runner_request(
| "runtime.cancel"
| "runtime.compact"
| "runner.attach_gui_owner"
| "platform.session.install"
| "platform.session.clear"
| "runner.shutdown"
| "shutdown"
| "runner.shutdown_for_client_exit"
@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 6;
pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock";
@@ -268,6 +268,14 @@ pub(super) struct ExternalAgentRunnerRequestParams {
pub(super) event_sink_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) event_sink_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_access_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_api_base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) platform_auth_generation: Option<u64>,
}
#[derive(Deserialize, Serialize)]
@@ -85,7 +85,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m
assert!(
external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5);
assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 6);
}
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
@@ -639,6 +639,80 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() {
);
}
#[test]
fn gui_owner_registration_replays_only_the_latest_platform_session() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
let config_dir = PathBuf::from("platform-session-replay-appdata");
register_external_agent_runner_gui_owner_attachment(
&state,
&config_dir,
ExternalAgentRunnerRequestParams {
event_sink_port: Some(31_330),
event_sink_token: Some("f".repeat(64)),
..ExternalAgentRunnerRequestParams::default()
},
);
remember_external_agent_runner_platform_session(
&state,
Some(("user-a", "token-a", "https://dev.genarrative.world")),
4,
);
remember_external_agent_runner_platform_session(&state, None, 5);
remember_external_agent_runner_platform_session(
&state,
Some(("user-a", "late-token-a", "https://dev.genarrative.world")),
4,
);
remember_external_agent_runner_platform_session(
&state,
Some((
"user-a",
"same-generation-token",
"https://dev.genarrative.world",
)),
5,
);
remember_external_agent_runner_platform_session(
&state,
Some(("user-b", "token-b", "https://dev.genarrative.world")),
6,
);
let endpoint = test_endpoint(
"platform-session-replay-runner-token",
"platform-session-replay-boot",
31_330,
);
attach_registered_external_agent_runner_gui_owner_if_needed_with(
&state,
&config_dir,
&endpoint,
|_, params| {
assert_eq!(params.platform_user_id.as_deref(), Some("user-b"));
assert_eq!(params.platform_access_token.as_deref(), Some("token-b"));
assert_eq!(
params.platform_api_base_url.as_deref(),
Some("https://dev.genarrative.world")
);
assert_eq!(params.platform_auth_generation, Some(6));
Ok(())
},
)
.expect("latest platform session is replayed");
}
#[test]
fn runner_output_redacts_camel_case_platform_access_token_fields() {
let config_dir = Path::new("private-appdata");
assert_eq!(
sanitize_agent_runner_output(
r#"request={"platformAccessToken":"must-not-leak"}"#,
config_dir,
),
"<sensitive runner output redacted>"
);
}
#[test]
fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() {
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
@@ -606,8 +606,12 @@ fn app_config_commands_write_runtime_config_file() {
Some(false)
);
assert!(!saved.config.agent_llm.contains_key("generator"));
assert_eq!(saved.config.editor_api.api_key, "editor-key");
assert_eq!(saved.config.editor_api.api_key, "");
assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file());
let persisted = fs::read_to_string(root.join(GAME_CREATOR_CONFIG_FILE_NAME))
.expect("read persisted runtime config");
assert!(!persisted.contains("editorApi"));
assert!(!persisted.contains("editor-key"));
let read_back = read_game_creator_app_config().expect("read runtime config");
assert_eq!(read_back.config.llm.model, "runtime-model");
+60 -43
View File
@@ -549,15 +549,18 @@ export function WorkspaceLauncher(props: WorkspaceLauncherProps) {
export function GameChatReleaseApp({
initialProjectPath = '',
initialSupervisorMessage = '',
allowAdvancedExternalEditorConfig = false,
}: {
initialProjectPath?: string;
initialSupervisorMessage?: string;
allowAdvancedExternalEditorConfig?: boolean;
}) {
return (
<App
initialProjectPath={initialProjectPath}
projectSupervisorOnly
gameChatOnly
allowAdvancedExternalEditorConfig={allowAdvancedExternalEditorConfig}
initialSupervisorMessage={initialSupervisorMessage}
/>
);
@@ -570,6 +573,7 @@ type AppProps = {
projectSupervisorOnly?: boolean;
supervisorChatOnly?: boolean;
gameChatOnly?: boolean;
allowAdvancedExternalEditorConfig?: boolean;
initialSupervisorMessage?: string;
onManifestChange?: (
projectPath: string,
@@ -590,6 +594,7 @@ export function App({
projectSupervisorOnly = false,
supervisorChatOnly = false,
gameChatOnly = false,
allowAdvancedExternalEditorConfig = false,
initialSupervisorMessage = '',
onManifestChange,
onPreviewChange,
@@ -10971,49 +10976,61 @@ export function App({
if (projectSupervisorOnly && gameChatOnly) {
const gameChatProjectPath = localProject?.projectPath ?? '';
return (
<SupervisorChatOnlyView
chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
gameChatMode
messagesRef={supervisorChatMessagesRef}
onCancelConfirmation={cancelUiCommandConfirmation}
onChatInputChange={setChatInput}
onCloseRuntimeConfig={() => setRuntimeConfigOpen(false)}
onConfirmConfirmation={confirmUiCommand}
onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)}
onProjectPick={() => void handleGameChatProjectPick()}
onScroll={handleSupervisorChatScroll}
onShowEarlierMessages={showEarlierConversationMessages}
onSubmit={handleProjectSupervisorOnlySubmit}
onToolAction={handleProjectSupervisorToolAction}
onUserInput={handleProjectSupervisorUserInput}
pendingConfirmation={pendingUiConfirmation}
pendingCommand={pendingCommand}
onCancelPendingCommand={handlePendingCommandCancel}
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate}
onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder}
onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder}
preview={preview}
previewRevision={gameChatPreviewRevision}
previewStatus={previewStatus}
projectPath={gameChatProjectPath}
projectReady={Boolean(localProject)}
projectSelectionBusy={gameChatProjectSelectionBusy}
runtime={projectSupervisorRuntime}
runtimeByAgentId={agentRuntimeById}
manifest={manifest}
runtimeConfigOpen={runtimeConfigOpen}
runtimeError={projectSupervisorRuntimeError}
transientReply={projectSupervisorTransientReply}
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
hasConversationControls={projectSupervisorHasConversationControls}
hiddenConversationCount={hiddenConversationCount}
needsUserInput={projectSupervisorNeedsUserInput}
visibleMessages={visibleMessages}
workspaceStatus={workspaceStatus}
expectedRunId={projectSupervisorExpectedRunId}
/>
<>
<SupervisorChatOnlyView
chatAgentBusy={chatAgentBusy}
chatInput={chatInput}
gameChatMode
messagesRef={supervisorChatMessagesRef}
onCancelConfirmation={cancelUiCommandConfirmation}
onChatInputChange={setChatInput}
onCloseRuntimeConfig={() => setRuntimeConfigOpen(false)}
onConfirmConfirmation={confirmUiCommand}
onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)}
onProjectPick={() => void handleGameChatProjectPick()}
onScroll={handleSupervisorChatScroll}
onShowEarlierMessages={showEarlierConversationMessages}
onSubmit={handleProjectSupervisorOnlySubmit}
onToolAction={handleProjectSupervisorToolAction}
onUserInput={handleProjectSupervisorUserInput}
pendingConfirmation={pendingUiConfirmation}
pendingCommand={pendingCommand}
onCancelPendingCommand={handlePendingCommandCancel}
onConfirmPendingCommand={() => void handlePendingCommandConfirm()}
pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate}
onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder}
onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder}
preview={preview}
previewRevision={gameChatPreviewRevision}
previewStatus={previewStatus}
projectPath={gameChatProjectPath}
projectReady={Boolean(localProject)}
projectSelectionBusy={gameChatProjectSelectionBusy}
runtime={projectSupervisorRuntime}
runtimeByAgentId={agentRuntimeById}
manifest={manifest}
runtimeConfigOpen={
allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen
}
runtimeError={projectSupervisorRuntimeError}
transientReply={projectSupervisorTransientReply}
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
hasConversationControls={projectSupervisorHasConversationControls}
hiddenConversationCount={hiddenConversationCount}
needsUserInput={projectSupervisorNeedsUserInput}
visibleMessages={visibleMessages}
workspaceStatus={workspaceStatus}
expectedRunId={projectSupervisorExpectedRunId}
/>
{runtimeConfigOpen && allowAdvancedExternalEditorConfig ? (
<RuntimeConfigDialog
allowAdvancedExternalEditorConfig
projectPath={gameChatProjectPath}
onClose={() => setRuntimeConfigOpen(false)}
onLog={(entry) => setCommandLog((current) => [...current, entry])}
/>
) : null}
</>
);
}
@@ -18,9 +18,16 @@ import {
loginClientWithPhoneCode,
logoutClientAuthSession,
normalizeAuthPhoneInput,
refreshClientAuthAccessToken,
sendClientPhoneLoginCode,
} from '../services/clientAuth';
import {
beginPlatformSessionTransition,
clearCommittedPlatformSession,
commitAuthenticatedPlatformSession,
currentPlatformSessionGeneration,
refreshPlatformSessionForGeneration,
subscribePlatformSessionRefresh,
} from '../services/platformSession';
type ClientRuntimeErrorBoundaryProps = {
children: ReactNode;
@@ -92,15 +99,30 @@ export function AuthenticatedClient({
useEffect(() => {
let disposed = false;
async function hydrateAuth() {
const hydrationGeneration = currentPlatformSessionGeneration();
try {
if (!getStoredAuthAccessToken()) {
await refreshClientAuthAccessToken();
const refreshed =
await refreshPlatformSessionForGeneration(hydrationGeneration);
if (!refreshed) {
return;
}
}
const user = await getCurrentClientAuthUser();
if (disposed) {
return;
}
if (user) {
const committedGeneration = await commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
);
if (committedGeneration === null) {
return;
}
if (disposed) {
return;
}
setAuthUser(user);
setAuthStatus('authenticated');
return;
@@ -123,12 +145,27 @@ export function AuthenticatedClient({
}
if (getStoredAuthAccessToken()) {
try {
await refreshClientAuthAccessToken();
const refreshed =
await refreshPlatformSessionForGeneration(hydrationGeneration);
if (!refreshed) {
return;
}
const user = await getCurrentClientAuthUser();
if (disposed) {
return;
}
if (user) {
const committedGeneration =
await commitAuthenticatedPlatformSession(
user,
hydrationGeneration,
);
if (committedGeneration === null) {
return;
}
if (disposed) {
return;
}
setAuthUser(user);
setAuthStatus('authenticated');
return;
@@ -159,6 +196,25 @@ export function AuthenticatedClient({
};
}, []);
useEffect(
() =>
subscribePlatformSessionRefresh((result) => {
if (result.status === 'refreshed') {
setAuthUser((current) =>
current?.id === result.user.id ? result.user : current,
);
return;
}
if (result.status === 'failed') {
clearStoredAuthAccessToken();
setAuthUser(null);
setAuthStatus('unauthenticated');
setLoginStatus('登录已失效,请重新登录');
}
}),
[],
);
useEffect(() => {
if (codeCooldownSeconds <= 0) {
return;
@@ -211,11 +267,19 @@ export function AuthenticatedClient({
}
setLoginBusy(true);
setLoginStatus('正在登录');
const loginGeneration = beginPlatformSessionTransition();
try {
const user =
loginMode === 'code'
? await loginClientWithPhoneCode(normalizedPhone, code)
: await loginClientWithPassword(normalizedPhone, password);
const committedGeneration = await commitAuthenticatedPlatformSession(
user,
loginGeneration,
);
if (committedGeneration === null) {
return;
}
setAuthUser(user);
setAuthStatus('authenticated');
setCode('');
@@ -228,11 +292,13 @@ export function AuthenticatedClient({
}
async function logout() {
const logoutGeneration = beginPlatformSessionTransition();
try {
await logoutClientAuthSession();
} catch {
clearStoredAuthAccessToken();
}
await clearCommittedPlatformSession(logoutGeneration);
setAuthUser(null);
setAuthStatus('unauthenticated');
setLoginStatus('已退出登录');
@@ -16,6 +16,7 @@ import type {
GameCreationAppAssetManifestEntry,
GameCreationAppManifest,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { requestPlatformSessionRefresh } from '../../services/platformSession';
const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER;
export const LOCAL_ASSET_COMMITTED_EVENT =
@@ -190,6 +191,14 @@ function failure(error: unknown): ImageCanvasHostResult<never> {
};
}
function isAuthenticationRequiredError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('authentication-required') ||
message.includes('登录已失效')
);
}
function conflict(
kind: 'project-identity' | 'host-revision' | 'draft-revision',
draft: ImageCanvasDraft | null,
@@ -237,6 +246,25 @@ export function createTauriImageCanvasHostAdapter(input: {
}): TauriImageCanvasHostAdapter {
const invokeInput = <T>(command: string, value: Record<string, unknown>) =>
input.invoke<T>(command, { input: value });
const invokeAuthenticatedInput = async <T>(
command: string,
value: Record<string, unknown>,
) => {
try {
return await invokeInput<T>(command, value);
} catch (error) {
if (!isAuthenticationRequiredError(error)) throw error;
const refresh = await requestPlatformSessionRefresh();
if (refresh.status !== 'refreshed') {
throw new Error(
refresh.status === 'stale'
? '登录账号已变化,原生成操作已停止'
: '登录已失效,请重新登录',
);
}
return invokeInput<T>(command, value);
}
};
const baseScope = (scope: ImageCanvasHostScope) => {
if (scope.projectId !== input.expectedProjectId) {
throw new Error(
@@ -286,7 +314,7 @@ export function createTauriImageCanvasHostAdapter(input: {
generationInput.generationId,
generationInput.onProgress,
);
const result = await invokeInput<GenerationCommandResult>(
const result = await invokeAuthenticatedInput<GenerationCommandResult>(
'generate_local_project_asset_canvas_image',
{
...baseScope(generationInput.scope),
@@ -326,7 +354,7 @@ export function createTauriImageCanvasHostAdapter(input: {
null,
recoverInput.onProgress,
);
const result = await invokeInput<{
const result = await invokeAuthenticatedInput<{
resumedGenerationIds: string[];
serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[];
}>(
@@ -84,7 +84,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
},
agentLlm: {},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
baseUrl: 'https://dev.genarrative.world',
apiKey: '',
},
mcpServers: {},
@@ -501,6 +501,7 @@ function normalizeRuntimeMcpServerConfig(
function normalizeRuntimeConfigDraft(
config: GameCreatorAppConfig,
allowAdvancedExternalEditorConfig: boolean,
): GameCreatorAppConfig {
const apiKind: GameCreatorLlmApiKind = [
'openai_responses',
@@ -564,16 +565,21 @@ function normalizeRuntimeConfigDraft(
retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1),
},
agentLlm,
editorApi: allowAdvancedExternalEditorConfig
? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi }
: { ...defaultRuntimeConfigDraft.editorApi },
mcpServers,
};
}
export function RuntimeConfigDialog({
projectPath,
allowAdvancedExternalEditorConfig = false,
onClose,
onLog,
}: {
projectPath?: string;
allowAdvancedExternalEditorConfig?: boolean;
onClose: () => void;
onLog?: (entry: string) => void;
}) {
@@ -694,18 +700,6 @@ export function RuntimeConfigDialog({
}));
}
function updateRuntimeEditorConfig<
K extends keyof GameCreatorAppConfig['editorApi'],
>(key: K, value: GameCreatorAppConfig['editorApi'][K]) {
setRuntimeConfigDraft((current) => ({
...current,
editorApi: {
...current.editorApi,
[key]: value,
},
}));
}
function updateRuntimeMcpServer<K extends keyof GameCreatorMcpServerConfig>(
serverId: string,
key: K,
@@ -801,7 +795,10 @@ export function RuntimeConfigDialog({
'read_game_creator_app_config',
);
setRuntimeConfigPath(result.path);
const config = normalizeRuntimeConfigDraft(result.config);
const config = normalizeRuntimeConfigDraft(
result.config,
allowAdvancedExternalEditorConfig,
);
setRuntimeConfigDraft(config);
setMcpStructuredDrafts(runtimeMcpStructuredDrafts(config.mcpServers));
setMcpCatalog(null);
@@ -832,19 +829,28 @@ export function RuntimeConfigDialog({
setRuntimeConfigBusy(true);
setRuntimeConfigStatus('正在保存');
try {
const config = normalizeRuntimeConfigDraft({
...runtimeConfigDraft,
mcpServers: materializeRuntimeMcpServers(
runtimeConfigDraft.mcpServers,
mcpStructuredDrafts,
),
});
const config = normalizeRuntimeConfigDraft(
{
...runtimeConfigDraft,
editorApi: allowAdvancedExternalEditorConfig
? runtimeConfigDraft.editorApi
: defaultRuntimeConfigDraft.editorApi,
mcpServers: materializeRuntimeMcpServers(
runtimeConfigDraft.mcpServers,
mcpStructuredDrafts,
),
},
allowAdvancedExternalEditorConfig,
);
const result = await invoke<GameCreatorAppConfigView>(
'write_game_creator_app_config',
{ config },
);
setRuntimeConfigPath(result.path);
const savedConfig = normalizeRuntimeConfigDraft(result.config);
const savedConfig = normalizeRuntimeConfigDraft(
result.config,
allowAdvancedExternalEditorConfig,
);
setRuntimeConfigDraft(savedConfig);
setMcpStructuredDrafts(
runtimeMcpStructuredDrafts(savedConfig.mcpServers),
@@ -1546,43 +1552,49 @@ export function RuntimeConfigDialog({
})}
</div>
) : null}
{activeSection === 'connections' ? (
<>
<label>
External Editor Base URL
<input
aria-label="External Editor Base URL"
value={runtimeConfigDraft.editorApi.baseUrl}
onChange={(event) =>
updateRuntimeEditorConfig(
'baseUrl',
event.currentTarget.value,
)
}
/>
</label>
<label>
External Editor API Key
<input
aria-label="External Editor API Key"
autoComplete="off"
type="password"
value={runtimeConfigDraft.editorApi.apiKey}
onChange={(event) =>
updateRuntimeEditorConfig(
'apiKey',
event.currentTarget.value,
)
}
/>
</label>
</>
) : null}
{activeSection === 'connections' ? (
<section
className="runtime-mcp-section"
aria-label="MCP servers"
>
{allowAdvancedExternalEditorConfig ? (
<div className="runtime-mcp-grid">
<label className="runtime-mcp-field-wide">
External Editor Base URL
<input
aria-label="External Editor Base URL"
value={runtimeConfigDraft.editorApi.baseUrl}
onChange={(event) =>
setRuntimeConfigDraft((current) => ({
...current,
editorApi: {
...current.editorApi,
baseUrl: event.currentTarget.value,
},
}))
}
/>
</label>
<label className="runtime-mcp-field-wide">
External Editor API Key
<input
aria-label="External Editor API Key"
autoComplete="off"
type="password"
value={runtimeConfigDraft.editorApi.apiKey}
onChange={(event) =>
setRuntimeConfigDraft((current) => ({
...current,
editorApi: {
...current.editorApi,
apiKey: event.currentTarget.value,
},
}))
}
/>
</label>
</div>
) : null}
<header className="runtime-mcp-header">
<div>
<h3>MCP servers</h3>
+1
View File
@@ -38,6 +38,7 @@ const gameChatApp = (
<GameChatReleaseApp
initialProjectPath={supervisorChatProjectPath}
initialSupervisorMessage={initialGameChatMessage}
allowAdvancedExternalEditorConfig={gameChatReleaseMode}
/>
);
@@ -0,0 +1,211 @@
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import {
getCurrentClientAuthUser,
getStoredAuthAccessToken,
refreshClientAuthAccessToken,
} from './clientAuth';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
type CommittedPlatformSession = {
user: AuthUser;
accessToken: string;
apiBaseUrl: string;
generation: number;
};
export type PlatformSessionRefreshResult =
| { status: 'refreshed'; user: AuthUser; generation: number }
| { status: 'stale' }
| { status: 'failed'; error: unknown };
type PlatformSessionRefreshListener = (
result: PlatformSessionRefreshResult,
) => void;
let platformAuthGeneration = 0;
let committedPlatformSession: CommittedPlatformSession | null = null;
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | null =
null;
const platformSessionRefreshListeners =
new Set<PlatformSessionRefreshListener>();
function restoreCommittedAccessToken() {
if (committedPlatformSession?.accessToken) {
window.localStorage.setItem(
ACCESS_TOKEN_STORAGE_KEY,
committedPlatformSession.accessToken,
);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) {
for (const listener of platformSessionRefreshListeners) {
listener(result);
}
}
async function installCommittedPlatformSession(
session: CommittedPlatformSession,
) {
const invoke = resolveTauriInvoke();
if (!invoke) return;
await invoke('install_platform_account_session', {
userId: session.user.id,
accessToken: session.accessToken,
apiBaseUrl: session.apiBaseUrl,
generation: session.generation,
});
}
async function resolvePlatformApiBaseUrl() {
if (!import.meta.env.DEV || import.meta.env.MODE === 'test') {
return 'https://dev.genarrative.world';
}
const response = await fetch('/__agc_dev_server.json', {
cache: 'no-store',
credentials: 'same-origin',
});
if (!response.ok) {
throw new Error('无法读取本地陶泥儿 API 服务地址');
}
const marker = (await response.json()) as { apiTarget?: unknown };
const apiBaseUrl =
typeof marker.apiTarget === 'string' ? marker.apiTarget.trim() : '';
if (!/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(apiBaseUrl)) {
throw new Error('本地陶泥儿 API 服务地址不在受信任白名单内');
}
return apiBaseUrl;
}
async function commitPlatformSession(
user: AuthUser,
expectedGeneration: number,
): Promise<CommittedPlatformSession | null> {
const accessToken = getStoredAuthAccessToken();
if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录');
}
if (platformAuthGeneration !== expectedGeneration) {
if (
committedPlatformSession?.user.id === user.id &&
committedPlatformSession.accessToken === accessToken
) {
return committedPlatformSession;
}
restoreCommittedAccessToken();
return null;
}
platformAuthGeneration += 1;
const apiBaseUrl = await resolvePlatformApiBaseUrl();
committedPlatformSession = {
user,
accessToken,
apiBaseUrl,
generation: platformAuthGeneration,
};
return committedPlatformSession;
}
export function currentPlatformSessionGeneration() {
return platformAuthGeneration;
}
export function beginPlatformSessionTransition() {
platformAuthGeneration += 1;
committedPlatformSession = null;
return platformAuthGeneration;
}
export async function commitAuthenticatedPlatformSession(
user: AuthUser,
expectedGeneration: number,
) {
const session = await commitPlatformSession(user, expectedGeneration);
if (!session) return null;
await installCommittedPlatformSession(session);
return session.generation;
}
export async function refreshPlatformSessionForGeneration(
expectedGeneration: number,
) {
const token = await refreshClientAuthAccessToken();
if (platformAuthGeneration !== expectedGeneration) {
restoreCommittedAccessToken();
return null;
}
return token;
}
export function requestPlatformSessionRefresh(expectedUserId?: string) {
if (platformSessionRefreshPromise) return platformSessionRefreshPromise;
const expectedGeneration = platformAuthGeneration;
const expectedSessionUserId =
expectedUserId?.trim() || committedPlatformSession?.user.id || '';
platformSessionRefreshPromise =
(async (): Promise<PlatformSessionRefreshResult> => {
try {
const refreshed =
await refreshPlatformSessionForGeneration(expectedGeneration);
if (!refreshed) return { status: 'stale' };
const user = await getCurrentClientAuthUser();
if (
platformAuthGeneration !== expectedGeneration ||
!user ||
(expectedSessionUserId && user.id !== expectedSessionUserId)
) {
restoreCommittedAccessToken();
return { status: 'stale' };
}
const session = await commitPlatformSession(user, expectedGeneration);
if (!session) return { status: 'stale' };
await installCommittedPlatformSession(session);
return {
status: 'refreshed',
user,
generation: session.generation,
};
} catch (error) {
if (platformAuthGeneration === expectedGeneration) {
const clearGeneration = beginPlatformSessionTransition();
await clearCommittedPlatformSession(clearGeneration);
}
return { status: 'failed', error };
}
})().then((result) => {
notifyPlatformSessionRefresh(result);
return result;
});
platformSessionRefreshPromise.finally(() => {
platformSessionRefreshPromise = null;
});
return platformSessionRefreshPromise;
}
export function subscribePlatformSessionRefresh(
listener: PlatformSessionRefreshListener,
) {
platformSessionRefreshListeners.add(listener);
return () => {
platformSessionRefreshListeners.delete(listener);
};
}
export async function clearCommittedPlatformSession(generation: number) {
restoreCommittedAccessToken();
const invoke = resolveTauriInvoke();
if (!invoke) return;
await invoke('clear_platform_account_session', { generation });
}
export function resetPlatformSessionStateForTests() {
platformAuthGeneration = 0;
committedPlatformSession = null;
platformSessionRefreshPromise = null;
platformSessionRefreshListeners.clear();
}
@@ -61,6 +61,7 @@ import {
LocalGamePreviewFrame,
resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame';
import { requestPlatformSessionRefresh } from '../../services/platformSession';
import {
type ProjectManifestSnapshotMetadata,
resolveResourceFocusIntent,
@@ -118,6 +119,31 @@ type ResourceSortMode = ProjectResourceCanvasLayoutMode;
type WorkbenchMode = 'resources' | 'run';
type ApprovalMode = 'strict' | 'risk' | 'none';
function isPlatformAuthenticationRequired(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('authentication-required') ||
message.includes('登录已失效')
);
}
async function withPlatformSessionRefresh<T>(operation: () => Promise<T>) {
try {
return await operation();
} catch (error) {
if (!isPlatformAuthenticationRequired(error)) throw error;
const refresh = await requestPlatformSessionRefresh();
if (refresh.status !== 'refreshed') {
throw new Error(
refresh.status === 'stale'
? '登录账号已变化,原资源操作已停止'
: '登录已失效,请重新登录',
);
}
return operation();
}
}
type WebKitGestureEvent = Event & {
clientX?: number;
clientY?: number;
@@ -1395,7 +1421,8 @@ export default function ProjectDevelopmentView({
}
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
ResourceCategory | undefined;
| ResourceCategory
| undefined;
if (!viewport || !category) {
return;
}
@@ -1414,7 +1441,8 @@ export default function ProjectDevelopmentView({
const event = rawEvent as WebKitGestureEvent;
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
ResourceCategory | undefined;
| ResourceCategory
| undefined;
if (!viewport || !category) {
return;
}
@@ -1502,7 +1530,8 @@ export default function ProjectDevelopmentView({
.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
.forEach((viewport) => {
const category = viewport.dataset.resourceSectionScroll as
ResourceCategory | undefined;
| ResourceCategory
| undefined;
if (!category) {
return;
}
@@ -1522,7 +1551,8 @@ export default function ProjectDevelopmentView({
?.querySelectorAll<HTMLElement>('[data-resource-section-scroll]')
.forEach((viewport) => {
const category = viewport.dataset.resourceSectionScroll as
ResourceCategory | undefined;
| ResourceCategory
| undefined;
if (!category) {
return;
}
@@ -2290,15 +2320,17 @@ export default function ProjectDevelopmentView({
setPendingResourceEditsError('');
setAssetCanvasNotice(`正在继续“${pending.assetName}”的原生成 operation…`);
try {
const result = await invoke<DeriveLocalProjectResourceResult>(
'resume_local_project_resource_edit',
{
input: {
projectPath: actionProject.projectPath,
expectedProjectId: actionProject.projectId,
operationId: pending.operationId,
const result = await withPlatformSessionRefresh(() =>
invoke<DeriveLocalProjectResourceResult>(
'resume_local_project_resource_edit',
{
input: {
projectPath: actionProject.projectPath,
expectedProjectId: actionProject.projectId,
operationId: pending.operationId,
},
},
},
),
);
if (!isCurrentRecoveryProject(actionProject)) return;
if (result.manifest.projectId !== actionProject.projectId) {
@@ -2595,30 +2627,32 @@ export default function ProjectDevelopmentView({
);
}
const resource = route.resource;
const result = await invoke<DeriveLocalProjectResourceResult>(
'derive_local_project_resource',
{
input: {
projectPath,
expectedProjectId: manifest.projectId,
expectedProjectRevision,
operationId: route.operationId,
idempotencyKey: route.idempotencyKey,
editKind: route.capability.editKind,
sourceResourceId: resource.id,
sourceAssetId: resource.manifestAssetId,
sourcePath:
resource.version || resource.subtype === 'agent-result'
? null
: resource.path,
sourceMediaType: route.capability.sourceMediaType,
sourceSubtype: resource.subtype,
producerTaskId: resource.producerTaskId,
sourceVersionId: resource.version?.versionId ?? null,
prompt,
assetName,
const result = await withPlatformSessionRefresh(() =>
invoke<DeriveLocalProjectResourceResult>(
'derive_local_project_resource',
{
input: {
projectPath,
expectedProjectId: manifest.projectId,
expectedProjectRevision,
operationId: route.operationId,
idempotencyKey: route.idempotencyKey,
editKind: route.capability.editKind,
sourceResourceId: resource.id,
sourceAssetId: resource.manifestAssetId,
sourcePath:
resource.version || resource.subtype === 'agent-result'
? null
: resource.path,
sourceMediaType: route.capability.sourceMediaType,
sourceSubtype: resource.subtype,
producerTaskId: resource.producerTaskId,
sourceVersionId: resource.version?.versionId ?? null,
prompt,
assetName,
},
},
},
),
);
if (
result.manifest.projectId !== manifest.projectId ||
@@ -2816,16 +2850,16 @@ export default function ProjectDevelopmentView({
dependencyLayoutSettled: dependencyLayout.settled,
dependencyPositioned: Boolean(
intent.resourceId &&
dependencyLayout.layout.positions.some(
(position) => position.resourceId === intent.resourceId,
),
dependencyLayout.layout.positions.some(
(position) => position.resourceId === intent.resourceId,
),
),
typeLayoutSettled: typeLayout.settled,
typePositioned: Boolean(
intent.resourceId &&
typeLayout.layout.positions.some(
(position) => position.resourceId === intent.resourceId,
),
typeLayout.layout.positions.some(
(position) => position.resourceId === intent.resourceId,
),
),
visible: targetVisible,
domRendered: Boolean(card),
@@ -1,3 +1,12 @@
import { afterEach } from 'vitest';
import {
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
currentPlatformSessionGeneration,
requestPlatformSessionRefresh,
resetPlatformSessionStateForTests,
} from '../../src/services/platformSession';
import {
AuthenticatedClient,
expect,
@@ -8,9 +17,182 @@ import {
screen,
testAuthUser,
vi,
waitFor,
} from './harness';
export function registerAuthTests() {
afterEach(() => {
resetPlatformSessionStateForTests();
delete window.__TAURI__;
});
it('keeps the client at login when the native platform session cannot be installed', async () => {
window.__TAURI__ = {
core: {
invoke: vi.fn(async (command: string) => {
if (command === 'install_platform_account_session') {
throw new Error('runner unavailable');
}
return null;
}),
},
};
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
return new Response('', { status: 401 });
}
if (url === '/api/auth/phone/login') {
return new Response(
JSON.stringify({
token: 'phone-token',
user: { ...testAuthUser, loginMethod: 'phone' },
created: false,
referral: null,
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, ({ user }) =>
React.createElement('main', { 'aria-label': '已登录' }, user.id),
),
);
await screen.findByRole('main', { name: '登录' });
fireEvent.change(screen.getByLabelText('手机号'), {
target: { value: '13800000000' },
});
fireEvent.change(screen.getByLabelText('验证码'), {
target: { value: '123456' },
});
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('runner unavailable')).not.toBeNull();
expect(screen.queryByLabelText('已登录')).toBeNull();
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('phone-token');
});
it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let resolveRefresh: ((response: Response) => void) | null = null;
let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
refreshCalls += 1;
return await new Promise<Response>((resolve) => {
resolveRefresh = resolve;
});
}
if (url === '/api/auth/me') {
return new Response(
JSON.stringify({
user: testAuthUser,
availableLoginMethods: ['password'],
}),
{ status: 200 },
);
}
throw new Error(`unexpected fetch ${url}`);
},
);
const first = requestPlatformSessionRefresh(testAuthUser.id);
const second = requestPlatformSessionRefresh(testAuthUser.id);
expect(first).toBe(second);
expect(refreshCalls).toBe(1);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'account-b-token',
);
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
resolveRefresh?.(
new Response(JSON.stringify({ token: 'late-account-a-token' }), {
status: 200,
}),
);
await expect(first).resolves.toEqual({ status: 'stale' });
expect(
window.localStorage.getItem('genarrative.auth.access-token.v1'),
).toBe('account-b-token');
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: 'user-b',
accessToken: 'account-b-token',
generation: currentPlatformSessionGeneration(),
}),
);
});
it('refreshes the current account once and synchronizes the replacement token', async () => {
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition();
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, generation);
let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/auth/refresh') {
refreshCalls += 1;
return new Response(JSON.stringify({ token: 'replacement-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 }),
);
expect(refreshCalls).toBe(1);
await waitFor(() => {
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
userId: testAuthUser.id,
accessToken: 'replacement-token',
}),
);
});
});
it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')

Some files were not shown because too many files have changed in this diff Show More