From 29894de43d39f743ad22ca56ecf5ecc1dae8dd46 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 23 Aug 2026 19:19:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=90=8E=E7=94=BB=E5=B8=83=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增账号隔离的 External Editor 项目与资源绑定 切换账号后从本地资源重新上传并登记 保留原账号在途生成与资源编辑归属 补充跨账号回归测试和项目文档 --- .../src-tauri/src/agent/direct_runtime.rs | 97 +- .../src/agent/generation/canvas_generation.rs | 492 ++++++- .../src-tauri/src/assets.rs | 2 +- .../src-tauri/src/platform_session.rs | 21 + .../src-tauri/src/project.rs | 2 + .../src/project/asset_canvas/generation.rs | 669 ++++++++-- .../src/project/external_editor_bindings.rs | 1150 +++++++++++++++++ .../src-tauri/src/project/resource_editor.rs | 1028 ++++++++++----- .../shared-memory/decision-log.md | 9 + docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 11 files changed, 3004 insertions(+), 478 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 8516a8f69..f0f64a28c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -565,21 +565,70 @@ async fn recover_direct_taonier_spritesheet_read_only_at( root: &Path, art_spec: &DirectTaonierArtAssetIdentity, ) -> Result { - let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; + let (api_base_url, api_key, frozen_platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + let access = ExternalEditorBindingAccess::new( + &api_base_url, + &api_key, + frozen_platform_session.as_ref(), + )?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH) + .ok_or_else(|| "陶泥儿规范图 manifest 记录缺失".to_string())?; + let source_path = resolve_local_project_path(root, &source_asset.local_path)?; + let source_bytes = + fs::read(source_path).map_err(|error| format!("读取陶泥儿规范图失败:{error}"))?; + let source_identity = new_external_editor_source_identity( + &source_asset.id, + &format!("{:x}", Sha256::digest(&source_bytes)), + &source_asset.media_type, + &source_asset.kind, + )?; + let principal = external_editor_binding_principal(&access)?; + let Some(project_binding) = + read_external_editor_project_binding_at(root, &manifest.project_id, &principal)? + else { + // 当前账号从未建立远端项目。后续正式生成路径会从本地规范图重新登记;这里不得 + // 用当前 token 查询 manifest 中历史账号的 canvasProjectId。 + return Ok(false); + }; + let Some(spec_binding) = read_external_editor_resource_binding_at( + root, + &manifest.project_id, + &principal, + &project_binding.remote_project_id, + &source_identity, + )? + else { + return Ok(false); + }; + let Some(remote_spec_resource_id) = spec_binding.remote_resource_id.clone() else { + return Ok(false); + }; + let remote_art_spec = DirectTaonierArtAssetIdentity { + resource_id: remote_spec_resource_id, + asset_object_id: spec_binding.asset_object_id, + canvas_project_id: project_binding.remote_project_id, + }; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) .build() .map_err(|error| format!("创建陶泥儿只读资源恢复客户端失败:{error}"))?; - let route = resolve_platform_editor_api_route(&format!( + let route = access.api_route(&format!( "/api/external/v1/editor/projects/{}", - percent_encode_query_component(&art_spec.canvas_project_id) + percent_encode_query_component(&remote_art_spec.canvas_project_id) )); + access.validate_frozen_session()?; let response = client .get(format!("{api_base_url}{route}")) .bearer_auth(&api_key) .send() .await .map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?; + access.validate_frozen_session()?; let status = response.status(); if !status.is_success() { return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); @@ -596,7 +645,8 @@ async fn recover_direct_taonier_spritesheet_read_only_at( .get("resources") .and_then(serde_json::Value::as_array) .ok_or_else(|| "陶泥儿画布资源响应缺少 resources".to_string())?; - let Some(resource) = direct_recoverable_spritesheet_resource(resources, art_spec)? else { + let Some(resource) = direct_recoverable_spritesheet_resource(resources, &remote_art_spec)? + else { return Ok(false); }; let resource_id = json_string_field(resource, "resourceId") @@ -608,6 +658,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource) .await? .ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?; + access.validate_frozen_session()?; let validated = validate_platform_art_png_bytes_with_limits( &download.bytes, "恢复的陶泥儿 art-spritesheet", @@ -626,7 +677,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( "/api/external/v1/editor/images/generations", "spec", &[], - Some(&art_spec.canvas_project_id), + None, ) .ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?; if current_art_spec != *art_spec { @@ -661,9 +712,9 @@ async fn recover_direct_taonier_spritesheet_read_only_at( "platform-art-recovery", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some(art_spec.canvas_project_id.clone()), + canvas_project_id: Some(remote_art_spec.canvas_project_id.clone()), resource_id: Some(resource_id), - asset_object_id: Some(asset_object_id), + asset_object_id: Some(asset_object_id.clone()), task_id: Some(task_id), prompt: json_string_field(resource, "actualPrompt") .or_else(|| json_string_field(resource, "prompt")), @@ -672,12 +723,36 @@ async fn recover_direct_taonier_spritesheet_read_only_at( "/api/external/v1/editor/icon-spritesheets/generations".to_string(), ), generation_kind: Some("icon-spritesheet".to_string()), - reference_resource_ids: vec![art_spec.resource_id.clone()], + reference_resource_ids: vec![remote_art_spec.resource_id.clone()], }, ); - if let Err(error) = register { - let _ = std::fs::remove_file(&output); - return Err(error); + let registered_asset = match register { + Ok(registered_asset) => registered_asset, + Err(error) => { + let _ = std::fs::remove_file(&output); + return Err(error); + } + }; + if let Some(object_key) = json_string_field(resource, "objectKey") { + let recovered_source = new_external_editor_source_identity( + ®istered_asset.id, + &validated.content_sha256, + "image/png", + "art-spritesheet", + )?; + let recovered_binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + &remote_art_spec.canvas_project_id, + &recovered_source, + json_string_field(resource, "resourceId").as_deref(), + &object_key, + &asset_object_id, + Some(validated.width), + Some(validated.height), + unix_timestamp(), + )?; + write_external_editor_resource_binding_at(root, &recovered_binding)?; } emit_direct_game_creator_progress( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 0a1c36e55..87c884942 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -13,6 +13,7 @@ use super::external_generation_state::{ PlatformArtGenerationRuntimeState, }; use super::*; +use reqwest::multipart::{Form, Part}; const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); const EXTERNAL_GENERATION_SUBMIT_TIMEOUT: Duration = EXTERNAL_GENERATION_POLL_TIMEOUT; @@ -767,48 +768,69 @@ async fn resume_prepared_external_generation_at( pub(crate) async fn prepare_external_canvas_generation_context( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ) -> Result { let manifest = read_existing_manifest_for_project(root)?; + let local_project_id = manifest.project_id.trim(); + if local_project_id.is_empty() { + return Err( + "本地项目 manifest 缺少 projectId,无法建立 External Editor 账号绑定".to_string(), + ); + } let canvas_name = manifest.name.trim().chars().take(80).collect::(); let canvas_name = if canvas_name.is_empty() { "未命名游戏原型".to_string() } else { canvas_name }; + access.validate_frozen_session()?; + let principal = external_editor_binding_principal(access)?; + let existing_binding = + read_external_editor_project_binding_at(root, local_project_id, &principal)?; let projects_payload = external_editor_json_request( client .get(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/projects") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/projects") )) - .bearer_auth(api_key), + .bearer_auth(access.bearer_token()), "读取外部画布项目", ) .await?; + access.validate_frozen_session()?; let projects = external_editor_response_data(&projects_payload) .get("projects") .and_then(serde_json::Value::as_array) .ok_or_else(|| "外部画布项目响应缺少 projects".to_string())?; - let project_id = projects - .iter() - .find(|project| json_string_field(project, "title").as_deref() == Some(&canvas_name)) - .and_then(|project| json_string_field(project, "projectId")); + // 项目标题只用于展示,不再参与身份匹配。同名项目、账号切换或本地改名都必须 + // 依赖本地 projectId + 当前账号 principal 的私有 binding。 + let project_id = existing_binding.as_ref().and_then(|binding| { + projects + .iter() + .any(|project| { + json_string_field(project, "projectId").as_deref() + == Some(binding.remote_project_id.as_str()) + }) + .then(|| binding.remote_project_id.clone()) + }); let project_id = match project_id { Some(project_id) => project_id, None => { + access.validate_frozen_session()?; let payload = external_editor_json_request( client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/projects") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/projects") )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ "title": canvas_name })), "创建外部画布项目", ) .await?; + access.validate_frozen_session()?; external_editor_response_data(&payload) .get("project") .and_then(|project| json_string_field(project, "projectId")) @@ -819,36 +841,46 @@ pub(crate) async fn prepare_external_canvas_generation_context( let library_payload = external_editor_json_request( client .get(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/assets/library") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/assets/library") )) - .bearer_auth(api_key), + .bearer_auth(access.bearer_token()), "读取外部素材库", ) .await?; + access.validate_frozen_session()?; let folders = external_editor_response_data(&library_payload) .get("library") .and_then(|library| library.get("folders")) .and_then(serde_json::Value::as_array) .ok_or_else(|| "外部素材库响应缺少 library.folders".to_string())?; - let asset_folder_id = folders - .iter() - .find(|folder| json_string_field(folder, "label").as_deref() == Some(&canvas_name)) - .and_then(|folder| json_string_field(folder, "folderId")); + let asset_folder_id = existing_binding.as_ref().and_then(|binding| { + folders + .iter() + .any(|folder| { + json_string_field(folder, "folderId").as_deref() + == Some(binding.remote_asset_folder_id.as_str()) + }) + .then(|| binding.remote_asset_folder_id.clone()) + }); let asset_folder_id = match asset_folder_id { Some(folder_id) => folder_id, None => { + access.validate_frozen_session()?; let payload = external_editor_json_request( client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/assets/folders") )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ "label": canvas_name })), "创建外部素材库目录", ) .await?; + access.validate_frozen_session()?; external_editor_response_data(&payload) .get("folder") .and_then(|folder| json_string_field(folder, "folderId")) @@ -856,6 +888,23 @@ pub(crate) async fn prepare_external_canvas_generation_context( } }; + access.validate_frozen_session()?; + let now = unix_timestamp(); + let mut binding = new_external_editor_project_binding( + local_project_id, + &principal, + &project_id, + &asset_folder_id, + now, + )?; + if let Some(existing_binding) = existing_binding.as_ref().filter(|binding| { + binding.remote_project_id == project_id && binding.remote_asset_folder_id == asset_folder_id + }) { + binding.created_at = existing_binding.created_at; + } + write_external_editor_project_binding_at(root, &binding)?; + access.validate_frozen_session()?; + Ok(ExternalCanvasGenerationContext { project_id, asset_folder_id, @@ -942,8 +991,18 @@ impl PreparedPlatformArtAssetGeneration { } } -fn canonical_art_spec_reference_at( +struct CanonicalArtSpecUploadTicket { + host: String, + bucket: String, + object_key: String, + success_action_status: u16, + form_fields: BTreeMap, +} + +async fn canonical_art_spec_reference_at( root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, expected_canvas_project_id: &str, ) -> Result { let manifest = read_manifest_for_project(root)?; @@ -967,22 +1026,199 @@ fn canonical_art_spec_reference_at( .to_string(), ); } - if source.source.canvas_project_id.as_deref() != Some(expected_canvas_project_id) { - return Err( - "assets/art-spec.png 不属于当前 External Editor 画布项目,不能作为派生视觉资产参考源" - .to_string(), - ); + let bytes = + fs::read(&source_path).map_err(|error| format!("读取派生视觉资产规范图失败:{error}"))?; + let decoded = image::load_from_memory(&bytes) + .map_err(|_| "派生视觉资产规范图不是可解析图片".to_string())?; + let principal = external_editor_binding_principal(access)?; + let source_identity = new_external_editor_source_identity( + &source.id, + &format!("{:x}", Sha256::digest(&bytes)), + &source.media_type, + &source.kind, + )?; + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &manifest.project_id, + &principal, + expected_canvas_project_id, + &source_identity, + )? { + return binding + .remote_resource_id + .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } - source - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .ok_or_else(|| { - "assets/art-spec.png 缺少 External Editor 项目资源 ID,不能伪造规范图引用".to_string() - }) + + // manifest 中的远端 ID 只保留生成来源。当前账号没有 binding 时,必须从本地正式 + // 文件重新上传并登记,不能用当前 token 探测或发送历史账号的 project/resource ID。 + access.validate_frozen_session()?; + let ticket_payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/direct-upload-tickets") + )) + .bearer_auth(access.bearer_token()) + .json(&serde_json::json!({ + "legacyPrefix": "generated-character-drafts", + "pathSegments": [ + "editor", + "account-scoped-bindings", + manifest.project_id.as_str(), + source_identity.source_sha256.as_str() + ], + "fileName": "art-spec.png", + "contentType": source.media_type, + "access": "private", + "maxSizeBytes": bytes.len(), + "successActionStatus": 204, + })), + "创建当前账号规范图上传凭证", + ) + .await?; + access.validate_frozen_session()?; + let upload = external_editor_response_data(&ticket_payload) + .get("upload") + .or_else(|| ticket_payload.pointer("/data/upload")) + .ok_or_else(|| "当前账号规范图上传凭证缺少 upload".to_string())?; + let ticket = CanonicalArtSpecUploadTicket { + host: json_string_field(upload, "host") + .or_else(|| json_string_field(upload, "endpoint")) + .ok_or_else(|| "当前账号规范图上传凭证缺少 host".to_string())?, + bucket: json_string_field(upload, "bucket") + .ok_or_else(|| "当前账号规范图上传凭证缺少 bucket".to_string())?, + object_key: json_string_field(upload, "objectKey") + .ok_or_else(|| "当前账号规范图上传凭证缺少 objectKey".to_string())?, + success_action_status: upload + .get("successActionStatus") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| matches!(value, 200 | 201 | 204)) + .ok_or_else(|| "当前账号规范图上传凭证 successActionStatus 无效".to_string())?, + form_fields: upload + .get("formFields") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "当前账号规范图上传凭证缺少 formFields".to_string())? + .iter() + .map(|(key, value)| { + value + .as_str() + .map(|value| (key.clone(), value.to_string())) + .ok_or_else(|| "当前账号规范图上传凭证 formFields 必须全为字符串".to_string()) + }) + .collect::, _>>()?, + }; + let upload_url = + validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) + .map_err(|_| "当前账号规范图上传地址不安全".to_string())?; + let upload_client = + build_external_asset_download_client(&upload_url, access.api_base_url(), true) + .await + .map_err(|_| "无法创建当前账号规范图上传客户端".to_string())?; + access.validate_frozen_session()?; + let form = ticket + .form_fields + .iter() + .fold(Form::new(), |form, (key, value)| { + form.text(key.clone(), value.clone()) + }); + let part = Part::bytes(bytes.clone()) + .file_name("art-spec.png") + .mime_str(&source.media_type) + .map_err(|_| "规范图媒体类型不能用于上传".to_string())?; + let upload_response = upload_client + .post(upload_url) + .multipart(form.part("file", part)) + .send() + .await + .map_err(|_| "上传当前账号规范图失败".to_string())?; + if upload_response.status().as_u16() != ticket.success_action_status { + return Err(format!( + "上传当前账号规范图失败:HTTP {}", + upload_response.status().as_u16() + )); + } + access.validate_frozen_session()?; + let confirm_payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/objects/confirm") + )) + .bearer_auth(access.bearer_token()) + .json(&serde_json::json!({ + "bucket": ticket.bucket, + "objectKey": ticket.object_key, + "contentType": source.media_type, + "contentLength": bytes.len(), + "contentHash": source_identity.source_sha256, + "assetKind": source.kind, + "accessPolicy": "private", + })), + "确认当前账号规范图上传", + ) + .await?; + access.validate_frozen_session()?; + let asset_object = external_editor_response_data(&confirm_payload) + .get("assetObject") + .or_else(|| confirm_payload.pointer("/data/assetObject")) + .ok_or_else(|| "当前账号规范图确认响应缺少 assetObject".to_string())?; + if json_string_field(asset_object, "objectKey").as_deref() != Some(ticket.object_key.as_str()) { + return Err("当前账号规范图确认响应 objectKey 不一致".to_string()); + } + let asset_object_id = json_string_field(asset_object, "assetObjectId") + .ok_or_else(|| "当前账号规范图确认响应缺少 assetObjectId".to_string())?; + let resource_payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route(&format!( + "/api/external/v1/editor/projects/{}/resources", + percent_encode_query_component(expected_canvas_project_id) + )) + )) + .bearer_auth(access.bearer_token()) + .json(&serde_json::json!({ + "imageSrc": format!("/{}", ticket.object_key.trim_start_matches('/')), + "objectKey": ticket.object_key, + "assetObjectId": asset_object_id, + "width": decoded.width(), + "height": decoded.height(), + "sourceType": "uploaded", + "assetKind": source.kind, + "generationInputs": { + "source": "game-creator-account-binding", + "localAssetId": source.id, + }, + })), + "登记当前账号规范图项目资源", + ) + .await?; + access.validate_frozen_session()?; + let resource_data = external_editor_response_data(&resource_payload); + let remote_resource_id = json_string_field( + resource_data.get("resource").unwrap_or(resource_data), + "resourceId", + ) + .ok_or_else(|| "当前账号规范图项目资源响应缺少 resourceId".to_string())?; + let binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + expected_canvas_project_id, + &source_identity, + Some(&remote_resource_id), + &ticket.object_key, + &asset_object_id, + Some(decoded.width()), + Some(decoded.height()), + unix_timestamp(), + )?; + write_external_editor_resource_binding_at(root, &binding)?; + access.validate_frozen_session()?; + Ok(remote_resource_id) } fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { @@ -1401,8 +1637,13 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } else { None }; - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; + let (api_base_url, api_key, frozen_platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + let binding_access = ExternalEditorBindingAccess::new( + &api_base_url, + &api_key, + frozen_platform_session.as_ref(), + )?; let persisted_runtime_state = persisted_runtime_state .map(|state| { migrate_platform_art_generation_external_configuration( @@ -1484,20 +1725,29 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) } else { let canvas_context = - prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key) - .await?; + prepare_external_canvas_generation_context(root, &client, &binding_access).await?; let generation_kind = match options.asset_kind.as_str() { "ui-prototype" => "ui-design", "art-spritesheet" => "icon-spritesheet", _ => "spec", }; let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; - let canonical_reference = matches!( + let canonical_reference = if matches!( options.asset_kind.as_str(), "ui-prototype" | "game-background" | "art-spritesheet" - ) - .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) - .transpose()?; + ) { + Some( + canonical_art_spec_reference_at( + root, + &client, + &binding_access, + &canvas_context.project_id, + ) + .await?, + ) + } else { + None + }; let (endpoint, request_body) = if is_canonical_art_spritesheet { let reference_id = canonical_reference .as_deref() @@ -1581,6 +1831,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::to_string(&request_body) .expect("External Editor request body Value must serialize") }); + binding_access.validate_frozen_session()?; let response = submit_external_generation_request( &submit_client, &api_base_url, @@ -6098,6 +6349,145 @@ mod canvas_generation_tests { .expect("expected request body separator") } + #[tokio::test] + async fn external_canvas_context_is_scoped_by_account_and_local_project_id() { + let temporary = tempfile::tempdir().expect("create account binding project"); + let root = temporary.path(); + init_local_game_project_at(root, "local-binding-project", "同名远端画布") + .expect("init account binding project"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind account binding fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut project_created = BTreeMap::::new(); + let mut folder_created = BTreeMap::::new(); + for _ in 0..10 { + let (mut stream, _) = listener.accept().expect("accept binding request"); + let request = read_test_http_request(&mut stream); + let account = match test_request_header(&request, "authorization") { + "Bearer token-a" => "a", + "Bearer token-b" => "b", + unexpected => panic!("unexpected authorization {unexpected}"), + }; + let response = if request.starts_with("GET /api/editor/projects ") { + let projects = if project_created.get(account).copied().unwrap_or(false) { + vec![serde_json::json!({ + "projectId": format!("remote-project-{account}"), + "title": "服务端标题不参与绑定", + })] + } else { + Vec::new() + }; + serde_json::json!({"data": {"projects": projects}}) + } else if request.starts_with("POST /api/editor/projects ") { + project_created.insert(account.to_string(), true); + serde_json::json!({"data": {"project": { + "projectId": format!("remote-project-{account}") + }}}) + } else if request.starts_with("GET /api/editor/assets/library ") { + let folders = if folder_created.get(account).copied().unwrap_or(false) { + vec![serde_json::json!({ + "folderId": format!("remote-folder-{account}"), + "label": "服务端目录标题不参与绑定", + })] + } else { + Vec::new() + }; + serde_json::json!({"data": {"library": {"folders": folders}}}) + } else if request.starts_with("POST /api/editor/assets/folders ") { + folder_created.insert(account.to_string(), true); + serde_json::json!({"data": {"folder": { + "folderId": format!("remote-folder-{account}") + }}}) + } else { + panic!("unexpected binding request: {request}"); + }; + request_sender + .send((account.to_string(), request)) + .expect("capture binding request"); + let body = response.to_string(); + let wire = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(wire.as_bytes()) + .expect("write binding response"); + } + }); + let client = reqwest::Client::new(); + + let context_a = { + let _session = crate::platform_session::install_test_platform_session( + "user-a", "token-a", &base_url, + ); + let (api_base_url, api_key, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("resolve account A"); + let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, frozen.as_ref()) + .expect("create account A access"); + prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("prepare account A context") + }; + let context_b = { + let _session = crate::platform_session::install_test_platform_session( + "user-b", "token-b", &base_url, + ); + let (api_base_url, api_key, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("resolve account B"); + let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, frozen.as_ref()) + .expect("create account B access"); + prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("prepare account B context") + }; + + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read manifest before rename"); + manifest.name = "本地项目已改名".to_string(); + write_manifest(&manifest_path, &manifest).expect("rename local project"); + let context_a_after_rename = { + let _session = crate::platform_session::install_test_platform_session( + "user-a", "token-a", &base_url, + ); + let (api_base_url, api_key, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("resolve account A again"); + let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, frozen.as_ref()) + .expect("create account A access again"); + prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("reuse account A context after rename") + }; + server.join().expect("join account binding fixture"); + + assert_eq!(context_a.project_id, "remote-project-a"); + assert_eq!(context_a.asset_folder_id, "remote-folder-a"); + assert_eq!(context_b.project_id, "remote-project-b"); + assert_eq!(context_b.asset_folder_id, "remote-folder-b"); + assert_eq!(context_a_after_rename.project_id, context_a.project_id); + assert_eq!( + context_a_after_rename.asset_folder_id, + context_a.asset_folder_id + ); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!(requests.len(), 10); + assert_eq!( + requests + .iter() + .filter(|(account, request)| { + account == "a" && request.starts_with("POST /api/editor/projects ") + }) + .count(), + 1, + "rename must not create a title-keyed duplicate project" + ); + for (_, request) in requests.iter().filter(|(account, _)| account == "b") { + assert!(!request.contains("remote-project-a"), "{request}"); + assert!(!request.contains("remote-folder-a"), "{request}"); + } + } + fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive) } @@ -7527,13 +7917,17 @@ mod canvas_generation_tests { .all(|description| description.chars().count() <= 200)); } - #[test] - fn derived_visual_asset_waits_for_registered_art_spec() { + #[tokio::test] + async fn derived_visual_asset_waits_for_registered_art_spec() { let temporary = tempfile::tempdir().expect("create art dependency project"); let root = temporary.path(); init_local_game_project_at(root, "art-dependency", "美术依赖测试").expect("init project"); + let client = reqwest::Client::new(); + let access = ExternalEditorBindingAccess::new("http://127.0.0.1:1", "test-key", None) + .expect("create test binding access"); - let error = canonical_art_spec_reference_at(root, "canvas-project") + let error = canonical_art_spec_reference_at(root, &client, &access, "canvas-project") + .await .expect_err("missing art spec must block derived visual generation"); assert!(error.contains("先完成并登记 assets/art-spec.png")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index c6df5a8a7..576347c80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1065,7 +1065,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit( .await } -async fn resolve_canvas_resource_download_with_limit_and_route( +pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route( _client: &reqwest::Client, api_base_url: &str, bearer_token: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index eb7b6ba5b..a028d2c08 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -237,6 +237,27 @@ pub(crate) fn install_test_platform_session( } } +/// Holds the shared test-session lock while presenting an explicit logged-out +/// state. Tests that assert missing-login behavior must use this guard so they +/// cannot observe a platform session installed by another parallel test. +#[cfg(test)] +pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard { + let isolation = PLATFORM_SESSION_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::mem::take(&mut *current); + *current = PlatformSessionState::default(); + drop(current); + TestPlatformSessionGuard { + _isolation: isolation, + previous: Some(previous), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 4a6b6d6bc..4ffe94674 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -8,6 +8,7 @@ mod asset_canvas; mod checkpoint; mod conversation; mod export; +mod external_editor_bindings; mod filesystem; mod manifest; mod memory; @@ -21,6 +22,7 @@ pub(crate) use asset_canvas::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; +pub(crate) use external_editor_bindings::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index edc0b8b95..001590c50 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -312,6 +312,8 @@ struct NormalizedGenerationRequest<'a> { struct ReferenceMaterial { stable_reference: Option, + local_asset_id: Option, + asset_kind: Option, bytes: Option>, media_type: Option, file_name: Option, @@ -345,7 +347,10 @@ impl ReferencePreparationError { } fn from_http_error(error: &str, fallback: Self) -> Self { - if error.contains("HTTP 401") { + if error.contains("HTTP 401") + || error.contains("authentication-required") + || error.contains("登录态已变化") + { Self::AuthenticationRequired } else if error.contains("HTTP 403") { Self::PermissionDenied @@ -361,6 +366,14 @@ impl From for ReferencePreparationError { } } +fn validate_reference_binding_access( + access: &ExternalEditorBindingAccess<'_>, +) -> Result<(), ReferencePreparationError> { + access.validate_frozen_session().map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) + }) +} + struct CanvasGenerationApiMode { api_key: String, } @@ -1190,21 +1203,6 @@ fn validate_and_prepare_ledger( Ok(ledger) } -fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Option { - asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - && !value.starts_with("task:") - }) - .map(str::to_string) -} - fn is_registered_editor_reference_id(value: &str) -> bool { value.starts_with("editor-resource-") || value.starts_with("editor-asset-") } @@ -1223,21 +1221,6 @@ fn reference_material_at( || asset_canvas_source_resource_id(asset) == resource_id || asset.source.resource_id.as_deref() == Some(resource_id) }) { - if let Some(stable_reference) = stable_manifest_reference(asset).filter(|reference| { - ledger.intent != AssetCanvasIntent::Refine - || ledger.source_resource_id.as_deref() != Some(resource_id) - || is_registered_editor_reference_id(reference) - }) { - return Ok(ReferenceMaterial { - stable_reference: Some(stable_reference), - bytes: None, - media_type: None, - file_name: None, - sha256: None, - width: None, - height: None, - }); - } let (bytes, width, height) = open_and_validate_image_file( &resolve_local_project_path(root, &asset.local_path)?, &asset.media_type, @@ -1247,6 +1230,8 @@ fn reference_material_at( let sha256 = asset_canvas_sha256(&bytes); return Ok(ReferenceMaterial { stable_reference: None, + local_asset_id: Some(asset.id.clone()), + asset_kind: Some(asset.kind.clone()), bytes: Some(bytes), media_type: Some(asset.media_type.clone()), file_name: Some(format!("reference-{sha256}.{extension}")), @@ -1272,6 +1257,8 @@ fn reference_material_at( let sha256 = asset_canvas_sha256(&bytes); Ok(ReferenceMaterial { stable_reference: None, + local_asset_id: None, + asset_kind: None, bytes: Some(bytes), media_type: Some(media_type), file_name: Some(format!("reference-{sha256}.{extension}")), @@ -1476,6 +1463,10 @@ async fn register_uploaded_image_reference( let height = material .height .ok_or(ReferencePreparationError::MaterialInvalid)?; + let asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); let endpoint = format!( "/api/external/v1/editor/projects/{}/resources", percent_encode_query_component(&context.project_id) @@ -1494,7 +1485,7 @@ async fn register_uploaded_image_reference( "width": width, "height": height, "sourceType": "uploaded", - "assetKind": ledger.asset_kind, + "assetKind": asset_kind, "generationInputs": { "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, "operationId": ledger.generation_id, @@ -1519,6 +1510,7 @@ async fn ensure_reference_states( client: &reqwest::Client, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result<(), ReferencePreparationError> { set_private_phase( root, @@ -1526,10 +1518,52 @@ async fn ensure_reference_states( GenerationLedgerPhase::ReferencesPreparing, None, )?; + let binding_access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session) + .map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) + })?; + validate_reference_binding_access(&binding_access)?; + let principal = external_editor_binding_principal(&binding_access) + .map_err(|_| ReferencePreparationError::Internal)?; + let canvas_context = ledger + .canvas_context + .as_ref() + .ok_or(ReferencePreparationError::Internal)?; + let project_binding = + read_external_editor_project_binding_at(root, &ledger.project_id, &principal) + .map_err(|_| ReferencePreparationError::Internal)? + .ok_or(ReferencePreparationError::Internal)?; + if project_binding.remote_project_id != canvas_context.project_id + || project_binding.remote_asset_folder_id != canvas_context.asset_folder_id + { + return Err(ReferencePreparationError::Internal); + } + let requested = ledger.requested_reference_resource_ids.clone(); for resource_id in requested { let material = reference_material_at(root, ledger, &resource_id) .map_err(|_| ReferencePreparationError::MaterialInvalid)?; + let source_identity = match ( + material.local_asset_id.as_deref(), + material.sha256.as_deref(), + material.media_type.as_deref(), + material.asset_kind.as_deref(), + ) { + (Some(local_asset_id), Some(source_sha256), Some(media_type), Some(asset_kind)) => { + Some( + new_external_editor_source_identity( + local_asset_id, + source_sha256, + media_type, + asset_kind, + ) + .map_err(|_| ReferencePreparationError::MaterialInvalid)?, + ) + } + (None, _, _, None) => None, + _ => return Err(ReferencePreparationError::MaterialInvalid), + }; let index = match ledger .reference_states .iter() @@ -1549,6 +1583,33 @@ async fn ensure_reference_states( ledger.reference_states.len() - 1 } }; + + if let Some(source_identity) = source_identity.as_ref() { + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + ) + .map_err(|_| ReferencePreparationError::Internal)? + { + if let Some(remote_resource_id) = binding.remote_resource_id { + ledger.reference_states[index].stable_reference = Some(remote_resource_id); + ledger.reference_states[index].asset_object_id = Some(binding.asset_object_id); + ledger.reference_states[index].upload_bucket = None; + ledger.reference_states[index].upload_object_key = None; + ledger.reference_states[index].upload_completed = true; + write_generation_ledger(root, ledger)?; + continue; + } + } + // A manifest resourceId and a stable reference left in an older operation ledger are + // only origin provenance. Neither proves that the current account owns the remote + // resource, so a cache miss must fall back to the local formal file. + ledger.reference_states[index].stable_reference = None; + write_generation_ledger(root, ledger)?; + } if ledger.reference_states[index].stable_reference.is_some() { let requires_registered_source = ledger.intent == AssetCanvasIntent::Refine && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); @@ -1575,21 +1636,27 @@ async fn ensure_reference_states( ledger.reference_states[index].upload_bucket.clone(), ledger.reference_states[index].upload_object_key.clone(), ) { - if let Some(asset_object_id) = try_confirm_uploaded_reference( + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); + validate_reference_binding_access(&binding_access)?; + let confirmed_asset_object_id = try_confirm_uploaded_reference( client, api_base_url, api_mode, &bucket, &object_key, &material, - &ledger.asset_kind, + source_asset_kind, ) - .await? - { - let stable_reference = if ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) - { - register_uploaded_image_reference( + .await; + validate_reference_binding_access(&binding_access)?; + let confirmed_asset_object_id = confirmed_asset_object_id?; + if let Some(asset_object_id) = confirmed_asset_object_id { + let stable_reference = if source_identity.is_some() { + validate_reference_binding_access(&binding_access)?; + let registered_reference = register_uploaded_image_reference( client, api_base_url, api_mode, @@ -1598,10 +1665,38 @@ async fn ensure_reference_states( &object_key, &asset_object_id, ) - .await? + .await; + validate_reference_binding_access(&binding_access)?; + registered_reference? } else { - object_key + object_key.clone() }; + if let Some(source_identity) = source_identity.as_ref() { + validate_reference_binding_access(&binding_access)?; + let binding = new_external_editor_resource_binding( + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + Some(&stable_reference), + &object_key, + &asset_object_id, + Some( + material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + Some( + material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + unix_timestamp(), + ) + .map_err(|_| ReferencePreparationError::Internal)?; + write_external_editor_resource_binding_at(root, &binding) + .map_err(|_| ReferencePreparationError::Internal)?; + } ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; @@ -1611,15 +1706,25 @@ async fn ensure_reference_states( continue; } } - let ticket = - request_upload_ticket(client, api_base_url, api_mode, ledger, &material).await?; + validate_reference_binding_access(&binding_access)?; + let ticket = request_upload_ticket(client, api_base_url, api_mode, ledger, &material).await; + validate_reference_binding_access(&binding_access)?; + let ticket = ticket?; ledger.reference_states[index].upload_bucket = Some(ticket.bucket.clone()); ledger.reference_states[index].upload_object_key = Some(ticket.object_key.clone()); ledger.reference_states[index].upload_completed = false; write_generation_ledger(root, ledger)?; - upload_reference(&ticket, &material, api_base_url).await?; + validate_reference_binding_access(&binding_access)?; + let upload_result = upload_reference(&ticket, &material, api_base_url).await; + validate_reference_binding_access(&binding_access)?; + upload_result?; ledger.reference_states[index].upload_completed = true; write_generation_ledger(root, ledger)?; + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); + validate_reference_binding_access(&binding_access)?; let asset_object_id = try_confirm_uploaded_reference( client, api_base_url, @@ -1627,14 +1732,14 @@ async fn ensure_reference_states( &ticket.bucket, &ticket.object_key, &material, - &ledger.asset_kind, + source_asset_kind, ) - .await? - .ok_or(ReferencePreparationError::ConfirmFailed)?; - let stable_reference = if ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) - { - register_uploaded_image_reference( + .await; + validate_reference_binding_access(&binding_access)?; + let asset_object_id = asset_object_id?.ok_or(ReferencePreparationError::ConfirmFailed)?; + let stable_reference = if source_identity.is_some() { + validate_reference_binding_access(&binding_access)?; + let registered_reference = register_uploaded_image_reference( client, api_base_url, api_mode, @@ -1643,10 +1748,38 @@ async fn ensure_reference_states( &ticket.object_key, &asset_object_id, ) - .await? + .await; + validate_reference_binding_access(&binding_access)?; + registered_reference? } else { - ticket.object_key + ticket.object_key.clone() }; + if let Some(source_identity) = source_identity.as_ref() { + validate_reference_binding_access(&binding_access)?; + let binding = new_external_editor_resource_binding( + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + Some(&stable_reference), + &ticket.object_key, + &asset_object_id, + Some( + material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + Some( + material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + unix_timestamp(), + ) + .map_err(|_| ReferencePreparationError::Internal)?; + write_external_editor_resource_binding_at(root, &binding) + .map_err(|_| ReferencePreparationError::Internal)?; + } ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; @@ -1681,9 +1814,14 @@ async fn prepare_canvas_generation_context( client: &reqwest::Client, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { - prepare_external_canvas_generation_context(root, client, api_base_url, api_mode.bearer_token()) - .await + let access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; + access.validate_frozen_session()?; + let context = prepare_external_canvas_generation_context(root, client, &access).await; + access.validate_frozen_session()?; + context } fn build_generation_request_snapshot( @@ -2106,12 +2244,17 @@ async fn reconcile_generation( &client, api_base_url, api_mode, + platform_session, ) .await { Ok(context) => context, Err(error) => { - let code = if error.contains("HTTP 401") || error.contains("HTTP 403") { + let code = if error.contains("HTTP 401") + || error.contains("HTTP 403") + || error.contains("authentication-required") + || error.contains("登录态已变化") + { "authentication-required" } else { "platform-service-configuration" @@ -2128,8 +2271,15 @@ async fn reconcile_generation( ledger.canvas_context = Some(context.into()); write_generation_ledger(root, &mut ledger)?; } - if let Err(error) = - ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await + if let Err(error) = ensure_reference_states( + root, + &mut ledger, + &client, + api_base_url, + api_mode, + platform_session, + ) + .await { let code = error.code(); if code == "authentication-required" { @@ -2816,6 +2966,31 @@ mod tests { .await } + fn write_test_external_editor_project_binding( + root: &Path, + local_project_id: &str, + api_base_url: &str, + bearer_token: &str, + platform_session: Option<&PlatformSessionSnapshot>, + remote_project_id: &str, + remote_asset_folder_id: &str, + ) { + let access = ExternalEditorBindingAccess::new(api_base_url, bearer_token, platform_session) + .expect("build test External Editor binding access"); + let principal = external_editor_binding_principal(&access) + .expect("build test External Editor principal"); + let binding = new_external_editor_project_binding( + local_project_id, + &principal, + remote_project_id, + remote_asset_folder_id, + unix_timestamp(), + ) + .expect("build test External Editor project binding"); + write_external_editor_project_binding_at(root, &binding) + .expect("persist test External Editor project binding"); + } + fn test_png_with_color(color: [u8; 4]) -> Vec { let image = RgbaImage::from_pixel(4, 3, Rgba(color)); let mut output = Cursor::new(Vec::new()); @@ -3093,6 +3268,16 @@ mod tests { "http://{}", listener.local_addr().expect("generation address") ); + let api_key = "phase-five-secret-api-key"; + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + api_key, + None, + "remote-project", + "remote-folder", + ); let signed_url = format!("{base_url}/generated.png"); let server_signed_url = signed_url.clone(); let png = test_png(); @@ -3172,7 +3357,6 @@ mod tests { } } }); - let api_key = "phase-five-secret-api-key"; let private_prompt = "只允许进入私有账本的完整提示词正文"; let input = generation_input(directory.path(), project_id, &draft, private_prompt); let mut progress = Vec::new(); @@ -3461,6 +3645,15 @@ mod tests { } } }); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + "refine-private-api-key", + None, + "remote-project", + "remote-folder", + ); let execution = with_test_external_editor_credentials(&base_url, "refine-private-api-key", async { generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await @@ -3555,6 +3748,300 @@ mod tests { })); } + #[tokio::test] + async fn manifest_reference_binding_is_account_scoped_and_source_identity_sensitive() { + let project_id = "asset-canvas-account-reference-binding"; + let project_name = "素材画布账号参考资源绑定测试"; + let (directory, draft, source_png) = + create_refine_generation_fixture(project_id, project_name); + let source_sha256 = asset_canvas_sha256(&source_png); + let source_identity = new_external_editor_source_identity( + "source-character", + &source_sha256, + "image/png", + "illustration", + ) + .expect("build source identity"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind account binding server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("account binding address") + ); + let upload_url = format!("{base_url}/oss-upload"); + + let principal_a = { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-a", + "reference-token-a", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("account A session snapshot"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account A binding access"); + let principal = + external_editor_binding_principal(&access).expect("account A principal"); + let project_binding = new_external_editor_project_binding( + project_id, + &principal, + "remote-project-a", + "remote-folder-a", + unix_timestamp(), + ) + .expect("account A project binding"); + write_external_editor_project_binding_at(directory.path(), &project_binding) + .expect("persist account A project binding"); + let resource_binding = new_external_editor_resource_binding( + project_id, + &principal, + "remote-project-a", + &source_identity, + Some("editor-resource-account-a"), + "accounts/a/source.png", + "asset-object-account-a", + Some(4), + Some(3), + unix_timestamp(), + ) + .expect("account A resource binding"); + write_external_editor_resource_binding_at(directory.path(), &resource_binding) + .expect("persist account A resource binding"); + principal + }; + + let different_sha_identity = new_external_editor_source_identity( + "source-character", + &asset_canvas_sha256(&test_png_with_color([39, 132, 76, 255])), + "image/png", + "illustration", + ) + .expect("build changed SHA identity"); + let different_kind_identity = new_external_editor_source_identity( + "source-character", + &source_sha256, + "image/png", + "character-art", + ) + .expect("build changed kind identity"); + assert!(read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_a, + "remote-project-a", + &different_sha_identity, + ) + .expect("read changed SHA binding") + .is_none()); + assert!(read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_a, + "remote-project-a", + &different_kind_identity, + ) + .expect("read changed kind binding") + .is_none()); + + let server_source_png_len = source_png.len(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set account binding server nonblocking"); + for request_index in 0..4 { + let mut stream = accept_generation_fixture_connection( + &listener, + "account binding fixture", + request_index, + ); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture account binding request"); + if request.starts_with("POST /api/assets/direct-upload-tickets ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url.clone(), + "bucket": "account-b-private-bucket", + "objectKey": "accounts/b/source.png", + "successActionStatus": 204, + "maxSizeBytes": server_source_png_len, + "formFields": { + "key": "accounts/b/source.png", + "success_action_status": "204", + "Content-Type": "image/png" + } + }}}), + ); + } else if request.starts_with("POST /oss-upload ") { + write_empty_response(&mut stream, "204 No Content"); + } else if request.starts_with("POST /api/assets/objects/confirm ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": "accounts/b/source.png", + "assetObjectId": "asset-object-account-b" + }}}), + ); + } else if request + .starts_with("POST /api/editor/projects/remote-project-b/resources ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-account-b", + "projectId": "remote-project-b", + "objectKey": "accounts/b/source.png", + "assetObjectId": "asset-object-account-b" + }}}), + ); + } else { + panic!("unexpected account binding request: {request}"); + } + } + }); + + let mut account_b_input = generation_input( + directory.path(), + project_id, + &draft, + "账号 B 使用账号隔离后的源图片", + ); + account_b_input.asset_kind = "ui-design".to_string(); + let mut account_b_ledger = validate_and_prepare_ledger(directory.path(), &account_b_input) + .expect("prepare account B ledger"); + account_b_ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project-b".to_string(), + asset_folder_id: "remote-folder-b".to_string(), + canvas_name: project_name.to_string(), + }); + account_b_ledger.platform_owner_user_id = Some("reference-owner-b".to_string()); + account_b_ledger.platform_owner_api_base_url = Some(base_url.clone()); + write_generation_ledger(directory.path(), &mut account_b_ledger) + .expect("persist account B ledger context"); + + let principal_b = { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-b", + "reference-token-b", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("account B session snapshot"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account B binding access"); + let principal = + external_editor_binding_principal(&access).expect("account B principal"); + let project_binding = new_external_editor_project_binding( + project_id, + &principal, + "remote-project-b", + "remote-folder-b", + unix_timestamp(), + ) + .expect("account B project binding"); + write_external_editor_project_binding_at(directory.path(), &project_binding) + .expect("persist account B project binding"); + ensure_reference_states( + directory.path(), + &mut account_b_ledger, + &reqwest::Client::new(), + &base_url, + &CanvasGenerationApiMode { + api_key: session.access_token.clone(), + }, + Some(&session), + ) + .await + .expect("account B re-registers the local manifest reference"); + principal + }; + server.join().expect("join account binding server"); + + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 4); + assert!(requests.iter().all(|request| { + !request.contains("remote-project-a") + && !request.contains("editor-resource-account-a") + && !request.contains("asset-object-account-a") + && !request.contains("accounts/a/source.png") + })); + let register_request = requests + .iter() + .find(|request| { + request.starts_with("POST /api/editor/projects/remote-project-b/resources ") + }) + .expect("account B registration request"); + assert!(register_request.contains("\"assetKind\":\"illustration\"")); + assert!(!register_request.contains("\"assetKind\":\"ui-design\"")); + assert_eq!( + account_b_ledger.resolved_reference_ids, + vec!["editor-resource-account-b".to_string()] + ); + let account_b_binding = read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_b, + "remote-project-b", + &source_identity, + ) + .expect("read account B resource binding") + .expect("account B resource binding exists"); + assert_eq!( + account_b_binding.remote_resource_id.as_deref(), + Some("editor-resource-account-b") + ); + + let account_a_input = generation_input( + directory.path(), + project_id, + &draft, + "切回账号 A 复用 A 自己的源图片绑定", + ); + let mut account_a_ledger = validate_and_prepare_ledger(directory.path(), &account_a_input) + .expect("prepare account A reuse ledger"); + account_a_ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project-a".to_string(), + asset_folder_id: "remote-folder-a".to_string(), + canvas_name: project_name.to_string(), + }); + account_a_ledger.platform_owner_user_id = Some("reference-owner-a".to_string()); + account_a_ledger.platform_owner_api_base_url = Some(base_url.clone()); + write_generation_ledger(directory.path(), &mut account_a_ledger) + .expect("persist account A reuse ledger context"); + { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-a", + "reference-token-a", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("restored account A session snapshot"); + ensure_reference_states( + directory.path(), + &mut account_a_ledger, + &reqwest::Client::new(), + &base_url, + &CanvasGenerationApiMode { + api_key: session.access_token.clone(), + }, + Some(&session), + ) + .await + .expect("account A reuses its own binding without remote traffic"); + } + assert_eq!( + account_a_ledger.resolved_reference_ids, + vec!["editor-resource-account-a".to_string()] + ); + } + #[tokio::test] async fn reference_ticket_failure_is_stage_specific_and_never_submits_generation() { let project_id = "asset-canvas-ticket-failure-project"; @@ -3565,6 +4052,15 @@ mod tests { "http://{}", listener.local_addr().expect("ticket failure address") ); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + "ticket-failure-private-api-key", + None, + "remote-project", + "remote-folder", + ); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { listener @@ -4103,31 +4599,6 @@ mod tests { #[test] fn refine_snapshot_uses_source_once_and_preserves_local_lineage_identity() { - let mut source_asset = GameCreationAppAssetManifestEntry { - id: "source".to_string(), - kind: "illustration".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: None, - resource_id: Some("local-asset:source".to_string()), - asset_object_id: Some("asset-object-source".to_string()), - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }; - assert_eq!(stable_manifest_reference(&source_asset), None); - source_asset.source.resource_id = Some("resource-source".to_string()); - assert_eq!( - stable_manifest_reference(&source_asset).as_deref(), - Some("resource-source") - ); - let body_json = serde_json::json!({"prompt": "private"}).to_string(); let source_resource_id = "local-asset:source".to_string(); let now = asset_canvas_now(); @@ -4441,10 +4912,12 @@ mod tests { let (directory, draft) = create_generation_fixture(project_id, "阶段五缺失配置测试"); let prompt = "配置缺失时也不能公开的正文"; let input = generation_input(directory.path(), project_id, &draft, prompt); + let no_session = crate::platform_session::clear_test_platform_session(); let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) .await .err() .expect("missing configuration must fail"); + drop(no_session); assert!(error.contains("陶泥儿登录态缺失")); assert!(!error.contains(prompt)); assert!(!error.contains(&directory.path().to_string_lossy().into_owned())); @@ -4554,6 +5027,17 @@ mod tests { "fresh-login-token", &base_url, ); + let platform_session = crate::platform_session::current_platform_session() + .expect("login recovery platform session"); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "login-recovery-project", + "login-recovery-folder", + ); let recovered = recover_asset_canvas_generations_at( directory.path(), &RecoverAssetCanvasGenerationsInput { @@ -4684,6 +5168,17 @@ mod tests { "session-token", &base_url, ); + let platform_session = crate::platform_session::current_platform_session() + .expect("session gate platform session"); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "remote-project", + "remote-folder", + ); let input = generation_input( directory.path(), project_id, @@ -4707,14 +5202,14 @@ mod tests { .err() .expect("changed session must stop before submit"); server.join().expect("join session gate server"); - assert!(error.contains("登录态已变化")); + assert_eq!(error, "陶泥儿登录已失效,请重新登录"); let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 2); assert!(requests.iter().all(|request| request.starts_with("GET "))); let ledger = read_generation_ledger(directory.path(), &generation_id) .expect("read session gate ledger") .expect("session gate ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::Prepared); + assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); assert!(ledger.operation_id.is_none()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs new file mode 100644 index 000000000..9d6778dcf --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -0,0 +1,1150 @@ +use super::*; + +const EXTERNAL_EDITOR_BINDING_ROOT: &str = ".agent/runtime/external-editor-bindings"; +const EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION: &str = + "game-creator-external-editor-project-binding.v1"; +const EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION: &str = + "game-creator-external-editor-resource-binding.v1"; +const EXTERNAL_EDITOR_BINDING_MAX_BYTES: usize = 64 * 1024; +const EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS: usize = 512; +const EXTERNAL_EDITOR_BINDING_MAX_ORIGIN_CHARS: usize = 2_048; +const EXTERNAL_EDITOR_BINDING_MAX_OBJECT_KEY_CHARS: usize = 2_048; +const EXTERNAL_EDITOR_BINDING_MAX_MEDIA_TYPE_CHARS: usize = 255; +const EXTERNAL_EDITOR_BINDING_MAX_ASSET_KIND_CHARS: usize = 128; +const EXTERNAL_EDITOR_PLATFORM_SCHEME: &str = "official-platform-v1"; +const EXTERNAL_EDITOR_DEVELOPER_SCHEME: &str = "external-developer-v1"; + +/// In-memory authorization for resolving an account-scoped binding. This type deliberately does +/// not implement `Serialize` or `Debug`: the bearer token must never enter a project sidecar, +/// diagnostic, event, or formatted error. +pub(crate) struct ExternalEditorBindingAccess<'a> { + api_base_url: &'a str, + bearer_token: &'a str, + frozen_platform_session: Option<&'a PlatformSessionSnapshot>, +} + +impl<'a> ExternalEditorBindingAccess<'a> { + pub(crate) fn for_platform( + frozen_platform_session: &'a PlatformSessionSnapshot, + ) -> Result { + Self::new( + &frozen_platform_session.api_base_url, + &frozen_platform_session.access_token, + Some(frozen_platform_session), + ) + } + + pub(crate) fn for_developer( + api_base_url: &'a str, + developer_api_key: &'a str, + ) -> Result { + Self::new(api_base_url, developer_api_key, None) + } + + pub(crate) fn new( + api_base_url: &'a str, + bearer_token: &'a str, + frozen_platform_session: Option<&'a PlatformSessionSnapshot>, + ) -> Result { + let access = Self { + api_base_url, + bearer_token, + frozen_platform_session, + }; + validate_external_editor_binding_access_shape(&access)?; + Ok(access) + } + + pub(crate) fn api_base_url(&self) -> &str { + self.api_base_url + } + + pub(crate) fn bearer_token(&self) -> &str { + self.bearer_token + } + + pub(crate) fn frozen_platform_session(&self) -> Option<&PlatformSessionSnapshot> { + self.frozen_platform_session + } + + pub(crate) fn api_route(&self, external_route: &str) -> String { + if self.frozen_platform_session.is_none() { + 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 generation_status_route(&self, operation_id: &str) -> String { + let operation_id = percent_encode_query_component(operation_id); + if self.frozen_platform_session.is_some() { + format!("/api/runtime/external-generation/jobs/{operation_id}") + } else { + format!("/api/external/v1/generations/{operation_id}") + } + } + + /// 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. + 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)?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExternalEditorPrincipalIdentity { + pub(crate) scheme: String, + pub(crate) service_origin: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) owner_user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) credential_sha256: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExternalEditorProjectBinding { + pub(crate) schema_version: String, + pub(crate) binding_key_sha256: String, + pub(crate) project_id: String, + pub(crate) principal: ExternalEditorPrincipalIdentity, + pub(crate) remote_project_id: String, + pub(crate) remote_asset_folder_id: String, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExternalEditorSourceIdentity { + pub(crate) local_asset_id: String, + pub(crate) source_sha256: String, + pub(crate) media_type: String, + pub(crate) canonical_asset_kind: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExternalEditorResourceBinding { + pub(crate) schema_version: String, + pub(crate) binding_key_sha256: String, + pub(crate) project_id: String, + pub(crate) principal_key_sha256: String, + pub(crate) remote_project_id: String, + pub(crate) source: ExternalEditorSourceIdentity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) remote_resource_id: Option, + pub(crate) object_key: String, + pub(crate) asset_object_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) height: Option, + pub(crate) created_at: u64, + pub(crate) verified_at: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectBindingKeyMaterial<'a> { + project_id: &'a str, + principal: &'a ExternalEditorPrincipalIdentity, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ResourceBindingKeyMaterial<'a> { + project_id: &'a str, + principal_key_sha256: &'a str, + remote_project_id: &'a str, + source: &'a ExternalEditorSourceIdentity, +} + +pub(crate) fn external_editor_binding_principal( + access: &ExternalEditorBindingAccess<'_>, +) -> Result { + validate_external_editor_binding_access_shape(access)?; + let service_origin = normalize_external_editor_binding_service_origin(access.api_base_url)?; + let principal = if let Some(session) = access.frozen_platform_session { + ExternalEditorPrincipalIdentity { + scheme: EXTERNAL_EDITOR_PLATFORM_SCHEME.to_string(), + service_origin, + owner_user_id: Some(session.user_id.clone()), + credential_sha256: None, + } + } else { + ExternalEditorPrincipalIdentity { + scheme: EXTERNAL_EDITOR_DEVELOPER_SCHEME.to_string(), + service_origin, + owner_user_id: None, + credential_sha256: Some(external_editor_binding_sha256( + access.bearer_token.as_bytes(), + )), + } + }; + validate_external_editor_principal(&principal)?; + Ok(principal) +} + +pub(crate) fn external_editor_project_binding_key_sha256( + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, +) -> Result { + validate_bounded_identity( + project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_external_editor_principal(principal)?; + sha256_serialized( + &ProjectBindingKeyMaterial { + project_id, + principal, + }, + "External Editor 项目 binding key", + ) +} + +pub(crate) fn external_editor_resource_binding_key_sha256( + project_id: &str, + principal_key_sha256: &str, + remote_project_id: &str, + source: &ExternalEditorSourceIdentity, +) -> Result { + validate_bounded_identity( + project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_sha256(principal_key_sha256, "External Editor principal key")?; + validate_bounded_identity( + remote_project_id, + "External Editor remote projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_external_editor_source_identity(source)?; + sha256_serialized( + &ResourceBindingKeyMaterial { + project_id, + principal_key_sha256, + remote_project_id, + source, + }, + "External Editor resource binding key", + ) +} + +pub(crate) fn new_external_editor_source_identity( + local_asset_id: &str, + source_sha256: &str, + media_type: &str, + canonical_asset_kind: &str, +) -> Result { + let source = ExternalEditorSourceIdentity { + local_asset_id: local_asset_id.to_string(), + source_sha256: source_sha256.to_string(), + media_type: media_type.to_string(), + canonical_asset_kind: canonical_asset_kind.to_string(), + }; + validate_external_editor_source_identity(&source)?; + Ok(source) +} + +pub(crate) fn new_external_editor_project_binding( + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + remote_asset_folder_id: &str, + now: u64, +) -> Result { + let binding = ExternalEditorProjectBinding { + schema_version: EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION.to_string(), + binding_key_sha256: external_editor_project_binding_key_sha256(project_id, principal)?, + project_id: project_id.to_string(), + principal: principal.clone(), + remote_project_id: remote_project_id.to_string(), + remote_asset_folder_id: remote_asset_folder_id.to_string(), + created_at: now, + updated_at: now, + }; + validate_external_editor_project_binding(&binding)?; + Ok(binding) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn new_external_editor_resource_binding( + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + source: &ExternalEditorSourceIdentity, + remote_resource_id: Option<&str>, + object_key: &str, + asset_object_id: &str, + width: Option, + height: Option, + now: u64, +) -> Result { + let principal_key_sha256 = external_editor_project_binding_key_sha256(project_id, principal)?; + let binding = ExternalEditorResourceBinding { + schema_version: EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION.to_string(), + binding_key_sha256: external_editor_resource_binding_key_sha256( + project_id, + &principal_key_sha256, + remote_project_id, + source, + )?, + project_id: project_id.to_string(), + principal_key_sha256, + remote_project_id: remote_project_id.to_string(), + source: source.clone(), + remote_resource_id: remote_resource_id.map(str::to_string), + object_key: object_key.to_string(), + asset_object_id: asset_object_id.to_string(), + width, + height, + created_at: now, + verified_at: now, + }; + validate_external_editor_resource_binding(&binding)?; + Ok(binding) +} + +pub(crate) fn read_external_editor_project_binding_at( + root: &Path, + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, +) -> Result, String> { + let binding_key = external_editor_project_binding_key_sha256(project_id, principal)?; + let binding = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &external_editor_project_binding_relative_path(&binding_key), + "External Editor 账号项目 binding", + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )?; + let Some(binding) = binding else { + return Ok(None); + }; + validate_external_editor_project_binding(&binding)?; + if binding.binding_key_sha256 != binding_key + || binding.project_id != project_id + || binding.principal != *principal + { + return Err("External Editor 账号项目 binding 与当前身份不匹配".to_string()); + } + Ok(Some(binding)) +} + +pub(crate) fn write_external_editor_project_binding_at( + root: &Path, + binding: &ExternalEditorProjectBinding, +) -> Result<(), String> { + validate_external_editor_project_binding(binding)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &external_editor_project_binding_relative_path(&binding.binding_key_sha256), + "External Editor 账号项目 binding", + binding, + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )?; + let persisted = + read_external_editor_project_binding_at(root, &binding.project_id, &binding.principal)? + .ok_or_else(|| "External Editor 账号项目 binding 写入后缺失".to_string())?; + if persisted != *binding { + return Err("External Editor 账号项目 binding 写入后回读不一致".to_string()); + } + Ok(()) +} + +pub(crate) fn read_external_editor_resource_binding_at( + root: &Path, + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + source: &ExternalEditorSourceIdentity, +) -> Result, String> { + let principal_key = external_editor_project_binding_key_sha256(project_id, principal)?; + let binding_key = external_editor_resource_binding_key_sha256( + project_id, + &principal_key, + remote_project_id, + source, + )?; + let binding = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &external_editor_resource_binding_relative_path(&principal_key, &binding_key), + "External Editor 账号资源 binding", + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )?; + let Some(binding) = binding else { + return Ok(None); + }; + validate_external_editor_resource_binding(&binding)?; + if binding.binding_key_sha256 != binding_key + || binding.project_id != project_id + || binding.principal_key_sha256 != principal_key + || binding.remote_project_id != remote_project_id + || binding.source != *source + { + return Err("External Editor 账号资源 binding 与当前身份不匹配".to_string()); + } + Ok(Some(binding)) +} + +pub(crate) fn write_external_editor_resource_binding_at( + root: &Path, + binding: &ExternalEditorResourceBinding, +) -> Result<(), String> { + validate_external_editor_resource_binding(binding)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &external_editor_resource_binding_relative_path( + &binding.principal_key_sha256, + &binding.binding_key_sha256, + ), + "External Editor 账号资源 binding", + binding, + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )?; + let persisted = + read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &external_editor_resource_binding_relative_path( + &binding.principal_key_sha256, + &binding.binding_key_sha256, + ), + "External Editor 账号资源 binding", + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )? + .ok_or_else(|| "External Editor 账号资源 binding 写入后缺失".to_string())?; + validate_external_editor_resource_binding(&persisted)?; + if persisted != *binding { + return Err("External Editor 账号资源 binding 写入后回读不一致".to_string()); + } + Ok(()) +} + +pub(crate) fn invalidate_external_editor_resource_binding_at( + root: &Path, + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + source: &ExternalEditorSourceIdentity, +) -> Result { + if read_external_editor_resource_binding_at( + root, + project_id, + principal, + remote_project_id, + source, + )? + .is_none() + { + return Ok(false); + } + let principal_key = external_editor_project_binding_key_sha256(project_id, principal)?; + let binding_key = external_editor_resource_binding_key_sha256( + project_id, + &principal_key, + remote_project_id, + source, + )?; + let path = resolve_local_project_path( + root, + &external_editor_resource_binding_relative_path(&principal_key, &binding_key), + )?; + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + remove_agent_runtime_json_sidecar_backup(&backup_path, "External Editor 账号资源 binding")?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("External Editor 账号资源 binding 必须是普通文件".to_string()) + } + Ok(_) => fs::remove_file(&path) + .map(|_| true) + .map_err(|error| format!("删除 External Editor 账号资源 binding 失败:{error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(format!( + "读取 External Editor 账号资源 binding 元数据失败:{error}" + )), + } +} + +fn external_editor_project_binding_relative_path(binding_key_sha256: &str) -> String { + format!("{EXTERNAL_EDITOR_BINDING_ROOT}/projects/{binding_key_sha256}.json") +} + +fn external_editor_resource_binding_relative_path( + principal_key_sha256: &str, + binding_key_sha256: &str, +) -> String { + format!( + "{EXTERNAL_EDITOR_BINDING_ROOT}/resources/{principal_key_sha256}/{binding_key_sha256}.json" + ) +} + +fn validate_external_editor_binding_access_shape( + access: &ExternalEditorBindingAccess<'_>, +) -> Result<(), String> { + let service_origin = normalize_external_editor_binding_service_origin(access.api_base_url)?; + if access.bearer_token.trim() != access.bearer_token + || access.bearer_token.is_empty() + || access.bearer_token.len() > 16 * 1024 + || access.bearer_token.chars().any(char::is_control) + { + return Err("External Editor binding 凭据格式无效".to_string()); + } + if let Some(session) = access.frozen_platform_session { + let session_origin = + normalize_external_editor_binding_service_origin(&session.api_base_url)?; + if service_origin != session_origin + || access.bearer_token != session.access_token + || session.user_id.trim() != session.user_id + || session.user_id.is_empty() + || session.user_id.len() > 256 + || session.user_id.chars().any(char::is_control) + { + return Err("External Editor binding 与冻结账号快照不匹配".to_string()); + } + } + Ok(()) +} + +fn normalize_external_editor_binding_service_origin(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + let parsed = reqwest::Url::parse(value) + .map_err(|_| "External Editor binding 服务地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err("External Editor binding 服务地址必须是纯 HTTP(S) origin".to_string()); + } + let origin = parsed.origin().ascii_serialization(); + if origin == "null" || origin.len() > EXTERNAL_EDITOR_BINDING_MAX_ORIGIN_CHARS { + return Err("External Editor binding 服务 origin 无效".to_string()); + } + Ok(origin) +} + +fn validate_external_editor_principal( + principal: &ExternalEditorPrincipalIdentity, +) -> Result<(), String> { + if normalize_external_editor_binding_service_origin(&principal.service_origin)? + != principal.service_origin + { + return Err("External Editor principal 服务 origin 不是规范值".to_string()); + } + match principal.scheme.as_str() { + EXTERNAL_EDITOR_PLATFORM_SCHEME => { + let owner_user_id = principal + .owner_user_id + .as_deref() + .ok_or_else(|| "External Editor 平台 principal 缺少 ownerUserId".to_string())?; + validate_bounded_identity(owner_user_id, "ownerUserId", 256)?; + if principal.credential_sha256.is_some() { + return Err("External Editor 平台 principal 不能保存凭据摘要".to_string()); + } + } + EXTERNAL_EDITOR_DEVELOPER_SCHEME => { + if principal.owner_user_id.is_some() { + return Err("External Editor Developer principal 不能保存 ownerUserId".to_string()); + } + validate_sha256( + principal.credential_sha256.as_deref().ok_or_else(|| { + "External Editor Developer principal 缺少凭据身份摘要".to_string() + })?, + "External Editor Developer 凭据身份摘要", + )?; + } + _ => return Err("External Editor principal scheme 无效".to_string()), + } + Ok(()) +} + +fn validate_external_editor_project_binding( + binding: &ExternalEditorProjectBinding, +) -> Result<(), String> { + if binding.schema_version != EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION { + return Err("External Editor 账号项目 binding schema 无效".to_string()); + } + validate_external_editor_principal(&binding.principal)?; + validate_bounded_identity( + &binding.project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_bounded_identity( + &binding.remote_project_id, + "External Editor remote projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_bounded_identity( + &binding.remote_asset_folder_id, + "External Editor remote assetFolderId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + let expected = + external_editor_project_binding_key_sha256(&binding.project_id, &binding.principal)?; + if binding.binding_key_sha256 != expected + || binding.created_at == 0 + || binding.updated_at < binding.created_at + { + return Err("External Editor 账号项目 binding 身份无效".to_string()); + } + Ok(()) +} + +fn validate_external_editor_source_identity( + source: &ExternalEditorSourceIdentity, +) -> Result<(), String> { + validate_bounded_identity( + &source.local_asset_id, + "本地 assetId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_sha256(&source.source_sha256, "本地资源 SHA-256")?; + validate_bounded_identity( + &source.media_type, + "本地资源媒体类型", + EXTERNAL_EDITOR_BINDING_MAX_MEDIA_TYPE_CHARS, + )?; + if !source.media_type.contains('/') + || source.media_type.starts_with(['/', '\\']) + || source.media_type.contains("://") + { + return Err("本地资源媒体类型无效".to_string()); + } + validate_bounded_identity( + &source.canonical_asset_kind, + "本地资源规范用途", + EXTERNAL_EDITOR_BINDING_MAX_ASSET_KIND_CHARS, + ) +} + +fn validate_external_editor_resource_binding( + binding: &ExternalEditorResourceBinding, +) -> Result<(), String> { + if binding.schema_version != EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION { + return Err("External Editor 账号资源 binding schema 无效".to_string()); + } + validate_bounded_identity( + &binding.project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_sha256( + &binding.principal_key_sha256, + "External Editor principal key", + )?; + validate_bounded_identity( + &binding.remote_project_id, + "External Editor remote projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_external_editor_source_identity(&binding.source)?; + if let Some(remote_resource_id) = binding.remote_resource_id.as_deref() { + validate_bounded_identity( + remote_resource_id, + "External Editor remote resourceId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + } + validate_bounded_identity( + &binding.asset_object_id, + "External Editor assetObjectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_stable_object_key(&binding.object_key)?; + match (binding.width, binding.height) { + (Some(width), Some(height)) if width > 0 && height > 0 => {} + (None, None) => {} + _ => return Err("External Editor 账号资源 binding 尺寸无效".to_string()), + } + if binding.created_at == 0 || binding.verified_at < binding.created_at { + return Err("External Editor 账号资源 binding 元数据无效".to_string()); + } + let expected = external_editor_resource_binding_key_sha256( + &binding.project_id, + &binding.principal_key_sha256, + &binding.remote_project_id, + &binding.source, + )?; + if binding.binding_key_sha256 != expected { + return Err("External Editor 账号资源 binding key 无效".to_string()); + } + Ok(()) +} + +fn validate_stable_object_key(value: &str) -> Result<(), String> { + validate_bounded_identity( + value, + "External Editor objectKey", + EXTERNAL_EDITOR_BINDING_MAX_OBJECT_KEY_CHARS, + )?; + if Path::new(value).is_absolute() + || value.starts_with(['/', '\\']) + || value.contains(['?', '#', '\\']) + || value.contains("://") + || value + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err("External Editor objectKey 必须是稳定相对对象键".to_string()); + } + Ok(()) +} + +fn validate_bounded_identity(value: &str, label: &str, max_chars: usize) -> Result<(), String> { + if value.trim() != value + || value.is_empty() + || value.chars().count() > max_chars + || value.chars().any(char::is_control) + || external_editor_binding_looks_like_absolute_path(value) + { + return Err(format!("{label} 无效")); + } + Ok(()) +} + +fn external_editor_binding_looks_like_absolute_path(value: &str) -> bool { + let bytes = value.as_bytes(); + Path::new(value).is_absolute() + || value.starts_with("\\\\") + || value.starts_with("~/") + || value.starts_with("~\\") + || value.contains("://") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')) +} + +fn validate_sha256(value: &str, label: &str) -> Result<(), String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!("{label} 必须是小写 SHA-256")); + } + Ok(()) +} + +fn external_editor_binding_sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn sha256_serialized(value: &T, label: &str) -> Result { + serde_json::to_vec(value) + .map(|bytes| external_editor_binding_sha256(&bytes)) + .map_err(|error| format!("序列化 {label} 失败:{error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn platform_session(user_id: &str, token: &str, generation: u64) -> PlatformSessionSnapshot { + PlatformSessionSnapshot { + user_id: user_id.to_string(), + access_token: token.to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + generation, + } + } + + fn platform_principal(session: &PlatformSessionSnapshot) -> ExternalEditorPrincipalIdentity { + let access = ExternalEditorBindingAccess::for_platform(session).expect("platform access"); + external_editor_binding_principal(&access).expect("platform principal") + } + + fn source_identity() -> ExternalEditorSourceIdentity { + new_external_editor_source_identity("asset:hero", &"1".repeat(64), "image/png", "character") + .expect("source identity") + } + + #[test] + fn account_bindings_are_isolated_while_token_rotation_reuses_identity() { + let account_a = platform_session("user-a", "token-a", 1); + let account_a_refreshed = platform_session("user-a", "token-a-refreshed", 2); + let account_b = platform_session("user-b", "token-b", 3); + let principal_a = platform_principal(&account_a); + let principal_a_refreshed = platform_principal(&account_a_refreshed); + let principal_b = platform_principal(&account_b); + + assert_eq!(principal_a, principal_a_refreshed); + assert_ne!(principal_a, principal_b); + assert_eq!( + external_editor_project_binding_key_sha256("local-project", &principal_a) + .expect("account A key"), + external_editor_project_binding_key_sha256("local-project", &principal_a_refreshed,) + .expect("refreshed account A key") + ); + assert_ne!( + external_editor_project_binding_key_sha256("local-project", &principal_a) + .expect("account A key"), + external_editor_project_binding_key_sha256("local-project", &principal_b) + .expect("account B key") + ); + } + + #[test] + fn project_store_separates_accounts_and_local_project_ids() { + let directory = tempfile::tempdir().expect("temp project"); + let principal_a = platform_principal(&platform_session("user-a", "token-a", 1)); + let principal_b = platform_principal(&platform_session("user-b", "token-b", 2)); + let binding_a = new_external_editor_project_binding( + "local-project-a", + &principal_a, + "remote-project-a", + "remote-folder-a", + 1, + ) + .expect("account A binding"); + let binding_b = new_external_editor_project_binding( + "local-project-a", + &principal_b, + "remote-project-b", + "remote-folder-b", + 2, + ) + .expect("account B binding"); + write_external_editor_project_binding_at(directory.path(), &binding_a) + .expect("write account A binding"); + write_external_editor_project_binding_at(directory.path(), &binding_b) + .expect("write account B binding"); + + assert_eq!( + read_external_editor_project_binding_at( + directory.path(), + "local-project-a", + &principal_a, + ) + .expect("read A"), + Some(binding_a) + ); + assert_eq!( + read_external_editor_project_binding_at( + directory.path(), + "local-project-a", + &principal_b, + ) + .expect("read B"), + Some(binding_b) + ); + assert_ne!( + external_editor_project_binding_key_sha256("local-project-a", &principal_a) + .expect("first local project"), + external_editor_project_binding_key_sha256("local-project-b", &principal_a) + .expect("second local project") + ); + } + + #[test] + fn resource_key_changes_with_source_sha_kind_and_remote_project() { + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let principal_key = external_editor_project_binding_key_sha256("local-project", &principal) + .expect("principal key"); + let source = source_identity(); + let baseline = external_editor_resource_binding_key_sha256( + "local-project", + &principal_key, + "remote-project", + &source, + ) + .expect("baseline key"); + for changed in [ + ExternalEditorSourceIdentity { + source_sha256: "2".repeat(64), + ..source.clone() + }, + ExternalEditorSourceIdentity { + canonical_asset_kind: "scene".to_string(), + ..source.clone() + }, + ExternalEditorSourceIdentity { + local_asset_id: "asset:other".to_string(), + ..source.clone() + }, + ExternalEditorSourceIdentity { + media_type: "image/webp".to_string(), + ..source.clone() + }, + ] { + assert_ne!( + baseline, + external_editor_resource_binding_key_sha256( + "local-project", + &principal_key, + "remote-project", + &changed, + ) + .expect("changed source key") + ); + } + assert_ne!( + baseline, + external_editor_resource_binding_key_sha256( + "local-project", + &principal_key, + "remote-project-other", + &source, + ) + .expect("changed remote project key") + ); + } + + #[test] + fn resource_store_round_trips_and_invalidates_exact_binding() { + let directory = tempfile::tempdir().expect("temp project"); + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let source = source_identity(); + let binding = new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source, + Some("editor-resource-1"), + "generated/assets/source.png", + "asset-object-1", + Some(128), + Some(64), + 1, + ) + .expect("resource binding"); + write_external_editor_resource_binding_at(directory.path(), &binding) + .expect("write resource binding"); + assert_eq!( + read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .expect("read resource binding"), + Some(binding) + ); + assert!(invalidate_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .expect("invalidate binding")); + assert!(read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .expect("read invalidated binding") + .is_none()); + } + + #[test] + fn video_object_binding_does_not_require_project_resource_or_dimensions() { + let directory = tempfile::tempdir().expect("temp project"); + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let source = new_external_editor_source_identity( + "asset:video", + &"2".repeat(64), + "video/mp4", + "video", + ) + .expect("video source identity"); + let binding = new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source, + None, + "generated/assets/clip.mp4", + "asset-object-video-1", + None, + None, + 1, + ) + .expect("video object binding"); + write_external_editor_resource_binding_at(directory.path(), &binding) + .expect("write video object binding"); + assert_eq!( + read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .expect("read video object binding"), + Some(binding) + ); + } + + #[test] + fn corrupt_and_identity_mismatched_sidecars_fail_closed() { + let directory = tempfile::tempdir().expect("temp project"); + let principal_a = platform_principal(&platform_session("user-a", "token-a", 1)); + let principal_b = platform_principal(&platform_session("user-b", "token-b", 2)); + let binding_a = new_external_editor_project_binding( + "local-project", + &principal_a, + "remote-project-a", + "remote-folder-a", + 1, + ) + .expect("account A binding"); + let b_key = external_editor_project_binding_key_sha256("local-project", &principal_b) + .expect("account B key"); + write_agent_runtime_json_sidecar_with_max_bytes( + directory.path(), + &external_editor_project_binding_relative_path(&b_key), + "mismatched test binding", + &binding_a, + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + ) + .expect("write mismatched binding"); + assert!(read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal_b, + ) + .expect_err("identity mismatch must fail") + .contains("身份不匹配")); + + let a_key = external_editor_project_binding_key_sha256("local-project", &principal_a) + .expect("account A key"); + let corrupt_path = resolve_local_project_path( + directory.path(), + &external_editor_project_binding_relative_path(&a_key), + ) + .expect("corrupt path"); + fs::create_dir_all(corrupt_path.parent().expect("binding parent")) + .expect("create binding parent"); + fs::write(&corrupt_path, b"{not-json\n").expect("write corrupt binding"); + assert!(read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal_a, + ) + .is_err()); + } + + #[test] + fn serialized_bindings_never_contain_tokens_or_absolute_paths() { + let session = platform_session("user-a", "super-secret-token", 1); + let principal = platform_principal(&session); + let project = new_external_editor_project_binding( + "local-project", + &principal, + "remote-project", + "remote-folder", + 1, + ) + .expect("project binding"); + let resource = new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source_identity(), + Some("editor-resource-1"), + "generated/assets/source.png", + "asset-object-1", + Some(16), + Some(16), + 1, + ) + .expect("resource binding"); + let serialized = format!( + "{}\n{}", + serde_json::to_string(&project).expect("serialize project"), + serde_json::to_string(&resource).expect("serialize resource"), + ); + assert!(!serialized.contains(&session.access_token)); + assert!(!serialized.contains("/data/")); + assert!(!serialized.contains("Authorization")); + assert!(new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source_identity(), + Some("editor-resource-1"), + "/data/private/source.png", + "asset-object-1", + Some(16), + Some(16), + 1, + ) + .is_err()); + } + + #[test] + fn frozen_access_owns_route_selection_instead_of_mutable_global_session() { + let session = platform_session("user-a", "token-a", 1); + let platform = + ExternalEditorBindingAccess::for_platform(&session).expect("platform binding access"); + let developer = ExternalEditorBindingAccess::for_developer( + "https://dev.genarrative.world", + "tnr_sk_secret_a", + ) + .expect("developer binding access"); + + assert_eq!( + platform.api_route("/api/external/v1/editor/projects"), + "/api/editor/projects" + ); + assert_eq!( + platform.api_route("/api/external/v1/assets/read-url"), + "/api/assets/read-url" + ); + assert_eq!( + platform.generation_status_route("operation/1"), + "/api/runtime/external-generation/jobs/operation%2F1" + ); + assert_eq!( + developer.api_route("/api/external/v1/editor/projects"), + "/api/external/v1/editor/projects" + ); + assert_eq!( + developer.generation_status_route("operation/1"), + "/api/external/v1/generations/operation%2F1" + ); + } + + #[test] + fn developer_principal_isolated_by_credential_hash_without_serializing_key() { + let access_a = ExternalEditorBindingAccess::for_developer( + "https://dev.genarrative.world", + "tnr_sk_secret_a", + ) + .expect("developer A access"); + let access_b = ExternalEditorBindingAccess::for_developer( + "https://dev.genarrative.world", + "tnr_sk_secret_b", + ) + .expect("developer B access"); + let principal_a = + external_editor_binding_principal(&access_a).expect("developer A principal"); + let principal_b = + external_editor_binding_principal(&access_b).expect("developer B principal"); + assert_ne!(principal_a, principal_b); + let serialized = serde_json::to_string(&principal_a).expect("serialize principal"); + assert!(!serialized.contains(access_a.bearer_token())); + assert!(serialized.contains("credentialSha256")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 91972015a..829f955b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -267,6 +267,11 @@ struct ResourceEditSourceSnapshot { source_version: Option, } +struct PreparedResourceEditSourceReference { + stable_reference: String, + canvas_context: ExternalCanvasGenerationContext, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] enum ResourceEditLedgerPhase { @@ -336,6 +341,8 @@ struct ResourceEditLedger { endpoint: Option, request_body_json: Option, remote_operation_id: Option, + #[serde(default)] + remote_project_id: Option, remote_resource_id: Option, remote_object_key: Option, remote_asset_object_id: Option, @@ -625,63 +632,6 @@ fn update_resource_edit_version_journal_phase( write_resource_edit_version_journal(root, journal) } -fn stable_resource_edit_object_key(value: &str) -> Option { - let value = value.trim(); - (!value.is_empty() - && !value.starts_with(['/', '\\']) - && !value.starts_with("http://") - && !value.starts_with("https://") - && !value.contains(['?', '#', '\\']) - && !value.chars().any(char::is_control) - && !value.split('/').any(|segment| segment == "..")) - .then(|| value.to_string()) -} - -fn committed_resource_edit_object_key_for_asset( - root: &Path, - asset_id: &str, -) -> Result, String> { - let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(format!("读取资源编辑账本目录失败:{error}")), - }; - let mut inspected = 0_usize; - for entry in entries { - let entry = entry.map_err(|error| format!("读取资源编辑账本失败:{error}"))?; - let file_type = entry - .file_type() - .map_err(|error| format!("读取资源编辑账本类型失败:{error}"))?; - if !file_type.is_file() - || entry.path().extension().and_then(|value| value.to_str()) != Some("json") - { - continue; - } - inspected += 1; - if inspected > 4_096 { - return Err("资源编辑账本数量超过安全扫描上限".to_string()); - } - let entry_path = entry.path(); - let Some(operation_id) = entry_path.file_stem().and_then(|value| value.to_str()) else { - continue; - }; - let Some(ledger) = read_resource_edit_ledger(root, operation_id)? else { - continue; - }; - if ledger.schema_version == RESOURCE_EDIT_SCHEMA_VERSION - && ledger.phase == ResourceEditLedgerPhase::Committed - && ledger.result_asset_id.as_deref() == Some(asset_id) - { - return Ok(ledger - .remote_object_key - .as_deref() - .and_then(stable_resource_edit_object_key)); - } - } - Ok(None) -} - fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> { let parsed = Uuid::parse_str(value).map_err(|_| format!("{label} 必须是 UUID v4"))?; if parsed.get_version_num() != 4 || parsed.hyphenated().to_string() != value { @@ -922,14 +872,7 @@ fn media_read_limit(edit_kind: &LocalProjectResourceEditKind) -> usize { } fn source_asset_canonical_resource_id(asset: &GameCreationAppAssetManifestEntry) -> String { - asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("local-asset:{}", asset.id)) + format!("local-asset:{}", asset.id) } fn resource_edit_audio_kind(asset_kind: &str, path: &str) -> LocalProjectResourceEditKind { @@ -1409,8 +1352,7 @@ fn read_optional_resource_edit_staging( async fn request_resource_edit_upload_ticket( client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, ) -> Result { @@ -1425,12 +1367,14 @@ async fn request_resource_edit_upload_ticket( .and_then(|value| value.to_str()) .map(sanitize_file_name) .unwrap_or_else(|| "source.bin".to_string()); + access.validate_frozen_session()?; let response = client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/assets/direct-upload-tickets") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/direct-upload-tickets") )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&resource_edit_upload_ticket_payload( input, source, @@ -1440,6 +1384,7 @@ async fn request_resource_edit_upload_ticket( .send() .await .map_err(|_| "创建源资源上传凭证失败".to_string())?; + access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); } @@ -1456,6 +1401,7 @@ async fn request_resource_edit_upload_ticket( .json::() .await .map_err(|_| "解析源资源上传凭证失败".to_string())?; + access.validate_frozen_session()?; let upload = external_editor_response_data(&payload) .get("upload") .or_else(|| payload.pointer("/data/upload")) @@ -1519,17 +1465,20 @@ fn resource_edit_upload_ticket_payload( async fn upload_resource_edit_source( ticket: &ResourceEditUploadTicket, source: &ResourceEditSourceSnapshot, - api_base_url: &str, + access: &ExternalEditorBindingAccess<'_>, ) -> Result<(), String> { let bytes = source .bytes .as_ref() .ok_or_else(|| "源媒体上传缺少文件内容".to_string())?; - let upload_url = validate_external_asset_download_url(&ticket.host, api_base_url, true) - .map_err(|_| "源资源上传地址不安全".to_string())?; - let client = build_external_asset_download_client(&upload_url, api_base_url, true) + let upload_url = + validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) + .map_err(|_| "源资源上传地址不安全".to_string())?; + access.validate_frozen_session()?; + let client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await .map_err(|_| "无法创建源资源上传客户端".to_string())?; + access.validate_frozen_session()?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { form = form.text(key.clone(), value.clone()); @@ -1545,12 +1494,14 @@ async fn upload_resource_edit_source( .file_name(file_name) .mime_str(&source.media_type) .map_err(|_| "源资源媒体类型不能用于上传".to_string())?; + access.validate_frozen_session()?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await .map_err(|_| "上传源资源失败".to_string())?; + access.validate_frozen_session()?; if response.status().as_u16() != ticket.success_action_status { return Err(format!( "上传源资源失败:HTTP {}", @@ -1562,8 +1513,7 @@ async fn upload_resource_edit_source( async fn confirm_resource_edit_source( client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ticket: &ResourceEditUploadTicket, source: &ResourceEditSourceSnapshot, ) -> Result { @@ -1571,12 +1521,14 @@ async fn confirm_resource_edit_source( .bytes .as_ref() .ok_or_else(|| "确认源媒体上传缺少文件内容".to_string())?; + access.validate_frozen_session()?; let response = client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/objects/confirm") )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ "bucket": ticket.bucket, "objectKey": ticket.object_key, @@ -1589,6 +1541,7 @@ async fn confirm_resource_edit_source( .send() .await .map_err(|_| "确认源资源上传失败".to_string())?; + access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); } @@ -1605,6 +1558,7 @@ async fn confirm_resource_edit_source( .json::() .await .map_err(|_| "解析源资源确认响应失败".to_string())?; + access.validate_frozen_session()?; let asset_object = external_editor_response_data(&payload) .get("assetObject") .or_else(|| payload.pointer("/data/assetObject")) @@ -1619,52 +1573,41 @@ async fn confirm_resource_edit_source( async fn resolve_resource_edit_source_project( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, - source: &ResourceEditSourceSnapshot, -) -> Result { - if let Some(project_id) = source - .source_asset - .as_ref() - .and_then(|asset| asset.source.canvas_project_id.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return Ok(project_id.to_string()); - } - prepare_external_canvas_generation_context(root, client, api_base_url, api_key) - .await - .map(|context| context.project_id) + access: &ExternalEditorBindingAccess<'_>, +) -> Result { + access.validate_frozen_session()?; + let context = prepare_external_canvas_generation_context(root, client, access).await?; + access.validate_frozen_session()?; + Ok(context) } async fn register_resource_edit_source_image( - root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, + remote_project_id: &str, ticket: &ResourceEditUploadTicket, asset_object_id: &str, -) -> Result { +) -> Result<(String, u32, u32), String> { let bytes = source .bytes .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; let decoded = image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; - let project_id = - resolve_resource_edit_source_project(root, client, api_base_url, api_key, source).await?; let endpoint = format!( "/api/external/v1/editor/projects/{}/resources", - percent_encode_query_component(&project_id) + percent_encode_query_component(remote_project_id) ); + access.validate_frozen_session()?; let response = client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route(&endpoint) + "{}{}", + access.api_base_url(), + access.api_route(&endpoint) )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ "imageSrc": format!("/{}", ticket.object_key.trim_start_matches('/')), "objectKey": ticket.object_key, @@ -1681,6 +1624,7 @@ async fn register_resource_edit_source_image( .send() .await .map_err(|_| "登记源图片项目资源失败".to_string())?; + access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); } @@ -1697,83 +1641,122 @@ async fn register_resource_edit_source_image( .json::() .await .map_err(|_| "解析源图片项目资源响应失败".to_string())?; + access.validate_frozen_session()?; let data = external_editor_response_data(&payload); - json_string_field(data.get("resource").unwrap_or(data), "resourceId") - .ok_or_else(|| "源图片项目资源响应缺少 resourceId".to_string()) + let resource_id = json_string_field(data.get("resource").unwrap_or(data), "resourceId") + .ok_or_else(|| "源图片项目资源响应缺少 resourceId".to_string())?; + Ok((resource_id, decoded.width(), decoded.height())) } async fn ensure_resource_edit_source_reference( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, + principal: &ExternalEditorPrincipalIdentity, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, ledger: &mut ResourceEditLedger, -) -> Result { - if let Some(reference) = ledger.source_stable_reference.clone() { - if input.edit_kind != LocalProjectResourceEditKind::ImageReference - || is_registered_editor_reference_id(&reference) - { - return Ok(reference); +) -> Result { + access.validate_frozen_session()?; + let canvas_context = resolve_resource_edit_source_project(root, client, access).await?; + access.validate_frozen_session()?; + let local_asset_id = source + .source_asset + .as_ref() + .map(|asset| asset.id.as_str()) + .unwrap_or(source.canonical_resource_id.as_str()); + let source_identity = new_external_editor_source_identity( + local_asset_id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + )?; + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &input.expected_project_id, + principal, + &canvas_context.project_id, + &source_identity, + )? { + let stable_reference = match input.edit_kind { + LocalProjectResourceEditKind::ImageReference => binding + .remote_resource_id + .filter(|reference| is_registered_editor_reference_id(reference)), + LocalProjectResourceEditKind::Video => Some(binding.object_key), + _ => return Err("当前资源类型不需要远端源 binding".to_string()), + }; + if let Some(stable_reference) = stable_reference { + ledger.source_stable_reference = Some(stable_reference.clone()); + ledger.remote_project_id = Some(canvas_context.project_id.clone()); + write_resource_edit_ledger(root, ledger)?; + return Ok(PreparedResourceEditSourceReference { + stable_reference, + canvas_context, + }); } - // Legacy prepared ledgers could store an objectKey here. An image edit - // accepts only a registered resource or asset, so discard that transient - // preparation before any remote operation is accepted and register again. - ledger.source_stable_reference = None; + invalidate_external_editor_resource_binding_at( + root, + &input.expected_project_id, + principal, + &canvas_context.project_id, + &source_identity, + )?; + } + + // Prepared legacy ledgers and manifest fields are not authorization evidence. If the current + // principal has no exact source binding, upload the committed local bytes again. + if ledger.source_stable_reference.take().is_some() { write_resource_edit_ledger(root, ledger)?; } - if let Some(asset) = source.source_asset.as_ref() { + let ticket = request_resource_edit_upload_ticket(client, access, input, source).await?; + access.validate_frozen_session()?; + upload_resource_edit_source(&ticket, source, access).await?; + access.validate_frozen_session()?; + let asset_object_id = confirm_resource_edit_source(client, access, &ticket, source).await?; + access.validate_frozen_session()?; + let (stable_reference, remote_resource_id, width, height) = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - if let Some(reference) = asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - && !value.starts_with("task:") - }) - .map(str::to_string) - .filter(|_| asset.source.canvas_project_id.is_some()) - { - ledger.source_stable_reference = Some(reference.clone()); - write_resource_edit_ledger(root, ledger)?; - return Ok(reference); - } - } else if let Some(reference) = - committed_resource_edit_object_key_for_asset(root, &asset.id)? - { - ledger.source_stable_reference = Some(reference.clone()); - write_resource_edit_ledger(root, ledger)?; - return Ok(reference); - } - } - let ticket = - request_resource_edit_upload_ticket(client, api_base_url, api_key, input, source).await?; - upload_resource_edit_source(&ticket, source, api_base_url).await?; - let asset_object_id = - confirm_resource_edit_source(client, api_base_url, api_key, &ticket, source).await?; - let reference = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - register_resource_edit_source_image( - root, - client, - api_base_url, - api_key, - input, - source, - &ticket, - &asset_object_id, - ) - .await? - } else { - ticket.object_key.clone() - }; - ledger.source_stable_reference = Some(reference.clone()); + let (resource_id, width, height) = register_resource_edit_source_image( + client, + access, + input, + source, + &canvas_context.project_id, + &ticket, + &asset_object_id, + ) + .await?; + ( + resource_id.clone(), + Some(resource_id), + Some(width), + Some(height), + ) + } else { + (ticket.object_key.clone(), None, None, None) + }; + access.validate_frozen_session()?; + let binding = new_external_editor_resource_binding( + &input.expected_project_id, + principal, + &canvas_context.project_id, + &source_identity, + remote_resource_id.as_deref(), + &ticket.object_key, + &asset_object_id, + width, + height, + unix_timestamp(), + )?; + write_external_editor_resource_binding_at(root, &binding)?; + access.validate_frozen_session()?; + ledger.source_stable_reference = Some(stable_reference.clone()); + ledger.remote_project_id = Some(canvas_context.project_id.clone()); write_resource_edit_ledger(root, ledger)?; - Ok(reference) + Ok(PreparedResourceEditSourceReference { + stable_reference, + canvas_context, + }) } fn take_resource_edit_chars(value: &str, max_chars: usize) -> String { @@ -1911,8 +1894,7 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { async fn submit_resource_edit_remote( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ledger: &mut ResourceEditLedger, ) -> Result { let endpoint = ledger @@ -1942,18 +1924,21 @@ async fn submit_resource_edit_remote( ); } } + access.validate_frozen_session()?; let response = client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route(endpoint) + "{}{}", + access.api_base_url(), + access.api_route(endpoint) )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .header("Idempotency-Key", &ledger.idempotency_key) .header(reqwest::header::CONTENT_TYPE, "application/json") .body(body_value.to_string()) .send() .await .map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?; + access.validate_frozen_session()?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); @@ -1992,6 +1977,7 @@ async fn submit_resource_edit_remote( return Err("result-unknown: 资源编辑响应无法解析".to_string()); } }; + access.validate_frozen_session()?; if resource_edit_operation_id(&payload).is_none() { update_resource_edit_phase( root, @@ -2006,8 +1992,7 @@ async fn submit_resource_edit_remote( async fn wait_for_resource_edit_remote( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ledger: &mut ResourceEditLedger, ) -> Result { let operation_id = ledger @@ -2015,8 +2000,9 @@ async fn wait_for_resource_edit_remote( .as_deref() .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string())?; let status_url = format!( - "{api_base_url}{}", - resolve_platform_generation_status_route(&operation_id) + "{}{}", + access.api_base_url(), + access.generation_status_route(&operation_id) ); let started_at = tokio::time::Instant::now(); let mut poll_after_ms = 1_000; @@ -2024,13 +2010,16 @@ async fn wait_for_resource_edit_remote( if started_at.elapsed() >= Duration::from_secs(35 * 60) { return Err("result-unknown: 资源编辑任务仍在执行,已停止本地等待".to_string()); } + access.validate_frozen_session()?; tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; + access.validate_frozen_session()?; let response = client .get(&status_url) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .send() .await .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; + access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); } @@ -2051,6 +2040,7 @@ async fn wait_for_resource_edit_remote( .json::() .await .map_err(|_| "result-unknown: 资源编辑任务响应无法解析".to_string())?; + access.validate_frozen_session()?; let job = platform_generation_status_data(&payload); match json_string_field(job, "status").as_deref() { Some("completed") => { @@ -2317,19 +2307,17 @@ async fn prepare_remote_resource_edit( prompt: &str, asset_name: &str, ledger: &mut ResourceEditLedger, - frozen_platform_session: Option<&PlatformSessionSnapshot>, + access: &ExternalEditorBindingAccess<'_>, + principal: &ExternalEditorPrincipalIdentity, ) -> Result<(), String> { - let (api_base_url, api_key, platform_session) = - resolve_canvas_sync_api_credentials(None, None)?; - if platform_session.as_ref() != frozen_platform_session { - return Err( - "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" - .to_string(), - ); - } - bind_resource_edit_platform_owner(root, ledger, frozen_platform_session)?; - ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; - match prepare_resource_edit_service_identity(root, ledger, &api_base_url, &api_key)? { + bind_resource_edit_platform_owner(root, ledger, access.frozen_platform_session())?; + access.validate_frozen_session()?; + match prepare_resource_edit_service_identity( + root, + ledger, + access.api_base_url(), + access.bearer_token(), + )? { ResourceEditServiceIdentityDecision::Ready => {} ResourceEditServiceIdentityDecision::ConfirmationRequired(_) => { return Err("service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原资源编辑 operation".to_string()) @@ -2355,44 +2343,29 @@ async fn prepare_remote_resource_edit( .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? } else { let generated = if ledger.remote_operation_id.is_some() { - let result = - wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger) - .await?; - ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; + access.validate_frozen_session()?; result } else { - let source_reference = if matches!( + let prepared_source = if matches!( input.edit_kind, LocalProjectResourceEditKind::ImageReference | LocalProjectResourceEditKind::Video ) { Some( ensure_resource_edit_source_reference( - root, - &client, - &api_base_url, - &api_key, - input, - source, - ledger, + root, &client, access, principal, input, source, ledger, ) .await?, ) } else { None }; - if input.edit_kind == LocalProjectResourceEditKind::ImageReference - && ledger.remote_operation_id.is_none() - && ledger.request_body_json.as_deref().is_some_and(|body| { - serde_json::from_str::(body) - .ok() - .is_some_and(|value| { - value.get("sourceImageSrc").is_some() - || value.get("sourceReferenceId").is_none() - }) - }) + if prepared_source.is_some() + && (ledger.endpoint.is_some() || ledger.request_body_json.is_some()) { - // Only a not-yet-submitted legacy request may be rebuilt. Once an - // operation exists, preserve its immutable request and reconcile it. + // A not-yet-submitted snapshot is not authorization evidence. Rebuild it from + // the exact current-principal source binding so a legacy prepared operation can + // never carry another owner's resourceId, objectKey, or projectId. ledger.endpoint = None; ledger.request_body_json = None; write_resource_edit_ledger(root, ledger)?; @@ -2403,8 +2376,25 @@ async fn prepare_remote_resource_edit( source, prompt, asset_name, - source_reference.as_deref(), + prepared_source + .as_ref() + .map(|prepared| prepared.stable_reference.as_str()), )?; + let mut body = body; + if let Some(prepared) = prepared_source.as_ref() { + let body_object = body + .as_object_mut() + .ok_or_else(|| "资源编辑生成请求正文必须是 object".to_string())?; + body_object.insert( + "projectId".to_string(), + serde_json::Value::String(prepared.canvas_context.project_id.clone()), + ); + body_object.insert( + "assetFolderId".to_string(), + serde_json::Value::String(prepared.canvas_context.asset_folder_id.clone()), + ); + ledger.remote_project_id = Some(prepared.canvas_context.project_id.clone()); + } ledger.endpoint = Some(endpoint.to_string()); ledger.request_body_json = Some( serde_json::to_string(&body) @@ -2412,18 +2402,15 @@ async fn prepare_remote_resource_edit( ); write_resource_edit_ledger(root, ledger)?; } - let submission = - submit_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await?; - ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + let submission = submit_resource_edit_remote(root, &client, access, ledger).await?; + access.validate_frozen_session()?; let operation_id = resource_edit_operation_id(&submission).ok_or_else(|| { "result-unknown: 资源编辑已受理但响应缺少 operationId".to_string() })?; ledger.remote_operation_id = Some(operation_id.clone()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; - let result = - wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger) - .await?; - ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; + access.validate_frozen_session()?; result }; let (resource_id, object_key, asset_object_id, model) = @@ -2436,17 +2423,20 @@ async fn prepare_remote_resource_edit( object_key }; let download_source = serde_json::json!({ "objectKey": object_key }); - let download = resolve_canvas_resource_download_with_limit( + access.validate_frozen_session()?; + let read_url_route = access.api_route("/api/external/v1/assets/read-url"); + let download = resolve_canvas_resource_download_with_limit_and_route( &client, - &api_base_url, - &api_key, + access.api_base_url(), + access.bearer_token(), &download_source, media_read_limit(&input.edit_kind), + &read_url_route, ) .await .map_err(|_| "result-unknown: 远端资源编辑结果下载或换签失败".to_string())? .ok_or_else(|| "远端资源编辑结果缺少可下载媒体".to_string())?; - ensure_frozen_resource_edit_platform_session(platform_session.as_ref())?; + access.validate_frozen_session()?; let (media_type, extension) = validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes)?; write_resource_edit_staging(root, &input.operation_id, &download.bytes)?; @@ -3129,11 +3119,8 @@ fn commit_resource_edit_asset_internal( GameCreationAppAssetSourceKind::Generated }, canvas_project_id: None, - resource_id: ledger - .remote_resource_id - .clone() - .or_else(|| Some(format!("local-asset:{asset_id}"))), - asset_object_id: ledger.remote_asset_object_id.clone(), + resource_id: Some(format!("local-asset:{asset_id}")), + asset_object_id: None, task_id: input.producer_task_id.clone(), prompt: Some(prompt.to_string()), model: ledger @@ -3438,6 +3425,67 @@ fn commit_resource_edit_asset( commit_resource_edit_asset_internal(root, input, source, prompt, asset_name, ledger, None) } +fn write_resource_edit_result_binding( + root: &Path, + input: &DeriveLocalProjectResourceInput, + result: &DeriveLocalProjectResourceResult, + ledger: &ResourceEditLedger, + access: &ExternalEditorBindingAccess<'_>, + principal: &ExternalEditorPrincipalIdentity, +) -> Result<(), String> { + let Some(asset) = result.asset.as_ref() else { + return Ok(()); + }; + let Some(remote_project_id) = ledger.remote_project_id.as_deref() else { + return Ok(()); + }; + let Some(object_key) = ledger.remote_object_key.as_deref() else { + return Ok(()); + }; + let Some(asset_object_id) = ledger.remote_asset_object_id.as_deref() else { + return Ok(()); + }; + if input.edit_kind == LocalProjectResourceEditKind::ImageReference + && ledger.remote_resource_id.is_none() + { + return Ok(()); + } + let bytes = read_stable_resource_edit_file( + root, + &asset.local_path, + media_read_limit(&input.edit_kind), + "派生资源 binding 文件", + )?; + let (width, height) = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + let decoded = image::load_from_memory(&bytes) + .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; + (Some(decoded.width()), Some(decoded.height())) + } else { + (None, None) + }; + let source_identity = new_external_editor_source_identity( + &asset.id, + &sha256_hex(&bytes), + &asset.media_type, + &asset.kind, + )?; + let binding = new_external_editor_resource_binding( + &input.expected_project_id, + principal, + remote_project_id, + &source_identity, + ledger.remote_resource_id.as_deref(), + object_key, + asset_object_id, + width, + height, + unix_timestamp(), + )?; + access.validate_frozen_session()?; + write_external_editor_resource_binding_at(root, &binding)?; + access.validate_frozen_session() +} + fn commit_resource_edit_version( root: &Path, input: &DeriveLocalProjectResourceInput, @@ -4101,6 +4149,7 @@ pub(crate) async fn derive_local_project_resource_at( endpoint: None, request_body_json: None, remote_operation_id: None, + remote_project_id: None, remote_resource_id: None, remote_object_key: None, remote_asset_object_id: None, @@ -4138,11 +4187,43 @@ pub(crate) async fn derive_local_project_resource_at( let _project_guard = project_lock.lock().await; return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger); } - let frozen_platform_session = if input.edit_kind.is_remote_media() { - current_platform_session() + let remote_credentials = if input.edit_kind.is_remote_media() + && ledger.phase != ResourceEditLedgerPhase::MediaDownloaded + { + Some(resolve_canvas_sync_api_credentials(None, None)?) } else { None }; + let frozen_platform_session = remote_credentials + .as_ref() + .and_then(|(_, _, session)| session.clone()) + .or_else(|| { + input + .edit_kind + .is_remote_media() + .then(current_platform_session) + .flatten() + }); + if input.edit_kind.is_remote_media() + && ledger.platform_owner_user_id.is_some() + && frozen_platform_session.is_none() + { + return Err( + "authentication-required: 此资源编辑属于已登录账号,请恢复原账号后提交本地结果" + .to_string(), + ); + } + let binding_principal = remote_credentials + .as_ref() + .map(|(api_base_url, api_key, _)| { + let access = ExternalEditorBindingAccess::new( + api_base_url, + api_key, + frozen_platform_session.as_ref(), + )?; + external_editor_binding_principal(&access) + }) + .transpose()?; bind_resource_edit_platform_owner(root, &mut ledger, frozen_platform_session.as_ref())?; if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { let generation_result = if input.edit_kind.is_text() { @@ -4172,6 +4253,14 @@ pub(crate) async fn derive_local_project_resource_at( })(), } } else if input.edit_kind.is_remote_media() { + let (api_base_url, api_key, _) = remote_credentials + .as_ref() + .ok_or_else(|| "资源编辑缺少 External Editor 凭据".to_string())?; + let access = ExternalEditorBindingAccess::new( + api_base_url, + api_key, + frozen_platform_session.as_ref(), + )?; prepare_remote_resource_edit( root, &input, @@ -4179,7 +4268,10 @@ pub(crate) async fn derive_local_project_resource_at( &prompt, &asset_name, &mut ledger, - frozen_platform_session.as_ref(), + &access, + binding_principal + .as_ref() + .ok_or_else(|| "资源编辑缺少 External Editor principal".to_string())?, ) .await } else { @@ -4202,14 +4294,29 @@ pub(crate) async fn derive_local_project_resource_at( let project_lock = resource_edit_project_mutation_lock(root)?; let _project_guard = project_lock.lock().await; ensure_frozen_resource_edit_platform_session(frozen_platform_session.as_ref())?; - commit_resource_edit_asset(root, &input, &source, &prompt, &asset_name, &mut ledger) + let result = + commit_resource_edit_asset(root, &input, &source, &prompt, &asset_name, &mut ledger)?; + if let (Some((api_base_url, api_key, _)), Some(principal)) = + (remote_credentials.as_ref(), binding_principal.as_ref()) + { + let access = ExternalEditorBindingAccess::new( + api_base_url, + api_key, + frozen_platform_session.as_ref(), + )?; + // The local asset transaction is already committed. A binding-sidecar failure must not + // roll back or hide that durable local result; a future edit will safely re-upload bytes. + let _ = + write_resource_edit_result_binding(root, &input, &result, &ledger, &access, principal); + } + Ok(result) } #[cfg(test)] mod tests { use super::*; use std::future::Future; - use std::io::{Read, Write}; + use std::io::{Cursor, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; @@ -4316,6 +4423,14 @@ mod tests { .expect("write resource editor media bytes"); } + fn resource_editor_test_png() -> Vec { + let mut output = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(2, 2) + .write_to(&mut output, image::ImageFormat::Png) + .expect("encode resource editor PNG"); + output.into_inner() + } + fn input( root: &Path, operation_id: String, @@ -4384,6 +4499,7 @@ mod tests { endpoint: None, request_body_json: None, remote_operation_id: None, + remote_project_id: None, remote_resource_id: None, remote_object_key: None, remote_asset_object_id: None, @@ -4649,60 +4765,297 @@ mod tests { } #[tokio::test] - async fn derived_video_reuses_committed_object_key_instead_of_asset_object_id() { - let directory = tempfile::tempdir().expect("create committed video fixture"); - let root = directory.path(); - init_local_game_project_at(root, PROJECT_ID, "派生视频二次编辑测试") - .expect("initialize project"); - let source_bytes = b"\0\0\0\x18ftypisom\0\0\0\0isomiso2"; - let uploaded = upload_local_asset_at(root, "derived-video.mp4", "video/mp4", source_bytes) - .expect("register derived video"); - let manifest = read_existing_manifest_for_project(root).expect("read manifest"); - let source_asset = manifest - .assets - .iter() - .find(|asset| asset.id == uploaded.id) - .cloned() - .expect("source asset"); - let mut prior_input = input( - root, - Uuid::new_v4().to_string(), - LocalProjectResourceEditKind::Video, - format!("asset:{}", source_asset.id), - ); - prior_input.source_asset_id = Some(source_asset.id.clone()); - prior_input.source_path = Some(source_asset.local_path.clone()); - prior_input.source_media_type = Some(source_asset.media_type.clone()); - let source = - resolve_resource_edit_source(root, &manifest, &prior_input).expect("resolve source"); - let mut committed = ledger_for(&prior_input, &source, ResourceEditLedgerPhase::Committed); - committed.result_asset_id = Some(source_asset.id.clone()); - committed.remote_object_key = Some("generated/stable-video.mp4".to_string()); - committed.remote_asset_object_id = Some("asset-object-must-not-be-used".to_string()); - write_resource_edit_ledger(root, &committed).expect("write committed ledger"); + async fn account_principal_switch_rebinds_image_and_video_without_sending_owner_a_ids() { + for (edit_kind, file_name, media_type, source_bytes) in [ + ( + LocalProjectResourceEditKind::ImageReference, + "owner-a-image.png", + "image/png", + resource_editor_test_png(), + ), + ( + LocalProjectResourceEditKind::Video, + "owner-a-video.mp4", + "video/mp4", + b"\0\0\0\x18ftypisom\0\0\0\0isomiso2".to_vec(), + ), + ] { + let directory = tempfile::tempdir().expect("create account switch fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "跨账号资源编辑测试") + .expect("initialize project"); + let uploaded = upload_local_asset_at(root, file_name, media_type, &source_bytes) + .expect("register local source"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter_mut() + .find(|asset| asset.id == uploaded.id) + .expect("source asset"); + source_asset.source.kind = GameCreationAppAssetSourceKind::Canvas; + source_asset.source.canvas_project_id = Some("owner-a-project".to_string()); + source_asset.source.resource_id = Some("editor-resource-owner-a".to_string()); + source_asset.source.asset_object_id = Some("owner-a-asset-object".to_string()); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write owner A manifest metadata"); - let mut current_input = prior_input.clone(); - current_input.operation_id = Uuid::new_v4().to_string(); - current_input.idempotency_key = Uuid::new_v4().to_string(); - let mut current = ledger_for(¤t_input, &source, ResourceEditLedgerPhase::Prepared); - let reference = ensure_resource_edit_source_reference( - root, - &reqwest::Client::new(), - "http://127.0.0.1:9", - "unused-key", - ¤t_input, - &source, - &mut current, - ) - .await - .expect("reuse committed object key without upload"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind account switch server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("account switch address") + ); + let upload_url = format!("{base_url}/upload-owner-b-source"); + let is_image = edit_kind == LocalProjectResourceEditKind::ImageReference; + let owner_b_object_key = format!( + "generated/owner-b-source.{}", + if is_image { "png" } else { "mp4" } + ); + let request_count = if is_image { 6 } else { 7 }; + let server_upload_url = upload_url.clone(); + let server_object_key = owner_b_object_key.clone(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + for request_index in 0..request_count { + let mut stream = accept_resource_editor_fixture_connection( + &listener, + "account switch fixture", + request_index, + ); + let request = read_http_request(&mut stream); + sender.send(request.clone()).expect("capture B request"); + let request_line = request.lines().next().unwrap_or_default(); + if request_line.starts_with("GET /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + if is_image { + serde_json::json!({"data": {"projects": [{ + "projectId": "owner-b-project" + }]}}) + } else { + serde_json::json!({"data": {"projects": []}}) + }, + ); + } else if request_line.starts_with("POST /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"project": {"projectId": "owner-b-project"}}}), + ); + } else if request_line + .starts_with("GET /api/external/v1/editor/assets/library ") + { + write_json( + &mut stream, + "200 OK", + if is_image { + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "owner-b-folder" + }]}}}) + } else { + serde_json::json!({"data": {"library": {"folders": []}}}) + }, + ); + } else if request_line + .starts_with("POST /api/external/v1/editor/assets/folders ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"folder": {"folderId": "owner-b-folder"}}}), + ); + } else if request_line + .starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": server_upload_url, + "bucket": "private-test-bucket", + "objectKey": server_object_key, + "successActionStatus": 204, + "formFields": {"key": server_object_key} + }}}), + ); + } else if request_line.starts_with("POST /upload-owner-b-source ") { + write_bytes(&mut stream, "204 No Content", "text/plain", &[]); + } else if request_line + .starts_with("POST /api/external/v1/assets/objects/confirm ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": server_object_key, + "assetObjectId": "owner-b-asset-object" + }}}), + ); + } else if request_line.starts_with( + "POST /api/external/v1/editor/projects/owner-b-project/resources ", + ) { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-owner-b" + }}}), + ); + } else { + panic!("unexpected account switch request: {request_line}"); + } + } + }); - assert_eq!(reference, "generated/stable-video.mp4"); - assert_ne!(reference, "asset-object-must-not-be-used"); - assert_eq!( - current.source_stable_reference.as_deref(), - Some(reference.as_str()) - ); + let owner_a_access = ExternalEditorBindingAccess::new(&base_url, "owner-a-token", None) + .expect("owner A access"); + let owner_a_principal = + external_editor_binding_principal(&owner_a_access).expect("owner A principal"); + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + PROJECT_ID, + &owner_a_principal, + "owner-a-project", + "owner-a-folder", + 1, + ) + .expect("owner A project binding"), + ) + .expect("write owner A project binding"); + + let mut request = input( + root, + Uuid::new_v4().to_string(), + edit_kind.clone(), + format!("asset:{}", uploaded.id), + ); + request.source_asset_id = Some(uploaded.id.clone()); + request.source_path = Some(uploaded.local_path.clone()); + request.source_media_type = Some(media_type.to_string()); + let manifest = read_existing_manifest_for_project(root).expect("read source manifest"); + let source = + resolve_resource_edit_source(root, &manifest, &request).expect("resolve source"); + let source_identity = new_external_editor_source_identity( + &uploaded.id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + ) + .expect("owner A source identity"); + let (a_resource_id, a_width, a_height) = + if edit_kind == LocalProjectResourceEditKind::ImageReference { + (Some("editor-resource-owner-a"), Some(2), Some(2)) + } else { + (None, None, None) + }; + write_external_editor_resource_binding_at( + root, + &new_external_editor_resource_binding( + PROJECT_ID, + &owner_a_principal, + "owner-a-project", + &source_identity, + a_resource_id, + "generated/owner-a-object", + "owner-a-asset-object", + a_width, + a_height, + 1, + ) + .expect("owner A resource binding"), + ) + .expect("write owner A resource binding"); + + let owner_b_access = ExternalEditorBindingAccess::new(&base_url, "owner-b-token", None) + .expect("owner B access"); + let owner_b_principal = + external_editor_binding_principal(&owner_b_access).expect("owner B principal"); + if is_image { + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + PROJECT_ID, + &owner_b_principal, + "owner-b-project", + "owner-b-folder", + 2, + ) + .expect("owner B project binding"), + ) + .expect("write owner B project binding"); + write_external_editor_resource_binding_at( + root, + &new_external_editor_resource_binding( + PROJECT_ID, + &owner_b_principal, + "owner-b-project", + &source_identity, + None, + "generated/stale-owner-b-image", + "stale-owner-b-asset-object", + Some(2), + Some(2), + 2, + ) + .expect("stale owner B image binding"), + ) + .expect("write stale owner B image binding"); + } + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + let prepared = ensure_resource_edit_source_reference( + root, + &reqwest::Client::new(), + &owner_b_access, + &owner_b_principal, + &request, + &source, + &mut ledger, + ) + .await + .expect("owner B rebinds committed local bytes"); + server.join().expect("join account switch server"); + + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), request_count); + for request in &requests { + for owner_a_id in [ + "owner-a-project", + "owner-a-folder", + "editor-resource-owner-a", + "generated/owner-a-object", + "owner-a-asset-object", + "owner-a-token", + ] { + assert!( + !request.contains(owner_a_id), + "B request leaked {owner_a_id}" + ); + } + } + assert_eq!(prepared.canvas_context.project_id, "owner-b-project"); + assert_eq!(ledger.remote_project_id.as_deref(), Some("owner-b-project")); + let owner_b_binding = read_external_editor_resource_binding_at( + root, + PROJECT_ID, + &owner_b_principal, + "owner-b-project", + &source_identity, + ) + .expect("read owner B binding") + .expect("owner B binding exists"); + assert_eq!(owner_b_binding.object_key, owner_b_object_key); + if edit_kind == LocalProjectResourceEditKind::ImageReference { + assert_eq!(prepared.stable_reference, "editor-resource-owner-b"); + assert_eq!( + owner_b_binding.remote_resource_id.as_deref(), + Some("editor-resource-owner-b") + ); + } else { + assert_eq!(prepared.stable_reference, owner_b_object_key); + assert_eq!(owner_b_binding.remote_resource_id, None); + } + } } #[tokio::test] @@ -4744,8 +5097,9 @@ mod tests { write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - submit_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + submit_resource_edit_remote(root, &client, &access, &mut ledger).await }) .await .expect_err("authentication status must fail"); @@ -4777,8 +5131,9 @@ mod tests { write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + wait_for_resource_edit_remote(root, &client, &access, &mut ledger).await }) .await .expect_err("poll authentication status must fail"); @@ -4809,8 +5164,9 @@ mod tests { ); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + wait_for_resource_edit_remote(root, &client, &access, &mut ledger).await }) .await .expect_err("missing remote status must stay unknown"); @@ -4997,14 +5353,10 @@ mod tests { .expect("read confirmed ledger") .expect("confirmed ledger"); with_test_external_editor_credentials(&base_url, new_api_key, async { - wait_for_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - new_api_key, - &mut resumed, - ) - .await + let access = ExternalEditorBindingAccess::new(&base_url, new_api_key, None) + .expect("binding access"); + wait_for_resource_edit_remote(root, &reqwest::Client::new(), &access, &mut resumed) + .await }) .await .expect("resume accepted operation by GET"); @@ -5073,14 +5425,10 @@ mod tests { ); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - submit_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + submit_resource_edit_remote(root, &reqwest::Client::new(), &access, &mut ledger) + .await }) .await .expect_err("submission status must fail"); @@ -5197,14 +5545,10 @@ mod tests { ); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - submit_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + submit_resource_edit_remote(root, &reqwest::Client::new(), &access, &mut ledger) + .await }) .await .expect_err("unproven accepted response must fail closed"); @@ -5345,14 +5689,9 @@ mod tests { }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - wait_for_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + wait_for_resource_edit_remote(root, &reqwest::Client::new(), &access, &mut ledger).await }) .await .expect_err("failed status must become terminal"); @@ -5579,7 +5918,7 @@ mod tests { let server_generated_video = generated_video.clone(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for request_index in 0..7 { + for request_index in 0..11 { let mut stream = accept_resource_editor_fixture_connection( &listener, "External video fixture", @@ -5591,7 +5930,37 @@ mod tests { .expect("capture External video request"); let request_line = request.lines().next().unwrap_or_default(); let request_lower = request.to_ascii_lowercase(); - if request_line.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") { + if request_line.starts_with("GET /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": []}}), + ); + } else if request_line.starts_with("POST /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"project": { + "projectId": "external-video-project" + }}}), + ); + } else if request_line.starts_with("GET /api/external/v1/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": []}}}), + ); + } else if request_line.starts_with("POST /api/external/v1/editor/assets/folders ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"folder": { + "folderId": "external-video-folder" + }}}), + ); + } else if request_line + .starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + { assert!(request_lower .contains("authorization: bearer resource-editor-external-key")); write_json( @@ -5630,6 +5999,7 @@ mod tests { .contains("authorization: bearer resource-editor-external-key")); assert!(request_lower.contains("idempotency-key:")); assert!(request.contains(&server_source_key)); + assert!(request.contains("external-video-project")); write_json( &mut stream, "202 Accepted", @@ -5696,7 +6066,7 @@ mod tests { .expect("derive External video"); server.join().expect("join External video server"); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 7); + assert_eq!(requests.len(), 11); assert!(requests.iter().all(|request| { !request.starts_with("POST /api/editor/") && !request.starts_with("POST /api/assets/") diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 773fc4d37..a0a10aba0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14355,3 +14355,12 @@ - 素材生成确认、离开确认和旧服务身份确认沿用现有独立 modal,不在当前面板下方追加内容。modal 打开后焦点必须进入对话框,并同时隔离工具栏、画布视口、缩放/小地图、状态区和并存操作层,使背景从键盘焦点顺序与 accessibility tree 中退出。 - `Tab` / `Shift+Tab` 必须在当前 modal 内双向循环;非异步 pending 状态允许 `Escape` 安全关闭。关闭后优先恢复到原触发器,自动弹出的 modal 则回退到可操作的工具栏入口,不能把焦点遗留在已卸载节点或被隔离背景中。 + +## 2026-08-23 AGC 本地资源与 External Editor 账号绑定分离 + +- 权威边界:本地项目 ID、manifest asset、正式本地文件和内容摘要属于设备上的本地项目;`canvasProjectId / resourceId / assetObjectId / objectKey` 属于具体 External Editor 服务 principal。manifest 中现有远端字段继续保留生成来源,不再承担“当前账号可编辑句柄”,本轮不修改共享 manifest schema。 +- 私有投影:统一在 `.agent/runtime/external-editor-bindings/` 持久化 active binding。项目 binding 按服务 origin、平台 userId 或 Developer Key 摘要以及本地 `projectId` 分区;资源 binding 再绑定远端项目、本地 asset ID、源 SHA-256、媒体类型和 canonical asset kind。sidecar 不保存 Token、Key、Cookie、Authorization、签名上传表单、媒体正文或绝对路径。 +- 切号语义:账号 B 打开账号 A 曾生成资源的同一本地项目时,不访问、迁移或覆盖 A 的私有画布;B 从本地正式文件在自己的远端项目重新上传、confirm、登记并形成独立 binding。切回 A 时复用 A binding。项目标题只用于远端展示,不能作为同名项目的权威关联;项目改名不换 binding。 +- 在途边界:资源编辑、素材画布生成和 Agent 美术生成的 `prepared / accepted / running` 账本继续锁定发起 principal。账号切换后只能停止请求并保留原 operation 供切回或对账,不得把在途副作用迁移到新账号,也不得重建正文或重复 POST。只有尚未提交远端生成的新操作可以为当前账号建立新的项目和资源 binding。 +- 集成边界:`resource_editor`、素材画布参考准备、`canvas.asset_generate` 的 art-spec 派生和直连只读恢复统一消费当前 principal binding;PR 176 rebase 后必须删除或整合其局部 canonical cache,不能保留第二套账号身份系统。External v1 路由和公开 DTO 不变,无需修改 OpenAPI。 +- 验收:至少覆盖 A 生成到本地后切 B 重登记、B 请求零 A 远端 ID、重启后复用 B、切回 A 复用 A、两个同名本地项目隔离、项目改名不漂移、源 SHA 或 kind 变化重登记、A 在途任务切 B 零网络,以及 sidecar 零凭据和身份篡改失败关闭。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 29ce6c558..655f864f0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4839,3 +4839,11 @@ - 原因:当前完整模块 init 的 RSS 会超过基础 Compose 旧 `896m` cgroup 上限;内核 OOM kill SpacetimeDB,客户端只看到传输错误,容易被误判为网络竞态。 - 处理:先查 kernel journal 的 `Memory cgroup out of memory` 和目标容器 ID,再把本地/预发完整容器 SpacetimeDB 上限统一为 `2g`;保留 page pool 限制。不要只增加 publish 重试,也不要把 `/healthz` 或首页改成数据库就绪探针。 - 验证:用新空卷完成模块 publish、五服务启动和 Web/API smoke,并确认容器未 OOM、SpacetimeDB 与 API/Nginx 最终 healthy。 + +## 同一本地项目切换账号后不能继续信任 manifest 远端 ID(2026-08-23) + +- 现象:本地资源文件仍存在,但账号 A 生成后切到账号 B,快速编辑、GIF/视频派生、art-spec 下游生成或直连恢复提示画布不存在、无权限或资源不属于当前项目;原请求重试仍失败。 +- 原因:本地 manifest 只有一组 `source.canvasProjectId / resourceId / assetObjectId`,旧实现把它同时当作历史来源和当前账号可编辑引用。新的 operation 虽已绑定 B,却会把 A 的 project/resource/object 身份配合 B Token 发出;另一部分链路又把 A 的画布 ID 与 B 按标题选出的项目严格比较。 +- 处理:manifest 远端字段只保留历史 provenance。所有远端编辑和派生先解析 `.agent/runtime/external-editor-bindings/` 中当前 principal 的项目/资源 binding;不存在时从本地正式文件按 asset ID、SHA-256、媒体类型和 canonical kind 在当前账号重登记。禁止按标题、manifest ID 或其他账号 committed ledger 自动采纳远端对象。A 的在途 operation 继续留在 A,B 只能开始自己的新操作。 +- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 localProjectId 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Developer Key fixture 不能替代平台账号隔离证据。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs`、`project/resource_editor.rs`、`project/asset_canvas/generation.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8a4479b41..edddd254e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -395,7 +395,9 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpoint;Runtime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 -- 后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;普通已认证客户端通过固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*` 调用平台,独立 standalone game-chat/显式高级模式才通过 `/api/external/v1/*` 与自备 Developer Key 调用。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 用图片生成 `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,用图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions` 调用图标 spritesheet 生成,产出真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把调用模式、相对 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;普通模式还冻结 `official-platform-v1 + 官方 origin + ownerUserId`,高级模式冻结 External service origin,两者均不保存 Token 或 Key。`accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许使用原 endpoint、原始 JSON 字节与同一键恢复 POST,不得重建远端项目/目录、重组正文或分配新键。普通模式站内响应兼容 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;高级 External v1 固定 `202 + operationId`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。轮询超时、临时鉴权或网关错误只保留原 operation 并在恢复时继续;结果损坏、状态损坏、透明派生失败或远端完成后的本地提交失败进入对账。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局。旧正式图不合格时只返回 `needs-repair`;Supervisor 认领后由原 owner 使用 `replaceExisting=true` 原位替换。凭据不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- 后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;普通已认证客户端通过固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*` 调用平台,独立 standalone game-chat/显式高级模式才通过 `/api/external/v1/*` 与自备 Developer Key 调用。生成前按当前服务 principal 与本地 `projectId` 的私有 binding 创建或复用远端画布项目和素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 用图片生成 `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,用图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions` 调用图标 spritesheet 生成,产出真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把调用模式、相对 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;普通模式还冻结 `official-platform-v1 + 官方 origin + ownerUserId`,高级模式冻结 External service origin,两者均不保存 Token 或 Key。`accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许使用原 endpoint、原始 JSON 字节与同一键恢复 POST,不得重建远端项目/目录、重组正文或分配新键。普通模式站内响应兼容 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;高级 External v1 固定 `202 + operationId`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。轮询超时、临时鉴权或网关错误只保留原 operation 并在恢复时继续;结果损坏、状态损坏、透明派生失败或远端完成后的本地提交失败进入对账。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局。旧正式图不合格时只返回 `needs-repair`;Supervisor 认领后由原 owner 使用 `replaceExisting=true` 原位替换。凭据不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- External Editor 的远端项目和资源属于服务 principal,不属于本地项目的跨账号身份。`.agent/manifest.json` 中 `source.canvasProjectId / resourceId / assetObjectId` 只记录生成来源,不得作为当前登录账号的 active editable binding。客户端在 `.agent/runtime/external-editor-bindings/` 私有保存账号级投影:项目键至少包含服务 origin、principal 和本地 `projectId`,资源键再包含远端项目、本地 asset ID、源文件 SHA-256、媒体类型与 canonical asset kind;值保存该 principal 下的远端 project/folder/resource/object 身份。Token 轮换继续复用同一 principal binding,账号、服务、源字节或 canonical kind 变化必须重新建立;同名本地项目和项目改名不得参与权威匹配。 +- 已完成的本地正式资源以本地文件和 manifest asset 身份为权威。切换账号后,当前账号没有资源 binding 时,必须从本地字节上传、confirm 并登记到当前账号的远端项目后再编辑或派生;任何请求都不得把旧账号的 `canvasProjectId / resourceId / objectKey` 与新账号 Token 组合。新绑定不得覆盖历史来源,也不得迁移或删除旧账号远端对象。已经 `prepared / accepted / running` 的生成或编辑 operation 仍冻结原 principal、原正文和原 operation;切换账号只能停止恢复并保留对账证据,不能改绑、重放或重复扣费。 - 2026-08-03 durable 恢复补充,2026-08-04 扩展 `prepared`:`prepared / accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源;`prepared` 只原样恢复同一逻辑 POST,`accepted` 只查询既有 operation。不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住恢复。显式“继续/恢复”复用原 action、pending、provider batch 与生成账本身份,不创建 successor 请求或新幂等键。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 - 生成账本必须绑定调用模式与服务身份:普通模式为固定官方 origin + ownerUserId,高级 External v1 模式为显式 service origin;Access Token 或 Developer Key 只负责当前请求授权,轮换不得改变已有 operation 的归属。退出或换号提升单调账号 generation,中止并脱离旧请求;旧 owner 账本在新 owner 下禁止 POST、GET、下载、安装和归档,只有重新登录同一 owner 后才能恢复。绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。首次请求结果已经未知后,鉴权或其它非成功响应不能证明原请求未入队,必须保留原账本。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。 -- 2.52.0 From ad93d4534d6e5b04597fe244e4c24a7ebe008e58 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 24 Aug 2026 01:32:30 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=BB=91=E5=AE=9A=E4=B8=8E=E5=88=87=E5=8F=B7?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按账号主体持久化项目与资源绑定并迁移旧版绑定 在线性化会话租约内冻结生成与编辑账本身份 通过 Runner owner claim 隔离跨 GUI 登录态 为编辑器幂等创建补充持久回执与公开契约 保留不确定本地登记现场并阻止重复生成 补充切号、恢复、下载、幂等和会话竞态回归 增加真实 SpacetimeDB 幂等事务门禁 --- .../src-tauri/src/agent/direct_runtime.rs | 584 +++- .../src-tauri/src/agent/generation.rs | 5 +- .../src/agent/generation/canvas_generation.rs | 2445 +++++++++++++++-- .../generation/external_generation_state.rs | 600 +++- .../src-tauri/src/agent/runtime_driver.rs | 7 +- .../src/agent/runtime_driver/entrypoints.rs | 25 +- .../src/agent/runtime_driver/recovery_scan.rs | 335 ++- .../src-tauri/src/agent/runtime_state.rs | 6 +- .../src-tauri/src/assets.rs | 148 +- .../src-tauri/src/commands.rs | 15 +- .../src-tauri/src/main.rs | 22 +- .../src-tauri/src/platform_session.rs | 216 +- .../src-tauri/src/project/agent_db.rs | 33 +- .../src/project/asset_canvas/generation.rs | 1455 ++++++++-- .../src/project/external_editor_bindings.rs | 827 +++++- .../src-tauri/src/project/manifest.rs | 35 +- .../src-tauri/src/project/resource_editor.rs | 2264 +++++++++++++-- .../src-tauri/src/runner/client.rs | 182 +- .../src-tauri/src/runner/dispatch.rs | 263 +- .../src-tauri/src/runner/endpoint.rs | 102 +- .../src-tauri/src/runner/protocol.rs | 8 +- .../src-tauri/src/runner/server.rs | 42 +- .../src-tauri/src/runner/state.rs | 9 + .../src-tauri/src/runner/tests.rs | 492 +++- .../src/app/AuthenticatedClient.tsx | 75 +- .../src/services/clientAuth.ts | 97 +- .../src/services/clientHttp.ts | 14 +- .../src/services/platformSession.ts | 303 +- .../src/view/project-development/index.tsx | 65 +- .../tests/appSurface/auth.suite.ts | 510 +++- .../genarrative-external-v1.openapi.json | 47 + .../shared-memory/decision-log.md | 16 +- docs/project-memory/shared-memory/pitfalls.md | 12 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 11 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 9 + package.json | 1 + .../spacetime-editor-idempotency-smoke.mjs | 757 +++++ ...spacetime-editor-idempotency-smoke.test.ts | 83 + .../crates/api-server/src/editor_project.rs | 136 +- .../api-server/src/external_editor_api.rs | 115 +- .../spacetime-client/src/module_bindings.rs | 42 +- .../editor_idempotent_create_receipt_table.rs | 238 ++ .../editor_idempotent_create_receipt_type.rs | 67 + .../src/editor_project_storage.rs | 557 +++- .../crates/spacetime-module/src/migration.rs | 1 + 45 files changed, 12113 insertions(+), 1163 deletions(-) create mode 100644 scripts/spacetime-editor-idempotency-smoke.mjs create mode 100644 scripts/spacetime-editor-idempotency-smoke.test.ts create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_idempotent_create_receipt_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_idempotent_create_receipt_type.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index f0f64a28c..25befc739 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -75,9 +75,15 @@ fn direct_prompt_requests_fresh_art_generation(prompt: &str) -> bool { #[derive(Clone, Debug, Eq, PartialEq)] struct DirectTaonierArtAssetIdentity { + project_id: String, + local_asset_id: String, + source_sha256: String, + media_type: String, + canonical_asset_kind: String, resource_id: String, asset_object_id: String, canvas_project_id: String, + reference_resource_ids: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -268,8 +274,7 @@ fn direct_taonier_art_asset_identity( expected_kind: &str, expected_generation_route: &str, expected_generation_kind: &str, - expected_reference_resource_ids: &[&str], - expected_canvas_project_id: Option<&str>, + expected_reference_source: Option<&DirectTaonierArtAssetIdentity>, ) -> Option { let manifest = read_manifest(&root.join(".agent/manifest.json")).ok()?; manifest.assets.iter().find_map(|asset| { @@ -279,15 +284,6 @@ fn direct_taonier_art_asset_identity( || asset.source.kind != GameCreationAppAssetSourceKind::Canvas || asset.source.generation_route.as_deref() != Some(expected_generation_route) || asset.source.generation_kind.as_deref() != Some(expected_generation_kind) - || asset.source.reference_resource_ids.len() != expected_reference_resource_ids.len() - || !asset - .source - .reference_resource_ids - .iter() - .map(String::as_str) - .eq(expected_reference_resource_ids.iter().copied()) - || expected_canvas_project_id - .is_some_and(|expected| asset.source.canvas_project_id.as_deref() != Some(expected)) { return None; } @@ -304,7 +300,12 @@ fn direct_taonier_art_asset_identity( if !validated.has_visible_pixels { return None; } - Some(DirectTaonierArtAssetIdentity { + let identity = DirectTaonierArtAssetIdentity { + project_id: manifest.project_id.clone(), + local_asset_id: asset.id.clone(), + source_sha256: validated.content_sha256, + media_type: asset.media_type.clone(), + canonical_asset_kind: asset.kind.clone(), resource_id: asset .source .resource_id @@ -326,10 +327,47 @@ fn direct_taonier_art_asset_identity( .map(str::trim) .filter(|value| !value.is_empty())? .to_string(), - }) + reference_resource_ids: asset.source.reference_resource_ids.clone(), + }; + let lineage_matches = match expected_reference_source { + Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity), + None => identity.reference_resource_ids.is_empty(), + }; + lineage_matches.then_some(identity) }) } +fn direct_taonier_reference_matches_local_source( + root: &Path, + source: &DirectTaonierArtAssetIdentity, + derived: &DirectTaonierArtAssetIdentity, +) -> bool { + let [remote_reference_id] = derived.reference_resource_ids.as_slice() else { + return false; + }; + if derived.canvas_project_id == source.canvas_project_id + && remote_reference_id == &source.resource_id + { + return true; + } + let Ok(source_identity) = new_external_editor_source_identity( + &source.local_asset_id, + &source.source_sha256, + &source.media_type, + &source.canonical_asset_kind, + ) else { + return false; + }; + external_editor_remote_reference_matches_local_source_at( + root, + &source.project_id, + &derived.canvas_project_id, + remote_reference_id, + &source_identity, + ) + .unwrap_or(false) +} + fn direct_taonier_art_base_is_valid(root: &Path) -> bool { let Some(art_spec) = direct_taonier_art_asset_identity( root, @@ -337,7 +375,6 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool { "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) else { return false; @@ -348,8 +385,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool { "game-background", "/api/external/v1/editor/images/generations", "spec", - &[&art_spec.resource_id], - Some(&art_spec.canvas_project_id), + Some(&art_spec), ) else { return false; }; @@ -364,7 +400,6 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) else { return false; @@ -378,8 +413,7 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { "art-spritesheet", "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", - &[&art_spec.resource_id], - Some(&art_spec.canvas_project_id), + Some(&art_spec), ) else { return false; }; @@ -561,6 +595,93 @@ fn direct_recoverable_spritesheet_resource<'a>( Ok(Some(resource)) } +fn snapshot_direct_spritesheet_recovery_manifest_for_install( + root: &Path, + expected_art_spec: &DirectTaonierArtAssetIdentity, +) -> Result { + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let current_art_spec = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + None, + ) + .ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?; + if current_art_spec != *expected_art_spec { + return Err("只读恢复期间本地规范图身份发生变化,已拒绝写入图集".to_string()); + } + Ok(manifest) +} + +fn register_direct_recovered_spritesheet_at( + root: &Path, + source: GameCreationAppAssetSource, +) -> Result { + register_local_asset_at( + root, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + "image/png", + "platform-art-recovery", + source, + ) + .map_err(|error| { + format!( + "reconciliation-required: 恢复的陶泥儿图集本地登记结果不确定;已保留文件与当前 manifest,禁止在无法证明 asset.register 审计结果时静默回滚或重新生成:{error}" + ) + }) +} + +#[allow(clippy::too_many_arguments)] +fn install_direct_recovered_spritesheet_binding_at( + root: &Path, + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + registered_asset: &UploadLocalAssetResult, + content_sha256: &str, + width: u32, + height: u32, + remote_resource_id: &str, + object_key: Option<&str>, + asset_object_id: &str, +) -> Result<(), String> { + let binding_install = (|| { + let object_key = object_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "恢复的陶泥儿图集资源缺少 objectKey,无法持久化账号资源 binding".to_string() + })?; + let recovered_source = new_external_editor_source_identity( + ®istered_asset.id, + content_sha256, + "image/png", + "art-spritesheet", + )?; + let binding = new_external_editor_resource_binding( + project_id, + principal, + remote_project_id, + &recovered_source, + Some(remote_resource_id), + object_key, + asset_object_id, + Some(width), + Some(height), + unix_timestamp(), + )?; + write_external_editor_resource_binding_at(root, &binding) + })(); + binding_install.map_err(|error| { + format!( + "reconciliation-required: 恢复的陶泥儿图集已完成本地文件、manifest 与 asset.register 审计登记,但账号资源 binding 安装失败;已保留可证明的本地状态,禁止静默回滚或重新生成:{error}" + ) + }) +} + async fn recover_direct_taonier_spritesheet_read_only_at( root: &Path, art_spec: &DirectTaonierArtAssetIdentity, @@ -609,9 +730,15 @@ async fn recover_direct_taonier_spritesheet_read_only_at( return Ok(false); }; let remote_art_spec = DirectTaonierArtAssetIdentity { + project_id: art_spec.project_id.clone(), + local_asset_id: art_spec.local_asset_id.clone(), + source_sha256: art_spec.source_sha256.clone(), + media_type: art_spec.media_type.clone(), + canonical_asset_kind: art_spec.canonical_asset_kind.clone(), resource_id: remote_spec_resource_id, asset_object_id: spec_binding.asset_object_id, canvas_project_id: project_binding.remote_project_id, + reference_resource_ids: Vec::new(), }; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) @@ -637,6 +764,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( .json::() .await .map_err(|error| format!("解析陶泥儿画布资源失败:{error}"))?; + access.validate_frozen_session()?; let project = payload .get("project") .or_else(|| payload.pointer("/data/project")) @@ -655,9 +783,11 @@ async fn recover_direct_taonier_spritesheet_read_only_at( .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 assetObjectId".to_string())?; let task_id = json_string_field(resource, "taskId") .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 taskId".to_string())?; - let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource) - .await? - .ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?; + access.validate_frozen_session()?; + let download = + resolve_canvas_resource_download_with_access(&client, &access, resource, 20 * 1024 * 1024) + .await? + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?; access.validate_frozen_session()?; let validated = validate_platform_art_png_bytes_with_limits( &download.bytes, @@ -670,19 +800,22 @@ async fn recover_direct_taonier_spritesheet_read_only_at( root, "direct-codex.art-spritesheet-recovery", )?; - let current_art_spec = direct_taonier_art_asset_identity( - root, - DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", - "/api/external/v1/editor/images/generations", - "spec", - &[], - None, - ) - .ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?; - if current_art_spec != *art_spec { - return Err("只读恢复期间本地规范图身份发生变化,已拒绝写入图集".to_string()); - } + 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())), + ) + }) + .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 + // restore the pre-network manifest over an unrelated local update made while we were waiting. + let manifest_before_install = + snapshot_direct_spritesheet_recovery_manifest_for_install(root, art_spec)?; let output = resolve_local_project_path(root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH)?; if output.exists() { return Err("本地图集文件已存在但合同不完整,已拒绝覆盖并保持失败状态".to_string()); @@ -704,16 +837,12 @@ async fn recover_direct_taonier_spritesheet_read_only_at( format!("写入恢复的陶泥儿图集失败:{error}") })?; drop(output_file); - let register = register_local_asset_at( + let registered_asset = register_direct_recovered_spritesheet_at( root, - DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - "art-spritesheet", - "image/png", - "platform-art-recovery", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, canvas_project_id: Some(remote_art_spec.canvas_project_id.clone()), - resource_id: Some(resource_id), + resource_id: Some(resource_id.clone()), asset_object_id: Some(asset_object_id.clone()), task_id: Some(task_id), prompt: json_string_field(resource, "actualPrompt") @@ -725,35 +854,20 @@ async fn recover_direct_taonier_spritesheet_read_only_at( generation_kind: Some("icon-spritesheet".to_string()), reference_resource_ids: vec![remote_art_spec.resource_id.clone()], }, - ); - let registered_asset = match register { - Ok(registered_asset) => registered_asset, - Err(error) => { - let _ = std::fs::remove_file(&output); - return Err(error); - } - }; - if let Some(object_key) = json_string_field(resource, "objectKey") { - let recovered_source = new_external_editor_source_identity( - ®istered_asset.id, - &validated.content_sha256, - "image/png", - "art-spritesheet", - )?; - let recovered_binding = new_external_editor_resource_binding( - &manifest.project_id, - &principal, - &remote_art_spec.canvas_project_id, - &recovered_source, - json_string_field(resource, "resourceId").as_deref(), - &object_key, - &asset_object_id, - Some(validated.width), - Some(validated.height), - unix_timestamp(), - )?; - write_external_editor_resource_binding_at(root, &recovered_binding)?; - } + )?; + install_direct_recovered_spritesheet_binding_at( + root, + &manifest_before_install.project_id, + &principal, + &remote_art_spec.canvas_project_id, + ®istered_asset, + &validated.content_sha256, + validated.width, + validated.height, + &resource_id, + json_string_field(resource, "objectKey").as_deref(), + &asset_object_id, + )?; emit_direct_game_creator_progress( root, "art.spritesheet.recovered", @@ -839,7 +953,6 @@ async fn generate_direct_taonier_art_asset_at( "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) .ok_or_else(|| { @@ -900,7 +1013,6 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ); let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| { @@ -910,8 +1022,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( "game-background", "/api/external/v1/editor/images/generations", "spec", - &[&art_spec.resource_id], - Some(&art_spec.canvas_project_id), + Some(art_spec), ) .is_some() }); @@ -936,7 +1047,6 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) .ok_or_else(|| "陶泥儿规范图生成后未形成可用平台合同,已终止代码生成".to_string())? @@ -948,8 +1058,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( "game-background", "/api/external/v1/editor/images/generations", "spec", - &[&art_spec.resource_id], - Some(&art_spec.canvas_project_id), + Some(&art_spec), ) .is_none() { @@ -3068,9 +3177,15 @@ mod tests { fn direct_art_spec_identity_fixture() -> DirectTaonierArtAssetIdentity { DirectTaonierArtAssetIdentity { + project_id: "local-project".to_string(), + local_asset_id: "local-art-spec".to_string(), + source_sha256: "a".repeat(64), + media_type: "image/png".to_string(), + canonical_asset_kind: "icon-spec".to_string(), resource_id: "taonier-resource-icon-spec".to_string(), asset_object_id: "taonier-object-icon-spec".to_string(), canvas_project_id: "taonier-project".to_string(), + reference_resource_ids: Vec::new(), } } @@ -3197,6 +3312,228 @@ mod tests { assert!(!ledger.exists()); } + #[test] + fn direct_recovery_rollback_snapshot_preserves_a_concurrent_manifest_change() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-late-snapshot", "恢复锁内快照") + .expect("init project"); + register_direct_taonier_art_base_fixture(root.path()); + let art_spec = direct_taonier_art_asset_identity( + root.path(), + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + None, + ) + .expect("current art spec identity"); + let stale_snapshot = read_manifest(&root.path().join(".agent/manifest.json")) + .expect("read pre-network manifest"); + + let concurrent_path = "assets/concurrent-local-change.png"; + std::fs::write(root.path().join(concurrent_path), tiny_visible_png()) + .expect("write concurrent local asset"); + let concurrent = register_local_asset_at( + root.path(), + concurrent_path, + "illustration", + "image/png", + "uploaded", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register concurrent local asset"); + + let rollback_snapshot = + snapshot_direct_spritesheet_recovery_manifest_for_install(root.path(), &art_spec) + .expect("capture lock-held rollback snapshot"); + assert!(rollback_snapshot.assets.len() > stale_snapshot.assets.len()); + assert!(rollback_snapshot + .assets + .iter() + .any(|asset| asset.id == concurrent.id)); + + let recovery_path = "assets/recovery-install-to-rollback.png"; + std::fs::write(root.path().join(recovery_path), tiny_visible_png()) + .expect("write simulated recovery asset"); + let recovery = register_local_asset_at( + root.path(), + recovery_path, + "art-spritesheet", + "image/png", + "platform-art-recovery", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("taonier-project".to_string()), + resource_id: Some("recovery-resource".to_string()), + asset_object_id: Some("recovery-object".to_string()), + task_id: Some("recovery-task".to_string()), + prompt: None, + model: None, + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec![art_spec.resource_id], + }, + ) + .expect("register simulated recovery asset"); + write_manifest( + &root.path().join(".agent/manifest.json"), + &rollback_snapshot, + ) + .expect("simulate failed install rollback"); + let restored = read_manifest(&root.path().join(".agent/manifest.json")) + .expect("read restored manifest"); + assert!(restored + .assets + .iter() + .any(|asset| asset.id == concurrent.id)); + assert!(!restored.assets.iter().any(|asset| asset.id == recovery.id)); + } + + #[test] + fn direct_recovery_binding_failure_preserves_registered_asset_and_audit_for_reconciliation() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at( + root.path(), + "direct-binding-reconciliation", + "恢复 binding 对账", + ) + .expect("init project"); + let local_path = DIRECT_CODEX_SPRITESHEET_ASSET_PATH; + let png = tiny_visible_png(); + std::fs::write(root.path().join(local_path), &png).expect("write recovered spritesheet"); + let registered = register_local_asset_at( + root.path(), + local_path, + "art-spritesheet", + "image/png", + "platform-art-recovery", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("taonier-project".to_string()), + resource_id: Some("taonier-resource-spritesheet".to_string()), + asset_object_id: Some("taonier-object-spritesheet".to_string()), + task_id: Some("taonier-task-spritesheet".to_string()), + prompt: None, + model: Some("gpt-image-2".to_string()), + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec!["taonier-resource-icon-spec".to_string()], + }, + ) + .expect("register recovered spritesheet"); + let access = ExternalEditorBindingAccess::for_developer( + "https://editor.example.test", + "developer-key", + ) + .expect("developer access"); + let principal = external_editor_binding_principal(&access).expect("principal"); + let validated = + validate_platform_art_png_bytes_with_limits(&png, "恢复图集").expect("validate png"); + + let error = install_direct_recovered_spritesheet_binding_at( + root.path(), + "direct-binding-reconciliation", + &principal, + "taonier-project", + ®istered, + &validated.content_sha256, + validated.width, + validated.height, + "taonier-resource-spritesheet", + None, + "taonier-object-spritesheet", + ) + .expect_err("missing objectKey must require reconciliation"); + + assert!(error.contains("reconciliation-required"), "{error}"); + assert!(root.path().join(local_path).is_file()); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")) + .expect("read preserved manifest"); + assert!(manifest + .assets + .iter() + .any(|asset| { asset.id == registered.id && asset.local_path == local_path })); + let (records, _) = + read_agent_db_records_bounded(root.path(), u64::MAX).expect("read preserved audit"); + assert!(records.iter().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) == Some("asset.register") + && record.get("assetId").and_then(serde_json::Value::as_str) + == Some(registered.id.as_str()) + })); + } + + #[test] + fn direct_recovery_register_after_sync_error_preserves_durable_asset_for_reconciliation() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at( + root.path(), + "direct-register-reconciliation", + "恢复登记不确定结果对账", + ) + .expect("init project"); + let local_path = DIRECT_CODEX_SPRITESHEET_ASSET_PATH; + std::fs::write(root.path().join(local_path), tiny_visible_png()) + .expect("write recovered spritesheet"); + let failure_marker = root + .path() + .join(".agent/runtime/test-fail-next-agent-db-record-after-sync"); + std::fs::create_dir_all(failure_marker.parent().expect("failure marker parent")) + .expect("create failure marker parent"); + std::fs::write(&failure_marker, "asset.register") + .expect("inject after-sync Agent DB failure"); + + let error = register_direct_recovered_spritesheet_at( + root.path(), + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("taonier-project".to_string()), + resource_id: Some("taonier-resource-spritesheet".to_string()), + asset_object_id: Some("taonier-object-spritesheet".to_string()), + task_id: Some("taonier-task-spritesheet".to_string()), + prompt: None, + model: Some("gpt-image-2".to_string()), + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec!["taonier-resource-icon-spec".to_string()], + }, + ) + .expect_err("durable audit followed by an error must require reconciliation"); + + assert!(error.contains("reconciliation-required"), "{error}"); + assert!(root.path().join(local_path).is_file()); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")) + .expect("read preserved manifest"); + let registered = manifest + .assets + .iter() + .find(|asset| asset.local_path == local_path) + .expect("registered asset remains"); + let (records, _) = + read_agent_db_records_bounded(root.path(), u64::MAX).expect("read durable audit"); + assert!(records.iter().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) == Some("asset.register") + && record.get("assetId").and_then(serde_json::Value::as_str) + == Some(registered.id.as_str()) + })); + } + fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) { let (generation_route, generation_kind, reference_resource_ids) = match kind { "art-spritesheet" => ( @@ -3594,6 +3931,99 @@ mod tests { assert!(direct_taonier_art_package_is_valid(root.path())); } + #[test] + fn direct_art_base_accepts_cross_account_reference_bound_to_exact_local_spec() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-cross-account", "跨账号本地美术") + .expect("init project"); + register_direct_taonier_art_base_fixture(root.path()); + let manifest_path = root.path().join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("manifest"); + let art_spec = manifest + .assets + .iter() + .find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH) + .cloned() + .expect("art spec"); + let background = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH) + .expect("background"); + background.source.canvas_project_id = Some("taonier-project-b".to_string()); + background.source.resource_id = Some("taonier-resource-background-b".to_string()); + background.source.asset_object_id = Some("taonier-object-background-b".to_string()); + background.source.reference_resource_ids = vec!["taonier-resource-spec-b".to_string()]; + write_manifest(&manifest_path, &manifest).expect("write account B provenance"); + + let access = ExternalEditorBindingAccess::for_developer( + "http://127.0.0.1:38201", + "account-b-developer-key", + ) + .expect("account B access"); + let principal = external_editor_binding_principal(&access).expect("account B principal"); + let project_binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + "taonier-project-b", + "taonier-folder-b", + unix_timestamp(), + ) + .expect("account B project binding"); + write_external_editor_project_binding_at(root.path(), &project_binding) + .expect("write account B project binding"); + let art_spec_bytes = + std::fs::read(root.path().join(&art_spec.local_path)).expect("read local art spec"); + let source_identity = new_external_editor_source_identity( + &art_spec.id, + &format!("{:x}", Sha256::digest(&art_spec_bytes)), + &art_spec.media_type, + &art_spec.kind, + ) + .expect("local art spec identity"); + let resource_binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + "taonier-project-b", + &source_identity, + Some("taonier-resource-spec-b"), + "account-b/art-spec.png", + "taonier-object-spec-b", + Some(1), + Some(1), + unix_timestamp(), + ) + .expect("account B art spec binding"); + write_external_editor_resource_binding_at(root.path(), &resource_binding) + .expect("write account B art spec binding"); + + assert!( + direct_taonier_art_base_is_valid(root.path()), + "account B derived background must remain linked to the exact local art spec" + ); + + let mut changed_png = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([24, 160, 255, 255]), + )) + .write_to( + &mut std::io::Cursor::new(&mut changed_png), + image::ImageFormat::Png, + ) + .expect("encode changed art spec"); + std::fs::write( + root.path().join(DIRECT_CODEX_ART_SPEC_ASSET_PATH), + changed_png, + ) + .expect("change local art spec"); + assert!( + !direct_taonier_art_base_is_valid(root.path()), + "binding for old bytes must not validate changed local art spec" + ); + } + #[test] fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index e6d8ca19f..e0b3ae072 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -19,8 +19,9 @@ pub(crate) use canvas_generation::{ external_generation_result_has_download_reference, external_generation_submit_rejection_is_definitive, platform_art_generation_error_needs_reconciliation, prepare_external_canvas_generation_context, - submit_external_generation_request, wait_for_external_generation_result, - ExternalCanvasGenerationContext, ExternalGenerationInitialResponse, + resolve_canvas_resource_download_with_access, submit_external_generation_request, + wait_for_external_generation_result_with_access, ExternalCanvasGenerationContext, + ExternalGenerationInitialResponse, }; pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 87c884942..36f10a766 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1,10 +1,7 @@ -#[cfg(test)] -use super::external_generation_state::platform_art_generation_external_configuration_fingerprint; use super::external_generation_state::{ mark_platform_art_generation_runtime_accepted, mark_platform_art_generation_runtime_legacy_completed, migrate_platform_art_generation_external_configuration, - platform_art_generation_external_service_fingerprint, platform_art_generation_runtime_idempotency_key, platform_art_generation_runtime_legacy_result, platform_art_generation_runtime_request_body_json, platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status, @@ -30,6 +27,249 @@ const PLATFORM_ART_SPRITESHEET_MAX_DIMENSION: u32 = 4_096; const PLATFORM_ART_SPRITESHEET_TOTAL_PIXELS: u64 = 16 * 1024 * 1024; const PLATFORM_ART_SPRITESHEET_MAX_DECODE_ALLOC: u64 = 64 * 1024 * 1024; +type ExternalCanvasBindingAsyncLock = tokio::sync::Mutex<()>; +const EXTERNAL_CANVAS_BINDING_PARTIAL_SCHEMA_VERSION: &str = + "game-creator-external-editor-project-binding-partial.v2"; +const EXTERNAL_CANVAS_BINDING_PARTIAL_MAX_BYTES: usize = 16 * 1024; +const EXTERNAL_CANVAS_BINDING_PARTIAL_MAX_ID_CHARS: usize = 512; +const EXTERNAL_CANVAS_BINDING_CROSS_PROCESS_LOCK_WAIT_ATTEMPTS: usize = 24_000; +const EXTERNAL_CANVAS_BINDING_CROSS_PROCESS_LOCK_WAIT_MS: u64 = 25; + +static EXTERNAL_CANVAS_BINDING_LOCKS: OnceLock< + std::sync::Mutex>>, +> = OnceLock::new(); + +fn external_canvas_binding_singleflight_lock( + root: &Path, + binding_key_sha256: &str, +) -> Result, String> { + let canonical_root = fs::canonicalize(root) + .map_err(|error| format!("解析 External Editor binding 项目路径失败:{error}"))?; + let key = (canonical_root, binding_key_sha256.to_string()); + let mut locks = EXTERNAL_CANVAS_BINDING_LOCKS + .get_or_init(|| std::sync::Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| "External Editor binding singleflight 状态已损坏".to_string())?; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&key).and_then(std::sync::Weak::upgrade) { + return Ok(lock); + } + let lock = Arc::new(ExternalCanvasBindingAsyncLock::new(())); + locks.insert(key, Arc::downgrade(&lock)); + Ok(lock) +} + +async fn acquire_external_canvas_binding_cross_process_lock( + root: &Path, + binding_key_sha256: &str, +) -> Result { + let relative_path = + format!(".agent/runtime/locks/external-editor-bindings/{binding_key_sha256}.lock"); + for attempt in 0..EXTERNAL_CANVAS_BINDING_CROSS_PROCESS_LOCK_WAIT_ATTEMPTS { + if let Some(file) = + try_open_game_creator_agent_runtime_task_lock_file(root, &relative_path)? + { + return Ok(file); + } + if attempt + 1 < EXTERNAL_CANVAS_BINDING_CROSS_PROCESS_LOCK_WAIT_ATTEMPTS { + tokio::time::sleep(Duration::from_millis( + EXTERNAL_CANVAS_BINDING_CROSS_PROCESS_LOCK_WAIT_MS, + )) + .await; + } + } + Err("等待 External Editor project binding 跨进程 singleflight 超时".to_string()) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ExternalCanvasBindingPartial { + schema_version: String, + binding_key_sha256: String, + payload_fingerprint_sha256: String, + local_project_id: String, + canvas_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + remote_project_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + remote_asset_folder_id: Option, + updated_at: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ExternalCanvasBindingPartialFingerprintMaterial<'a> { + schema_version: &'a str, + binding_key_sha256: &'a str, + local_project_id: &'a str, + canvas_name: &'a str, + remote_project_id: Option<&'a str>, + remote_asset_folder_id: Option<&'a str>, +} + +fn external_canvas_binding_partial_relative_path(binding_key_sha256: &str) -> String { + format!(".agent/runtime/external-editor-bindings/partials/{binding_key_sha256}.json") +} + +fn validate_external_canvas_binding_partial_id(value: &str, label: &str) -> Result<(), String> { + if value.trim() != value + || value.is_empty() + || value.chars().count() > EXTERNAL_CANVAS_BINDING_PARTIAL_MAX_ID_CHARS + || value.chars().any(char::is_control) + { + return Err(format!("{label} 无效")); + } + Ok(()) +} + +fn external_canvas_binding_partial_payload_fingerprint( + partial: &ExternalCanvasBindingPartial, +) -> Result { + let bytes = serde_json::to_vec(&ExternalCanvasBindingPartialFingerprintMaterial { + schema_version: &partial.schema_version, + binding_key_sha256: &partial.binding_key_sha256, + local_project_id: &partial.local_project_id, + canvas_name: &partial.canvas_name, + remote_project_id: partial.remote_project_id.as_deref(), + remote_asset_folder_id: partial.remote_asset_folder_id.as_deref(), + }) + .map_err(|error| format!("序列化 External Editor project binding partial 指纹失败:{error}"))?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} + +fn validate_external_canvas_binding_partial( + partial: &ExternalCanvasBindingPartial, + binding_key_sha256: &str, + local_project_id: &str, +) -> Result<(), String> { + if partial.schema_version != EXTERNAL_CANVAS_BINDING_PARTIAL_SCHEMA_VERSION + || partial.binding_key_sha256 != binding_key_sha256 + || partial.local_project_id != local_project_id + || partial.updated_at == 0 + || binding_key_sha256.len() != 64 + || !binding_key_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err("External Editor project binding partial 身份无效".to_string()); + } + if partial.payload_fingerprint_sha256.len() != 64 + || !partial + .payload_fingerprint_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + || external_canvas_binding_partial_payload_fingerprint(partial)? + != partial.payload_fingerprint_sha256 + { + return Err("External Editor project binding partial 内容指纹无效".to_string()); + } + validate_external_canvas_binding_partial_id(local_project_id, "本地 projectId")?; + if partial.canvas_name.trim() != partial.canvas_name + || partial.canvas_name.is_empty() + || partial.canvas_name.chars().count() > 80 + || partial.canvas_name.chars().any(char::is_control) + { + return Err("External Editor project binding partial 的 canvasName 无效".to_string()); + } + if let Some(remote_project_id) = partial.remote_project_id.as_deref() { + validate_external_canvas_binding_partial_id( + remote_project_id, + "External Editor remote projectId", + )?; + } + if partial.remote_asset_folder_id.is_some() && partial.remote_project_id.is_none() { + return Err( + "External Editor project binding partial 的 folder 缺少 remote projectId".to_string(), + ); + } + if let Some(remote_asset_folder_id) = partial.remote_asset_folder_id.as_deref() { + validate_external_canvas_binding_partial_id( + remote_asset_folder_id, + "External Editor remote assetFolderId", + )?; + } + Ok(()) +} + +fn read_external_canvas_binding_partial( + root: &Path, + binding_key_sha256: &str, + local_project_id: &str, +) -> Result, String> { + let partial = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &external_canvas_binding_partial_relative_path(binding_key_sha256), + "External Editor project binding partial", + EXTERNAL_CANVAS_BINDING_PARTIAL_MAX_BYTES, + )?; + if let Some(partial) = partial.as_ref() { + validate_external_canvas_binding_partial(partial, binding_key_sha256, local_project_id)?; + } + Ok(partial) +} + +fn write_external_canvas_binding_partial( + root: &Path, + partial: &mut ExternalCanvasBindingPartial, +) -> Result<(), String> { + partial.payload_fingerprint_sha256 = + external_canvas_binding_partial_payload_fingerprint(partial)?; + validate_external_canvas_binding_partial( + partial, + &partial.binding_key_sha256, + &partial.local_project_id, + )?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &external_canvas_binding_partial_relative_path(&partial.binding_key_sha256), + "External Editor project binding partial", + partial, + EXTERNAL_CANVAS_BINDING_PARTIAL_MAX_BYTES, + )?; + let persisted = read_external_canvas_binding_partial( + root, + &partial.binding_key_sha256, + &partial.local_project_id, + )? + .ok_or_else(|| "External Editor project binding partial 写入后缺失".to_string())?; + if persisted != *partial { + return Err("External Editor project binding partial 写入后回读不一致".to_string()); + } + Ok(()) +} + +fn remove_external_canvas_binding_partial( + root: &Path, + binding_key_sha256: &str, +) -> Result<(), String> { + let relative_path = external_canvas_binding_partial_relative_path(binding_key_sha256); + let path = resolve_local_project_path(root, &relative_path)?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err("External Editor project binding partial 必须是普通文件".to_string()); + } + Ok(_) => fs::remove_file(&path).map_err(|error| { + format!( + "删除 External Editor project binding partial 失败:{}: {error}", + path.display() + ) + })?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 External Editor project binding partial 元数据失败:{}: {error}", + path.display() + )); + } + } + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + remove_agent_runtime_json_sidecar_backup( + &backup_path, + "External Editor project binding partial", + )?; + sync_agent_runtime_sidecar_parent(&path, "External Editor project binding partial") +} + pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec { read_manifest_for_project(root) .map(|manifest| { @@ -570,14 +810,46 @@ pub(crate) async fn wait_for_external_generation_result( api_base_url: &str, api_key: &str, submission_payload: &serde_json::Value, +) -> Result { + wait_for_external_generation_result_inner( + client, + api_base_url, + api_key, + None, + submission_payload, + ) + .await +} + +pub(crate) async fn wait_for_external_generation_result_with_access( + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + submission_payload: &serde_json::Value, +) -> Result { + wait_for_external_generation_result_inner( + client, + access.api_base_url(), + access.bearer_token(), + Some(access), + submission_payload, + ) + .await +} + +async fn wait_for_external_generation_result_inner( + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + access: Option<&ExternalEditorBindingAccess<'_>>, + submission_payload: &serde_json::Value, ) -> Result { let submission = external_editor_response_data(submission_payload); let operation_id = json_string_field(submission, "operationId") .ok_or_else(|| "外部图片生成提交响应缺少 operationId".to_string())?; - let status_url = format!( - "{api_base_url}{}", - resolve_platform_generation_status_route(&operation_id) - ); + let status_route = access + .map(|access| access.generation_status_route(&operation_id)) + .unwrap_or_else(|| resolve_platform_generation_status_route(&operation_id)); + let status_url = format!("{api_base_url}{status_route}"); let started_at = tokio::time::Instant::now(); let mut poll_after_ms = external_generation_poll_after_ms(submission_payload); @@ -590,12 +862,18 @@ pub(crate) async fn wait_for_external_generation_result( if poll_after_ms > 0 { tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; } - let payload = match external_editor_json_request( + if let Some(access) = access { + access.validate_frozen_session()?; + } + let payload_result = external_editor_json_request( client.get(&status_url).bearer_auth(api_key), "查询平台图片生成任务", ) - .await - { + .await; + if let Some(access) = access { + access.validate_frozen_session()?; + } + let payload = match payload_result { Ok(payload) => payload, Err(error) if !error.contains("HTTP ") @@ -629,6 +907,9 @@ pub(crate) async fn wait_for_external_generation_result( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务完成结果缺少可下载媒体;operationId={operation_id}" )); } + if let Some(access) = access { + access.validate_frozen_session()?; + } return Ok(result); } Some("failed") => { @@ -703,35 +984,39 @@ async fn resume_prepared_external_generation_at( root: &Path, poll_client: &reqwest::Client, submit_client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, endpoint: &str, state: PlatformArtGenerationRuntimeState, ) -> Result { + access.validate_frozen_session()?; let response = submit_external_generation_request( submit_client, - api_base_url, + access.api_base_url(), endpoint, - api_key, + access.bearer_token(), platform_art_generation_runtime_idempotency_key(&state), platform_art_generation_runtime_request_body_json(&state), ) .await?; + let post_submit_session = access.validate_frozen_session(); let status = response.status(); if !status.is_success() { + post_submit_session?; return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留", status.as_u16() )); } - let submission_payload = response + let submission_payload_result = response .json::() .await .map_err(|error| { format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 解析 External Editor prepared 恢复响应失败:{error}" ) - })?; + }); + let post_body_session = access.validate_frozen_session(); + let submission_payload = submission_payload_result?; match classify_external_generation_initial_response(status, &submission_payload)? { ExternalGenerationInitialResponse::LegacyCompleted(generated) => { mark_platform_art_generation_runtime_legacy_completed(root, state, &generated).map_err( @@ -741,6 +1026,8 @@ async fn resume_prepared_external_generation_at( ) }, )?; + post_submit_session?; + post_body_session?; Ok(generated) } ExternalGenerationInitialResponse::AsyncSubmission(submission) => { @@ -759,8 +1046,9 @@ async fn resume_prepared_external_generation_at( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} prepared 恢复的 operationId 无法持久化:{error}" ) })?; - wait_for_external_generation_result(poll_client, api_base_url, api_key, &submission) - .await + post_submit_session?; + post_body_session?; + wait_for_external_generation_result_with_access(poll_client, access, &submission).await } } } @@ -777,116 +1065,194 @@ pub(crate) async fn prepare_external_canvas_generation_context( "本地项目 manifest 缺少 projectId,无法建立 External Editor 账号绑定".to_string(), ); } - let canvas_name = manifest.name.trim().chars().take(80).collect::(); - let canvas_name = if canvas_name.is_empty() { + let current_canvas_name = manifest.name.trim().chars().take(80).collect::(); + let current_canvas_name = if current_canvas_name.is_empty() { "未命名游戏原型".to_string() } else { - canvas_name + current_canvas_name }; access.validate_frozen_session()?; let principal = external_editor_binding_principal(access)?; + let binding_key = external_editor_project_binding_key_sha256(local_project_id, &principal)?; + let binding_lock = external_canvas_binding_singleflight_lock(root, &binding_key)?; + let _binding_guard = binding_lock.lock().await; + let _cross_process_binding_guard = + acquire_external_canvas_binding_cross_process_lock(root, &binding_key).await?; + // The first waiter may have created and installed the binding while this call was queued. + // Revalidate the frozen account and reread only after entering both per-key critical sections; + // the std mutex above protects the in-process lock registry only and is never held across await. + access.validate_frozen_session()?; let existing_binding = read_external_editor_project_binding_at(root, local_project_id, &principal)?; - let projects_payload = external_editor_json_request( - client - .get(format!( - "{}{}", - access.api_base_url(), - access.api_route("/api/external/v1/editor/projects") - )) - .bearer_auth(access.bearer_token()), - "读取外部画布项目", - ) - .await?; - access.validate_frozen_session()?; - let projects = external_editor_response_data(&projects_payload) - .get("projects") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "外部画布项目响应缺少 projects".to_string())?; - // 项目标题只用于展示,不再参与身份匹配。同名项目、账号切换或本地改名都必须 - // 依赖本地 projectId + 当前账号 principal 的私有 binding。 - let project_id = existing_binding.as_ref().and_then(|binding| { - projects - .iter() - .any(|project| { - json_string_field(project, "projectId").as_deref() - == Some(binding.remote_project_id.as_str()) - }) - .then(|| binding.remote_project_id.clone()) - }); - let project_id = match project_id { - Some(project_id) => project_id, - None => { - access.validate_frozen_session()?; - let payload = external_editor_json_request( - client - .post(format!( - "{}{}", - access.api_base_url(), - access.api_route("/api/external/v1/editor/projects") - )) - .bearer_auth(access.bearer_token()) - .json(&serde_json::json!({ "title": canvas_name })), - "创建外部画布项目", - ) - .await?; - access.validate_frozen_session()?; - external_editor_response_data(&payload) - .get("project") - .and_then(|project| json_string_field(project, "projectId")) - .ok_or_else(|| "创建外部画布项目响应缺少 projectId".to_string())? + let mut partial = + match read_external_canvas_binding_partial(root, &binding_key, local_project_id)? { + Some(partial) => partial, + None => { + let mut partial = ExternalCanvasBindingPartial { + schema_version: EXTERNAL_CANVAS_BINDING_PARTIAL_SCHEMA_VERSION.to_string(), + binding_key_sha256: binding_key.clone(), + payload_fingerprint_sha256: String::new(), + local_project_id: local_project_id.to_string(), + canvas_name: current_canvas_name, + remote_project_id: None, + remote_asset_folder_id: None, + updated_at: unix_timestamp(), + }; + // Freeze the exact create request material before any remote request. If a response is + // lost or the process exits, a local rename cannot reuse the same key with new bytes. + write_external_canvas_binding_partial(root, &mut partial)?; + partial + } + }; + let canvas_name = partial.canvas_name.clone(); + let project_id = if let Some(project_id) = partial.remote_project_id.clone() { + project_id + } else { + let projects_payload = external_editor_json_request( + client + .get(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/projects") + )) + .bearer_auth(access.bearer_token()), + "读取外部画布项目", + ) + .await?; + access.validate_frozen_session()?; + let projects = external_editor_response_data(&projects_payload) + .get("projects") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "外部画布项目响应缺少 projects".to_string())?; + // 项目标题只用于展示,不再参与身份匹配。同名项目、账号切换或本地改名都必须 + // 依赖本地 projectId + 当前账号 principal 的私有 binding。 + match existing_binding.as_ref().and_then(|binding| { + projects + .iter() + .any(|project| { + json_string_field(project, "projectId").as_deref() + == Some(binding.remote_project_id.as_str()) + }) + .then(|| binding.remote_project_id.clone()) + }) { + Some(project_id) => project_id, + None => { + access.validate_frozen_session()?; + let payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/projects") + )) + .bearer_auth(access.bearer_token()) + .header( + "Idempotency-Key", + format!("game-creator-project-{binding_key}"), + ) + .json(&serde_json::json!({ "title": canvas_name })), + "创建外部画布项目", + ) + .await?; + let post_response_session = access.validate_frozen_session(); + let project_id = external_editor_response_data(&payload) + .get("project") + .and_then(|project| json_string_field(project, "projectId")) + .ok_or_else(|| "创建外部画布项目响应缺少 projectId".to_string())?; + validate_external_canvas_binding_partial_id( + &project_id, + "External Editor remote projectId", + )?; + partial.remote_project_id = Some(project_id.clone()); + partial.remote_asset_folder_id = None; + partial.updated_at = unix_timestamp(); + write_external_canvas_binding_partial(root, &mut partial)?; + post_response_session?; + project_id + } } }; + validate_external_canvas_binding_partial_id(&project_id, "External Editor remote projectId")?; + if partial.remote_project_id.as_deref() != Some(project_id.as_str()) { + partial.remote_project_id = Some(project_id.clone()); + partial.remote_asset_folder_id = None; + partial.updated_at = unix_timestamp(); + write_external_canvas_binding_partial(root, &mut partial)?; + } - let library_payload = external_editor_json_request( - client - .get(format!( - "{}{}", - access.api_base_url(), - access.api_route("/api/external/v1/editor/assets/library") - )) - .bearer_auth(access.bearer_token()), - "读取外部素材库", - ) - .await?; - access.validate_frozen_session()?; - let folders = external_editor_response_data(&library_payload) - .get("library") - .and_then(|library| library.get("folders")) - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "外部素材库响应缺少 library.folders".to_string())?; - let asset_folder_id = existing_binding.as_ref().and_then(|binding| { - folders - .iter() - .any(|folder| { - json_string_field(folder, "folderId").as_deref() - == Some(binding.remote_asset_folder_id.as_str()) - }) + let asset_folder_id = if let Some(asset_folder_id) = partial.remote_asset_folder_id.clone() { + asset_folder_id + } else { + let library_payload = external_editor_json_request( + client + .get(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/assets/library") + )) + .bearer_auth(access.bearer_token()), + "读取外部素材库", + ) + .await?; + access.validate_frozen_session()?; + let folders = external_editor_response_data(&library_payload) + .get("library") + .and_then(|library| library.get("folders")) + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "外部素材库响应缺少 library.folders".to_string())?; + match existing_binding.as_ref().and_then(|binding| { + (binding.remote_project_id == project_id + && folders.iter().any(|folder| { + json_string_field(folder, "folderId").as_deref() + == Some(binding.remote_asset_folder_id.as_str()) + })) .then(|| binding.remote_asset_folder_id.clone()) - }); - let asset_folder_id = match asset_folder_id { - Some(folder_id) => folder_id, - None => { - access.validate_frozen_session()?; - let payload = external_editor_json_request( - client - .post(format!( - "{}{}", - access.api_base_url(), - access.api_route("/api/external/v1/editor/assets/folders") - )) - .bearer_auth(access.bearer_token()) - .json(&serde_json::json!({ "label": canvas_name })), - "创建外部素材库目录", - ) - .await?; - access.validate_frozen_session()?; - external_editor_response_data(&payload) - .get("folder") - .and_then(|folder| json_string_field(folder, "folderId")) - .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())? + }) { + Some(asset_folder_id) => asset_folder_id, + None => { + access.validate_frozen_session()?; + let payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/assets/folders") + )) + .bearer_auth(access.bearer_token()) + .header( + "Idempotency-Key", + format!("game-creator-folder-{binding_key}"), + ) + .json(&serde_json::json!({ "label": canvas_name })), + "创建外部素材库目录", + ) + .await?; + let post_response_session = access.validate_frozen_session(); + let asset_folder_id = external_editor_response_data(&payload) + .get("folder") + .and_then(|folder| json_string_field(folder, "folderId")) + .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())?; + validate_external_canvas_binding_partial_id( + &asset_folder_id, + "External Editor remote assetFolderId", + )?; + partial.remote_asset_folder_id = Some(asset_folder_id.clone()); + partial.updated_at = unix_timestamp(); + write_external_canvas_binding_partial(root, &mut partial)?; + post_response_session?; + asset_folder_id + } } }; + validate_external_canvas_binding_partial_id( + &asset_folder_id, + "External Editor remote assetFolderId", + )?; + if partial.remote_asset_folder_id.as_deref() != Some(asset_folder_id.as_str()) { + partial.remote_asset_folder_id = Some(asset_folder_id.clone()); + partial.updated_at = unix_timestamp(); + write_external_canvas_binding_partial(root, &mut partial)?; + } access.validate_frozen_session()?; let now = unix_timestamp(); @@ -903,6 +1269,7 @@ pub(crate) async fn prepare_external_canvas_generation_context( binding.created_at = existing_binding.created_at; } write_external_editor_project_binding_at(root, &binding)?; + remove_external_canvas_binding_partial(root, &binding_key)?; access.validate_frozen_session()?; Ok(ExternalCanvasGenerationContext { @@ -960,7 +1327,59 @@ struct PreparedPlatformArtAssetSlice { extension: String, } +#[derive(Clone)] +struct PreparedPlatformSessionFence { + user_id: String, + api_base_url: String, + generation: u64, + access_token_sha256: String, +} + +impl PreparedPlatformSessionFence { + fn from_access(access: &ExternalEditorBindingAccess<'_>) -> Option { + 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()) + ), + }) + } + + 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(), + ) + } + } + + fn acquire_lease(&self) -> Result { + acquire_validated_platform_session_fingerprint( + &self.user_id, + &self.api_base_url, + self.generation, + &self.access_token_sha256, + ) + } +} + pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { + platform_session_fence: Option, requested_output_path: Option, replacement_fingerprint: Option, download: CanvasResourceDownload, @@ -989,6 +1408,21 @@ impl PreparedPlatformArtAssetGeneration { pub(in crate::agent) fn slice_count(&self) -> usize { self.slices.len() } + + fn validate_frozen_session(&self) -> Result<(), String> { + self.platform_session_fence + .as_ref() + .map_or(Ok(()), PreparedPlatformSessionFence::validate) + } + + fn acquire_frozen_session_lease( + &self, + ) -> Result, String> { + self.platform_session_fence + .as_ref() + .map(PreparedPlatformSessionFence::acquire_lease) + .transpose() + } } struct CanonicalArtSpecUploadTicket { @@ -1048,6 +1482,29 @@ async fn canonical_art_spec_reference_at( .remote_resource_id .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } + let principal_key = + external_editor_project_binding_key_sha256(&manifest.project_id, &principal)?; + let resource_binding_key = external_editor_resource_binding_key_sha256( + &manifest.project_id, + &principal_key, + expected_canvas_project_id, + &source_identity, + )?; + let _resource_binding_guard = + acquire_external_editor_resource_binding_singleflight(root, &resource_binding_key, access) + .await?; + access.validate_frozen_session()?; + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &manifest.project_id, + &principal, + expected_canvas_project_id, + &source_identity, + )? { + return binding + .remote_resource_id + .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + } // manifest 中的远端 ID 只保留生成来源。当前账号没有 binding 时,必须从本地正式 // 文件重新上传并登记,不能用当前 token 探测或发送历史账号的 project/resource ID。 @@ -1060,6 +1517,10 @@ async fn canonical_art_spec_reference_at( access.api_route("/api/external/v1/assets/direct-upload-tickets") )) .bearer_auth(access.bearer_token()) + .header( + "Idempotency-Key", + format!("game-creator-resource-{resource_binding_key}"), + ) .json(&serde_json::json!({ "legacyPrefix": "generated-character-drafts", "pathSegments": [ @@ -1181,6 +1642,10 @@ async fn canonical_art_spec_reference_at( )) )) .bearer_auth(access.bearer_token()) + .header( + "Idempotency-Key", + format!("game-creator-resource-{resource_binding_key}"), + ) .json(&serde_json::json!({ "imageSrc": format!("/{}", ticket.object_key.trim_start_matches('/')), "objectKey": ticket.object_key, @@ -1197,7 +1662,7 @@ async fn canonical_art_spec_reference_at( "登记当前账号规范图项目资源", ) .await?; - access.validate_frozen_session()?; + let post_response_session = access.validate_frozen_session(); let resource_data = external_editor_response_data(&resource_payload); let remote_resource_id = json_string_field( resource_data.get("resource").unwrap_or(resource_data), @@ -1217,7 +1682,7 @@ async fn canonical_art_spec_reference_at( unix_timestamp(), )?; write_external_editor_resource_binding_at(root, &binding)?; - access.validate_frozen_session()?; + post_response_session?; Ok(remote_resource_id) } @@ -1372,10 +1837,28 @@ fn platform_art_generation_warning(generated: &serde_json::Value) -> Option, + resource: &serde_json::Value, + max_bytes: usize, +) -> Result, String> { + let read_url_route = access.api_route("/api/external/v1/assets/read-url"); + resolve_canvas_resource_download_with_limit_route_and_fence( + client, + access.api_base_url(), + access.bearer_token(), + resource, + max_bytes, + &read_url_route, + || access.validate_frozen_session(), + ) + .await +} + async fn prepare_platform_art_spritesheet_slices( client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, generated: &serde_json::Value, initial_download_bytes: usize, initial_decoded_pixels: u64, @@ -1410,10 +1893,9 @@ async fn prepare_platform_art_spritesheet_slices( let remaining_download_bytes = PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES .checked_sub(total_download_bytes) .ok_or_else(|| "平台图集切片累计大小溢出".to_string())?; - let download = resolve_canvas_resource_download_with_limit( + let download = resolve_canvas_resource_download_with_access( client, - api_base_url, - api_key, + access, resource, remaining_download_bytes.min(PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES), ) @@ -1507,16 +1989,18 @@ pub(crate) async fn generate_platform_art_asset_with_options_at( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> Result { - { - let recovery_lock = acquire_project_write_lock(root, "canvas.asset_generate.recover")?; - recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?; - } - let prepared = - request_platform_art_asset_with_options_at(root, prompt, briefs, options).await?; - let lock = acquire_project_write_lock(root, "canvas.asset_generate")?; - recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?; - advance_agent_runtime_project_revision_locked(root)?; - commit_prepared_platform_art_asset_at(root, prepared, options, |_| Ok(())) + let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let runtime_context = + standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; + generate_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + false, + &runtime_context, + ) + .await } /// Generates the canonical game spritesheet together with the four durable @@ -1532,17 +2016,110 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( if options.asset_kind != "art-spritesheet" { return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); } - { - let recovery_lock = - acquire_project_write_lock(root, "canvas.asset_generate.strict.recover")?; - recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?; + let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let runtime_context = + standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?; + generate_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + true, + &runtime_context, + ) + .await +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { + prompt: &'a str, + output_path: Option<&'a str>, + aspect_ratio: &'a str, + image_size: &'a str, + asset_kind: &'a str, + asset_label: &'a str, + replace_existing: bool, + require_slices: bool, +} + +fn standalone_platform_art_generation_runtime_context( + prompt: &str, + options: &PlatformArtAssetGenerationOptions, + require_slices: bool, +) -> Result { + let agent_id = if require_slices { + "manual-canvas-asset-generate-strict" + } else { + "manual-canvas-asset-generate" + }; + let output_slot = options + .output_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(normalize_relative_path) + .transpose()? + .unwrap_or_else(|| "(automatic-output)".to_string()); + let slot_bytes = serde_json::to_vec(&serde_json::json!({ + "outputPath": output_slot, + "requireSlices": require_slices, + })) + .map_err(|error| format!("序列化 standalone 图片生成输出槽失败:{error}"))?; + let run_id = format!("slot-{:x}", Sha256::digest(slot_bytes)); + let identity = format!("{agent_id}:{run_id}"); + let fingerprint_bytes = + serde_json::to_vec(&StandalonePlatformArtGenerationFingerprintMaterial { + prompt, + output_path: options.output_path.as_deref(), + aspect_ratio: &options.aspect_ratio, + image_size: &options.image_size, + asset_kind: &options.asset_kind, + asset_label: &options.asset_label, + replace_existing: options.replace_existing, + require_slices, + }) + .map_err(|error| format!("序列化 standalone 图片生成动作指纹失败:{error}"))?; + Ok(PlatformArtGenerationRuntimeContext { + agent_id: agent_id.to_string(), + task_id: identity.clone(), + session_id: identity.clone(), + run_id, + source: "tauri-command".to_string(), + action_id: identity, + action_fingerprint: format!("{:x}", Sha256::digest(fingerprint_bytes)), + }) +} + +fn resolve_standalone_platform_art_generation_result( + root: &Path, + runtime_context: Option<&PlatformArtGenerationRuntimeContext>, + result: Result, +) -> Result { + let Err(error) = &result else { + return result; + }; + let Some(runtime_context) = runtime_context.filter(|context| { + super::external_generation_state::is_standalone_platform_art_generation_runtime_context( + context, + ) + }) else { + return result; + }; + if !error.starts_with("平台图片生成任务失败:") { + return result; } - let prepared = - request_platform_art_asset_with_options_at(root, prompt, briefs, options).await?; - let lock = acquire_project_write_lock(root, "canvas.asset_generate.strict")?; - recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?; - advance_agent_runtime_project_revision_locked(root)?; - commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(())) + remove_platform_art_generation_runtime_state_at( + root, + &runtime_context.agent_id, + &runtime_context.run_id, + ) + .map_err(|cleanup_error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台已权威确认生成失败,但本地账本清理失败:{cleanup_error};原错误:{error}" + ) + })?; + result } /// Generates a platform art asset with a durable request identity owned by a @@ -1560,6 +2137,22 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_a if require_slices && options.asset_kind != "art-spritesheet" { return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); } + if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( + runtime_context, + ) && game_creator_agent_runtime_external_generation_exists( + root, + &runtime_context.agent_id, + &runtime_context.agent_id, + ) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 检测到旧固定槽 standalone 生成账本;为避免遗失已受理操作并重复计费,已禁止创建新的远端请求" + )); + } + let _durable_generation_guard = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, + runtime_context, + )?; { // Direct Codex chat persists the user turn concurrently with the first // platform-art request. Both operations are short-lived project writes; @@ -1579,6 +2172,7 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_a Some(runtime_context), ) .await?; + prepared.validate_frozen_session()?; let lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "canvas.asset_generate.runtime", @@ -1598,6 +2192,7 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_runtime_options_a Ok(generated) } +#[cfg(test)] pub(in crate::agent) async fn request_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -1646,17 +2241,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at )?; let persisted_runtime_state = persisted_runtime_state .map(|state| { - migrate_platform_art_generation_external_configuration( - root, - state, - &api_base_url, - &api_key, - ) - .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}")) + migrate_platform_art_generation_external_configuration(root, state, &binding_access) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}")) }) .transpose()?; - let external_configuration_fingerprint = - platform_art_generation_external_service_fingerprint(&api_base_url); let client = reqwest::Client::builder() .timeout(Duration::from_secs(60)) .build() @@ -1680,35 +2268,44 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本请求快照无法恢复:{error}" ) })?; - let generated = match platform_art_generation_runtime_status(&state) { + let generated_result = match platform_art_generation_runtime_status(&state) { "prepared" => { resume_prepared_external_generation_at( root, &client, &submit_client, - &api_base_url, - &api_key, + &binding_access, &snapshot.endpoint, state, ) - .await? + .await } "accepted" => { - let submission = platform_art_generation_runtime_submission_payload(&state) - .map_err(|error| { - format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}") - })?; - wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) - .await? + match platform_art_generation_runtime_submission_payload(&state) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}")) + { + Ok(submission) => { + wait_for_external_generation_result_with_access( + &client, + &binding_access, + &submission, + ) + .await + } + Err(error) => Err(error), + } } "legacy-completed" => platform_art_generation_runtime_legacy_result(&state) - .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?, - _ => { - return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复" - )); - } + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}")), + _ => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复" + )), }; + let generated = resolve_standalone_platform_art_generation_result( + root, + runtime_context, + generated_result, + )?; let is_canonical_art_spritesheet = snapshot.generation_kind == "icon-spritesheet"; ( generated, @@ -1805,7 +2402,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at &canvas_context.canvas_name, &generation_prompt, &request_body, - &external_configuration_fingerprint, + &binding_access, ) .map_err(|error| { format!( @@ -1841,9 +2438,13 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at &request_body_json, ) .await?; + let post_submit_session = binding_access.validate_frozen_session(); let status = response.status(); if !status.is_success() { + post_submit_session?; + binding_access.validate_frozen_session()?; let body = response.text().await.unwrap_or_default(); + binding_access.validate_frozen_session()?; if external_generation_submit_rejection_is_definitive(status) { if let Some(context) = runtime_context { remove_platform_art_generation_runtime_state_at( @@ -1870,14 +2471,16 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at &body, )); } - let submission_payload = response + let submission_payload_result = response .json::() .await .map_err(|error| { format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 解析平台图片生成提交响应失败:{error}" ) - })?; + }); + let post_body_session = binding_access.validate_frozen_session(); + let submission_payload = submission_payload_result?; let generated = match classify_external_generation_initial_response( status, &submission_payload, @@ -1891,6 +2494,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; } + post_submit_session?; + post_body_session?; generated } ExternalGenerationInitialResponse::AsyncSubmission(submission) => { @@ -1911,8 +2516,20 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; } - wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) - .await? + // A 202 is durable acceptance. Persist operationId first even if the account + // changed while receiving the response, then stop before the first poll. + post_submit_session?; + post_body_session?; + resolve_standalone_platform_art_generation_result( + root, + runtime_context, + wait_for_external_generation_result_with_access( + &client, + &binding_access, + &submission, + ) + .await, + )? } }; ( @@ -1962,10 +2579,14 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }; let download_source = external_generation_download_source(generated, resource, is_canonical_art_spritesheet); - let download = - resolve_canvas_resource_download(&client, &api_base_url, &api_key, &download_source) - .await? - .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; + let download = resolve_canvas_resource_download_with_access( + &client, + &binding_access, + &download_source, + 20 * 1024 * 1024, + ) + .await? + .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; let (spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, spritesheet_pixels) = if is_canonical_art_spritesheet { let (has_transparent, has_visible, pixels) = @@ -1993,8 +2614,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at let slices = if is_canonical_art_spritesheet { prepare_platform_art_spritesheet_slices( &client, - &api_base_url, - &api_key, + &binding_access, generated, download.bytes.len(), spritesheet_pixels, @@ -2049,7 +2669,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .or_else(|| json_string_field(generated, "imageSrc")) .or_else(|| json_string_field(generated, "spritesheetImageSrc")); let extension = infer_file_extension(source_hint.as_deref(), &download.media_type).to_string(); + binding_access.validate_frozen_session()?; Ok(PreparedPlatformArtAssetGeneration { + platform_session_fence: PreparedPlatformSessionFence::from_access(&binding_access), requested_output_path, replacement_fingerprint, download, @@ -2092,6 +2714,7 @@ pub(in crate::agent) fn commit_prepared_platform_art_asset_at( options: &PlatformArtAssetGenerationOptions, before_replace: impl FnOnce(&Path) -> Result<(), String>, ) -> Result { + prepared.validate_frozen_session()?; commit_prepared_platform_art_asset_with_before_replace_hook( root, prepared, @@ -2108,6 +2731,7 @@ pub(in crate::agent) fn commit_prepared_platform_art_asset_strict_slices_at( options: &PlatformArtAssetGenerationOptions, before_replace: impl FnOnce(&Path) -> Result<(), String>, ) -> Result { + prepared.validate_frozen_session()?; commit_prepared_platform_art_asset_with_before_replace_hook( root, prepared, @@ -5751,7 +6375,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( before_replace: impl FnOnce(&Path) -> Result<(), String>, after_backup_before_install: impl FnOnce(&Path) -> Result<(), String>, ) -> Result { + let _platform_session_lease = prepared.acquire_frozen_session_lease()?; let PreparedPlatformArtAssetGeneration { + platform_session_fence: _, requested_output_path, replacement_fingerprint, download, @@ -6274,6 +6900,78 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; + #[test] + fn standalone_generation_binds_complete_request_to_stable_output_slot() { + let options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/manual-art.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + asset_kind: "game-background".to_string(), + asset_label: "手工背景".to_string(), + replace_existing: true, + }; + let ordinary = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) + .expect("ordinary standalone context"); + let repeated = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) + .expect("repeat standalone context"); + let strict = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, true) + .expect("strict standalone context"); + assert_eq!(ordinary, repeated); + assert_ne!(ordinary.run_id, strict.run_id); + assert_eq!(ordinary.source, "tauri-command"); + assert_eq!(strict.source, "tauri-command"); + assert_eq!( + ordinary.task_id, + format!("{}:{}", ordinary.agent_id, ordinary.run_id) + ); + assert_eq!(ordinary.task_id, ordinary.session_id); + assert_eq!(ordinary.task_id, ordinary.action_id); + + let different_prompt = + standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) + .expect("different prompt context"); + assert_eq!(ordinary.run_id, different_prompt.run_id); + assert_ne!( + ordinary.action_fingerprint, + different_prompt.action_fingerprint + ); + + let mut changed_options = Vec::new(); + let mut changed = options.clone(); + changed.output_path = Some("assets/another-art.png".to_string()); + changed_options.push(changed); + let mut changed = options.clone(); + changed.aspect_ratio = "1:1".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.image_size = "1K".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.asset_kind = "ui-prototype".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.asset_label = "另一个标签".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.replace_existing = false; + changed_options.push(changed); + for changed in changed_options { + let context = standalone_platform_art_generation_runtime_context( + "完整生成提示词", + &changed, + false, + ) + .expect("changed option context"); + assert_ne!( + ordinary.action_fingerprint, context.action_fingerprint, + "every option field must participate in the action fingerprint" + ); + } + } + #[test] fn external_http_error_summary_keeps_validation_details_without_sensitive_context() { let body = serde_json::json!({ @@ -6328,7 +7026,7 @@ mod canvas_generation_tests { break; } } - String::from_utf8(bytes).expect("request must be UTF-8") + String::from_utf8_lossy(&bytes).into_owned() } fn test_request_header<'a>(request: &'a str, expected_name: &str) -> &'a str { @@ -6349,6 +7047,334 @@ mod canvas_generation_tests { .expect("expected request body separator") } + fn write_test_json_response( + stream: &mut std::net::TcpStream, + status: &str, + body: &serde_json::Value, + ) { + let body = body.to_string(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ) + .expect("write JSON fixture response"); + } + + fn install_test_external_project_binding(root: &Path, api_base_url: &str, api_key: &str) { + let manifest = read_manifest(&root.join(".agent/manifest.json")) + .expect("read project manifest for binding"); + let access = ExternalEditorBindingAccess::for_developer(api_base_url, api_key) + .expect("create developer binding access"); + let principal = + external_editor_binding_principal(&access).expect("derive developer principal"); + let binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + "manual-test-canvas", + "manual-test-assets", + unix_timestamp(), + ) + .expect("create project binding"); + write_external_editor_project_binding_at(root, &binding).expect("persist project binding"); + let mut partial = ExternalCanvasBindingPartial { + schema_version: EXTERNAL_CANVAS_BINDING_PARTIAL_SCHEMA_VERSION.to_string(), + binding_key_sha256: binding.binding_key_sha256.clone(), + payload_fingerprint_sha256: String::new(), + local_project_id: manifest.project_id, + canvas_name: manifest.name, + remote_project_id: Some(binding.remote_project_id), + remote_asset_folder_id: Some(binding.remote_asset_folder_id), + updated_at: unix_timestamp(), + }; + write_external_canvas_binding_partial(root, &mut partial) + .expect("persist project binding partial"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_standalone_generation_conflicts_before_a_second_post() { + let temporary = tempfile::tempdir().expect("create standalone concurrency project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-concurrency", "手工生成并发测试") + .expect("init standalone concurrency project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow standalone generation"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind standalone fixture"); + listener + .set_nonblocking(true) + .expect("set standalone fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let api_key = "manual-concurrency-key"; + install_test_external_project_binding(root, &base_url, api_key); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut first_request = true; + loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept standalone generation request: {error}"), + }; + let request = read_test_http_request(&mut stream); + request_sender + .send(request) + .expect("capture standalone generation request"); + if first_request { + first_request = false; + release_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release first standalone POST"); + } + write_test_json_response( + &mut stream, + "400 Bad Request", + &serde_json::json!({ + "error": {"code": "invalid-request", "message": "fixture rejection"} + }), + ); + } + }); + + let options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/manual-concurrent.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "并发手工素材".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let first_root = root.to_path_buf(); + let first_options = options.clone(); + let first_credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let first = tokio::spawn(async move { + crate::assets::with_external_editor_api_credentials(first_credentials, async move { + generate_platform_art_asset_with_options_at( + &first_root, + "第一个手工请求", + &[], + &first_options, + ) + .await + }) + .await + }); + let (first_request, request_receiver) = tokio::task::spawn_blocking(move || { + let request = request_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("first standalone POST"); + (request, request_receiver) + }) + .await + .expect("join request observer"); + assert!( + first_request.starts_with("POST /api/external/v1/editor/images/generations "), + "unexpected first standalone request: {first_request}" + ); + + let second_root = root.to_path_buf(); + let second_credentials = + crate::assets::external_editor_api_credentials_for_test(base_url, api_key.to_string()); + let second = tokio::time::timeout( + Duration::from_secs(2), + crate::assets::with_external_editor_api_credentials(second_credentials, async move { + generate_platform_art_asset_with_options_at( + &second_root, + "不同的第二个手工请求", + &[], + &options, + ) + .await + }), + ) + .await + .expect("concurrent request must fail without waiting for the first POST") + .expect_err("concurrent request must conflict"); + assert!(second.contains("任何远端 POST 前拒绝并发请求"), "{second}"); + + release_sender.send(()).expect("release first request"); + let first = first + .await + .expect("join first standalone generation") + .expect_err("fixture rejects first generation"); + assert!(first.contains("HTTP 400"), "{first}"); + assert!( + request_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "concurrent request must not emit a second POST" + ); + stop_sender.send(()).expect("stop standalone fixture"); + server.join().expect("join standalone fixture"); + } + + #[tokio::test] + async fn standalone_authoritative_failed_result_clears_ledger_for_retry() { + let temporary = tempfile::tempdir().expect("create standalone retry project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-failed-retry", "手工生成失败重试测试") + .expect("init standalone retry project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow standalone retry"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind standalone retry fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let api_key = "manual-failed-retry-key"; + install_test_external_project_binding(root, &base_url, api_key); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut generation_post_count = 0_usize; + loop { + let (mut stream, _) = listener.accept().expect("accept standalone retry request"); + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture standalone retry request"); + let completed = if request + .starts_with("POST /api/external/v1/editor/images/generations ") + { + generation_post_count += 1; + if generation_post_count == 1 { + write_test_json_response( + &mut stream, + "202 Accepted", + &serde_json::json!({ + "data": { + "operationId": "manual-failed-operation", + "status": "queued", + "pollAfterMs": 0 + } + }), + ); + false + } else { + write_test_json_response( + &mut stream, + "400 Bad Request", + &serde_json::json!({ + "error": { + "code": "invalid-request", + "message": "retry reached POST" + } + }), + ); + true + } + } else if request + .starts_with("GET /api/external/v1/generations/manual-failed-operation ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({ + "data": { + "operationId": "manual-failed-operation", + "status": "failed", + "error": "provider rejected fixture" + } + }), + ); + false + } else if request.starts_with("GET /api/external/v1/editor/projects ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({ + "data": {"projects": [{"projectId": "manual-test-canvas"}]} + }), + ); + false + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({ + "data": {"library": {"folders": [{"folderId": "manual-test-assets"}]}} + }), + ); + false + } else { + panic!("unexpected standalone retry request: {request}"); + }; + if completed { + break; + } + } + }); + + let options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/manual-failed-retry.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "失败后可重试素材".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &[], &options); + let runtime_context = + standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false) + .expect("standalone retry context"); + let credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let first_error = crate::assets::with_external_editor_api_credentials(credentials, async { + generate_platform_art_asset_with_options_at(root, "相同手工请求", &[], &options).await + }) + .await + .expect_err("first operation is authoritatively failed"); + assert!( + first_error.contains("平台图片生成任务失败"), + "{first_error}" + ); + assert!(!game_creator_agent_runtime_external_generation_exists( + root, + &runtime_context.agent_id, + &runtime_context.run_id, + )); + + let credentials = + crate::assets::external_editor_api_credentials_for_test(base_url, api_key.to_string()); + let retry_error = crate::assets::with_external_editor_api_credentials(credentials, async { + generate_platform_art_asset_with_options_at(root, "相同手工请求", &[], &options).await + }) + .await + .expect_err("retry fixture rejects the second POST"); + assert!(retry_error.contains("retry reached POST"), "{retry_error}"); + server.join().expect("join standalone retry fixture"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 2, + "authoritative failure must make the same request POST-able again: {requests:#?}" + ); + } + #[tokio::test] async fn external_canvas_context_is_scoped_by_account_and_local_project_id() { let temporary = tempfile::tempdir().expect("create account binding project"); @@ -6488,6 +7514,519 @@ mod canvas_generation_tests { } } + #[tokio::test] + async fn external_canvas_context_concurrent_first_binding_is_singleflight() { + #[derive(Default)] + struct FixtureState { + project_created: bool, + folder_created: bool, + project_get_waiters: usize, + folder_get_waiters: usize, + } + + let temporary = tempfile::tempdir().expect("create concurrent binding project"); + let root = temporary.path(); + init_local_game_project_at(root, "concurrent-binding-project", "并发绑定画布") + .expect("init concurrent binding project"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind concurrent binding fixture"); + listener + .set_nonblocking(true) + .expect("set concurrent binding fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let state = Arc::new(( + std::sync::Mutex::new(FixtureState::default()), + std::sync::Condvar::new(), + )); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server_state = state.clone(); + let server = std::thread::spawn(move || { + let mut handlers = Vec::new(); + loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept concurrent binding request: {error}"), + }; + let state = server_state.clone(); + let request_sender = request_sender.clone(); + handlers.push(std::thread::spawn(move || { + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture concurrent binding request"); + let response = if request.starts_with("GET /api/editor/projects ") { + let (state_lock, ready) = &*state; + let mut state = state_lock.lock().expect("lock project fixture state"); + if !state.project_created { + state.project_get_waiters += 1; + ready.notify_all(); + if state.project_get_waiters < 2 { + let (next, _) = ready + .wait_timeout(state, Duration::from_millis(150)) + .expect("wait for concurrent project GET"); + state = next; + } + } + let projects = state.project_created.then(|| { + vec![serde_json::json!({"projectId": "single-project"})] + }); + serde_json::json!({"data": {"projects": projects.unwrap_or_default()}}) + } else if request.starts_with("POST /api/editor/projects ") { + let (state_lock, ready) = &*state; + let mut state = state_lock.lock().expect("lock project create state"); + state.project_created = true; + ready.notify_all(); + serde_json::json!({"data": {"project": {"projectId": "single-project"}}}) + } else if request.starts_with("GET /api/editor/assets/library ") { + let (state_lock, ready) = &*state; + let mut state = state_lock.lock().expect("lock folder fixture state"); + if !state.folder_created { + state.folder_get_waiters += 1; + ready.notify_all(); + if state.folder_get_waiters < 2 { + let (next, _) = ready + .wait_timeout(state, Duration::from_millis(150)) + .expect("wait for concurrent folder GET"); + state = next; + } + } + let folders = state.folder_created.then(|| { + vec![serde_json::json!({"folderId": "single-folder"})] + }); + serde_json::json!({ + "data": {"library": {"folders": folders.unwrap_or_default()}} + }) + } else if request.starts_with("POST /api/editor/assets/folders ") { + let (state_lock, ready) = &*state; + let mut state = state_lock.lock().expect("lock folder create state"); + state.folder_created = true; + ready.notify_all(); + serde_json::json!({"data": {"folder": {"folderId": "single-folder"}}}) + } else { + panic!("unexpected concurrent binding request: {request}"); + }; + let body = response.to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ) + .expect("write concurrent binding response"); + })); + } + for handler in handlers { + handler.join().expect("join concurrent binding handler"); + } + }); + + let _session = crate::platform_session::install_test_platform_session( + "singleflight-user", + "singleflight-token", + &base_url, + ); + let (api_base_url, api_key, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("resolve singleflight account"); + let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, frozen.as_ref()) + .expect("create singleflight access"); + let client = reqwest::Client::new(); + let start = tokio::sync::Barrier::new(3); + let first = async { + start.wait().await; + prepare_external_canvas_generation_context(root, &client, &access).await + }; + let second = async { + start.wait().await; + prepare_external_canvas_generation_context(root, &client, &access).await + }; + let release = async { + start.wait().await; + }; + let (_, first, second) = tokio::join!(release, first, second); + let first = first.expect("prepare first concurrent context"); + let second = second.expect("prepare second concurrent context"); + stop_sender + .send(()) + .expect("stop concurrent binding fixture"); + server.join().expect("join concurrent binding fixture"); + + assert_eq!(first.project_id, "single-project"); + assert_eq!(first, second); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /api/editor/projects ")) + .count(), + 1, + "same project/principal binding must create one remote project" + ); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /api/editor/assets/folders ")) + .count(), + 1, + "same project/principal binding must create one remote folder" + ); + } + + #[tokio::test] + async fn external_canvas_context_reuses_partial_project_after_later_failure() { + let temporary = tempfile::tempdir().expect("create project partial recovery fixture"); + let root = temporary.path(); + init_local_game_project_at(root, "partial-project-local", "分阶段恢复画布") + .expect("init project partial recovery fixture"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind project partial fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut failed_library_once = false; + for _ in 0..5 { + let (mut stream, _) = listener.accept().expect("accept project partial request"); + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture project partial request"); + if request.starts_with("GET /api/external/v1/editor/projects ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"projects": []}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/projects ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"project": { + "projectId": "partial-remote-project" + }}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") + && !failed_library_once + { + failed_library_once = true; + write_test_json_response( + &mut stream, + "500 Internal Server Error", + &serde_json::json!({"error": {"code": "fixture-failure"}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"library": {"folders": []}}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"folder": { + "folderId": "partial-remote-folder" + }}}), + ); + } else { + panic!("unexpected project partial request: {request}"); + } + } + }); + let client = reqwest::Client::new(); + let access = ExternalEditorBindingAccess::for_developer(&base_url, "partial-secret-key") + .expect("create project partial access"); + + let first_error = prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect_err("failure after project creation must be surfaced"); + assert!(first_error.contains("读取外部素材库"), "{first_error}"); + let manifest = read_existing_manifest_for_project(root).expect("read partial manifest"); + let principal = external_editor_binding_principal(&access).expect("partial principal"); + let binding_key = + external_editor_project_binding_key_sha256(&manifest.project_id, &principal) + .expect("partial binding key"); + let partial = + read_external_canvas_binding_partial(root, &binding_key, &manifest.project_id) + .expect("read project partial") + .expect("project partial exists"); + assert_eq!( + partial.remote_project_id.as_deref(), + Some("partial-remote-project") + ); + assert_eq!(partial.remote_asset_folder_id, None); + let partial_text = fs::read_to_string( + resolve_local_project_path( + root, + &external_canvas_binding_partial_relative_path(&binding_key), + ) + .expect("resolve project partial path"), + ) + .expect("read project partial text"); + assert!(!partial_text.contains("partial-secret-key")); + let partial_path = resolve_local_project_path( + root, + &external_canvas_binding_partial_relative_path(&binding_key), + ) + .expect("resolve tampered project partial path"); + fs::write( + &partial_path, + partial_text.replace("partial-remote-project", "tampered-remote-project"), + ) + .expect("tamper project partial"); + let tamper_error = prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect_err("tampered project partial must fail closed"); + assert!(tamper_error.contains("内容指纹无效"), "{tamper_error}"); + fs::write(&partial_path, &partial_text).expect("restore project partial"); + + let recovered = prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("recover project partial"); + server.join().expect("join project partial fixture"); + assert_eq!(recovered.project_id, "partial-remote-project"); + assert_eq!(recovered.asset_folder_id, "partial-remote-folder"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /api/external/v1/editor/projects ")) + .count(), + 1, + "retry after a later failure must not create another remote project" + ); + assert_eq!( + requests + .iter() + .filter( + |request| request.starts_with("POST /api/external/v1/editor/assets/folders ") + ) + .count(), + 1 + ); + } + + #[tokio::test] + async fn external_canvas_context_replays_frozen_create_bodies_after_response_loss_and_rename() { + let temporary = tempfile::tempdir().expect("create frozen binding request fixture"); + let root = temporary.path(); + init_local_game_project_at(root, "frozen-binding-local", "首次画布名称") + .expect("init frozen binding request fixture"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind frozen binding fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut project_posts = 0_u8; + let mut folder_posts = 0_u8; + for _ in 0..8 { + let (mut stream, _) = listener.accept().expect("accept frozen binding request"); + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture frozen binding request"); + if request.starts_with("GET /api/external/v1/editor/projects ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"projects": []}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/projects ") { + project_posts += 1; + if project_posts == 1 { + // Simulate a successful remote create whose response is lost. The retry + // must reuse the request body frozen before this POST. + continue; + } + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"project": { + "projectId": "frozen-remote-project" + }}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"library": {"folders": []}}}), + ); + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { + folder_posts += 1; + if folder_posts == 1 { + continue; + } + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"folder": { + "folderId": "frozen-remote-folder" + }}}), + ); + } else { + panic!("unexpected frozen binding request: {request}"); + } + } + }); + let client = reqwest::Client::new(); + let access = ExternalEditorBindingAccess::for_developer(&base_url, "frozen-binding-key") + .expect("create frozen binding access"); + + prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect_err("lost project create response must be reported"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read manifest before rename"); + manifest.name = "响应丢失后改名".to_string(); + write_manifest(&manifest_path, &manifest).expect("rename local project"); + + prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect_err("lost folder create response must be reported"); + let mut manifest = + read_manifest(&manifest_path).expect("read manifest before second rename"); + manifest.name = "目录响应丢失后再次改名".to_string(); + write_manifest(&manifest_path, &manifest).expect("rename local project again"); + + let recovered = prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("retry frozen project and folder requests"); + server.join().expect("join frozen binding fixture"); + assert_eq!(recovered.project_id, "frozen-remote-project"); + assert_eq!(recovered.asset_folder_id, "frozen-remote-folder"); + let requests = request_receiver.try_iter().collect::>(); + let project_posts = requests + .iter() + .filter(|request| request.starts_with("POST /api/external/v1/editor/projects ")) + .collect::>(); + assert_eq!(project_posts.len(), 2); + assert!(project_posts + .iter() + .all(|request| request.contains("\"title\":\"首次画布名称\""))); + assert!(project_posts + .iter() + .all(|request| !request.contains("响应丢失后改名"))); + let folder_posts = requests + .iter() + .filter(|request| request.starts_with("POST /api/external/v1/editor/assets/folders ")) + .collect::>(); + assert_eq!(folder_posts.len(), 2); + assert!(folder_posts + .iter() + .all(|request| request.contains("\"label\":\"首次画布名称\""))); + assert!(folder_posts + .iter() + .all(|request| !request.contains("响应丢失后改名"))); + } + + #[tokio::test] + async fn external_canvas_context_reuses_partial_folder_after_binding_write_failure() { + let temporary = tempfile::tempdir().expect("create folder partial recovery fixture"); + let root = temporary.path(); + init_local_game_project_at(root, "partial-folder-local", "目录分阶段恢复画布") + .expect("init folder partial recovery fixture"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind folder partial fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let client = reqwest::Client::new(); + let access = ExternalEditorBindingAccess::for_developer(&base_url, "folder-partial-key") + .expect("create folder partial access"); + let manifest = read_existing_manifest_for_project(root).expect("read folder manifest"); + let principal = external_editor_binding_principal(&access).expect("folder principal"); + let binding_key = + external_editor_project_binding_key_sha256(&manifest.project_id, &principal) + .expect("folder binding key"); + let binding_path = resolve_local_project_path( + root, + &format!(".agent/runtime/external-editor-bindings/projects/{binding_key}.json"), + ) + .expect("resolve blocked binding path"); + let blocked_binding_path = binding_path.clone(); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + for _ in 0..4 { + let (mut stream, _) = listener.accept().expect("accept folder partial request"); + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture folder partial request"); + let response = if request.starts_with("GET /api/external/v1/editor/projects ") { + serde_json::json!({"data": {"projects": []}}) + } else if request.starts_with("POST /api/external/v1/editor/projects ") { + serde_json::json!({"data": {"project": { + "projectId": "folder-partial-project" + }}}) + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + serde_json::json!({"data": {"library": {"folders": []}}}) + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { + fs::create_dir_all(&blocked_binding_path) + .expect("block final binding write after folder creation"); + serde_json::json!({"data": {"folder": { + "folderId": "folder-partial-folder" + }}}) + } else { + panic!("unexpected folder partial request: {request}"); + }; + write_test_json_response(&mut stream, "200 OK", &response); + } + }); + + let first_error = prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect_err("final binding write must fail"); + assert!(first_error.contains("必须是普通文件"), "{first_error}"); + server.join().expect("join folder partial fixture"); + let partial = + read_external_canvas_binding_partial(root, &binding_key, &manifest.project_id) + .expect("read folder partial") + .expect("folder partial exists"); + assert_eq!( + partial.remote_project_id.as_deref(), + Some("folder-partial-project") + ); + assert_eq!( + partial.remote_asset_folder_id.as_deref(), + Some("folder-partial-folder") + ); + fs::remove_dir(&binding_path).expect("unblock final binding write"); + + let recovered = prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("recover folder partial without remote requests"); + assert_eq!(recovered.project_id, "folder-partial-project"); + assert_eq!(recovered.asset_folder_id, "folder-partial-folder"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /api/external/v1/editor/projects ")) + .count(), + 1 + ); + assert_eq!( + requests + .iter() + .filter( + |request| request.starts_with("POST /api/external/v1/editor/assets/folders ") + ) + .count(), + 1, + "retry after final binding failure must not create another remote folder" + ); + assert!( + read_external_canvas_binding_partial(root, &binding_key, &manifest.project_id) + .expect("read cleaned partial") + .is_none(), + "completed binding must remove its partial sidecar" + ); + } + fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive) } @@ -6561,10 +8100,11 @@ mod canvas_generation_tests { "test-api-key".to_string(), ), async { + let access = ExternalEditorBindingAccess::for_developer(&base_url, "test-api-key") + .expect("create slice download access"); prepare_platform_art_spritesheet_slices( &reqwest::Client::new(), - &base_url, - "test-api-key", + &access, &generated, 0, 0, @@ -6678,10 +8218,14 @@ mod canvas_generation_tests { "imageSrc": "https://cdn.example.test/slice.png" }] }); - let error = prepare_platform_art_spritesheet_slices( - &reqwest::Client::new(), + let access = ExternalEditorBindingAccess::for_developer( "https://editor.example.test", "test-api-key", + ) + .expect("create slice budget access"); + let error = prepare_platform_art_spritesheet_slices( + &reqwest::Client::new(), + &access, &generated, PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES, 1, @@ -6865,10 +8409,9 @@ mod canvas_generation_tests { "assetFolderId": "persisted-asset-folder", "referenceImageSrcs": [] }); - let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( - &base_url, - "prepared-recovery-key", - ); + let ledger_access = + ExternalEditorBindingAccess::for_developer(&base_url, "prepared-recovery-key") + .expect("create prepared recovery ledger access"); let (state, created) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -6876,7 +8419,7 @@ mod canvas_generation_tests { "持久化画布名", "持久化的生成提示词", &request_body, - &configuration_fingerprint, + &ledger_access, ) .expect("prepare recovery ledger"); assert!(created); @@ -6989,10 +8532,9 @@ mod canvas_generation_tests { "assetFolderId": "persisted-asset-folder", "referenceImageSrcs": [] }); - let fingerprint = platform_art_generation_external_configuration_fingerprint( - &base_url, - "prepared-auth-key", - ); + let ledger_access = + ExternalEditorBindingAccess::for_developer(&base_url, "prepared-auth-key") + .expect("create prepared auth ledger access"); let (state, created) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -7000,7 +8542,7 @@ mod canvas_generation_tests { "持久化画布名", "持久化的生成提示词", &request_body, - &fingerprint, + &ledger_access, ) .expect("prepare auth recovery ledger"); assert!(created); @@ -7016,12 +8558,14 @@ mod canvas_generation_tests { "prepared-auth-key".to_string(), ), async { + let access = + ExternalEditorBindingAccess::for_developer(&base_url, "prepared-auth-key") + .expect("create prepared auth access"); resume_prepared_external_generation_at( root, &client, &client, - &base_url, - "prepared-auth-key", + &access, "/api/external/v1/editor/images/generations", state, ) @@ -7135,6 +8679,288 @@ mod canvas_generation_tests { assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); } + async fn platform_generation_account_switch_recovery_stops_before_network(accepted: bool) { + let temporary = tempfile::tempdir().expect("create cross-account recovery project"); + let root = temporary.path(); + init_local_game_project_at( + root, + if accepted { + "accepted-cross-account" + } else { + "prepared-cross-account" + }, + "跨账号生成恢复", + ) + .expect("init cross-account recovery project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow cross-account recovery generation"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind zero-request fixture"); + listener + .set_nonblocking(true) + .expect("set zero-request fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let _platform_session = crate::platform_session::install_test_platform_session( + "durable-owner-a", + "durable-owner-a-token", + &base_url, + ); + let (_, _, frozen_owner_a) = + resolve_canvas_sync_api_credentials(None, None).expect("freeze owner A credentials"); + let frozen_owner_a = frozen_owner_a.expect("owner A platform snapshot"); + let owner_a_access = ExternalEditorBindingAccess::for_platform(&frozen_owner_a) + .expect("create owner A ledger access"); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "cross-account-session".to_string(), + run_id: if accepted { + "accepted-cross-account-run".to_string() + } else { + "prepared-cross-account-run".to_string() + }, + source: "agent-ready-task-scheduler".to_string(), + action_id: "cross-account-action".to_string(), + action_fingerprint: "cross-account-fingerprint".to_string(), + }; + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "账号 A 画布", + "账号 A 耐久生成请求", + &serde_json::json!({ + "prompt": "账号 A 耐久生成请求", + "kind": "spec", + "projectId": "owner-a-canvas-project", + "assetFolderId": "owner-a-asset-folder", + "referenceImageSrcs": [] + }), + &owner_a_access, + ) + .expect("persist owner A generation ledger"); + assert!(created); + if accepted { + mark_platform_art_generation_runtime_accepted(root, state, "owner-a-operation", 0) + .expect("mark owner A generation accepted"); + } + drop(owner_a_access); + drop(frozen_owner_a); + install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2) + .expect("switch to owner B"); + + let error = match request_platform_art_asset_with_runtime_options_at( + root, + "账号 B 不得恢复账号 A 的请求", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("cross-account durable recovery must fail closed"), + }; + assert!(error.contains("账号主体"), "{error}"); + assert!(matches!( + listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + let ledger_path = resolve_local_project_path( + root, + &format!( + ".agent/runtime/canvas-generation-requests/{}/{}.json", + agent_runtime_confirmation_path_component(&runtime_context.agent_id, "agent"), + agent_runtime_confirmation_path_component(&runtime_context.run_id, "run") + ), + ) + .expect("resolve durable generation ledger"); + let durable_json = fs::read_to_string(ledger_path).expect("read durable generation ledger"); + assert!(durable_json.contains("durable-owner-a")); + assert!(!durable_json.contains("durable-owner-a-token")); + assert!(!durable_json.contains("durable-owner-b-token")); + } + + #[tokio::test] + async fn prepared_runtime_generation_rejects_cross_account_recovery_before_post() { + platform_generation_account_switch_recovery_stops_before_network(false).await; + } + + #[tokio::test] + async fn accepted_runtime_generation_rejects_cross_account_recovery_before_get() { + platform_generation_account_switch_recovery_stops_before_network(true).await; + } + + #[tokio::test] + async fn accepted_submission_account_switch_stops_before_poll_download_and_commit() { + let temporary = tempfile::tempdir().expect("create post-202 account switch project"); + let root = temporary.path(); + init_local_game_project_at(root, "post-202-account-switch", "202 后切换账号") + .expect("init post-202 account switch project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow post-202 account switch generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind post-202 switch fixture"); + listener + .set_nonblocking(true) + .expect("set post-202 switch fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (response_sender, response_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept post-202 switch request: {error}"), + }; + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture post-202 switch request"); + if request.starts_with("POST /api/editor/images/generations ") { + let body = serde_json::json!({ + "data": { + "operationId": "post-202-operation", + "status": "queued", + "pollAfterMs": 1_000, + } + }) + .to_string(); + write!( + stream, + "HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ) + .expect("write post-202 accepted response"); + response_sender + .send(()) + .expect("signal post-202 accepted response"); + } else { + stream + .write_all( + b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("reject request after account switch"); + } + }); + + let _session = crate::platform_session::install_test_platform_session( + "post-202-user-a", + "post-202-token-a", + &base_url, + ); + let switch_base_url = base_url.clone(); + let switcher = std::thread::spawn(move || { + response_receiver + .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"); + }); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "post-202-session".to_string(), + run_id: "post-202-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "post-202-action".to_string(), + action_fingerprint: "post-202-fingerprint".to_string(), + }; + let request_body = serde_json::json!({ + "prompt": "账号 A 已提交的生成请求", + "kind": "spec", + "projectId": "post-202-project", + "assetFolderId": "post-202-folder", + "referenceImageSrcs": [], + }); + let (_, _, frozen_platform_session) = + resolve_canvas_sync_api_credentials(None, None).expect("freeze account A access"); + let frozen_platform_session = + frozen_platform_session.expect("platform account A session must be present"); + let ledger_access = ExternalEditorBindingAccess::for_platform(&frozen_platform_session) + .expect("create account A generation ledger access"); + let (_, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "202 后切换账号", + "账号 A 已提交的生成请求", + &request_body, + &ledger_access, + ) + .expect("prepare post-202 runtime ledger"); + assert!(created); + + let error = generate_platform_art_asset_with_runtime_options_at( + root, + "调用输入不得覆盖持久化请求", + &[], + &PlatformArtAssetGenerationOptions { + asset_kind: "game-art".to_string(), + asset_label: "账号隔离测试".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }, + false, + &runtime_context, + ) + .await + .expect_err("account switch after 202 must stop before polling"); + switcher.join().expect("join post-202 account switcher"); + stop_sender.send(()).expect("stop post-202 switch fixture"); + server.join().expect("join post-202 switch fixture"); + + assert!(error.contains("authentication-required"), "{error}"); + let persisted = read_platform_art_generation_runtime_state(root, &runtime_context) + .expect("read post-202 accepted ledger") + .expect("post-202 accepted ledger remains"); + assert_eq!( + platform_art_generation_runtime_status(&persisted), + "accepted" + ); + assert_eq!( + platform_art_generation_runtime_submission_payload(&persisted) + .expect("accepted submission payload")["operationId"], + "post-202-operation" + ); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests.len(), + 1, + "no poll or download is allowed after switch" + ); + assert!(requests[0].starts_with("POST /api/editor/images/generations ")); + assert!(!root.join("assets/art-spec.png").exists()); + let manifest = read_manifest_for_project(root).expect("read post-202 manifest"); + assert!( + manifest.assets.is_empty(), + "no local asset may be installed" + ); + } + #[tokio::test] async fn accepted_runtime_generation_resumes_with_operation_get_only_and_prepares_download() { let temporary = tempfile::tempdir().expect("create accepted recovery project"); @@ -7206,18 +9032,18 @@ mod canvas_generation_tests { }) .to_string(); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body, - ); + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); stream .write_all(response.as_bytes()) .expect("write accepted operation response"); } else if request.starts_with("GET /download.png ") { let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - png.len() - ); + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ); stream .write_all(headers.as_bytes()) .and_then(|_| stream.write_all(&png)) @@ -7225,9 +9051,9 @@ mod canvas_generation_tests { } else { let body = b"unexpected request"; let response = format!( - "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); + "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); stream .write_all(response.as_bytes()) .and_then(|_| stream.write_all(body)) @@ -7255,10 +9081,12 @@ mod canvas_generation_tests { "assetFolderId": "persisted-asset-folder", "referenceImageSrcs": [] }); - let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( - &base_url, - "accepted-recovery-key", - ); + let (_, _, frozen_platform_session) = + resolve_canvas_sync_api_credentials(None, None).expect("freeze accepted account"); + let frozen_platform_session = + frozen_platform_session.expect("accepted recovery uses platform mode"); + let ledger_access = ExternalEditorBindingAccess::for_platform(&frozen_platform_session) + .expect("create accepted recovery ledger access"); let (state, created) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -7266,7 +9094,7 @@ mod canvas_generation_tests { "持久化画布名", "持久化的生成提示词", &request_body, - &configuration_fingerprint, + &ledger_access, ) .expect("prepare accepted recovery ledger"); assert!(created); @@ -7365,8 +9193,11 @@ mod canvas_generation_tests { action_id: "configuration-drift-action".to_string(), action_fingerprint: "configuration-drift-fingerprint".to_string(), }; - let stale_configuration_fingerprint = - platform_art_generation_external_service_fingerprint("https://old-editor.example.test"); + let stale_access = ExternalEditorBindingAccess::for_developer( + "https://old-editor.example.test", + "stale-key", + ) + .expect("create stale service access"); let (state, _) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -7380,7 +9211,7 @@ mod canvas_generation_tests { "assetFolderId": "old-asset-folder", "referenceImageSrcs": [] }), - &stale_configuration_fingerprint, + &stale_access, ) .expect("prepare stale configuration ledger"); mark_platform_art_generation_runtime_accepted(root, state, "stale-operation", 0) @@ -7407,7 +9238,7 @@ mod canvas_generation_tests { }, ) .await; - assert!(error.contains("服务地址身份"), "{error}"); + assert!(error.contains("服务 origin"), "{error}"); assert!(matches!( listener.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock @@ -7486,10 +9317,9 @@ mod canvas_generation_tests { action_id: "accepted-failure-action".to_string(), action_fingerprint: "accepted-failure-fingerprint".to_string(), }; - let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( - &base_url, - "accepted-failure-key", - ); + let ledger_access = + ExternalEditorBindingAccess::for_developer(&base_url, "accepted-failure-key") + .expect("create accepted failure ledger access"); let (state, _) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -7503,7 +9333,7 @@ mod canvas_generation_tests { "assetFolderId": "failed-asset-folder", "referenceImageSrcs": [] }), - &configuration_fingerprint, + &ledger_access, ) .expect("prepare accepted failure ledger"); mark_platform_art_generation_runtime_accepted(root, state, "failed-operation", 0) @@ -7603,18 +9433,18 @@ mod canvas_generation_tests { }) .to_string(); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body, - ); + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); stream .write_all(response.as_bytes()) .expect("write accepted scan operation response"); } else if request.starts_with("GET /artifact.png ") { let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - image_bytes.len(), - ); + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + image_bytes.len(), + ); stream .write_all(response.as_bytes()) .and_then(|_| stream.write_all(&image_bytes)) @@ -7934,6 +9764,167 @@ mod canvas_generation_tests { assert!(error.contains("等待 art-director")); } + #[tokio::test] + async fn concurrent_canonical_art_spec_reference_registers_remote_resource_once() { + let temporary = tempfile::tempdir().expect("create concurrent art spec fixture"); + let root = temporary.path(); + init_local_game_project_at(root, "concurrent-art-spec", "并发规范图") + .expect("init concurrent art spec project"); + let art_spec_path = root.join(AGENT_RUNTIME_ART_SPEC_PATH); + fs::create_dir_all(art_spec_path.parent().expect("art spec parent")) + .expect("create art spec directory"); + fs::write(&art_spec_path, rgba_test_png(u8::MAX).bytes).expect("write concurrent art spec"); + import_canvas_asset_at( + root, + AGENT_RUNTIME_ART_SPEC_PATH, + "icon-spec", + "image/png", + "historical-canvas-project", + Some("historical-resource".to_string()), + Some("historical-object".to_string()), + Some("historical-task".to_string()), + Some("并发规范图".to_string()), + Some("fixture-model".to_string()), + ) + .expect("register concurrent art spec"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind concurrent art spec fixture"); + listener + .set_nonblocking(true) + .expect("set art spec fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let ticket_host = base_url.clone(); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept concurrent art spec request: {error}"), + }; + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture concurrent art spec request"); + if request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") { + // Hold the first owner in its remote section long enough for the second call to + // observe the same initial binding miss and queue on the per-resource lock. + std::thread::sleep(Duration::from_millis(150)); + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"upload": { + "host": ticket_host, + "bucket": "art-spec-bucket", + "objectKey": "account/art-spec.png", + "successActionStatus": 204, + "formFields": {}, + }}}), + ); + } else if request.starts_with("POST / HTTP/1.1") { + stream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write art spec upload response"); + } else if request.starts_with("POST /api/external/v1/assets/objects/confirm ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"assetObject": { + "objectKey": "account/art-spec.png", + "assetObjectId": "account-art-spec-object" + }}}), + ); + } else if request + .starts_with("POST /api/external/v1/editor/projects/canvas-project/resources ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"resource": { + "resourceId": "account-art-spec-resource" + }}}), + ); + } else { + panic!("unexpected concurrent art spec request: {request}"); + } + } + }); + let access = ExternalEditorBindingAccess::for_developer(&base_url, "concurrent-art-key") + .expect("create concurrent art spec access"); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build() + .expect("build concurrent art spec client"); + let start = tokio::sync::Barrier::new(3); + let first = async { + start.wait().await; + canonical_art_spec_reference_at(root, &client, &access, "canvas-project").await + }; + let second = async { + start.wait().await; + canonical_art_spec_reference_at(root, &client, &access, "canvas-project").await + }; + let release = async { + start.wait().await; + }; + let (_, first, second) = tokio::join!(release, first, second); + let first = first.expect("first concurrent art spec reference"); + let second = second.expect("second concurrent art spec reference"); + stop_sender + .send(()) + .expect("stop concurrent art spec fixture"); + server.join().expect("join concurrent art spec fixture"); + assert_eq!(first, "account-art-spec-resource"); + assert_eq!(second, first); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests + .iter() + .filter(|request| request + .starts_with("POST /api/external/v1/assets/direct-upload-tickets ")) + .count(), + 1, + "one source resource binding may request one upload ticket" + ); + assert_eq!( + requests + .iter() + .filter(|request| request + .starts_with("POST /api/external/v1/editor/projects/canvas-project/resources ")) + .count(), + 1, + "one source resource binding may register one project resource" + ); + let idempotency_headers = requests + .iter() + .filter(|request| { + request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + || request.starts_with( + "POST /api/external/v1/editor/projects/canvas-project/resources ", + ) + }) + .map(|request| { + request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("idempotency-key:")) + .map(str::to_ascii_lowercase) + .expect("stable resource binding idempotency header") + }) + .collect::>(); + assert_eq!(idempotency_headers.len(), 2); + assert_eq!(idempotency_headers[0], idempotency_headers[1]); + } + #[test] fn postprocess_warning_wins_over_slice_warning_and_preserves_reason() { let payload = serde_json::json!({ @@ -8033,6 +10024,7 @@ mod canvas_generation_tests { fn prepared_replacement(root: &Path, bytes: &[u8]) -> PreparedPlatformArtAssetGeneration { PreparedPlatformArtAssetGeneration { + platform_session_fence: None, requested_output_path: Some("assets/art-spritesheet.png".to_string()), replacement_fingerprint: Some( read_existing_platform_art_asset_fingerprint(root, "assets/art-spritesheet.png") @@ -8355,6 +10347,7 @@ mod canvas_generation_tests { }) .collect::>(); let prepared = PreparedPlatformArtAssetGeneration { + platform_session_fence: None, requested_output_path: Some("assets/art-spritesheet.png".to_string()), replacement_fingerprint: None, download: main_download, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index da77a8314..80d54d619 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -1,12 +1,49 @@ use super::*; pub(in crate::agent) const PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION: &str = + "agent-runtime-canvas-generation-request.v3"; +const PLATFORM_ART_GENERATION_RUNTIME_LEGACY_SCHEMA_VERSION: &str = "agent-runtime-canvas-generation-request.v2"; pub(crate) const PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME: &str = "service-origin-v1"; +const PLATFORM_ART_GENERATION_PLATFORM_ACCESS_SCHEME: &str = "official-platform-v1"; +const PLATFORM_ART_GENERATION_DEVELOPER_ACCESS_SCHEME: &str = "external-developer-v1"; const PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES: usize = 256 * 1024; const PLATFORM_ART_GENERATION_STATUS_PREPARED: &str = "prepared"; const PLATFORM_ART_GENERATION_STATUS_ACCEPTED: &str = "accepted"; const PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED: &str = "legacy-completed"; +const DURABLE_PLATFORM_ART_GENERATION_LOCK_ROOT: &str = + ".agent/runtime/locks/durable-canvas-generations"; + +type DurablePlatformArtGenerationAsyncLock = tokio::sync::Mutex<()>; + +static DURABLE_PLATFORM_ART_GENERATION_LOCKS: OnceLock< + std::sync::Mutex< + BTreeMap<(PathBuf, String, String), std::sync::Weak>, + >, +> = OnceLock::new(); + +pub(super) struct DurablePlatformArtGenerationGuard { + _process: tokio::sync::OwnedMutexGuard<()>, + _system: File, +} + +fn is_direct_platform_art_generation_runtime_context( + context: &PlatformArtGenerationRuntimeContext, +) -> bool { + let asset_kind = match context.run_id.as_str() { + "art-spec" => "icon-spec", + "game-background" => "game-background", + "art-spritesheet" => "art-spritesheet", + _ => return false, + }; + context.agent_id == "direct-codex-art" + && context.task_id == format!("direct-codex-art-{}", context.run_id) + && !context.session_id.trim().is_empty() + && context.source == "direct-codex" + && context.action_id == format!("direct-taonier-{}", context.run_id) + && context.action_fingerprint + == format!("direct-taonier-art-v1:{asset_kind}:{}", context.run_id) +} #[derive(Clone, Debug, Eq, PartialEq)] pub(in crate::agent) struct PlatformArtGenerationRuntimeContext { @@ -19,6 +56,83 @@ pub(in crate::agent) struct PlatformArtGenerationRuntimeContext { pub(in crate::agent) action_fingerprint: String, } +fn is_lowercase_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +pub(super) fn is_standalone_platform_art_generation_runtime_context( + context: &PlatformArtGenerationRuntimeContext, +) -> bool { + let known_agent = matches!( + context.agent_id.as_str(), + "manual-canvas-asset-generate" | "manual-canvas-asset-generate-strict" + ); + let Some(slot_sha256) = context.run_id.strip_prefix("slot-") else { + return false; + }; + let identity = format!("{}:{}", context.agent_id, context.run_id); + known_agent + && is_lowercase_sha256(slot_sha256) + && context.task_id == identity + && context.session_id == identity + && context.source == "tauri-command" + && context.action_id == identity + && is_lowercase_sha256(&context.action_fingerprint) +} + +pub(super) fn acquire_durable_platform_art_generation_guard( + root: &Path, + context: &PlatformArtGenerationRuntimeContext, +) -> Result, String> { + let standalone = is_standalone_platform_art_generation_runtime_context(context); + let direct = is_direct_platform_art_generation_runtime_context(context); + if context.agent_id == "direct-codex-art" && !direct { + return Err("Direct 图片生成 durable slot 身份无效,已在远端请求前失败关闭".to_string()); + } + if !standalone && !direct { + return Ok(None); + } + let canonical_root = fs::canonicalize(root) + .map_err(|error| format!("解析 durable 图片生成项目路径失败:{error}"))?; + let key = ( + canonical_root, + context.agent_id.clone(), + context.run_id.clone(), + ); + let process_lock = { + let mut locks = DURABLE_PLATFORM_ART_GENERATION_LOCKS + .get_or_init(|| std::sync::Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| "durable 图片生成 singleflight 状态已损坏".to_string())?; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&key).and_then(std::sync::Weak::upgrade) { + lock + } else { + let lock = Arc::new(DurablePlatformArtGenerationAsyncLock::new(())); + locks.insert(key, Arc::downgrade(&lock)); + lock + } + }; + let process = process_lock.try_lock_owned().map_err(|_| { + "durable 图片生成输出槽已有请求执行中;已在任何远端 POST 前拒绝并发请求".to_string() + })?; + let relative_path = format!( + "{DURABLE_PLATFORM_ART_GENERATION_LOCK_ROOT}/{}/{}.lock", + context.agent_id, context.run_id + ); + let system = try_open_game_creator_agent_runtime_task_lock_file(root, &relative_path)? + .ok_or_else(|| { + "durable 图片生成输出槽已被其他进程占用;已在任何远端 POST 前拒绝并发请求".to_string() + })?; + Ok(Some(DurablePlatformArtGenerationGuard { + _process: process, + _system: system, + })) +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(super) struct PlatformArtGenerationRuntimeState { @@ -32,6 +146,12 @@ pub(super) struct PlatformArtGenerationRuntimeState { action_id: String, action_fingerprint: String, external_configuration_fingerprint: String, + #[serde(default)] + access_scheme: Option, + #[serde(default)] + external_service_origin: Option, + #[serde(default)] + platform_owner_user_id: Option, endpoint: String, canvas_name: String, generation_prompt: String, @@ -49,6 +169,14 @@ pub(super) struct PlatformArtGenerationRuntimeState { updated_at: u64, } +#[derive(Clone, Debug, Eq, PartialEq)] +struct PlatformArtGenerationAccessIdentity { + access_scheme: String, + external_service_origin: String, + platform_owner_user_id: Option, + external_configuration_fingerprint: String, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct PlatformArtGenerationRuntimeRequestSnapshot { pub(super) endpoint: String, @@ -132,15 +260,52 @@ fn request_body_json_and_sha256( Ok((request_body_json, request_body_sha256)) } -pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String { +pub(crate) fn platform_art_generation_external_service_fingerprint( + api_base_url: &str, + platform_session: Option<&PlatformSessionSnapshot>, +) -> String { let normalized_base_url = api_base_url.trim().trim_end_matches('/'); - let account_identity = platform_session_service_identity().unwrap_or_default(); + let account_identity = platform_session + .map(|session| format!("{}\nuser:{}", session.api_base_url, session.user_id)) + .unwrap_or_default(); format!( "{:x}", Sha256::digest(format!("{normalized_base_url}\n{account_identity}").as_bytes()) ) } +fn platform_art_generation_access_identity( + access: &ExternalEditorBindingAccess<'_>, +) -> Result { + access.validate_frozen_session()?; + let external_service_origin = + platform_art_generation_external_service_origin(access.api_base_url())?; + let platform_session = access.frozen_platform_session(); + let (access_scheme, platform_owner_user_id) = if let Some(session) = platform_session { + if session.user_id.trim().is_empty() || session.user_id.len() > 256 { + return Err("External Editor 生成账本平台账号主体无效".to_string()); + } + ( + PLATFORM_ART_GENERATION_PLATFORM_ACCESS_SCHEME.to_string(), + Some(session.user_id.clone()), + ) + } else { + ( + PLATFORM_ART_GENERATION_DEVELOPER_ACCESS_SCHEME.to_string(), + None, + ) + }; + Ok(PlatformArtGenerationAccessIdentity { + access_scheme, + external_service_origin, + platform_owner_user_id, + external_configuration_fingerprint: platform_art_generation_external_service_fingerprint( + access.api_base_url(), + platform_session, + ), + }) +} + pub(crate) fn platform_art_generation_external_service_origin( api_base_url: &str, ) -> Result { @@ -177,6 +342,7 @@ pub(crate) fn classify_platform_art_generation_service_identity( fingerprint: Option<&str>, api_base_url: &str, api_key: &str, + platform_session: Option<&PlatformSessionSnapshot>, ) -> PlatformArtGenerationServiceIdentityMatch { let Some(fingerprint) = fingerprint else { return if scheme.is_none() { @@ -185,7 +351,8 @@ pub(crate) fn classify_platform_art_generation_service_identity( PlatformArtGenerationServiceIdentityMatch::Changed }; }; - let current = platform_art_generation_external_service_fingerprint(api_base_url); + let current = + platform_art_generation_external_service_fingerprint(api_base_url, platform_session); match scheme { Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) => { if fingerprint == current { @@ -215,25 +382,62 @@ pub(crate) fn platform_art_generation_external_configuration_fingerprint( api_base_url: &str, _api_key: &str, ) -> String { - platform_art_generation_external_service_fingerprint(api_base_url) + platform_art_generation_external_service_fingerprint(api_base_url, None) } pub(super) fn validate_platform_art_generation_external_configuration( state: &PlatformArtGenerationRuntimeState, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ) -> Result<(), String> { - if !matches!( - classify_platform_art_generation_service_identity( - None, - Some(&state.external_configuration_fingerprint), - api_base_url, - api_key, - ), - PlatformArtGenerationServiceIdentityMatch::Current - | PlatformArtGenerationServiceIdentityMatch::LegacyVerified - ) { - return Err("External Editor 生成账本与当前服务地址身份不一致".to_string()); + let identity = platform_art_generation_access_identity(access)?; + match state.schema_version.as_str() { + PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION => { + if state.access_scheme.as_deref() != Some(identity.access_scheme.as_str()) + || state.external_service_origin.as_deref() + != Some(identity.external_service_origin.as_str()) + || state.platform_owner_user_id != identity.platform_owner_user_id + || state.external_configuration_fingerprint + != identity.external_configuration_fingerprint + { + return Err( + "External Editor 生成账本与当前调用模式、服务 origin 或账号主体不一致" + .to_string(), + ); + } + } + PLATFORM_ART_GENERATION_RUNTIME_LEGACY_SCHEMA_VERSION => { + if state.access_scheme.is_some() + || state.external_service_origin.is_some() + || state.platform_owner_user_id.is_some() + { + return Err("External Editor v2 生成账本包含无法验证的主体字段".to_string()); + } + let legacy_verified = if access.frozen_platform_session().is_some() { + // A v2 platform ledger did not store ownerUserId separately. It is safe to bind + // exactly once only when its account-aware fingerprint proves the same frozen + // service/user identity. The historical origin-only value is deliberately not + // accepted because it cannot identify an owner. + state.external_configuration_fingerprint + == identity.external_configuration_fingerprint + } else { + state.external_configuration_fingerprint + == identity.external_configuration_fingerprint + || state.external_configuration_fingerprint + == platform_art_generation_legacy_external_configuration_fingerprint( + access.api_base_url(), + access.bearer_token(), + ) + }; + if !legacy_verified { + return Err("External Editor v2 生成账本无法由当前冻结调用主体安全验证".to_string()); + } + } + _ => { + return Err(format!( + "不支持的 External Editor 生成账本版本:{}", + state.schema_version + )); + } } Ok(()) } @@ -241,13 +445,16 @@ pub(super) fn validate_platform_art_generation_external_configuration( pub(super) fn migrate_platform_art_generation_external_configuration( root: &Path, mut state: PlatformArtGenerationRuntimeState, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ) -> Result { - validate_platform_art_generation_external_configuration(&state, api_base_url, api_key)?; - let current = platform_art_generation_external_service_fingerprint(api_base_url); - if state.external_configuration_fingerprint != current { - state.external_configuration_fingerprint = current; + validate_platform_art_generation_external_configuration(&state, access)?; + if state.schema_version == PLATFORM_ART_GENERATION_RUNTIME_LEGACY_SCHEMA_VERSION { + let identity = platform_art_generation_access_identity(access)?; + state.schema_version = PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION.to_string(); + state.access_scheme = Some(identity.access_scheme); + state.external_service_origin = Some(identity.external_service_origin); + state.platform_owner_user_id = identity.platform_owner_user_id; + state.external_configuration_fingerprint = identity.external_configuration_fingerprint; state.updated_at = unix_timestamp(); write_platform_art_generation_runtime_state(root, &state)?; } @@ -259,12 +466,61 @@ fn validate_platform_art_generation_runtime_identity( state: &PlatformArtGenerationRuntimeState, context: &PlatformArtGenerationRuntimeContext, ) -> Result<(), String> { - if state.schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION { + if !matches!( + state.schema_version.as_str(), + PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION + | PLATFORM_ART_GENERATION_RUNTIME_LEGACY_SCHEMA_VERSION + ) { return Err(format!( "不支持的 External Editor 生成账本版本:{}", state.schema_version )); } + match state.schema_version.as_str() { + PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION => { + let access_scheme = state + .access_scheme + .as_deref() + .ok_or_else(|| "External Editor v3 生成账本缺少 accessScheme".to_string())?; + let service_origin = state.external_service_origin.as_deref().ok_or_else(|| { + "External Editor v3 生成账本缺少 externalServiceOrigin".to_string() + })?; + if platform_art_generation_external_service_origin(service_origin)? != service_origin { + return Err("External Editor v3 生成账本 service origin 未规范化".to_string()); + } + match access_scheme { + PLATFORM_ART_GENERATION_PLATFORM_ACCESS_SCHEME => { + if state + .platform_owner_user_id + .as_deref() + .is_none_or(|owner| owner.trim().is_empty() || owner.len() > 256) + { + return Err("External Editor 平台生成账本缺少有效 ownerUserId".to_string()); + } + } + PLATFORM_ART_GENERATION_DEVELOPER_ACCESS_SCHEME => { + if state.platform_owner_user_id.is_some() { + return Err( + "External Editor 开发者生成账本禁止携带 ownerUserId".to_string() + ); + } + } + _ => return Err("External Editor 生成账本 accessScheme 无效".to_string()), + } + } + PLATFORM_ART_GENERATION_RUNTIME_LEGACY_SCHEMA_VERSION => { + if state.access_scheme.is_some() + || state.external_service_origin.is_some() + || state.platform_owner_user_id.is_some() + { + return Err("External Editor v2 生成账本主体字段无效".to_string()); + } + } + _ => unreachable!("generation ledger schema was checked above"), + } + if !is_lowercase_sha256(&state.external_configuration_fingerprint) { + return Err("External Editor 生成账本服务身份指纹无效".to_string()); + } let project_id = game_creator_agent_runtime_context_project_id(root)?; if state.project_id != project_id || state.agent_id != context.agent_id @@ -367,16 +623,20 @@ pub(super) fn prepare_platform_art_generation_runtime_state( canvas_name: &str, generation_prompt: &str, request_body: &serde_json::Value, - external_configuration_fingerprint: &str, + access: &ExternalEditorBindingAccess<'_>, ) -> Result<(PlatformArtGenerationRuntimeState, bool), String> { + let identity = platform_art_generation_access_identity(access)?; let (request_body_json, request_body_sha256) = request_body_json_and_sha256(request_body)?; if let Some(existing) = read_platform_art_generation_runtime_state(root, context)? { + let existing = + migrate_platform_art_generation_external_configuration(root, existing, access)?; if existing.endpoint != endpoint || existing.canvas_name != canvas_name || existing.generation_prompt != generation_prompt || existing.request_body_sha256 != request_body_sha256 || existing.request_body_json != request_body_json - || existing.external_configuration_fingerprint != external_configuration_fingerprint + || existing.external_configuration_fingerprint + != identity.external_configuration_fingerprint { return Err("External Editor 生成账本请求与当前精确动作不一致".to_string()); } @@ -393,7 +653,10 @@ pub(super) fn prepare_platform_art_generation_runtime_state( source: context.source.clone(), action_id: context.action_id.clone(), action_fingerprint: context.action_fingerprint.clone(), - external_configuration_fingerprint: external_configuration_fingerprint.to_string(), + external_configuration_fingerprint: identity.external_configuration_fingerprint, + access_scheme: Some(identity.access_scheme), + external_service_origin: Some(identity.external_service_origin), + platform_owner_user_id: identity.platform_owner_user_id, endpoint: endpoint.to_string(), canvas_name: canvas_name.to_string(), generation_prompt: generation_prompt.to_string(), @@ -773,10 +1036,16 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( pending: &AgentRuntimePendingToolAction, ) -> Result<(), String> { let context = platform_art_generation_runtime_context_from_pending(pending); - let api_base_url = - resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string()); - let external_configuration_fingerprint = - platform_art_generation_external_service_fingerprint(&api_base_url); + let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) + .unwrap_or_else(|_| { + ( + "http://127.0.0.1:1".to_string(), + "durable-test-key".to_string(), + None, + ) + }); + let access = + ExternalEditorBindingAccess::new(&api_base_url, &api_key, platform_session.as_ref())?; let (state, created) = prepare_platform_art_generation_runtime_state( root, &context, @@ -790,7 +1059,7 @@ pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( "assetFolderId": "test-asset-folder", "referenceImageSrcs": [] }), - &external_configuration_fingerprint, + &access, )?; if !created { return Err("External Editor 测试账本已存在".to_string()); @@ -805,10 +1074,16 @@ pub(crate) fn write_platform_art_generation_runtime_prepared_for_test( pending: &AgentRuntimePendingToolAction, ) -> Result<(), String> { let context = platform_art_generation_runtime_context_from_pending(pending); - let api_base_url = - resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string()); - let external_configuration_fingerprint = - platform_art_generation_external_service_fingerprint(&api_base_url); + let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) + .unwrap_or_else(|_| { + ( + "http://127.0.0.1:1".to_string(), + "durable-test-key".to_string(), + None, + ) + }); + let access = + ExternalEditorBindingAccess::new(&api_base_url, &api_key, platform_session.as_ref())?; let (_, created) = prepare_platform_art_generation_runtime_state( root, &context, @@ -822,7 +1097,7 @@ pub(crate) fn write_platform_art_generation_runtime_prepared_for_test( "assetFolderId": "test-asset-folder", "referenceImageSrcs": [] }), - &external_configuration_fingerprint, + &access, )?; if !created { return Err("External Editor 测试账本已存在".to_string()); @@ -892,6 +1167,55 @@ pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test( mod external_generation_state_tests { use super::*; + fn developer_access<'a>( + api_base_url: &'a str, + api_key: &'a str, + ) -> ExternalEditorBindingAccess<'a> { + ExternalEditorBindingAccess::for_developer(api_base_url, api_key) + .expect("create developer binding access") + } + + fn direct_art_spec_runtime_context(project_id: &str) -> PlatformArtGenerationRuntimeContext { + PlatformArtGenerationRuntimeContext { + agent_id: "direct-codex-art".to_string(), + task_id: "direct-codex-art-art-spec".to_string(), + session_id: project_id.to_string(), + run_id: "art-spec".to_string(), + source: "direct-codex".to_string(), + action_id: "direct-taonier-art-spec".to_string(), + action_fingerprint: "direct-taonier-art-v1:icon-spec:art-spec".to_string(), + } + } + + #[test] + fn direct_art_slot_uses_the_same_process_and_cross_process_singleflight() { + let temporary = tempfile::tempdir().expect("create direct slot fixture"); + init_local_game_project_at(temporary.path(), "direct-slot", "Direct 生成槽") + .expect("init direct slot project"); + let context = direct_art_spec_runtime_context("direct-slot"); + + let first = acquire_durable_platform_art_generation_guard(temporary.path(), &context) + .expect("acquire first direct slot") + .expect("direct context requires a durable slot guard"); + let error = acquire_durable_platform_art_generation_guard(temporary.path(), &context) + .err() + .expect("same Direct stage must conflict before remote submission"); + assert!(error.contains("任何远端 POST 前拒绝并发请求"), "{error}"); + drop(first); + assert!( + acquire_durable_platform_art_generation_guard(temporary.path(), &context) + .expect("reacquire released direct slot") + .is_some() + ); + + let mut malformed = context; + malformed.action_fingerprint = "direct-taonier-art-v1:wrong:art-spec".to_string(); + let error = acquire_durable_platform_art_generation_guard(temporary.path(), &malformed) + .err() + .expect("malformed Direct slot identity must fail closed"); + assert!(error.contains("durable slot 身份无效"), "{error}"); + } + #[test] fn external_service_fingerprint_ignores_key_rotation_and_normalizes_trailing_slash() { let original = platform_art_generation_external_configuration_fingerprint( @@ -904,6 +1228,7 @@ mod external_generation_state_tests { ); let different_service = platform_art_generation_external_service_fingerprint( "https://other-editor.example.test", + None, ); assert_eq!(original, rotated); @@ -913,7 +1238,7 @@ mod external_generation_state_tests { #[test] fn versioned_service_identity_distinguishes_verified_and_unverified_legacy_ledgers() { let base_url = "https://editor.example.test/"; - let current = platform_art_generation_external_service_fingerprint(base_url); + let current = platform_art_generation_external_service_fingerprint(base_url, None); let legacy = platform_art_generation_legacy_external_configuration_fingerprint( base_url, "original-key", @@ -924,6 +1249,7 @@ mod external_generation_state_tests { Some(¤t), base_url, "rotated-key", + None, ), PlatformArtGenerationServiceIdentityMatch::Current ); @@ -933,6 +1259,7 @@ mod external_generation_state_tests { Some(&legacy), base_url, "original-key", + None, ), PlatformArtGenerationServiceIdentityMatch::LegacyVerified ); @@ -942,6 +1269,7 @@ mod external_generation_state_tests { Some(&legacy), base_url, "rotated-key", + None, ), PlatformArtGenerationServiceIdentityMatch::LegacyUnverified ); @@ -954,6 +1282,36 @@ mod external_generation_state_tests { ); } + #[test] + fn frozen_platform_service_fingerprint_never_reads_a_switched_global_session() { + let base_url = "https://dev.genarrative.world"; + let _session = crate::platform_session::install_test_platform_session( + "fingerprint-owner-a", + "fingerprint-token-a", + 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"); + replace_platform_session_for_gui_owner( + "fingerprint-owner-b", + "fingerprint-token-b", + base_url, + 2, + ) + .expect("switch global session to owner B"); + let current_b = current_platform_session().expect("owner B is current after switch"); + + let frozen_fingerprint = + platform_art_generation_external_service_fingerprint(base_url, Some(&frozen_a)); + let current_fingerprint = + platform_art_generation_external_service_fingerprint(base_url, Some(¤t_b)); + assert_ne!(frozen_fingerprint, current_fingerprint); + assert_eq!( + frozen_fingerprint, + platform_art_generation_external_service_fingerprint(base_url, Some(&frozen_a)) + ); + } + #[test] fn legacy_external_configuration_fingerprint_migrates_before_key_rotation() { let temporary = crate::tests::canonical_test_tempdir("legacy-fingerprint-"); @@ -967,7 +1325,8 @@ mod external_generation_state_tests { api_base_url, "original-key", ); - let (legacy_state, created) = prepare_platform_art_generation_runtime_state( + let original_access = developer_access(api_base_url, "original-key"); + let (mut legacy_state, created) = prepare_platform_art_generation_runtime_state( root, &context, "/api/external/v1/editor/images/generations", @@ -980,34 +1339,37 @@ mod external_generation_state_tests { "assetFolderId": "asset-folder", "referenceImageSrcs": [] }), - &legacy_fingerprint, + &original_access, ) .expect("prepare legacy generation ledger"); assert!(created); + legacy_state.schema_version = + PLATFORM_ART_GENERATION_RUNTIME_LEGACY_SCHEMA_VERSION.to_string(); + legacy_state.external_configuration_fingerprint = legacy_fingerprint; + legacy_state.access_scheme = None; + legacy_state.external_service_origin = None; + legacy_state.platform_owner_user_id = None; + write_platform_art_generation_runtime_state(root, &legacy_state) + .expect("downgrade fixture to the exact v2 shape"); let migrated = migrate_platform_art_generation_external_configuration( root, legacy_state, - api_base_url, - "original-key", + &original_access, ) .expect("migrate legacy fingerprint"); assert_eq!( migrated.external_configuration_fingerprint, - platform_art_generation_external_service_fingerprint(api_base_url) + platform_art_generation_external_service_fingerprint(api_base_url, None) + ); + let rotated_access = developer_access("https://editor.example.test", "rotated-key"); + validate_platform_art_generation_external_configuration(&migrated, &rotated_access) + .expect("rotated key must recover the migrated operation"); + let other_access = developer_access("https://other-editor.example.test", "rotated-key"); + assert!( + validate_platform_art_generation_external_configuration(&migrated, &other_access) + .is_err() ); - validate_platform_art_generation_external_configuration( - &migrated, - "https://editor.example.test", - "rotated-key", - ) - .expect("rotated key must recover the migrated operation"); - assert!(validate_platform_art_generation_external_configuration( - &migrated, - "https://other-editor.example.test", - "rotated-key", - ) - .is_err()); let persisted = read_platform_art_generation_runtime_state(root, &context) .expect("read migrated ledger") @@ -1015,6 +1377,85 @@ mod external_generation_state_tests { assert_eq!(persisted, migrated); } + #[test] + fn v2_platform_ledger_migrates_only_with_a_verified_frozen_owner() { + let temporary = crate::tests::canonical_test_tempdir("platform-v2-owner-"); + let root = temporary.path(); + init_local_game_project_at(root, "platform-v2-owner", "平台旧账本主体迁移") + .expect("init project"); + let _platform_session = crate::platform_session::install_test_platform_session( + "verified-owner-a", + "verified-owner-token", + "https://editor.example.test", + ); + let frozen = current_platform_session().expect("freeze verified owner A"); + let access = ExternalEditorBindingAccess::for_platform(&frozen) + .expect("create verified owner A access"); + let pending = pending_canvas_generation(root); + let context = platform_art_generation_runtime_context_from_pending(&pending); + let request_body = serde_json::json!({ + "prompt": "恢复平台 v2 请求", + "kind": "spec", + "projectId": "canvas-project", + "assetFolderId": "asset-folder", + "referenceImageSrcs": [] + }); + let (mut verified_v2, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "platform-v2-canvas", + "恢复平台 v2 请求", + &request_body, + &access, + ) + .expect("prepare verified platform ledger"); + assert!(created); + verified_v2.schema_version = + PLATFORM_ART_GENERATION_RUNTIME_LEGACY_SCHEMA_VERSION.to_string(); + verified_v2.access_scheme = None; + verified_v2.external_service_origin = None; + verified_v2.platform_owner_user_id = None; + write_platform_art_generation_runtime_state(root, &verified_v2) + .expect("persist verified platform v2 fixture"); + let migrated = + migrate_platform_art_generation_external_configuration(root, verified_v2, &access) + .expect("migrate account-aware platform v2 ledger"); + assert_eq!( + migrated.platform_owner_user_id.as_deref(), + Some("verified-owner-a") + ); + + remove_platform_art_generation_runtime_state_at(root, &context.agent_id, &context.run_id) + .expect("remove migrated fixture"); + let (mut unowned_v2, _) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "platform-v2-canvas", + "恢复平台 v2 请求", + &request_body, + &access, + ) + .expect("prepare unowned platform ledger fixture"); + unowned_v2.schema_version = + PLATFORM_ART_GENERATION_RUNTIME_LEGACY_SCHEMA_VERSION.to_string(); + unowned_v2.external_configuration_fingerprint = + platform_art_generation_external_service_fingerprint( + "https://editor.example.test", + None, + ); + unowned_v2.access_scheme = None; + unowned_v2.external_service_origin = None; + unowned_v2.platform_owner_user_id = None; + write_platform_art_generation_runtime_state(root, &unowned_v2) + .expect("persist unowned platform v2 fixture"); + let error = + migrate_platform_art_generation_external_configuration(root, unowned_v2, &access) + .expect_err("origin-only v2 platform ledger must not guess an owner"); + assert!(error.contains("无法由当前冻结调用主体安全验证"), "{error}"); + } + fn pending_canvas_generation(root: &Path) -> AgentRuntimePendingToolAction { let mut runtime = start_game_creator_agent_runtime_task_at( root, @@ -1080,10 +1521,7 @@ mod external_generation_state_tests { "assetFolderId": "asset-folder", "referenceImageSrcs": [] }); - let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( - "https://editor.example.test", - "test-api-key", - ); + let access = developer_access("https://editor.example.test", "test-api-key"); let (prepared, created) = prepare_platform_art_generation_runtime_state( root, @@ -1092,7 +1530,7 @@ mod external_generation_state_tests { "generation-ledger-canvas", "生成统一视觉规范图", &request_body, - &configuration_fingerprint, + &access, ) .expect("prepare generation ledger"); assert!(created); @@ -1132,24 +1570,17 @@ mod external_generation_state_tests { .expect_err("spaces are forbidden by the External v1 key contract") .contains("Idempotency-Key") ); - validate_platform_art_generation_external_configuration( - &prepared, - "https://editor.example.test/", - "test-api-key", - ) - .expect("matching External Editor configuration"); - assert!(validate_platform_art_generation_external_configuration( - &prepared, - "https://other-editor.example.test", - "test-api-key", - ) - .is_err()); - validate_platform_art_generation_external_configuration( - &prepared, - "https://editor.example.test", - "different-api-key", - ) - .expect("rotated API Key must keep ownership of the accepted service operation"); + let matching_access = developer_access("https://editor.example.test/", "test-api-key"); + validate_platform_art_generation_external_configuration(&prepared, &matching_access) + .expect("matching External Editor configuration"); + let other_access = developer_access("https://other-editor.example.test", "test-api-key"); + assert!( + validate_platform_art_generation_external_configuration(&prepared, &other_access) + .is_err() + ); + let rotated_access = developer_access("https://editor.example.test", "different-api-key"); + validate_platform_art_generation_external_configuration(&prepared, &rotated_access) + .expect("rotated API Key must keep ownership of the accepted service operation"); assert_eq!( platform_art_generation_runtime_recovery_at(root, &pending) .expect("read prepared recovery"), @@ -1163,7 +1594,7 @@ mod external_generation_state_tests { "generation-ledger-canvas", "生成统一视觉规范图", &request_body, - &configuration_fingerprint, + &access, ) .expect("reload generation ledger"); assert!(!created_again); @@ -1197,7 +1628,7 @@ mod external_generation_state_tests { "generation-ledger-canvas", "生成统一视觉规范图", &serde_json::json!({"prompt": "different request"}), - &configuration_fingerprint, + &access, ) .is_err()); @@ -1218,10 +1649,7 @@ mod external_generation_state_tests { .expect("init project"); let pending = pending_canvas_generation(root); let context = platform_art_generation_runtime_context_from_pending(&pending); - let fingerprint = platform_art_generation_external_configuration_fingerprint( - "https://editor.example.test", - "test-api-key", - ); + let access = developer_access("https://editor.example.test", "test-api-key"); let request_body = serde_json::json!({ "prompt": "生成统一视觉规范图", "kind": "spec", @@ -1236,7 +1664,7 @@ mod external_generation_state_tests { "legacy-generation-canvas", "生成统一视觉规范图", &request_body, - &fingerprint, + &access, ) .expect("prepare legacy generation ledger"); let completed = mark_platform_art_generation_runtime_legacy_completed( @@ -1304,7 +1732,7 @@ mod external_generation_state_tests { "legacy-generation-canvas", "生成统一视觉规范图", &request_body, - &fingerprint, + &access, ) .expect("prepare unsafe legacy generation ledger"); assert!(mark_platform_art_generation_runtime_legacy_completed( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 4ddb4fe01..03e134242 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -262,10 +262,11 @@ pub(crate) use entrypoints::{ chat_with_game_creator_role_agent_stream_for_session_at, configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, - generate_local_game_draft_at, read_game_creator_agent_runtime_at, - read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, - set_game_creator_agent_runtime_update_app_handle, + generate_local_game_draft_at, install_game_creator_manifest_invalidation_event_sink, + read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, + read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, + validate_game_creator_manifest_invalidation_event_sink, }; #[cfg(test)] pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index ebc60d098..659a13c2f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -145,6 +145,15 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( port: u16, token: &str, ) -> Result<(), String> { + let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?; + install_game_creator_manifest_invalidation_event_sink(sink); + Ok(()) +} + +pub(crate) fn validate_game_creator_manifest_invalidation_event_sink( + port: u16, + token: &str, +) -> Result { if port == 0 { return Err("manifest 失效事件接收端口无效".to_string()); } @@ -152,12 +161,16 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("manifest 失效事件接收令牌无效".to_string()); } - *lock_game_creator_manifest_invalidation_event_sink() = - Some(GameCreatorManifestInvalidationEventSink { - port, - token: token.to_string(), - }); - Ok(()) + Ok(GameCreatorManifestInvalidationEventSink { + port, + token: token.to_string(), + }) +} + +pub(crate) fn install_game_creator_manifest_invalidation_event_sink( + sink: GameCreatorManifestInvalidationEventSink, +) { + *lock_game_creator_manifest_invalidation_event_sink() = Some(sink); } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index c15a2e2fc..70ccb6bee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -434,13 +434,17 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at( ".agent/runtime/pending-actions", ".agent/runtime/parallel-read-batches", ".agent/runtime/provider-action-batches", - ".agent/runtime/canvas-generation-requests", ".agent/runtime/cancel", ] { if durable_agent_runtime_recovery_directory_has_entries(&root.join(relative_directory)) { return Ok(true); } } + match collect_agent_owned_platform_art_generation_runtime_identities_at(root) { + Ok(identities) if !identities.is_empty() => return Ok(true), + Err(_) => return Ok(true), + Ok(_) => {} + } if durable_process_session_recovery_exists_at(root) { return Ok(true); } @@ -480,13 +484,211 @@ fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> boo false } -fn cleanup_orphaned_platform_art_generation_runtime_states_at( +fn lowercase_sha256(value: Option<&str>) -> bool { + value.is_some_and(|value| { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + }) +} + +fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value) -> bool { + const ALLOWED_FIELDS: &[&str] = &[ + "schemaVersion", + "projectId", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "actionId", + "actionFingerprint", + "externalConfigurationFingerprint", + "accessScheme", + "externalServiceOrigin", + "platformOwnerUserId", + "endpoint", + "canvasName", + "generationPrompt", + "requestBodySha256", + "requestBodyJson", + "idempotencyKey", + "status", + "operationId", + "pollAfterMs", + "legacyResult", + "createdAt", + "updatedAt", + ]; + if !payload.as_object().is_some_and(|object| { + object + .keys() + .all(|field| ALLOWED_FIELDS.contains(&field.as_str())) + }) { + return false; + } + let Some(request_body_json) = payload + .get("requestBodyJson") + .and_then(serde_json::Value::as_str) + else { + return false; + }; + let expected_sha256 = format!("{:x}", Sha256::digest(request_body_json.as_bytes())); + if payload + .get("requestBodySha256") + .and_then(serde_json::Value::as_str) + != Some(expected_sha256.as_str()) + { + return false; + } + let Ok(request_body) = serde_json::from_str::(request_body_json) else { + return false; + }; + let endpoint = payload.get("endpoint").and_then(serde_json::Value::as_str); + let endpoint_is_valid = matches!( + endpoint, + Some( + "/api/external/v1/editor/images/generations" + | "/api/external/v1/editor/icon-spritesheets/generations" + ) + ); + endpoint_is_valid + && payload + .get("canvasName") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + && payload + .get("generationPrompt") + .and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.trim().is_empty()) + && json_string_field(&request_body, "projectId") + .is_some_and(|value| !value.trim().is_empty()) + && json_string_field(&request_body, "assetFolderId") + .is_some_and(|value| !value.trim().is_empty()) +} + +fn standalone_platform_art_generation_ledger_identity_is_valid( + payload: &serde_json::Value, + project_id: &str, +) -> bool { + let string_field = |name| payload.get(name).and_then(serde_json::Value::as_str); + let principal_is_valid = match string_field("schemaVersion") { + Some(PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION) => { + let Some(service_origin) = string_field("externalServiceOrigin") else { + return false; + }; + let origin_is_canonical = + platform_art_generation_external_service_origin(service_origin) + .is_ok_and(|normalized| normalized == service_origin); + let access_is_valid = match string_field("accessScheme") { + Some("official-platform-v1") => string_field("platformOwnerUserId") + .is_some_and(|owner| !owner.trim().is_empty() && owner.len() <= 256), + Some("external-developer-v1") => payload + .get("platformOwnerUserId") + .is_none_or(serde_json::Value::is_null), + _ => false, + }; + origin_is_canonical && access_is_valid + } + Some("agent-runtime-canvas-generation-request.v2") => { + !payload.as_object().is_some_and(|object| { + object.contains_key("accessScheme") + || object.contains_key("externalServiceOrigin") + || object.contains_key("platformOwnerUserId") + }) + } + _ => false, + }; + if string_field("projectId") != Some(project_id) + || !lowercase_sha256(string_field("externalConfigurationFingerprint")) + || !lowercase_sha256(string_field("requestBodySha256")) + || !platform_art_generation_request_snapshot_is_valid(payload) + || !principal_is_valid + { + return false; + } + let Some(agent_id) = string_field("agentId") else { + return false; + }; + let Some(run_id) = string_field("runId") else { + return false; + }; + let manual_identity_is_valid = if matches!( + agent_id, + "manual-canvas-asset-generate" | "manual-canvas-asset-generate-strict" + ) { + let identity = format!("{agent_id}:{run_id}"); + let current_identity_is_valid = run_id + .strip_prefix("slot-") + .is_some_and(|value| lowercase_sha256(Some(value))) + && string_field("taskId") == Some(identity.as_str()) + && string_field("sessionId") == Some(identity.as_str()) + && string_field("source") == Some("tauri-command") + && string_field("actionId") == Some(identity.as_str()) + && lowercase_sha256(string_field("actionFingerprint")); + let legacy_identity_is_valid = run_id == agent_id + && string_field("taskId") == Some(agent_id) + && string_field("sessionId") == Some(agent_id) + && string_field("source") == Some("tauri-command") + && string_field("actionId") == Some(agent_id) + && string_field("actionFingerprint") == Some(agent_id); + current_identity_is_valid || legacy_identity_is_valid + } else { + false + }; + let direct_identity_is_valid = if agent_id == "direct-codex-art" { + let expected_asset_kind = match run_id { + "art-spec" => Some("icon-spec"), + "game-background" => Some("game-background"), + "art-spritesheet" => Some("art-spritesheet"), + _ => None, + }; + expected_asset_kind.is_some_and(|asset_kind| { + string_field("taskId") == Some(format!("direct-codex-art-{run_id}").as_str()) + && string_field("sessionId") == Some(project_id) + && string_field("source") == Some("direct-codex") + && string_field("actionId") == Some(format!("direct-taonier-{run_id}").as_str()) + && string_field("actionFingerprint") + == Some(format!("direct-taonier-art-v1:{asset_kind}:{run_id}").as_str()) + }) + } else { + false + }; + let idempotency_key_is_valid = string_field("idempotencyKey").is_some_and(|value| { + !value.is_empty() && value.len() <= 128 && value.bytes().all(|byte| byte.is_ascii_graphic()) + }); + let status_is_valid = match string_field("status") { + Some("prepared") => true, + Some("accepted") => string_field("operationId").is_some_and(|value| !value.is_empty()), + Some("legacy-completed") => payload + .get("legacyResult") + .is_some_and(|result| !result.is_null()), + _ => false, + }; + (manual_identity_is_valid || direct_identity_is_valid) + && idempotency_key_is_valid + && status_is_valid + && payload + .get("createdAt") + .and_then(serde_json::Value::as_u64) + .is_some_and(|created| { + created > 0 + && payload + .get("updatedAt") + .and_then(serde_json::Value::as_u64) + .is_some_and(|updated| updated >= created) + }) +} + +fn collect_agent_owned_platform_art_generation_runtime_identities_at( root: &Path, -) -> Result { +) -> Result, String> { let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?; + let project_id = game_creator_agent_runtime_context_project_id(root)?; let agent_entries = match fs::read_dir(&directory) { Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeSet::new()), Err(error) => { return Err(format!( "读取 External Editor 生成账本目录失败:{}: {error}", @@ -561,7 +763,11 @@ fn cleanup_orphaned_platform_art_generation_runtime_states_at( .get("schemaVersion") .and_then(serde_json::Value::as_str) .unwrap_or("(missing)"); - if schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION { + if !matches!( + schema_version, + PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION + | "agent-runtime-canvas-generation-request.v2" + ) { return Err(format!( "External Editor 生成账本版本无效:{file_name}: {schema_version}" )); @@ -588,10 +794,27 @@ fn cleanup_orphaned_platform_art_generation_runtime_states_at( { return Err("External Editor 生成账本路径与内部身份不一致".to_string()); } + if standalone_platform_art_generation_ledger_identity_is_valid(&payload, &project_id) { + continue; + } + if matches!( + agent_id, + "manual-canvas-asset-generate" + | "manual-canvas-asset-generate-strict" + | "direct-codex-art" + ) { + return Err("standalone External Editor 生成账本身份无效".to_string()); + } identities.insert((agent_id.to_string(), run_id.to_string())); } } + Ok(identities) +} +fn cleanup_orphaned_platform_art_generation_runtime_states_at( + root: &Path, +) -> Result { + let identities = collect_agent_owned_platform_art_generation_runtime_identities_at(root)?; let mut removed = 0_usize; for (agent_id, run_id) in identities { if game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &run_id) { @@ -1242,6 +1465,108 @@ pub(crate) fn resume_game_creator_agent_pending_action_for_agent_at( mod orphaned_external_generation_recovery_tests { use super::*; + #[test] + fn standalone_generation_ledgers_do_not_trigger_agent_recovery_or_orphan_cleanup() { + let temporary = crate::tests::canonical_test_tempdir("standalone-generation-scan-"); + let root = temporary.path(); + init_local_game_project_at(root, "standalone-generation-scan", "手工生成账本扫描测试") + .expect("init standalone scan project"); + let project_id = game_creator_agent_runtime_context_project_id(root) + .expect("read standalone scan project id"); + let mut persisted_paths = Vec::new(); + for (index, agent_id) in [ + "manual-canvas-asset-generate", + "manual-canvas-asset-generate-strict", + ] + .into_iter() + .enumerate() + { + let run_id = format!("slot-{}", if index == 0 { "a" } else { "b" }.repeat(64)); + let identity = format!("{agent_id}:{run_id}"); + let request_body = serde_json::json!({ + "prompt": "standalone scanner fixture", + "kind": "spec", + "projectId": "standalone-remote-project", + "assetFolderId": "standalone-remote-folder", + "referenceImageSrcs": [] + }); + let request_body_json = + serde_json::to_string(&request_body).expect("serialize standalone request body"); + let relative_path = format!( + ".agent/runtime/canvas-generation-requests/{}/{}.json", + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(&run_id, "run") + ); + let mut ledger = serde_json::json!({ + "schemaVersion": PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION, + "projectId": project_id, + "agentId": agent_id, + "taskId": identity, + "sessionId": identity, + "runId": run_id, + "source": "tauri-command", + "actionId": identity, + "actionFingerprint": "c".repeat(64), + "externalConfigurationFingerprint": "d".repeat(64), + "accessScheme": "external-developer-v1", + "externalServiceOrigin": "https://editor.example.test", + "platformOwnerUserId": null, + "endpoint": "/api/external/v1/editor/images/generations", + "canvasName": "standalone scanner canvas", + "generationPrompt": "standalone scanner fixture", + "requestBodySha256": format!("{:x}", Sha256::digest(request_body_json.as_bytes())), + "requestBodyJson": request_body_json, + "idempotencyKey": format!("standalone-scanner-{index}"), + "status": "prepared", + "createdAt": 1, + "updatedAt": 1 + }); + if index == 1 { + let object = ledger + .as_object_mut() + .expect("standalone scanner fixture is an object"); + object.insert( + "schemaVersion".to_string(), + serde_json::Value::String( + "agent-runtime-canvas-generation-request.v2".to_string(), + ), + ); + object.remove("accessScheme"); + object.remove("externalServiceOrigin"); + object.remove("platformOwnerUserId"); + } + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "standalone scanner fixture", + &ledger, + 256 * 1024, + ) + .expect("write standalone scanner ledger"); + let path = resolve_local_project_path(root, &relative_path) + .expect("resolve standalone scanner ledger"); + persisted_paths.push(( + path.clone(), + fs::read(path).expect("read standalone ledger"), + )); + } + + assert!( + !has_recoverable_game_creator_agent_background_tasks_at(root) + .expect("standalone ledgers must not look like Agent recovery work") + ); + let resumed = resume_game_creator_agent_background_tasks_at(root) + .expect("standalone ledgers must not block Agent recovery scan"); + assert!(resumed.is_empty()); + for (path, original) in persisted_paths { + assert_eq!( + fs::read(path).expect("standalone ledger remains"), + original, + "Agent orphan cleanup must preserve standalone ledger bytes" + ); + } + } + #[test] fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 52aa4a66d..cd187fe46 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -2202,7 +2202,7 @@ pub(super) fn try_acquire_game_creator_agent_runtime_task_lock_with_wait( } #[cfg(unix)] -pub(super) fn try_open_game_creator_agent_runtime_task_lock_file( +pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file( root: &Path, relative_path: &str, ) -> Result, String> { @@ -2311,7 +2311,7 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file( } #[cfg(windows)] -pub(super) fn try_open_game_creator_agent_runtime_task_lock_file( +pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file( root: &Path, relative_path: &str, ) -> Result, String> { @@ -2442,7 +2442,7 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file( } #[cfg(not(any(unix, windows)))] -pub(super) fn try_open_game_creator_agent_runtime_task_lock_file( +pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file( root: &Path, relative_path: &str, ) -> Result, String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 576347c80..bb6cd8676 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1073,6 +1073,30 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route( max_bytes: usize, read_url_route: &str, ) -> Result, String> { + resolve_canvas_resource_download_with_limit_route_and_fence( + _client, + api_base_url, + bearer_token, + resource, + max_bytes, + read_url_route, + || Ok(()), + ) + .await +} + +pub(crate) async fn resolve_canvas_resource_download_with_limit_route_and_fence( + _client: &reqwest::Client, + api_base_url: &str, + bearer_token: &str, + resource: &serde_json::Value, + max_bytes: usize, + read_url_route: &str, + mut fence: F, +) -> Result, String> +where + F: FnMut() -> Result<(), String>, +{ if max_bytes == 0 { return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string()); } @@ -1091,10 +1115,11 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route( api_base_url, percent_encode_query_component(object_key) ); - ( - Some(resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await?), - true, - ) + fence()?; + let signed_url = + resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await; + fence()?; + (Some(signed_url?), true) } else if let Some(image_src) = image_src.as_deref() { if image_src.starts_with('/') { let read_url = format!( @@ -1102,13 +1127,11 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route( api_base_url, percent_encode_query_component(image_src) ); - ( - Some( - resolve_external_asset_signed_url(&secure_client, bearer_token, read_url) - .await?, - ), - true, - ) + fence()?; + let signed_url = + resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await; + fence()?; + (Some(signed_url?), true) } else if image_src.starts_with("http://") || image_src.starts_with("https://") { (Some(image_src.to_string()), false) } else { @@ -1121,14 +1144,15 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route( return Ok(None); }; let url = validate_external_asset_download_url(&url, api_base_url, came_from_stable_reference)?; - let download_client = - build_external_asset_download_client(&url, api_base_url, came_from_stable_reference) - .await?; - let mut response = download_client - .get(url) - .send() - .await - .map_err(|error| format!("下载画板资产失败:{error}"))?; + fence()?; + let download_client_result = + build_external_asset_download_client(&url, api_base_url, came_from_stable_reference).await; + fence()?; + let download_client = download_client_result?; + fence()?; + let response_result = download_client.get(url).send().await; + fence()?; + let mut response = response_result.map_err(|error| format!("下载画板资产失败:{error}"))?; let status = response.status(); if status.is_redirection() { return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string()); @@ -1160,11 +1184,14 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route( .unwrap_or_default() .min(max_bytes), ); - while let Some(chunk) = response - .chunk() - .await - .map_err(|error| format!("读取画板资产失败:{error}"))? - { + loop { + fence()?; + let chunk = response.chunk().await; + fence()?; + let Some(chunk) = chunk.map_err(|error| format!("读取画板资产失败:{error}"))? + else { + break; + }; let next_len = bytes .len() .checked_add(chunk.len()) @@ -1882,7 +1909,7 @@ mod tests { assert!(!external_editor_api_credentials_override_is_active()); } - fn read_asset_test_request(stream: &mut std::net::TcpStream) { + fn read_asset_test_request(stream: &mut std::net::TcpStream) -> String { stream .set_read_timeout(Some(Duration::from_secs(2))) .expect("set asset test read timeout"); @@ -1895,6 +1922,7 @@ mod tests { } bytes.extend_from_slice(&buffer[..read]); } + String::from_utf8_lossy(&bytes).into_owned() } #[test] @@ -2107,4 +2135,74 @@ mod tests { server.join().expect("join bounded fixture"); assert!(error.contains("下载预算")); } + + #[tokio::test] + async fn canvas_download_revalidates_session_after_signed_url_before_media_get() { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind fenced download fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let signed_url = format!("{base_url}/signed.png"); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut signing, _) = listener.accept().expect("accept signing request"); + request_sender + .send(read_asset_test_request(&mut signing)) + .expect("capture signing request"); + let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string(); + write!( + signing, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .expect("write signing response"); + listener + .set_nonblocking(true) + .expect("set fenced fixture nonblocking"); + let deadline = std::time::Instant::now() + Duration::from_millis(200); + while std::time::Instant::now() < deadline { + match listener.accept() { + Ok((mut media, _)) => { + request_sender + .send(read_asset_test_request(&mut media)) + .expect("capture forbidden media request"); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("accept fenced media request: {error}"), + } + } + }); + + let mut fence_calls = 0_u8; + let error = match resolve_canvas_resource_download_with_limit_route_and_fence( + &reqwest::Client::new(), + &base_url, + "test-api-key", + &serde_json::json!({"objectKey": "stable/slice.png"}), + 1024, + "/api/external/v1/assets/read-url", + || { + fence_calls = fence_calls.saturating_add(1); + if fence_calls == 2 { + Err("session-switched-after-read-url".to_string()) + } else { + Ok(()) + } + }, + ) + .await + { + Err(error) => error, + Ok(_) => panic!("session switch after signed URL must stop before media GET"), + }; + server.join().expect("join fenced download fixture"); + + assert_eq!(error, "session-switched-after-read-url"); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!(requests.len(), 1, "media GET is forbidden after switch"); + assert!(requests[0].starts_with("GET /api/external/v1/assets/read-url?")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 048986210..d4dab7aab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1511,6 +1511,11 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { check_game_creator_llm_config_from_config() } +#[tauri::command] +pub(crate) fn read_platform_account_session_generation() -> u64 { + current_platform_session_generation() +} + #[tauri::command] pub(crate) fn install_platform_account_session( user_id: String, @@ -1518,20 +1523,20 @@ pub(crate) fn install_platform_account_session( 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( + validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + install_external_agent_runner_platform_session( &user_id, &access_token, &api_base_url, generation, - ); - Ok(()) + )?; + install_platform_session(&user_id, &access_token, &api_base_url, generation) } #[tauri::command] pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> { + clear_external_agent_runner_platform_session(generation)?; clear_platform_session(generation); - let _ = clear_external_agent_runner_platform_session(generation); Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index e0dbd9757..deb01da59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2217,32 +2217,15 @@ fn main() { format!("获取 GUI owner 锁失败:{error}"), ) })?; + let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string(); app.manage(gui_owner_lock); if let Some(path) = setup_log.as_deref() { let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin"); } - ensure_external_agent_runner_started_for_gui() - .inspect_err(|error| { - if let Some(path) = setup_log.as_deref() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - let _ = append_bounded_diagnostic_line( - path, - &format!("startup.runner.start.failed details={details}"), - ); - show_startup_error_dialog(path); - } - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("启动 Agent Runner 失败:{error}"), - ) - })?; set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); let manifest_event_sink = start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; - attach_external_agent_runner_gui_owner(&manifest_event_sink) + attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch) .inspect_err(|error| { if let Some(path) = setup_log.as_deref() { let details = @@ -2305,6 +2288,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, install_platform_account_session, clear_platform_account_session, read_game_creator_app_config, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index a028d2c08..e773d1875 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,3 +1,4 @@ +use sha2::{Digest, Sha256}; use std::sync::{Mutex, OnceLock}; #[derive(Clone, Debug, Eq, PartialEq)] @@ -86,6 +87,27 @@ pub(crate) fn install_platform_session( api_base_url: &str, generation: u64, ) -> Result<(), String> { + let snapshot = + validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + install_platform_session_in( + &mut current, + &snapshot.user_id, + &snapshot.access_token, + &snapshot.api_base_url, + snapshot.generation, + ); + Ok(()) +} + +fn validated_platform_session_snapshot( + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) -> Result { if editor_api_mode() == EditorApiMode::ExternalDeveloper { return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string()); } @@ -98,17 +120,62 @@ pub(crate) fn install_platform_session( if access_token.is_empty() || access_token.len() > 16 * 1024 { return Err("陶泥儿登录凭据无效".to_string()); } + Ok(PlatformSessionSnapshot { + user_id: user_id.to_string(), + access_token: access_token.to_string(), + api_base_url, + generation, + }) +} + +pub(crate) fn validate_platform_session_input( + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) -> Result<(), String> { + validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ()) +} + +pub(crate) fn replace_platform_session_for_gui_owner( + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) -> Result<(), String> { + let snapshot = + validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + current.generation = snapshot.generation; + current.snapshot = Some(snapshot); + Ok(()) +} + +pub(crate) fn install_platform_session_checked( + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) -> Result<(), String> { + let snapshot = + validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; 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, + &snapshot.user_id, + &snapshot.access_token, + &snapshot.api_base_url, + snapshot.generation, ); - Ok(()) + if current.snapshot.as_ref() == Some(&snapshot) { + Ok(()) + } else { + Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string()) + } } fn normalize_platform_api_base_url(value: &str) -> Result { @@ -144,6 +211,26 @@ pub(crate) fn clear_platform_session(generation: u64) { clear_platform_session_in(&mut current, generation); } +pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) { + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + current.generation = generation; + current.snapshot = None; +} + +pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> { + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + clear_platform_session_in(&mut current, generation); + if current.generation == generation && current.snapshot.is_none() { + Ok(()) + } else { + Err("authentication-required: 平台登出 generation 已过期".to_string()) + } +} + pub(crate) fn current_platform_session() -> Option { platform_session() .lock() @@ -152,6 +239,13 @@ pub(crate) fn current_platform_session() -> Option { .clone() } +pub(crate) fn current_platform_session_generation() -> u64 { + platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .generation +} + pub(crate) fn validate_platform_session_snapshot( expected: &PlatformSessionSnapshot, ) -> Result<(), String> { @@ -165,6 +259,53 @@ pub(crate) fn validate_platform_session_snapshot( } } +pub(crate) fn with_validated_platform_session_fingerprint( + expected_user_id: &str, + expected_api_base_url: &str, + expected_generation: u64, + expected_access_token_sha256: &str, + action: impl FnOnce() -> Result, +) -> Result { + let lease = acquire_validated_platform_session_fingerprint( + expected_user_id, + expected_api_base_url, + expected_generation, + expected_access_token_sha256, + )?; + let result = action(); + drop(lease); + result +} + +pub(crate) struct ValidatedPlatformSessionLease { + _guard: std::sync::MutexGuard<'static, PlatformSessionState>, +} + +pub(crate) fn acquire_validated_platform_session_fingerprint( + expected_user_id: &str, + expected_api_base_url: &str, + expected_generation: u64, + expected_access_token_sha256: &str, +) -> Result { + let current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let matches = current.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.user_id == expected_user_id + && snapshot.api_base_url == expected_api_base_url + && snapshot.generation == expected_generation + && format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes())) + == expected_access_token_sha256 + }); + if !matches { + return Err( + "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" + .to_string(), + ); + } + Ok(ValidatedPlatformSessionLease { _guard: current }) +} + fn platform_session_snapshot_matches( current: Option<&PlatformSessionSnapshot>, expected: &PlatformSessionSnapshot, @@ -342,6 +483,22 @@ mod tests { ); } + #[test] + fn current_generation_preserves_the_floor_after_session_clear() { + let _session = clear_test_platform_session(); + install_platform_session( + "generation-floor-user", + "generation-floor-token", + "https://dev.genarrative.world", + 41, + ) + .expect("install session generation floor"); + clear_platform_session(42); + + assert_eq!(current_platform_session_generation(), 42); + assert!(current_platform_session().is_none()); + } + #[test] fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() { assert_eq!( @@ -407,4 +564,53 @@ mod tests { )); } } + + #[test] + fn validated_session_lease_linearizes_local_commit_with_account_switch() { + let _session = install_test_platform_session( + "lease-user-a", + "lease-token-a", + "https://dev.genarrative.world", + ); + let expected = current_platform_session().expect("current lease session"); + let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes())); + let lease = acquire_validated_platform_session_fingerprint( + &expected.user_id, + &expected.api_base_url, + expected.generation, + &token_sha256, + ) + .expect("acquire validated session lease"); + let (started_sender, started_receiver) = std::sync::mpsc::channel(); + let (finished_sender, finished_receiver) = std::sync::mpsc::channel(); + let switcher = std::thread::spawn(move || { + started_sender.send(()).expect("signal account switch"); + install_platform_session( + "lease-user-b", + "lease-token-b", + "https://dev.genarrative.world", + 2, + ) + .expect("switch account after lease release"); + finished_sender.send(()).expect("signal switched account"); + }); + started_receiver + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("switcher started"); + assert!( + finished_receiver + .recv_timeout(std::time::Duration::from_millis(100)) + .is_err(), + "account switch must wait until the synchronous local commit lease is released" + ); + drop(lease); + finished_receiver + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("account switch completed after lease release"); + switcher.join().expect("join account switcher"); + assert_eq!( + current_platform_session().map(|session| session.user_id), + Some("lease-user-b".to_string()) + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 56a9cc65e..1012d6881 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -984,6 +984,27 @@ fn take_agent_db_record_failure_injection( } } +#[cfg(test)] +fn take_agent_db_record_after_sync_failure_injection( + root: &Path, + record_type: Option<&str>, +) -> Result<(), String> { + let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record-after-sync"); + match fs::read_to_string(&failure_path) { + Ok(expected_record_type) if record_type == Some(expected_record_type.trim()) => { + fs::remove_file(&failure_path) + .map_err(|error| format!("清理 Agent DB 测试落盘后失败注入标记失败:{error}"))?; + Err(format!( + "测试注入 Agent DB 记录已落盘后返回失败:{}", + expected_record_type.trim() + )) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("读取 Agent DB 测试落盘后失败注入标记失败:{error}")), + } +} + #[cfg(test)] fn take_conversation_audit_failure_injection(root: &Path, message_id: &str) -> Result<(), String> { let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record"); @@ -1027,7 +1048,17 @@ fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Re verify_agent_db_storage_current(&storage)?; let line = serialize_agent_db_record(record)?; validate_agent_db_append_class_record_size(append_class, &line)?; - append_agent_db_classified_line_unlocked(&mut storage, &line, append_class) + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?; + #[cfg(test)] + take_agent_db_record_after_sync_failure_injection( + root, + serde_json::from_str::(&line) + .ok() + .as_ref() + .and_then(|record| record.get("recordType")) + .and_then(serde_json::Value::as_str), + )?; + Ok(()) } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 001590c50..30a4508bd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -12,11 +12,44 @@ const ASSET_CANVAS_SERVICE_IDENTITY_CONFIRMATION_TTL_MILLIS: u64 = 10 * 60 * 1_0 const ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor"; const ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX: &str = "generated-character-drafts"; const ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE: &str = "asset-canvas-references"; +const ASSET_CANVAS_PLATFORM_ACCESS_SCHEME: &str = "official-platform-v1"; +const ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME: &str = "external-developer-v1"; static ASSET_CANVAS_GENERATION_LOCKS: OnceLock< tokio::sync::Mutex>>>, > = OnceLock::new(); +#[cfg(test)] +#[derive(Clone)] +struct AssetCanvasAccountBoundWriteTestBarrier { + entered: Arc, + release: Arc, +} + +#[cfg(test)] +thread_local! { + static ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn install_asset_canvas_account_bound_write_test_barrier( + barrier: AssetCanvasAccountBoundWriteTestBarrier, +) { + ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER.with(|slot| { + *slot.borrow_mut() = Some(barrier); + }); +} + +#[cfg(test)] +fn wait_asset_canvas_account_bound_write_test_barrier() { + let barrier = + ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER.with(|slot| slot.borrow_mut().take()); + if let Some(barrier) = barrier { + barrier.entered.wait(); + barrier.release.wait(); + } +} + #[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct GenerateAssetCanvasImageInput { @@ -262,6 +295,8 @@ struct AssetCanvasGenerationLedger { reference_states: Vec, resolved_reference_ids: Vec, #[serde(default)] + access_scheme: Option, + #[serde(default)] api_identity_scheme: Option, #[serde(default, alias = "externalConfigurationFingerprint")] api_identity_fingerprint: Option, @@ -374,6 +409,36 @@ fn validate_reference_binding_access( }) } +async fn acquire_reference_binding_singleflight( + root: &Path, + binding_key_sha256: &str, + access: &ExternalEditorBindingAccess<'_>, +) -> Result { + acquire_external_editor_resource_binding_singleflight(root, binding_key_sha256, access) + .await + .map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) + }) +} + +fn install_registered_reference_binding( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + index: usize, + binding: ExternalEditorResourceBinding, +) -> Result { + let Some(remote_resource_id) = binding.remote_resource_id else { + return Ok(false); + }; + ledger.reference_states[index].stable_reference = Some(remote_resource_id); + ledger.reference_states[index].asset_object_id = Some(binding.asset_object_id); + ledger.reference_states[index].upload_bucket = None; + ledger.reference_states[index].upload_object_key = None; + ledger.reference_states[index].upload_completed = true; + write_generation_ledger(root, ledger).map_err(|_| ReferencePreparationError::Internal)?; + Ok(true) +} + struct CanvasGenerationApiMode { api_key: String, } @@ -384,35 +449,107 @@ impl CanvasGenerationApiMode { } } -fn resolve_generation_api_mode() -> Result<(String, CanvasGenerationApiMode), String> { - let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; - Ok((api_base_url, CanvasGenerationApiMode { api_key })) +fn resolve_generation_api_mode() -> Result< + ( + String, + CanvasGenerationApiMode, + Option, + ), + String, +> { + let (api_base_url, api_key, platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + Ok(( + api_base_url, + CanvasGenerationApiMode { api_key }, + platform_session, + )) +} + +fn initialize_generation_access_identity( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + api_base_url: &str, + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + with_frozen_generation_platform_session_commit(session, || { + if ledger.access_scheme.is_some() + || ledger.api_identity_scheme.is_some() + || ledger.api_identity_fingerprint.is_some() + || ledger.platform_owner_user_id.is_some() + || ledger.platform_owner_api_base_url.is_some() + { + return Err("素材画布首次访问主体只能写入一次".to_string()); + } + ledger.access_scheme = Some( + if session.is_some() { + ASSET_CANVAS_PLATFORM_ACCESS_SCHEME + } else { + ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME + } + .to_string(), + ); + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some( + platform_art_generation_external_service_fingerprint(api_base_url, session), + ); + if let Some(session) = session { + ledger.platform_owner_user_id = Some(session.user_id.clone()); + ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); + } + write_generation_ledger(root, ledger) + }) } fn bind_generation_platform_owner( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, + ledger: &AssetCanvasGenerationLedger, session: Option<&PlatformSessionSnapshot>, ) -> Result<(), String> { - let Some(session) = session else { - return Ok(()); - }; + with_frozen_generation_platform_session_commit(session, || { + match ( + ledger.access_scheme.as_deref(), + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + session, + ) { + ( + Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME), + Some(user_id), + Some(api_base_url), + Some(session), + ) if user_id == session.user_id && api_base_url == session.api_base_url => Ok(()), + (Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME), None, None, None) => Ok(()), + _ => Err( + "authentication-required: 此素材生成缺少完整访问主体或属于其他陶泥儿账号,当前账号不能恢复或提交".to_string(), + ), + } + }) +} + +fn validate_generation_access_identity( + ledger: &AssetCanvasGenerationLedger, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { match ( + ledger.access_scheme.as_deref(), ledger.platform_owner_user_id.as_deref(), ledger.platform_owner_api_base_url.as_deref(), + platform_session, ) { - (None, None) if ledger.operation_id.is_none() && ledger.remote_result.is_none() => { - ledger.platform_owner_user_id = Some(session.user_id.clone()); - ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); - write_generation_ledger(root, ledger) - } - (Some(user_id), Some(api_base_url)) - if user_id == session.user_id && api_base_url == session.api_base_url => + ( + Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME), + Some(owner_user_id), + Some(owner_api_base_url), + Some(session), + ) if owner_user_id == session.user_id.as_str() + && owner_api_base_url == session.api_base_url.as_str() => { Ok(()) } + (Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME), None, None, None) => Ok(()), _ => Err( - "authentication-required: 此素材生成属于其他陶泥儿账号,当前账号不能恢复或提交" + "authentication-required: 素材画布访问模式、服务地址或账本 owner 与当前调用主体不一致" .to_string(), ), } @@ -427,9 +564,29 @@ fn ensure_frozen_generation_platform_session( Ok(()) } -fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String { +fn with_frozen_generation_platform_session_commit( + session: Option<&PlatformSessionSnapshot>, + action: impl FnOnce() -> Result, +) -> Result { + let Some(session) = session else { + return action(); + }; + with_validated_platform_session_fingerprint( + &session.user_id, + &session.api_base_url, + session.generation, + &asset_canvas_sha256(session.access_token.as_bytes()), + action, + ) +} + +fn canvas_api_identity_fingerprint( + api_base_url: &str, + mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, +) -> String { let _ = mode; - platform_art_generation_external_service_fingerprint(api_base_url) + platform_art_generation_external_service_fingerprint(api_base_url, platform_session) } #[derive(Clone, Debug, Eq, PartialEq)] @@ -450,6 +607,7 @@ fn generation_service_identity_snapshot_sha256( "operationId": ledger.operation_id, "endpoint": ledger.endpoint, "requestBodySha256": ledger.request_body_sha256, + "accessScheme": ledger.access_scheme, "apiIdentityScheme": ledger.api_identity_scheme, "apiIdentityFingerprint": ledger.api_identity_fingerprint, }); @@ -459,7 +617,10 @@ fn generation_service_identity_snapshot_sha256( } fn generation_has_service_identity_evidence(ledger: &AssetCanvasGenerationLedger) -> bool { - ledger.canvas_context.is_some() + ledger.access_scheme.is_some() + || ledger.platform_owner_user_id.is_some() + || ledger.platform_owner_api_base_url.is_some() + || ledger.canvas_context.is_some() || ledger.endpoint.is_some() || ledger.request_body_json.is_some() || ledger.operation_id.is_some() @@ -467,6 +628,13 @@ fn generation_has_service_identity_evidence(ledger: &AssetCanvasGenerationLedger || ledger.phase != GenerationLedgerPhase::ContextPreparing } +fn validate_generation_service_identity_owner( + ledger: &AssetCanvasGenerationLedger, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + validate_generation_access_identity(ledger, platform_session) +} + fn public_service_identity_confirmation( ledger: &AssetCanvasGenerationLedger, service_origin: String, @@ -487,13 +655,34 @@ fn prepare_generation_service_identity( ledger: &mut AssetCanvasGenerationLedger, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { - let fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); + with_frozen_generation_platform_session_commit(platform_session, || { + prepare_generation_service_identity_under_lease( + root, + ledger, + api_base_url, + api_mode, + platform_session, + ) + }) +} + +fn prepare_generation_service_identity_under_lease( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result { + validate_generation_service_identity_owner(ledger, platform_session)?; + let fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); let identity_match = classify_platform_art_generation_service_identity( ledger.api_identity_scheme.as_deref(), ledger.api_identity_fingerprint.as_deref(), api_base_url, api_mode.bearer_token(), + platform_session, ); match identity_match { PlatformArtGenerationServiceIdentityMatch::Current @@ -685,6 +874,22 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() { return Err("素材画布私有生成账本服务身份无效".to_string()); } + if ledger.access_scheme.as_deref().is_some_and(|scheme| { + !matches!( + scheme, + ASSET_CANVAS_PLATFORM_ACCESS_SCHEME | ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME + ) + }) || ledger.platform_owner_user_id.as_ref().is_some_and(|value| { + value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) + }) || ledger + .platform_owner_api_base_url + .as_ref() + .is_some_and(|value| { + value.trim().is_empty() || value.len() > 2_048 || value.chars().any(char::is_control) + }) + { + return Err("素材画布私有生成账本访问主体无效".to_string()); + } if let Some(confirmation) = ledger.service_identity_confirmation.as_ref() { validate_safe_revision( confirmation.expires_at, @@ -758,6 +963,8 @@ fn write_generation_ledger( ) -> Result<(), String> { ledger.updated_at = asset_canvas_now(); validate_generation_ledger(ledger)?; + #[cfg(test)] + wait_asset_canvas_account_bound_write_test_barrier(); write_agent_runtime_json_sidecar_with_max_bytes( root, &generation_ledger_relative_path(&ledger.generation_id), @@ -1070,6 +1277,8 @@ fn validate_generation_identity_uniqueness( fn validate_and_prepare_ledger( root: &Path, input: &GenerateAssetCanvasImageInput, + api_base_url: &str, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { for (value, label) in [ (&input.draft_id, "draftId"), @@ -1179,6 +1388,7 @@ fn validate_and_prepare_ledger( requested_reference_resource_ids: references, reference_states: Vec::new(), resolved_reference_ids: Vec::new(), + access_scheme: None, api_identity_scheme: None, api_identity_fingerprint: None, platform_owner_user_id: None, @@ -1199,7 +1409,7 @@ fn validate_and_prepare_ledger( created_at: now, updated_at: now, }; - write_generation_ledger(root, &mut ledger)?; + initialize_generation_access_identity(root, &mut ledger, api_base_url, platform_session)?; Ok(ledger) } @@ -1336,24 +1546,27 @@ async fn request_upload_ticket( client .post(format!("{api_base_url}{endpoint}")) .json(&serde_json::json!({ - "legacyPrefix": ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX, - "pathSegments": ["editor", ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE, ledger.project_id.as_str(), ledger.draft_id.as_str(), ledger.generation_id.as_str()], - "fileName": material.file_name, - "contentType": material.media_type, - "access": "private", - "maxSizeBytes": material.bytes.as_ref().map(Vec::len), - "successActionStatus": 204, - })), + "legacyPrefix": ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX, + "pathSegments": [ + "editor", + ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE, + ledger.project_id.as_str(), + material.local_asset_id.as_deref().unwrap_or(ledger.draft_id.as_str()), + material.sha256.as_deref().unwrap_or(ledger.generation_id.as_str()) + ], + "fileName": material.file_name, + "contentType": material.media_type, + "access": "private", + "maxSizeBytes": material.bytes.as_ref().map(Vec::len), + "successActionStatus": 204, + })), api_mode, ), "创建参考资源上传凭证", ) .await .map_err(|error| { - ReferencePreparationError::from_http_error( - &error, - ReferencePreparationError::TicketFailed, - ) + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::TicketFailed) })?; let upload = external_editor_response_data(&payload) .get("upload") @@ -1452,6 +1665,7 @@ async fn register_uploaded_image_reference( material: &ReferenceMaterial, object_key: &str, asset_object_id: &str, + resource_binding_key: &str, ) -> Result { let context = ledger .canvas_context @@ -1478,6 +1692,10 @@ async fn register_uploaded_image_reference( "{api_base_url}{}", resolve_platform_editor_api_route(&endpoint) )) + .header( + "Idempotency-Key", + format!("game-creator-resource-{resource_binding_key}"), + ) .json(&serde_json::json!({ "imageSrc": format!("/{}", object_key.trim_start_matches('/')), "objectKey": object_key, @@ -1487,8 +1705,9 @@ async fn register_uploaded_image_reference( "sourceType": "uploaded", "assetKind": asset_kind, "generationInputs": { - "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, - "operationId": ledger.generation_id, + "source": "game-creator-account-binding", + "localAssetId": material.local_asset_id, + "sourceSha256": material.sha256, }, })), api_mode, @@ -1584,7 +1803,20 @@ async fn ensure_reference_states( } }; - if let Some(source_identity) = source_identity.as_ref() { + let resource_binding_key = source_identity + .as_ref() + .map(|source_identity| { + external_editor_resource_binding_key_sha256( + &ledger.project_id, + &project_binding.binding_key_sha256, + &project_binding.remote_project_id, + source_identity, + ) + .map_err(|_| ReferencePreparationError::Internal) + }) + .transpose()?; + + let _reference_binding_guard = if let Some(source_identity) = source_identity.as_ref() { if let Some(binding) = read_external_editor_resource_binding_at( root, &ledger.project_id, @@ -1594,13 +1826,7 @@ async fn ensure_reference_states( ) .map_err(|_| ReferencePreparationError::Internal)? { - if let Some(remote_resource_id) = binding.remote_resource_id { - ledger.reference_states[index].stable_reference = Some(remote_resource_id); - ledger.reference_states[index].asset_object_id = Some(binding.asset_object_id); - ledger.reference_states[index].upload_bucket = None; - ledger.reference_states[index].upload_object_key = None; - ledger.reference_states[index].upload_completed = true; - write_generation_ledger(root, ledger)?; + if install_registered_reference_binding(root, ledger, index, binding)? { continue; } } @@ -1609,7 +1835,29 @@ async fn ensure_reference_states( // resource, so a cache miss must fall back to the local formal file. ledger.reference_states[index].stable_reference = None; write_generation_ledger(root, ledger)?; - } + let binding_key = resource_binding_key + .as_deref() + .ok_or(ReferencePreparationError::Internal)?; + let guard = + acquire_reference_binding_singleflight(root, binding_key, &binding_access).await?; + validate_reference_binding_access(&binding_access)?; + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + ) + .map_err(|_| ReferencePreparationError::Internal)? + { + if install_registered_reference_binding(root, ledger, index, binding)? { + continue; + } + } + Some(guard) + } else { + None + }; if ledger.reference_states[index].stable_reference.is_some() { let requires_registered_source = ledger.intent == AssetCanvasIntent::Refine && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); @@ -1664,15 +1912,16 @@ async fn ensure_reference_states( &material, &object_key, &asset_object_id, + resource_binding_key + .as_deref() + .ok_or(ReferencePreparationError::Internal)?, ) - .await; - validate_reference_binding_access(&binding_access)?; - registered_reference? + .await?; + registered_reference } else { object_key.clone() }; if let Some(source_identity) = source_identity.as_ref() { - validate_reference_binding_access(&binding_access)?; let binding = new_external_editor_resource_binding( &ledger.project_id, &principal, @@ -1696,6 +1945,7 @@ async fn ensure_reference_states( .map_err(|_| ReferencePreparationError::Internal)?; write_external_editor_resource_binding_at(root, &binding) .map_err(|_| ReferencePreparationError::Internal)?; + validate_reference_binding_access(&binding_access)?; } ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); @@ -1747,15 +1997,16 @@ async fn ensure_reference_states( &material, &ticket.object_key, &asset_object_id, + resource_binding_key + .as_deref() + .ok_or(ReferencePreparationError::Internal)?, ) - .await; - validate_reference_binding_access(&binding_access)?; - registered_reference? + .await?; + registered_reference } else { ticket.object_key.clone() }; if let Some(source_identity) = source_identity.as_ref() { - validate_reference_binding_access(&binding_access)?; let binding = new_external_editor_resource_binding( &ledger.project_id, &principal, @@ -1779,6 +2030,7 @@ async fn ensure_reference_states( .map_err(|_| ReferencePreparationError::Internal)?; write_external_editor_resource_binding_at(root, &binding) .map_err(|_| ReferencePreparationError::Internal)?; + validate_reference_binding_access(&binding_access)?; } ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); @@ -2102,17 +2354,10 @@ fn classify_canvas_generation_initial_response( async fn wait_for_canvas_generation_result( client: &reqwest::Client, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, + access: &ExternalEditorBindingAccess<'_>, submission_payload: &serde_json::Value, ) -> Result { - wait_for_external_generation_result( - client, - api_base_url, - api_mode.bearer_token(), - submission_payload, - ) - .await + wait_for_external_generation_result_with_access(client, access, submission_payload).await } async fn classify_canvas_submit_error(response: reqwest::Response) -> (&'static str, bool) { @@ -2183,6 +2428,7 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { return committed_execution_from_private_result(root, &ledger); } + bind_generation_platform_owner(&ledger, platform_session)?; migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.phase == GenerationLedgerPhase::Failed { return Err(sanitized_generation_error( @@ -2190,10 +2436,16 @@ async fn reconcile_generation( )); } ensure_generation_draft_active(root, &ledger)?; - bind_generation_platform_owner(root, &mut ledger, platform_session)?; ensure_frozen_generation_platform_session(platform_session)?; - let configuration_fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); - match prepare_generation_service_identity(root, &mut ledger, api_base_url, api_mode) { + let configuration_fingerprint = + canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); + match prepare_generation_service_identity( + root, + &mut ledger, + api_base_url, + api_mode, + platform_session, + ) { Ok(CanvasServiceIdentityDecision::Ready) => {} Ok(CanvasServiceIdentityDecision::ConfirmationRequired(_)) => { return Err( @@ -2226,6 +2478,8 @@ async fn reconcile_generation( .timeout(Duration::from_secs(35 * 60)) .build() .map_err(|_| "无法创建图片生成提交客户端".to_string())?; + let poll_access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; if matches!( ledger.phase, @@ -2331,9 +2585,10 @@ async fn reconcile_generation( return Err(sanitized_generation_error("submit-result-unknown")); } }; - ensure_frozen_generation_platform_session(platform_session)?; + let post_submit_session = ensure_frozen_generation_platform_session(platform_session); let status = response.status(); if !status.is_success() { + post_submit_session?; let (code, reconciliation) = classify_canvas_submit_error(response).await; let reconciliation = preserve_submit_reconciliation(&ledger.phase, code, reconciliation); @@ -2356,6 +2611,7 @@ async fn reconcile_generation( return Err(sanitized_generation_error("submit-result-unknown")); } }; + let post_body_session = ensure_frozen_generation_platform_session(platform_session); match classify_canvas_generation_initial_response(status, &submission) { Ok(CanvasGenerationInitialResponse::Async(submission)) => { let operation_id = @@ -2365,16 +2621,12 @@ async fn reconcile_generation( ledger.poll_after_ms = Some(external_generation_poll_after_ms(&submission)); set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; publish_public_phase(root, &mut ledger, emit)?; + post_submit_session?; + post_body_session?; + ensure_frozen_generation_platform_session(platform_session)?; set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; publish_public_phase(root, &mut ledger, emit)?; - match wait_for_canvas_generation_result( - &client, - api_base_url, - api_mode, - &submission, - ) - .await - { + match wait_for_canvas_generation_result(&client, &poll_access, &submission).await { Ok(result) => { ensure_frozen_generation_platform_session(platform_session)?; result @@ -2400,10 +2652,10 @@ async fn reconcile_generation( "operationId": operation_id, "pollAfterMs": ledger.poll_after_ms.unwrap_or(2_000), }); + poll_access.validate_frozen_session()?; set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; publish_public_phase(root, &mut ledger, emit)?; - match wait_for_canvas_generation_result(&client, api_base_url, api_mode, &submission).await - { + match wait_for_canvas_generation_result(&client, &poll_access, &submission).await { Ok(result) => { ensure_frozen_generation_platform_session(platform_session)?; result @@ -2448,25 +2700,24 @@ async fn reconcile_generation( GenerationLedgerPhase::RemoteCompleted | GenerationLedgerPhase::ReconciliationRequired ) && ledger.commit_result.is_none() { - let staged_exists = synchronize_staged_image_revision(root, &mut ledger)?; + let staged_exists = + with_frozen_generation_platform_session_commit(platform_session, || { + synchronize_staged_image_revision(root, &mut ledger) + })?; if !staged_exists { let remote = ledger .remote_result .as_ref() .ok_or_else(|| "远端完成账本缺少稳定引用".to_string())?; - let download_result = resolve_canvas_resource_download( + let download_result = crate::agent::resolve_canvas_resource_download_with_access( &client, - api_base_url, - api_mode.bearer_token(), + &poll_access, &serde_json::json!({ "objectKey": remote.object_key }), + 20 * 1024 * 1024, ) .await; let download = match download_result { - Ok(Some(download)) => { - ensure_frozen_generation_platform_session(platform_session)?; - ensure_generation_draft_active(root, &ledger)?; - download - } + Ok(Some(download)) => download, _ => { mark_generation_error( root, @@ -2480,81 +2731,89 @@ async fn reconcile_generation( )); } }; - let staged = stage_asset_canvas_image_with_token_at( - root, - &StageAssetCanvasImageInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: ledger.project_id.clone(), - draft_id: ledger.draft_id.clone(), - expected_draft_revision: ledger - .current_draft_revision - .unwrap_or(ledger.expected_draft_revision), - media_type: download.media_type, - bytes: download.bytes, - }, - Some(&ledger.staged_image_token), - )?; - if staged.status != "staged" { - mark_generation_error( + with_frozen_generation_platform_session_commit(platform_session, || { + ensure_generation_draft_active(root, &ledger)?; + let staged = stage_asset_canvas_image_with_token_at( + root, + &StageAssetCanvasImageInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: ledger.project_id.clone(), + draft_id: ledger.draft_id.clone(), + expected_draft_revision: ledger + .current_draft_revision + .unwrap_or(ledger.expected_draft_revision), + media_type: download.media_type, + bytes: download.bytes, + }, + Some(&ledger.staged_image_token), + )?; + if staged.status != "staged" { + mark_generation_error( + root, + &mut ledger, + true, + "download-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error( + "download-reconciliation-required", + )); + } + ledger.current_draft_revision = Some(staged.draft_revision); + ledger.staged_draft_revision = Some(staged.draft_revision); + write_generation_ledger(root, &mut ledger)?; + set_private_phase( root, &mut ledger, - true, - "download-reconciliation-required", - emit, + GenerationLedgerPhase::MediaDownloaded, + None, )?; - return Err(sanitized_generation_error( - "download-reconciliation-required", - )); - } - ledger.current_draft_revision = Some(staged.draft_revision); - ledger.staged_draft_revision = Some(staged.draft_revision); - write_generation_ledger(root, &mut ledger)?; + publish_public_phase(root, &mut ledger, emit) + })?; + } else { + with_frozen_generation_platform_session_commit(platform_session, || { + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::MediaDownloaded, + None, + )?; + publish_public_phase(root, &mut ledger, emit) + })?; } - set_private_phase( - root, - &mut ledger, - GenerationLedgerPhase::MediaDownloaded, - None, - )?; - publish_public_phase(root, &mut ledger, emit)?; } if ledger.phase == GenerationLedgerPhase::MediaDownloaded { - ensure_frozen_generation_platform_session(platform_session)?; - ensure_generation_draft_active(root, &ledger)?; - if !synchronize_staged_image_revision(root, &mut ledger)? { - mark_generation_error( + let commit = with_frozen_generation_platform_session_commit(platform_session, || { + ensure_generation_draft_active(root, &ledger)?; + if !synchronize_staged_image_revision(root, &mut ledger)? { + return Err("commit-reconciliation-required: 暂存素材已失效".to_string()); + } + commit_asset_canvas_at( root, - &mut ledger, - true, - "commit-reconciliation-required", - emit, - )?; - return Err(sanitized_generation_error("commit-reconciliation-required")); - } - let commit = commit_asset_canvas_at( - root, - &CommitAssetCanvasInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: ledger.project_id.clone(), - expected_revision: ledger.expected_host_revision, - expected_draft_revision: ledger - .current_draft_revision - .unwrap_or(ledger.expected_draft_revision), - draft_id: ledger.draft_id.clone(), - commit_id: ledger.commit_id.clone(), - idempotency_key: ledger.commit_idempotency_key.clone(), - intent: ledger.intent.clone(), - source_asset_id: ledger.source_asset_id.clone(), - staged_image_token: ledger.staged_image_token.clone(), - name: ledger.asset_name.clone(), - asset_kind: ledger.asset_kind.clone(), - reference_resource_ids: ledger.requested_reference_resource_ids.clone(), - generation_provenance: None, - }, - ); + &CommitAssetCanvasInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: ledger.project_id.clone(), + expected_revision: ledger.expected_host_revision, + expected_draft_revision: ledger + .current_draft_revision + .unwrap_or(ledger.expected_draft_revision), + draft_id: ledger.draft_id.clone(), + commit_id: ledger.commit_id.clone(), + idempotency_key: ledger.commit_idempotency_key.clone(), + intent: ledger.intent.clone(), + source_asset_id: ledger.source_asset_id.clone(), + staged_image_token: ledger.staged_image_token.clone(), + name: ledger.asset_name.clone(), + asset_kind: ledger.asset_kind.clone(), + reference_resource_ids: ledger.requested_reference_resource_ids.clone(), + generation_provenance: None, + }, + ) + }); let execution = match commit { Ok(execution) => execution, + Err(error) if error.contains("authentication-required") => return Err(error), Err(_) => { mark_generation_error( root, @@ -2696,20 +2955,10 @@ pub(crate) async fn generate_asset_canvas_image_at( enforce_project_permission_policy(root, "canvas.asset_generate")?; let _guard = generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; - let mut ledger = validate_and_prepare_ledger(root, input)?; - let (api_base_url, api_key, platform_session) = - match resolve_canvas_sync_api_credentials(None, None) { - Ok(value) => value, - Err(_) => { - let code = "configuration-missing"; - // Credential availability is not a durable business outcome. Keep - // the original private identity retryable so login/configuration - // repair can resume the same idempotency keys. - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; - return Err(sanitized_generation_error(code)); - } - }; + let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) + .map_err(|_| sanitized_generation_error("configuration-missing"))?; + let ledger = + validate_and_prepare_ledger(root, input, &api_base_url, platform_session.as_ref())?; let api_mode = CanvasGenerationApiMode { api_key }; reconcile_generation( root, @@ -2740,7 +2989,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( service_identity_confirmations: Vec::new(), }, events: Vec::new(), - }) + }); } Err(_) => return Err("读取素材画布私有生成账本目录失败".to_string()), }; @@ -2819,6 +3068,9 @@ pub(crate) async fn recover_asset_canvas_generations_at( let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; + if bind_generation_platform_owner(&ledger, platform_session.as_ref()).is_err() { + continue; + } migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id @@ -2829,10 +3081,13 @@ pub(crate) async fn recover_asset_canvas_generations_at( { continue; } - if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { - continue; - } - match prepare_generation_service_identity(root, &mut ledger, &api_base_url, &api_mode) { + match prepare_generation_service_identity( + root, + &mut ledger, + &api_base_url, + &api_mode, + platform_session.as_ref(), + ) { Ok(CanvasServiceIdentityDecision::ConfirmationRequired(confirmation)) => { service_identity_confirmations.push(confirmation); continue; @@ -2891,10 +3146,29 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( let _project_lock = acquire_project_write_lock(root, "asset-canvas.generation-service-identity")?; validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let (api_base_url, api_mode) = resolve_generation_api_mode() + let (api_base_url, api_mode, platform_session) = resolve_generation_api_mode() .map_err(|_| sanitized_generation_error("configuration-missing"))?; - let service_fingerprint = canvas_api_identity_fingerprint(&api_base_url, &api_mode); - let service_origin = platform_art_generation_external_service_origin(&api_base_url)?; + with_frozen_generation_platform_session_commit(platform_session.as_ref(), || { + confirm_asset_canvas_generation_service_identity_under_lease( + root, + input, + &api_base_url, + &api_mode, + platform_session.as_ref(), + ) + }) +} + +fn confirm_asset_canvas_generation_service_identity_under_lease( + root: &Path, + input: &ConfirmAssetCanvasGenerationServiceIdentityInput, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result { + let service_fingerprint = + canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); + let service_origin = platform_art_generation_external_service_origin(api_base_url)?; let mut ledger = read_generation_ledger(root, &input.generation_id)? .ok_or_else(|| "素材画布服务身份确认对应的生成账本不存在".to_string())?; if ledger.project_id != input.expected_project_id @@ -2904,6 +3178,7 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( { return Err("素材画布服务身份确认对应的 operation 身份已变化".to_string()); } + validate_generation_service_identity_owner(&ledger, platform_session)?; let confirmation = ledger .service_identity_confirmation .clone() @@ -2920,8 +3195,9 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( classify_platform_art_generation_service_identity( ledger.api_identity_scheme.as_deref(), ledger.api_identity_fingerprint.as_deref(), - &api_base_url, + api_base_url, api_mode.bearer_token(), + platform_session, ), PlatformArtGenerationServiceIdentityMatch::LegacyUnverified | PlatformArtGenerationServiceIdentityMatch::Unbound @@ -2950,6 +3226,10 @@ mod tests { use std::io::{Cursor, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; async fn with_test_external_editor_credentials( api_base_url: &str, @@ -3229,6 +3509,7 @@ mod tests { requested_reference_resource_ids: Vec::new(), reference_states: Vec::new(), resolved_reference_ids: Vec::new(), + access_scheme: Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME.to_string()), api_identity_scheme: None, api_identity_fingerprint: Some( platform_art_generation_external_configuration_fingerprint(base_url, api_key), @@ -3525,7 +3806,7 @@ mod tests { let reference_sha256 = asset_canvas_sha256(&source_png); let expected_object_key = format!( "generated-character-drafts/editor/asset-canvas-references/{}/{}/{}/reference-{}.png", - project_id, draft.draft_id, input.generation_id, reference_sha256 + project_id, "source-character", reference_sha256, reference_sha256 ); let server_object_key = expected_object_key.clone(); let server_signed_url = signed_url.clone(); @@ -3696,7 +3977,7 @@ mod tests { assert!(requests[ticket_index].contains("\"legacyPrefix\":\"generated-character-drafts\"")); assert!(requests[ticket_index].contains(&format!( "\"pathSegments\":[\"editor\",\"asset-canvas-references\",\"{}\",\"{}\",\"{}\"]", - project_id, draft.draft_id, input.generation_id + project_id, "source-character", reference_sha256 ))); assert!(requests[confirm_index].contains(&expected_object_key)); assert!(requests[register_index] @@ -3748,6 +4029,241 @@ mod tests { })); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_generations_register_the_same_reference_binding_once() { + let project_id = "asset-canvas-concurrent-reference-binding"; + let project_name = "素材画布并发参考资源绑定测试"; + let (directory, draft, source_png) = + create_refine_generation_fixture(project_id, project_name); + let listener = + TcpListener::bind("127.0.0.1:0").expect("bind concurrent reference binding server"); + let base_url = format!( + "http://{}", + listener + .local_addr() + .expect("concurrent reference binding address") + ); + let upload_url = format!("{base_url}/oss-upload"); + let source_png_len = source_png.len(); + let (ticket_sender, ticket_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set concurrent reference fixture nonblocking"); + for request_index in 0..4 { + let mut stream = accept_generation_fixture_connection( + &listener, + "concurrent reference binding fixture", + request_index, + ); + let request = read_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture concurrent reference request"); + if request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") { + ticket_sender + .send(()) + .expect("signal reference ticket request"); + release_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release reference ticket response"); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url.clone(), + "bucket": "concurrent-reference-bucket", + "objectKey": "concurrent/reference-source.png", + "successActionStatus": 204, + "maxSizeBytes": source_png_len, + "formFields": { + "key": "concurrent/reference-source.png", + "success_action_status": "204", + "Content-Type": "image/png" + } + }}}), + ); + } else if request.starts_with("POST /oss-upload ") { + write_empty_response(&mut stream, "204 No Content"); + } else if request.starts_with("POST /api/external/v1/assets/objects/confirm ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": "concurrent/reference-source.png", + "assetObjectId": "concurrent-reference-object" + }}}), + ); + } else if request + .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-concurrent-source", + "projectId": "remote-project", + "objectKey": "concurrent/reference-source.png", + "assetObjectId": "concurrent-reference-object" + }}}), + ); + } else { + panic!("unexpected concurrent reference request: {request}"); + } + } + }); + + let api_key = "concurrent-reference-private-key"; + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + api_key, + None, + "remote-project", + "remote-folder", + ); + let first_input = generation_input( + directory.path(), + project_id, + &draft, + "第一个 generation 使用同一张源图", + ); + let second_input = generation_input( + directory.path(), + project_id, + &draft, + "第二个 generation 并发使用同一张源图", + ); + assert_ne!(first_input.generation_id, second_input.generation_id); + let mut first_ledger = + validate_and_prepare_ledger(directory.path(), &first_input, &base_url, None) + .expect("prepare first concurrent reference ledger"); + let mut second_ledger = + validate_and_prepare_ledger(directory.path(), &second_input, &base_url, None) + .expect("prepare second concurrent reference ledger"); + for ledger in [&mut first_ledger, &mut second_ledger] { + ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project".to_string(), + asset_folder_id: "remote-folder".to_string(), + canvas_name: project_name.to_string(), + }); + write_generation_ledger(directory.path(), ledger) + .expect("persist concurrent reference ledger context"); + } + + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let first_root = directory.path().to_path_buf(); + let first_base_url = base_url.clone(); + let first_barrier = Arc::clone(&barrier); + let first = tokio::spawn(async move { + first_barrier.wait().await; + let result = with_test_external_editor_credentials( + &first_base_url, + api_key, + ensure_reference_states( + &first_root, + &mut first_ledger, + &reqwest::Client::new(), + &first_base_url, + &CanvasGenerationApiMode { + api_key: api_key.to_string(), + }, + None, + ), + ) + .await; + (result, first_ledger) + }); + let second_root = directory.path().to_path_buf(); + let second_base_url = base_url.clone(); + let second_barrier = Arc::clone(&barrier); + let second = tokio::spawn(async move { + second_barrier.wait().await; + let result = with_test_external_editor_credentials( + &second_base_url, + api_key, + ensure_reference_states( + &second_root, + &mut second_ledger, + &reqwest::Client::new(), + &second_base_url, + &CanvasGenerationApiMode { + api_key: api_key.to_string(), + }, + None, + ), + ) + .await; + (result, second_ledger) + }); + barrier.wait().await; + ticket_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("one generation reaches the reference ticket endpoint"); + tokio::time::sleep(Duration::from_millis(50)).await; + release_sender + .send(()) + .expect("release single reference ticket response"); + + let (first_result, first_ledger) = first.await.expect("join first reference generation"); + first_result.expect("first concurrent reference generation succeeds"); + let (second_result, second_ledger) = + second.await.expect("join second reference generation"); + second_result.expect("second concurrent reference generation succeeds"); + server.join().expect("join concurrent reference server"); + + for ledger in [&first_ledger, &second_ledger] { + assert_eq!( + ledger.resolved_reference_ids, + vec!["editor-resource-concurrent-source".to_string()] + ); + } + let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 4); + for prefix in [ + "POST /api/external/v1/assets/direct-upload-tickets ", + "POST /oss-upload ", + "POST /api/external/v1/assets/objects/confirm ", + "POST /api/external/v1/editor/projects/remote-project/resources ", + ] { + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with(prefix)) + .count(), + 1, + "reference side effect must execute once: {prefix}" + ); + } + let access = ExternalEditorBindingAccess::new(&base_url, api_key, None) + .expect("build concurrent reference binding access"); + let principal = external_editor_binding_principal(&access) + .expect("build concurrent reference principal"); + let source_identity = new_external_editor_source_identity( + "source-character", + &asset_canvas_sha256(&source_png), + "image/png", + "illustration", + ) + .expect("build concurrent source identity"); + let binding = read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal, + "remote-project", + &source_identity, + ) + .expect("read concurrent reference binding") + .expect("concurrent reference binding exists"); + assert_eq!( + binding.remote_resource_id.as_deref(), + Some("editor-resource-concurrent-source") + ); + } + #[tokio::test] async fn manifest_reference_binding_is_account_scoped_and_source_identity_sensitive() { let project_id = "asset-canvas-account-reference-binding"; @@ -3913,8 +4429,10 @@ mod tests { "账号 B 使用账号隔离后的源图片", ); account_b_input.asset_kind = "ui-design".to_string(); - let mut account_b_ledger = validate_and_prepare_ledger(directory.path(), &account_b_input) - .expect("prepare account B ledger"); + let mut account_b_ledger = + validate_and_prepare_ledger(directory.path(), &account_b_input, &base_url, None) + .expect("prepare account B ledger"); + account_b_ledger.access_scheme = Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME.to_string()); account_b_ledger.canvas_context = Some(PrivateCanvasContext { project_id: "remote-project-b".to_string(), asset_folder_id: "remote-folder-b".to_string(), @@ -4004,8 +4522,10 @@ mod tests { &draft, "切回账号 A 复用 A 自己的源图片绑定", ); - let mut account_a_ledger = validate_and_prepare_ledger(directory.path(), &account_a_input) - .expect("prepare account A reuse ledger"); + let mut account_a_ledger = + validate_and_prepare_ledger(directory.path(), &account_a_input, &base_url, None) + .expect("prepare account A reuse ledger"); + account_a_ledger.access_scheme = Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME.to_string()); account_a_ledger.canvas_context = Some(PrivateCanvasContext { project_id: "remote-project-a".to_string(), asset_folder_id: "remote-folder-a".to_string(), @@ -4318,6 +4838,286 @@ mod tests { assert_eq!(manifest.assets.len(), 1); } + #[test] + fn canvas_service_identity_helper_rejects_switch_without_writing_mixed_owner_fingerprint() { + let project_id = "asset-canvas-identity-switch"; + let (directory, draft) = create_generation_fixture(project_id, "素材画布切号指纹测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "冻结账号 A 后切换账号 B", + ); + let base_url = "https://dev.genarrative.world"; + let _session = crate::platform_session::install_test_platform_session( + "canvas-owner-a", + "canvas-token-a", + base_url, + ); + let frozen_a = current_platform_session().expect("freeze canvas owner A"); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input, base_url, Some(&frozen_a)) + .expect("prepare generation ledger"); + replace_platform_session_for_gui_owner("canvas-owner-b", "canvas-token-b", base_url, 2) + .expect("switch global canvas session to owner B"); + let api_mode = CanvasGenerationApiMode { + api_key: frozen_a.access_token.clone(), + }; + + let error = prepare_generation_service_identity( + directory.path(), + &mut ledger, + base_url, + &api_mode, + Some(&frozen_a), + ) + .expect_err("switched owner must stop before canvas identity ledger mutation"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!( + ledger.platform_owner_user_id.as_deref(), + Some("canvas-owner-a") + ); + assert_eq!( + ledger.api_identity_fingerprint, + Some(platform_art_generation_external_service_fingerprint( + base_url, + Some(&frozen_a), + )) + ); + } + + #[tokio::test] + async fn canvas_identity_confirm_rejects_current_non_owner_forged_challenge_without_mutation() { + let project_id = "asset-canvas-identity-owner"; + let (directory, draft) = + create_generation_fixture(project_id, "素材画布身份确认 owner 隔离测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证非 owner 不能确认伪造 challenge", + ); + let api_base_url = "https://canvas-identity-owner.example.test"; + let _session = install_test_platform_session( + "canvas-identity-owner-a", + "canvas-identity-token-a", + api_base_url, + ); + let owner_a = current_platform_session().expect("canvas identity owner A"); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input, api_base_url, Some(&owner_a)) + .expect("prepare canvas identity owner ledger"); + let owner_b = PlatformSessionSnapshot { + user_id: "canvas-identity-owner-b".to_string(), + access_token: "canvas-identity-token-b".to_string(), + api_base_url: owner_a.api_base_url.clone(), + generation: owner_a.generation + 1, + }; + let challenge = new_asset_canvas_token().expect("create forged canvas challenge"); + let ledger_snapshot_sha256 = generation_service_identity_snapshot_sha256(&ledger) + .expect("canvas owner ledger snapshot"); + ledger.service_identity_confirmation = Some(PrivateServiceIdentityConfirmation { + challenge: challenge.clone(), + service_fingerprint: platform_art_generation_external_service_fingerprint( + api_base_url, + Some(&owner_b), + ), + ledger_snapshot_sha256, + expires_at: asset_canvas_now() + 60_000, + }); + write_generation_ledger(directory.path(), &mut ledger) + .expect("write canvas owner A ledger"); + let ledger_path = directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)); + let before = fs::read(&ledger_path).expect("read canvas owner A ledger"); + replace_platform_session_for_gui_owner( + &owner_b.user_id, + &owner_b.access_token, + &owner_b.api_base_url, + owner_b.generation, + ) + .expect("switch to canvas non-owner B"); + + let error = confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + generation_id: input.generation_id.clone(), + operation_id: ledger.operation_id.clone(), + challenge, + }, + ) + .await + .expect_err("non-owner B must not confirm forged canvas challenge"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!( + fs::read(&ledger_path).expect("reread after rejected canvas confirm"), + before + ); + } + + #[test] + fn canvas_first_ledger_write_lease_blocks_switch_and_rejects_stale_snapshot() { + let project_id = "asset-canvas-owner-lease"; + let (directory, draft) = + create_generation_fixture(project_id, "素材画布 owner 写入线性化测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证 owner 账本写入与切号线性化", + ); + let api_base_url = "https://canvas-owner-lease.example.test"; + let _session = install_test_platform_session( + "canvas-lease-owner-a", + "canvas-lease-token-a", + api_base_url, + ); + let frozen_a = current_platform_session().expect("freeze canvas lease owner A"); + let entered = Arc::new(std::sync::Barrier::new(2)); + let release = Arc::new(std::sync::Barrier::new(2)); + let (switched_sender, switched_receiver) = mpsc::channel(); + + std::thread::scope(|scope| { + let write_entered = Arc::clone(&entered); + let write_release = Arc::clone(&release); + let first_write = scope.spawn(|| { + install_asset_canvas_account_bound_write_test_barrier( + AssetCanvasAccountBoundWriteTestBarrier { + entered: write_entered, + release: write_release, + }, + ); + validate_and_prepare_ledger(directory.path(), &input, api_base_url, Some(&frozen_a)) + }); + entered.wait(); + let switch = scope.spawn(|| { + replace_platform_session_for_gui_owner( + "canvas-lease-owner-b", + "canvas-lease-token-b", + api_base_url, + frozen_a.generation + 1, + ) + .expect("switch canvas lease owner"); + switched_sender.send(()).expect("signal canvas switch"); + }); + assert!(switched_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + release.wait(); + first_write + .join() + .expect("join canvas first ledger write") + .expect("write canvas owner A under lease"); + switch.join().expect("join canvas owner switch"); + }); + switched_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("canvas owner switch completes after write"); + let persisted = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read canvas owner ledger") + .expect("canvas owner ledger exists"); + assert_eq!( + persisted.platform_owner_user_id.as_deref(), + Some("canvas-lease-owner-a") + ); + assert_eq!( + persisted.platform_owner_api_base_url.as_deref(), + Some(api_base_url) + ); + assert_eq!( + persisted.access_scheme.as_deref(), + Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME) + ); + + let stale_input = generation_input( + directory.path(), + project_id, + &draft, + "旧 owner snapshot 不得写入", + ); + let stale_path = directory + .path() + .join(generation_ledger_relative_path(&stale_input.generation_id)); + let error = validate_and_prepare_ledger( + directory.path(), + &stale_input, + api_base_url, + Some(&frozen_a), + ) + .expect_err("stale canvas owner snapshot must fail before first write"); + assert!(error.contains("authentication-required"), "{error}"); + assert!(!stale_path.exists()); + } + + #[tokio::test] + async fn historical_unowned_canvas_recovery_is_rejected_without_network_or_mutation() { + let platform_session_guard = clear_test_platform_session(); + let project_id = "asset-canvas-unowned-recovery"; + let (directory, draft) = create_generation_fixture(project_id, "历史未归属画布恢复测试"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind canvas zero-network fixture"); + let api_base_url = format!( + "http://{}", + listener + .local_addr() + .expect("canvas zero-network fixture address") + ); + let input = generation_input( + directory.path(), + project_id, + &draft, + "历史未归属账本不得被账号 B 认领", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, &api_base_url, None) + .expect("prepare historical canvas fixture"); + ledger.access_scheme = None; + ledger.api_identity_scheme = None; + ledger.api_identity_fingerprint = None; + ledger.platform_owner_user_id = None; + ledger.platform_owner_api_base_url = None; + write_generation_ledger(directory.path(), &mut ledger) + .expect("write historical unowned canvas ledger"); + let ledger_path = directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)); + let before = fs::read(&ledger_path).expect("read historical canvas ledger bytes"); + drop(platform_session_guard); + + let _session = install_test_platform_session( + "unowned-canvas-owner-b", + "unowned-canvas-token-b", + &api_base_url, + ); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("unowned canvas recovery must fail closed without exposing identity"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + assert!(recovered.result.service_identity_confirmations.is_empty()); + assert!(recovered.events.is_empty()); + assert_eq!( + fs::read(&ledger_path).expect("reread rejected unowned canvas ledger"), + before + ); + listener + .set_nonblocking(true) + .expect("set canvas zero-network fixture nonblocking"); + assert!(matches!( + listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + #[tokio::test] async fn asset_canvas_service_identity_rejects_expired_and_stale_challenges() { let project_id = "service-identity-stale-challenge"; @@ -4649,6 +5449,7 @@ mod tests { "editor-resource-source".to_string(), "objects/style.png".to_string(), ], + access_scheme: Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME.to_string()), api_identity_scheme: Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()), api_identity_fingerprint: Some("configuration".to_string()), platform_owner_user_id: None, @@ -4843,10 +5644,16 @@ mod tests { let (mut ledger, api_base_url, api_mode) = with_test_external_editor_credentials("http://127.0.0.1:9", secret, async { - let ledger = - validate_and_prepare_ledger(directory.path(), &input).expect("prepare ledger"); - let (api_base_url, api_mode) = + let ledger = validate_and_prepare_ledger( + directory.path(), + &input, + "http://127.0.0.1:9", + None, + ) + .expect("prepare ledger"); + let (api_base_url, api_mode, platform_session) = resolve_generation_api_mode().expect("resolve external mode"); + assert_eq!(platform_session, None); (ledger, api_base_url, api_mode) }) .await; @@ -4889,8 +5696,11 @@ mod tests { .is_err()); ledger.api_identity_scheme = Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = - Some(canvas_api_identity_fingerprint(&api_base_url, &api_mode)); + ledger.api_identity_fingerprint = Some(canvas_api_identity_fingerprint( + &api_base_url, + &api_mode, + None, + )); write_generation_ledger(directory.path(), &mut ledger).expect("persist private ledger"); let persisted = fs::read_to_string( @@ -4928,7 +5738,7 @@ mod tests { &ReadAssetCanvasDraftInput { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, + draft_id: draft.draft_id.clone(), }, ) .expect("read failed generation draft") @@ -4937,13 +5747,11 @@ mod tests { let public = serde_json::to_string(&public_draft).expect("serialize failed draft"); assert!(!public.contains(prompt)); assert!(!public.contains(&input.idempotency_key)); - let ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read retryable generation ledger") - .expect("missing-login ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); - assert_eq!(ledger.error_code.as_deref(), Some("configuration-missing")); - assert_eq!(ledger.idempotency_key, input.idempotency_key); - assert_eq!(ledger.commit_id, input.commit_id); + assert!( + read_generation_ledger(directory.path(), &input.generation_id) + .expect("read missing-login generation ledger") + .is_none() + ); let listener = TcpListener::bind("127.0.0.1:0").expect("bind login recovery server"); let base_url = format!( @@ -5038,22 +5846,10 @@ mod tests { "login-recovery-project", "login-recovery-folder", ); - let recovered = recover_asset_canvas_generations_at( - directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: ledger.draft_id.clone(), - }, - |_| {}, - ) - .await - .expect("fresh login resumes original private identity"); + generate_asset_canvas_image_at(directory.path(), &input, |_| {}) + .await + .expect("fresh login creates the first owned ledger and completes generation"); server.join().expect("join login recovery server"); - assert_eq!( - recovered.result.resumed_generation_ids, - vec![input.generation_id.clone()] - ); let resumed = read_generation_ledger(directory.path(), &input.generation_id) .expect("read resumed login ledger") .expect("resumed login ledger exists"); @@ -5213,6 +6009,253 @@ mod tests { assert!(ledger.operation_id.is_none()); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn accepted_operation_is_persisted_before_session_switch_error_and_can_resume() { + let project_id = "generation-session-accepted-recovery"; + let project_name = "生成接受后切号恢复测试"; + let (directory, draft) = create_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind accepted recovery server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("accepted recovery address") + ); + let signed_url = format!("{base_url}/accepted-recovered.png"); + let server_signed_url = signed_url.clone(); + let recovered_png = test_png(); + let (submit_sender, submit_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set accepted recovery fixture nonblocking"); + for request_index in 0..6 { + let mut stream = accept_generation_fixture_connection( + &listener, + "accepted recovery fixture", + request_index, + ); + let request = read_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture accepted recovery request"); + if request.starts_with("GET /api/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "accepted-recovery-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("GET /api/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "accepted-recovery-folder", + "label": project_name, + }]}}}), + ); + } else if request.starts_with("POST /api/editor/images/generations ") { + submit_sender.send(()).expect("signal submitted generation"); + response_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release accepted response after account switch"); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "accepted-recovery-operation", + "status": "queued", + "pollAfterMs": 0, + }}), + ); + } else if request.starts_with( + "GET /api/runtime/external-generation/jobs/accepted-recovery-operation ", + ) { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "accepted-recovery-operation", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "accepted-recovery-resource", + "objectKey": "generated/accepted-recovered.png", + "assetObjectId": "accepted-recovery-object", + }} + }}), + ); + } else if request.starts_with("GET /api/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": server_signed_url}}), + ); + } else if request.starts_with("GET /accepted-recovered.png ") { + write_png(&mut stream, &recovered_png); + } else { + panic!("unexpected accepted recovery request: {request}"); + } + } + }); + let _session = crate::platform_session::install_test_platform_session( + "accepted-recovery-owner", + "accepted-recovery-token", + &base_url, + ); + let platform_session = crate::platform_session::current_platform_session() + .expect("accepted recovery platform session"); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "accepted-recovery-project", + "accepted-recovery-folder", + ); + let input = generation_input( + directory.path(), + project_id, + &draft, + "202 返回后切号必须先保存 operationId", + ); + let root = directory.path().to_path_buf(); + let generation_id = input.generation_id.clone(); + let generation = + tokio::spawn( + async move { generate_asset_canvas_image_at(&root, &input, |_| {}).await }, + ); + submit_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("generation submission reached server"); + crate::platform_session::clear_platform_session(2); + response_sender + .send(()) + .expect("release generation accepted response"); + let error = generation + .await + .expect("join accepted recovery generation") + .err() + .expect("changed account must stop before polling"); + assert!(error.contains("authentication-required")); + + let initial_requests = + std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); + assert_eq!(initial_requests.len(), 3); + assert!(initial_requests + .iter() + .all(|request| !request.starts_with("GET /api/runtime/external-generation/jobs/"))); + let accepted = read_generation_ledger(directory.path(), &generation_id) + .expect("read accepted recovery ledger") + .expect("accepted recovery ledger exists"); + assert_eq!(accepted.phase, GenerationLedgerPhase::Accepted); + assert_eq!( + accepted.operation_id.as_deref(), + Some("accepted-recovery-operation") + ); + assert_eq!(accepted.error_code, None); + + crate::platform_session::install_platform_session( + "accepted-recovery-owner", + "accepted-recovery-token", + &base_url, + 3, + ) + .expect("restore accepted recovery owner"); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("resume accepted operation with restored owner"); + server.join().expect("join accepted recovery server"); + assert_eq!(recovered.result.resumed_generation_ids, vec![generation_id]); + let committed = read_generation_ledger( + directory.path(), + &recovered.result.resumed_generation_ids[0], + ) + .expect("read resumed accepted ledger") + .expect("resumed accepted ledger exists"); + assert_eq!( + committed.phase, + GenerationLedgerPhase::AssetDurableCommitted + ); + assert!(committed.commit_result.is_some()); + } + + #[test] + fn local_commit_session_fence_linearizes_account_switch() { + let _session = crate::platform_session::install_test_platform_session( + "commit-fence-owner", + "commit-fence-token", + "http://127.0.0.1:39321", + ); + let frozen = crate::platform_session::current_platform_session() + .expect("commit fence platform session"); + let committed = Arc::new(AtomicBool::new(false)); + let (entered_sender, entered_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let (switched_sender, switched_receiver) = mpsc::channel(); + + std::thread::scope(|scope| { + let committed_in_action = Arc::clone(&committed); + let frozen_in_commit = frozen.clone(); + let commit = scope.spawn(move || { + with_frozen_generation_platform_session_commit(Some(&frozen_in_commit), || { + entered_sender + .send(()) + .expect("signal commit fence entered"); + release_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release linearized local commit"); + committed_in_action.store(true, Ordering::SeqCst); + Ok(()) + }) + }); + entered_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("local commit entered session fence"); + let switch = scope.spawn(move || { + crate::platform_session::clear_platform_session(2); + switched_sender + .send(()) + .expect("signal account switch completed"); + }); + assert!(switched_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + assert!(!committed.load(Ordering::SeqCst)); + release_sender.send(()).expect("release local commit"); + commit + .join() + .expect("join local commit") + .expect("linearized local commit succeeds"); + switch.join().expect("join account switch"); + switched_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("account switch completes after local commit"); + }); + assert!(committed.load(Ordering::SeqCst)); + + let called_after_switch = AtomicBool::new(false); + let error = with_frozen_generation_platform_session_commit(Some(&frozen), || { + called_after_switch.store(true, Ordering::SeqCst); + Ok(()) + }) + .expect_err("stale session cannot enter local commit"); + assert!(error.contains("authentication-required")); + assert!(!called_after_switch.load(Ordering::SeqCst)); + } + #[test] fn cancelled_draft_rejects_public_generation_projection_and_keeps_private_evidence() { let project_id = "cancelled-generation-projection"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 9d6778dcf..391bb6c10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -2,8 +2,12 @@ use super::*; const EXTERNAL_EDITOR_BINDING_ROOT: &str = ".agent/runtime/external-editor-bindings"; const EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION: &str = + "game-creator-external-editor-project-binding.v2"; +const EXTERNAL_EDITOR_PROJECT_BINDING_LEGACY_SCHEMA_VERSION: &str = "game-creator-external-editor-project-binding.v1"; const EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION: &str = + "game-creator-external-editor-resource-binding.v2"; +const EXTERNAL_EDITOR_RESOURCE_BINDING_LEGACY_SCHEMA_VERSION: &str = "game-creator-external-editor-resource-binding.v1"; const EXTERNAL_EDITOR_BINDING_MAX_BYTES: usize = 64 * 1024; const EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS: usize = 512; @@ -11,9 +15,25 @@ const EXTERNAL_EDITOR_BINDING_MAX_ORIGIN_CHARS: usize = 2_048; const EXTERNAL_EDITOR_BINDING_MAX_OBJECT_KEY_CHARS: usize = 2_048; const EXTERNAL_EDITOR_BINDING_MAX_MEDIA_TYPE_CHARS: usize = 255; const EXTERNAL_EDITOR_BINDING_MAX_ASSET_KIND_CHARS: usize = 128; +const EXTERNAL_EDITOR_PROJECT_BINDING_SCAN_MAX_ENTRIES: usize = 4_096; +const EXTERNAL_EDITOR_RESOURCE_BINDING_LOCK_ROOT: &str = + ".agent/runtime/locks/external-editor-resource-bindings"; +const EXTERNAL_EDITOR_RESOURCE_BINDING_LOCK_WAIT_ATTEMPTS: usize = 24_000; +const EXTERNAL_EDITOR_RESOURCE_BINDING_LOCK_WAIT_MS: u64 = 25; const EXTERNAL_EDITOR_PLATFORM_SCHEME: &str = "official-platform-v1"; const EXTERNAL_EDITOR_DEVELOPER_SCHEME: &str = "external-developer-v1"; +type ExternalEditorResourceBindingAsyncLock = tokio::sync::Mutex<()>; + +static EXTERNAL_EDITOR_RESOURCE_BINDING_LOCKS: OnceLock< + Mutex>>, +> = OnceLock::new(); + +pub(crate) struct ExternalEditorResourceBindingSingleflightGuard { + _process: tokio::sync::OwnedMutexGuard<()>, + _system: File, +} + /// In-memory authorization for resolving an account-scoped binding. This type deliberately does /// not implement `Serialize` or `Debug`: the bearer token must never enter a project sidecar, /// diagnostic, event, or formatted error. @@ -100,6 +120,54 @@ impl<'a> ExternalEditorBindingAccess<'a> { } } +pub(crate) async fn acquire_external_editor_resource_binding_singleflight( + root: &Path, + binding_key_sha256: &str, + access: &ExternalEditorBindingAccess<'_>, +) -> Result { + validate_sha256(binding_key_sha256, "External Editor resource binding key")?; + access.validate_frozen_session()?; + let canonical_root = fs::canonicalize(root) + .map_err(|error| format!("解析 External Editor resource binding 项目路径失败:{error}"))?; + let key = (canonical_root, binding_key_sha256.to_string()); + let process_lock = { + let mut locks = EXTERNAL_EDITOR_RESOURCE_BINDING_LOCKS + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| "External Editor resource binding singleflight 状态已损坏".to_string())?; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&key).and_then(std::sync::Weak::upgrade) { + lock + } else { + let lock = Arc::new(ExternalEditorResourceBindingAsyncLock::new(())); + locks.insert(key, Arc::downgrade(&lock)); + lock + } + }; + let process = process_lock.lock_owned().await; + for attempt in 0..EXTERNAL_EDITOR_RESOURCE_BINDING_LOCK_WAIT_ATTEMPTS { + access.validate_frozen_session()?; + let relative_path = + format!("{EXTERNAL_EDITOR_RESOURCE_BINDING_LOCK_ROOT}/{binding_key_sha256}.lock"); + if let Some(system) = + crate::agent::try_open_game_creator_agent_runtime_task_lock_file(root, &relative_path)? + { + access.validate_frozen_session()?; + return Ok(ExternalEditorResourceBindingSingleflightGuard { + _process: process, + _system: system, + }); + } + if attempt + 1 < EXTERNAL_EDITOR_RESOURCE_BINDING_LOCK_WAIT_ATTEMPTS { + tokio::time::sleep(Duration::from_millis( + EXTERNAL_EDITOR_RESOURCE_BINDING_LOCK_WAIT_MS, + )) + .await; + } + } + Err("等待 External Editor resource binding singleflight 超时".to_string()) +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ExternalEditorPrincipalIdentity { @@ -116,6 +184,7 @@ pub(crate) struct ExternalEditorPrincipalIdentity { pub(crate) struct ExternalEditorProjectBinding { pub(crate) schema_version: String, pub(crate) binding_key_sha256: String, + pub(crate) payload_fingerprint_sha256: String, pub(crate) project_id: String, pub(crate) principal: ExternalEditorPrincipalIdentity, pub(crate) remote_project_id: String, @@ -124,6 +193,22 @@ pub(crate) struct ExternalEditorProjectBinding { pub(crate) updated_at: u64, } +// Keep the retired v1 wire shapes explicit and closed. In particular, do not make the v2 +// fingerprint optional/defaulted: only a document that exactly matches one of these legacy +// shapes may enter the one-time migration path. +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct LegacyExternalEditorProjectBindingV1 { + schema_version: String, + binding_key_sha256: String, + project_id: String, + principal: ExternalEditorPrincipalIdentity, + remote_project_id: String, + remote_asset_folder_id: String, + created_at: u64, + updated_at: u64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ExternalEditorSourceIdentity { @@ -138,6 +223,7 @@ pub(crate) struct ExternalEditorSourceIdentity { pub(crate) struct ExternalEditorResourceBinding { pub(crate) schema_version: String, pub(crate) binding_key_sha256: String, + pub(crate) payload_fingerprint_sha256: String, pub(crate) project_id: String, pub(crate) principal_key_sha256: String, pub(crate) remote_project_id: String, @@ -154,6 +240,38 @@ pub(crate) struct ExternalEditorResourceBinding { pub(crate) verified_at: u64, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct LegacyExternalEditorResourceBindingV1 { + schema_version: String, + binding_key_sha256: String, + project_id: String, + principal_key_sha256: String, + remote_project_id: String, + source: ExternalEditorSourceIdentity, + remote_resource_id: Option, + object_key: String, + asset_object_id: String, + width: Option, + height: Option, + created_at: u64, + verified_at: u64, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ExternalEditorProjectBindingWire { + V2(ExternalEditorProjectBinding), + LegacyV1(LegacyExternalEditorProjectBindingV1), +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ExternalEditorResourceBindingWire { + V2(ExternalEditorResourceBinding), + LegacyV1(LegacyExternalEditorResourceBindingV1), +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProjectBindingKeyMaterial<'a> { @@ -170,6 +288,36 @@ struct ResourceBindingKeyMaterial<'a> { source: &'a ExternalEditorSourceIdentity, } +// These unkeyed fingerprints detect accidental edits and stale partial rewrites of the private +// sidecar. They are not an authorization proof against a hostile workspace that can recompute +// SHA-256; bearer ownership checks at the External Editor service remain the security authority. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectBindingPayloadFingerprintMaterial<'a> { + schema_version: &'a str, + binding_key_sha256: &'a str, + project_id: &'a str, + principal: &'a ExternalEditorPrincipalIdentity, + remote_project_id: &'a str, + remote_asset_folder_id: &'a str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ResourceBindingPayloadFingerprintMaterial<'a> { + schema_version: &'a str, + binding_key_sha256: &'a str, + project_id: &'a str, + principal_key_sha256: &'a str, + remote_project_id: &'a str, + source: &'a ExternalEditorSourceIdentity, + remote_resource_id: Option<&'a str>, + object_key: &'a str, + asset_object_id: &'a str, + width: Option, + height: Option, +} + pub(crate) fn external_editor_binding_principal( access: &ExternalEditorBindingAccess<'_>, ) -> Result { @@ -244,6 +392,104 @@ pub(crate) fn external_editor_resource_binding_key_sha256( ) } +fn external_editor_project_binding_payload_fingerprint_sha256( + binding: &ExternalEditorProjectBinding, +) -> Result { + sha256_serialized( + &ProjectBindingPayloadFingerprintMaterial { + schema_version: &binding.schema_version, + binding_key_sha256: &binding.binding_key_sha256, + project_id: &binding.project_id, + principal: &binding.principal, + remote_project_id: &binding.remote_project_id, + remote_asset_folder_id: &binding.remote_asset_folder_id, + }, + "External Editor 项目 binding payload fingerprint", + ) +} + +fn external_editor_resource_binding_payload_fingerprint_sha256( + binding: &ExternalEditorResourceBinding, +) -> Result { + sha256_serialized( + &ResourceBindingPayloadFingerprintMaterial { + schema_version: &binding.schema_version, + binding_key_sha256: &binding.binding_key_sha256, + project_id: &binding.project_id, + principal_key_sha256: &binding.principal_key_sha256, + remote_project_id: &binding.remote_project_id, + source: &binding.source, + remote_resource_id: binding.remote_resource_id.as_deref(), + object_key: &binding.object_key, + asset_object_id: &binding.asset_object_id, + width: binding.width, + height: binding.height, + }, + "External Editor resource binding payload fingerprint", + ) +} + +fn upgrade_external_editor_project_binding_wire( + wire: ExternalEditorProjectBindingWire, +) -> Result<(ExternalEditorProjectBinding, bool), String> { + match wire { + ExternalEditorProjectBindingWire::V2(binding) => Ok((binding, false)), + ExternalEditorProjectBindingWire::LegacyV1(legacy) => { + if legacy.schema_version != EXTERNAL_EDITOR_PROJECT_BINDING_LEGACY_SCHEMA_VERSION { + return Err("External Editor 账号项目 binding schema 无效".to_string()); + } + let mut binding = ExternalEditorProjectBinding { + schema_version: EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION.to_string(), + binding_key_sha256: legacy.binding_key_sha256, + payload_fingerprint_sha256: String::new(), + project_id: legacy.project_id, + principal: legacy.principal, + remote_project_id: legacy.remote_project_id, + remote_asset_folder_id: legacy.remote_asset_folder_id, + created_at: legacy.created_at, + updated_at: legacy.updated_at, + }; + binding.payload_fingerprint_sha256 = + external_editor_project_binding_payload_fingerprint_sha256(&binding)?; + validate_external_editor_project_binding(&binding)?; + Ok((binding, true)) + } + } +} + +fn upgrade_external_editor_resource_binding_wire( + wire: ExternalEditorResourceBindingWire, +) -> Result<(ExternalEditorResourceBinding, bool), String> { + match wire { + ExternalEditorResourceBindingWire::V2(binding) => Ok((binding, false)), + ExternalEditorResourceBindingWire::LegacyV1(legacy) => { + if legacy.schema_version != EXTERNAL_EDITOR_RESOURCE_BINDING_LEGACY_SCHEMA_VERSION { + return Err("External Editor 账号资源 binding schema 无效".to_string()); + } + let mut binding = ExternalEditorResourceBinding { + schema_version: EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION.to_string(), + binding_key_sha256: legacy.binding_key_sha256, + payload_fingerprint_sha256: String::new(), + project_id: legacy.project_id, + principal_key_sha256: legacy.principal_key_sha256, + remote_project_id: legacy.remote_project_id, + source: legacy.source, + remote_resource_id: legacy.remote_resource_id, + object_key: legacy.object_key, + asset_object_id: legacy.asset_object_id, + width: legacy.width, + height: legacy.height, + created_at: legacy.created_at, + verified_at: legacy.verified_at, + }; + binding.payload_fingerprint_sha256 = + external_editor_resource_binding_payload_fingerprint_sha256(&binding)?; + validate_external_editor_resource_binding(&binding)?; + Ok((binding, true)) + } + } +} + pub(crate) fn new_external_editor_source_identity( local_asset_id: &str, source_sha256: &str, @@ -267,9 +513,10 @@ pub(crate) fn new_external_editor_project_binding( remote_asset_folder_id: &str, now: u64, ) -> Result { - let binding = ExternalEditorProjectBinding { + let mut binding = ExternalEditorProjectBinding { schema_version: EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION.to_string(), binding_key_sha256: external_editor_project_binding_key_sha256(project_id, principal)?, + payload_fingerprint_sha256: String::new(), project_id: project_id.to_string(), principal: principal.clone(), remote_project_id: remote_project_id.to_string(), @@ -277,6 +524,8 @@ pub(crate) fn new_external_editor_project_binding( created_at: now, updated_at: now, }; + binding.payload_fingerprint_sha256 = + external_editor_project_binding_payload_fingerprint_sha256(&binding)?; validate_external_editor_project_binding(&binding)?; Ok(binding) } @@ -295,7 +544,7 @@ pub(crate) fn new_external_editor_resource_binding( now: u64, ) -> Result { let principal_key_sha256 = external_editor_project_binding_key_sha256(project_id, principal)?; - let binding = ExternalEditorResourceBinding { + let mut binding = ExternalEditorResourceBinding { schema_version: EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION.to_string(), binding_key_sha256: external_editor_resource_binding_key_sha256( project_id, @@ -303,6 +552,7 @@ pub(crate) fn new_external_editor_resource_binding( remote_project_id, source, )?, + payload_fingerprint_sha256: String::new(), project_id: project_id.to_string(), principal_key_sha256, remote_project_id: remote_project_id.to_string(), @@ -315,6 +565,8 @@ pub(crate) fn new_external_editor_resource_binding( created_at: now, verified_at: now, }; + binding.payload_fingerprint_sha256 = + external_editor_resource_binding_payload_fingerprint_sha256(&binding)?; validate_external_editor_resource_binding(&binding)?; Ok(binding) } @@ -325,15 +577,16 @@ pub(crate) fn read_external_editor_project_binding_at( principal: &ExternalEditorPrincipalIdentity, ) -> Result, String> { let binding_key = external_editor_project_binding_key_sha256(project_id, principal)?; - let binding = read_agent_runtime_json_sidecar_with_max_bytes::( + let wire = read_agent_runtime_json_sidecar_with_max_bytes::( root, &external_editor_project_binding_relative_path(&binding_key), "External Editor 账号项目 binding", EXTERNAL_EDITOR_BINDING_MAX_BYTES, )?; - let Some(binding) = binding else { + let Some(wire) = wire else { return Ok(None); }; + let (binding, migrated_from_v1) = upgrade_external_editor_project_binding_wire(wire)?; validate_external_editor_project_binding(&binding)?; if binding.binding_key_sha256 != binding_key || binding.project_id != project_id @@ -341,6 +594,9 @@ pub(crate) fn read_external_editor_project_binding_at( { return Err("External Editor 账号项目 binding 与当前身份不匹配".to_string()); } + if migrated_from_v1 { + write_external_editor_project_binding_at(root, &binding)?; + } Ok(Some(binding)) } @@ -379,15 +635,16 @@ pub(crate) fn read_external_editor_resource_binding_at( remote_project_id, source, )?; - let binding = read_agent_runtime_json_sidecar_with_max_bytes::( + let wire = read_agent_runtime_json_sidecar_with_max_bytes::( root, &external_editor_resource_binding_relative_path(&principal_key, &binding_key), "External Editor 账号资源 binding", EXTERNAL_EDITOR_BINDING_MAX_BYTES, )?; - let Some(binding) = binding else { + let Some(wire) = wire else { return Ok(None); }; + let (binding, migrated_from_v1) = upgrade_external_editor_resource_binding_wire(wire)?; validate_external_editor_resource_binding(&binding)?; if binding.binding_key_sha256 != binding_key || binding.project_id != project_id @@ -397,9 +654,125 @@ pub(crate) fn read_external_editor_resource_binding_at( { return Err("External Editor 账号资源 binding 与当前身份不匹配".to_string()); } + if migrated_from_v1 { + write_external_editor_resource_binding_at(root, &binding)?; + } Ok(Some(binding)) } +/// Proves that a remote reference used by a derived local asset was registered from the exact +/// current local source bytes under one of this project's validated account bindings. Manifest +/// remote IDs remain provenance only: callers do not need the currently logged-in principal, and +/// switching accounts does not invalidate an already-installed local DAG. +pub(crate) fn external_editor_remote_reference_matches_local_source_at( + root: &Path, + project_id: &str, + remote_project_id: &str, + remote_resource_id: &str, + source: &ExternalEditorSourceIdentity, +) -> Result { + validate_bounded_identity( + project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_bounded_identity( + remote_project_id, + "External Editor remote projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_bounded_identity( + remote_resource_id, + "External Editor remote resourceId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_external_editor_source_identity(source)?; + + let relative_root = format!("{EXTERNAL_EDITOR_BINDING_ROOT}/projects"); + let bindings_root = resolve_local_project_path(root, &relative_root)?; + match fs::symlink_metadata(&bindings_root) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err("External Editor 项目 binding 根目录必须是普通目录".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取 External Editor 项目 binding 根目录失败:{error}" + )); + } + } + + let mut candidates = Vec::new(); + let mut scanned_entries = 0_usize; + for entry in fs::read_dir(&bindings_root) + .map_err(|error| format!("扫描 External Editor 项目 binding 失败:{error}"))? + { + if scanned_entries >= EXTERNAL_EDITOR_PROJECT_BINDING_SCAN_MAX_ENTRIES { + return Err("External Editor 项目 binding 数量超过安全上限".to_string()); + } + scanned_entries += 1; + let entry = + entry.map_err(|error| format!("读取 External Editor 项目 binding 失败:{error}"))?; + let file_name = entry + .file_name() + .into_string() + .map_err(|_| "External Editor 项目 binding 文件名不是 UTF-8".to_string())?; + let Some(binding_key) = file_name.strip_suffix(".json") else { + continue; + }; + if binding_key.len() != 64 + || !binding_key + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + continue; + } + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|error| format!("读取 External Editor 项目 binding 元数据失败:{error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("External Editor 项目 binding 必须是普通文件".to_string()); + } + let relative_path = external_editor_project_binding_relative_path(binding_key); + let wire = + read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "External Editor 账号项目 binding", + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )? + .ok_or_else(|| "External Editor 项目 binding 扫描期间消失".to_string())?; + let (binding, migrated_from_v1) = upgrade_external_editor_project_binding_wire(wire)?; + validate_external_editor_project_binding(&binding)?; + if binding.binding_key_sha256 != binding_key { + return Err("External Editor 项目 binding 文件名与内容身份不一致".to_string()); + } + if migrated_from_v1 { + write_external_editor_project_binding_at(root, &binding)?; + } + if binding.project_id == project_id && binding.remote_project_id == remote_project_id { + candidates.push(binding); + } + } + + for binding in candidates { + let Some(resource_binding) = read_external_editor_resource_binding_at( + root, + project_id, + &binding.principal, + remote_project_id, + source, + )? + else { + continue; + }; + if resource_binding.remote_resource_id.as_deref() == Some(remote_resource_id) { + return Ok(true); + } + } + Ok(false) +} + pub(crate) fn write_external_editor_resource_binding_at( root: &Path, binding: &ExternalEditorResourceBinding, @@ -604,6 +977,14 @@ fn validate_external_editor_project_binding( { return Err("External Editor 账号项目 binding 身份无效".to_string()); } + validate_sha256( + &binding.payload_fingerprint_sha256, + "External Editor 项目 binding payload fingerprint", + )?; + let expected_payload = external_editor_project_binding_payload_fingerprint_sha256(binding)?; + if binding.payload_fingerprint_sha256 != expected_payload { + return Err("External Editor 账号项目 binding payload fingerprint 无效".to_string()); + } Ok(()) } @@ -685,6 +1066,14 @@ fn validate_external_editor_resource_binding( if binding.binding_key_sha256 != expected { return Err("External Editor 账号资源 binding key 无效".to_string()); } + validate_sha256( + &binding.payload_fingerprint_sha256, + "External Editor resource binding payload fingerprint", + )?; + let expected_payload = external_editor_resource_binding_payload_fingerprint_sha256(binding)?; + if binding.payload_fingerprint_sha256 != expected_payload { + return Err("External Editor 账号资源 binding payload fingerprint 无效".to_string()); + } Ok(()) } @@ -776,6 +1165,22 @@ mod tests { .expect("source identity") } + fn write_binding_json_fixture(root: &Path, relative_path: &str, value: &serde_json::Value) { + let path = resolve_local_project_path(root, relative_path).expect("resolve fixture path"); + fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent"); + fs::write( + path, + serde_json::to_vec_pretty(value).expect("serialize fixture JSON"), + ) + .expect("write fixture JSON"); + } + + fn read_binding_json_fixture(root: &Path, relative_path: &str) -> serde_json::Value { + let path = resolve_local_project_path(root, relative_path).expect("resolve fixture path"); + serde_json::from_slice(&fs::read(path).expect("read fixture JSON")) + .expect("parse fixture JSON") + } + #[test] fn account_bindings_are_isolated_while_token_rotation_reuses_identity() { let account_a = platform_session("user-a", "token-a", 1); @@ -853,6 +1258,277 @@ mod tests { ); } + #[test] + fn legacy_v1_project_and_resource_first_read_migrate_atomically_to_v2() { + let directory = tempfile::tempdir().expect("temp legacy binding project"); + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let project_id = "local-project"; + let remote_project_id = "remote-project"; + let project_key = external_editor_project_binding_key_sha256(project_id, &principal) + .expect("project key"); + let project_path = external_editor_project_binding_relative_path(&project_key); + let legacy_project = serde_json::json!({ + "schemaVersion": EXTERNAL_EDITOR_PROJECT_BINDING_LEGACY_SCHEMA_VERSION, + "bindingKeySha256": project_key, + "projectId": project_id, + "principal": { + "scheme": EXTERNAL_EDITOR_PLATFORM_SCHEME, + "serviceOrigin": "https://dev.genarrative.world", + "ownerUserId": "user-a" + }, + "remoteProjectId": remote_project_id, + "remoteAssetFolderId": "remote-folder", + "createdAt": 11, + "updatedAt": 12 + }); + write_binding_json_fixture(directory.path(), &project_path, &legacy_project); + + let migrated_project = + read_external_editor_project_binding_at(directory.path(), project_id, &principal) + .expect("migrate legacy project binding") + .expect("migrated project binding"); + assert_eq!( + migrated_project.schema_version, + EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION + ); + validate_external_editor_project_binding(&migrated_project) + .expect("validate migrated project binding"); + let persisted_project = read_binding_json_fixture(directory.path(), &project_path); + assert_eq!( + persisted_project["schemaVersion"], + EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION + ); + assert_eq!( + persisted_project["payloadFingerprintSha256"], + migrated_project.payload_fingerprint_sha256 + ); + assert_eq!( + read_external_editor_project_binding_at(directory.path(), project_id, &principal,) + .expect("reread migrated project binding"), + Some(migrated_project) + ); + + let source = source_identity(); + let resource_key = external_editor_resource_binding_key_sha256( + project_id, + &project_key, + remote_project_id, + &source, + ) + .expect("resource key"); + let resource_path = + external_editor_resource_binding_relative_path(&project_key, &resource_key); + let legacy_resource = serde_json::json!({ + "schemaVersion": EXTERNAL_EDITOR_RESOURCE_BINDING_LEGACY_SCHEMA_VERSION, + "bindingKeySha256": resource_key, + "projectId": project_id, + "principalKeySha256": project_key, + "remoteProjectId": remote_project_id, + "source": { + "localAssetId": "asset:hero", + "sourceSha256": "1".repeat(64), + "mediaType": "image/png", + "canonicalAssetKind": "character" + }, + "remoteResourceId": "editor-resource-1", + "objectKey": "generated/assets/source.png", + "assetObjectId": "asset-object-1", + "width": 128, + "height": 64, + "createdAt": 21, + "verifiedAt": 22 + }); + write_binding_json_fixture(directory.path(), &resource_path, &legacy_resource); + + let migrated_resource = read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal, + remote_project_id, + &source, + ) + .expect("migrate legacy resource binding") + .expect("migrated resource binding"); + assert_eq!( + migrated_resource.schema_version, + EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION + ); + validate_external_editor_resource_binding(&migrated_resource) + .expect("validate migrated resource binding"); + let persisted_resource = read_binding_json_fixture(directory.path(), &resource_path); + assert_eq!( + persisted_resource["schemaVersion"], + EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION + ); + assert_eq!( + persisted_resource["payloadFingerprintSha256"], + migrated_resource.payload_fingerprint_sha256 + ); + assert_eq!( + read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal, + remote_project_id, + &source, + ) + .expect("reread migrated resource binding"), + Some(migrated_resource) + ); + } + + #[test] + fn legacy_v1_unknown_or_tampered_bindings_fail_closed_without_rewrite() { + let directory = tempfile::tempdir().expect("temp tampered legacy project"); + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let project_key = external_editor_project_binding_key_sha256("local-project", &principal) + .expect("project key"); + let project_path = external_editor_project_binding_relative_path(&project_key); + let mut legacy_project = serde_json::json!({ + "schemaVersion": EXTERNAL_EDITOR_PROJECT_BINDING_LEGACY_SCHEMA_VERSION, + "bindingKeySha256": project_key, + "projectId": "local-project", + "principal": { + "scheme": EXTERNAL_EDITOR_PLATFORM_SCHEME, + "serviceOrigin": "https://dev.genarrative.world", + "ownerUserId": "user-a" + }, + "remoteProjectId": "remote-project", + "remoteAssetFolderId": "remote-folder", + "createdAt": 1, + "updatedAt": 1, + "unexpected": true + }); + write_binding_json_fixture(directory.path(), &project_path, &legacy_project); + assert!(read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal, + ) + .is_err()); + assert_eq!( + read_binding_json_fixture(directory.path(), &project_path)["schemaVersion"], + EXTERNAL_EDITOR_PROJECT_BINDING_LEGACY_SCHEMA_VERSION + ); + + legacy_project + .as_object_mut() + .expect("legacy project object") + .remove("unexpected"); + legacy_project["principal"]["ownerUserId"] = serde_json::json!("user-b"); + write_binding_json_fixture(directory.path(), &project_path, &legacy_project); + assert!(read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal, + ) + .is_err()); + assert_eq!( + read_binding_json_fixture(directory.path(), &project_path)["schemaVersion"], + EXTERNAL_EDITOR_PROJECT_BINDING_LEGACY_SCHEMA_VERSION + ); + + legacy_project["principal"]["ownerUserId"] = serde_json::json!("user-a"); + legacy_project["remoteAssetFolderId"] = serde_json::json!("/tmp/remote-folder"); + write_binding_json_fixture(directory.path(), &project_path, &legacy_project); + assert!(read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal, + ) + .is_err()); + assert_eq!( + read_binding_json_fixture(directory.path(), &project_path)["schemaVersion"], + EXTERNAL_EDITOR_PROJECT_BINDING_LEGACY_SCHEMA_VERSION + ); + + let current_project = new_external_editor_project_binding( + "local-project", + &principal, + "remote-project", + "remote-folder", + 1, + ) + .expect("current project binding"); + let mut unknown_v2 = + serde_json::to_value(¤t_project).expect("serialize current project binding"); + unknown_v2 + .as_object_mut() + .expect("current project object") + .insert("unexpected".to_string(), serde_json::json!(true)); + write_binding_json_fixture(directory.path(), &project_path, &unknown_v2); + assert!(read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal, + ) + .is_err()); + assert_eq!( + read_binding_json_fixture(directory.path(), &project_path)["unexpected"], + true + ); + + let source = source_identity(); + let resource_key = external_editor_resource_binding_key_sha256( + "local-project", + &project_key, + "remote-project", + &source, + ) + .expect("resource key"); + let resource_path = + external_editor_resource_binding_relative_path(&project_key, &resource_key); + let mut legacy_resource = serde_json::json!({ + "schemaVersion": EXTERNAL_EDITOR_RESOURCE_BINDING_LEGACY_SCHEMA_VERSION, + "bindingKeySha256": resource_key, + "projectId": "local-project", + "principalKeySha256": project_key, + "remoteProjectId": "remote-project", + "source": { + "localAssetId": "asset:hero", + "sourceSha256": "1".repeat(64), + "mediaType": "image/png", + "canonicalAssetKind": "character" + }, + "remoteResourceId": "editor-resource-1", + "objectKey": "../private/source.png", + "assetObjectId": "asset-object-1", + "width": 128, + "height": 64, + "createdAt": 1, + "verifiedAt": 1 + }); + write_binding_json_fixture(directory.path(), &resource_path, &legacy_resource); + assert!(read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .is_err()); + assert_eq!( + read_binding_json_fixture(directory.path(), &resource_path)["schemaVersion"], + EXTERNAL_EDITOR_RESOURCE_BINDING_LEGACY_SCHEMA_VERSION + ); + + legacy_resource["objectKey"] = serde_json::json!("generated/assets/source.png"); + legacy_resource["source"]["localAssetId"] = serde_json::json!("asset:tampered"); + write_binding_json_fixture(directory.path(), &resource_path, &legacy_resource); + assert!(read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .is_err()); + assert_eq!( + read_binding_json_fixture(directory.path(), &resource_path)["schemaVersion"], + EXTERNAL_EDITOR_RESOURCE_BINDING_LEGACY_SCHEMA_VERSION + ); + } + #[test] fn resource_key_changes_with_source_sha_kind_and_remote_project() { let principal = platform_principal(&platform_session("user-a", "token-a", 1)); @@ -907,6 +1583,29 @@ mod tests { ); } + #[test] + fn local_source_lineage_scan_caps_unrelated_project_binding_entries() { + let directory = tempfile::tempdir().expect("temp lineage scan project"); + let bindings_root = directory + .path() + .join(".agent/runtime/external-editor-bindings/projects"); + fs::create_dir_all(&bindings_root).expect("create project binding scan root"); + for index in 0..=EXTERNAL_EDITOR_PROJECT_BINDING_SCAN_MAX_ENTRIES { + fs::write(bindings_root.join(format!("unrelated-{index}")), b"ignored") + .expect("write unrelated project binding entry"); + } + + let error = external_editor_remote_reference_matches_local_source_at( + directory.path(), + "local-project", + "remote-project", + "remote-resource", + &source_identity(), + ) + .expect_err("unrelated directory entries must still count toward the scan cap"); + assert!(error.contains("数量超过安全上限"), "{error}"); + } + #[test] fn resource_store_round_trips_and_invalidates_exact_binding() { let directory = tempfile::tempdir().expect("temp project"); @@ -1045,6 +1744,122 @@ mod tests { .is_err()); } + #[test] + fn project_binding_remote_identity_tampering_fails_closed() { + let directory = tempfile::tempdir().expect("temp project"); + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let binding = new_external_editor_project_binding( + "local-project", + &principal, + "remote-project", + "remote-folder", + 1, + ) + .expect("project binding"); + write_external_editor_project_binding_at(directory.path(), &binding) + .expect("write project binding"); + let path = resolve_local_project_path( + directory.path(), + &external_editor_project_binding_relative_path(&binding.binding_key_sha256), + ) + .expect("project binding path"); + + for tampered in [ + ExternalEditorProjectBinding { + remote_project_id: "remote-project-tampered".to_string(), + ..binding.clone() + }, + ExternalEditorProjectBinding { + remote_asset_folder_id: "remote-folder-tampered".to_string(), + ..binding.clone() + }, + ] { + fs::write( + &path, + serde_json::to_vec_pretty(&tampered).expect("serialize tampered project binding"), + ) + .expect("tamper persisted project binding"); + assert!( + read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal, + ) + .is_err(), + "changing a persisted remote project identity must invalidate the payload" + ); + } + } + + #[test] + fn resource_binding_remote_identity_tampering_fails_closed() { + let directory = tempfile::tempdir().expect("temp project"); + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let binding = new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source_identity(), + Some("editor-resource-1"), + "generated/assets/source.png", + "asset-object-1", + Some(16), + Some(16), + 1, + ) + .expect("resource binding"); + write_external_editor_resource_binding_at(directory.path(), &binding) + .expect("write resource binding"); + let path = resolve_local_project_path( + directory.path(), + &external_editor_resource_binding_relative_path( + &binding.principal_key_sha256, + &binding.binding_key_sha256, + ), + ) + .expect("resource binding path"); + + for tampered in [ + ExternalEditorResourceBinding { + remote_resource_id: Some("editor-resource-tampered".to_string()), + ..binding.clone() + }, + ExternalEditorResourceBinding { + object_key: "generated/assets/tampered.png".to_string(), + ..binding.clone() + }, + ExternalEditorResourceBinding { + asset_object_id: "asset-object-tampered".to_string(), + ..binding.clone() + }, + ExternalEditorResourceBinding { + width: Some(32), + ..binding.clone() + }, + ExternalEditorResourceBinding { + height: Some(32), + ..binding.clone() + }, + ] { + fs::write( + &path, + serde_json::to_vec_pretty(&tampered).expect("serialize tampered resource binding"), + ) + .expect("tamper persisted resource binding"); + assert!( + read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source_identity(), + ) + .is_err(), + "changing a persisted remote resource identity must invalidate the payload" + ); + } + } + #[test] fn serialized_bindings_never_contain_tokens_or_absolute_paths() { let session = platform_session("user-a", "super-secret-token", 1); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 851c1899e..e738530c2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -921,15 +921,38 @@ pub(crate) fn validate_manifest_required_visual_asset( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; - if canvas_project_id != art_spec_project_id { - return Err(format!( - "派生视觉资产与当前统一视觉规范图不属于同一画布项目:{expected_path}" - )); - } - if asset.source.reference_resource_ids.as_slice() != [art_spec_resource_id] { + let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else { return Err(format!( "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" )); + }; + let original_provenance_matches = + canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id; + let rebound_local_source_matches = if original_provenance_matches { + true + } else { + let art_spec_bytes = resolve_local_project_path(root, &art_spec.local_path) + .ok() + .and_then(|path| fs::read(path).ok()) + .ok_or_else(|| "读取统一视觉规范图失败".to_string())?; + let source_identity = new_external_editor_source_identity( + &art_spec.id, + &format!("{:x}", Sha256::digest(&art_spec_bytes)), + &art_spec.media_type, + &art_spec.kind, + )?; + external_editor_remote_reference_matches_local_source_at( + root, + &manifest.project_id, + canvas_project_id, + reference_resource_id, + &source_identity, + )? + }; + if !rebound_local_source_matches { + return Err(format!( + "派生视觉资产未绑定当前统一视觉规范图的本地内容身份:{expected_path}" + )); } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 829f955b4..99e95d7f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -25,6 +25,8 @@ const RESOURCE_EDIT_PROVIDER_HANDOFF_SCHEMA_VERSION: &str = "game-creator-resource-edit-provider-handoff.v1"; const RESOURCE_EDIT_PROVIDER_HANDOFF_MAX_BYTES: usize = 16 * 1024 * 1024; const RESOURCE_EDIT_SERVICE_IDENTITY_CONFIRMATION_TTL_SECONDS: u64 = 10 * 60; +const RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME: &str = "official-platform-v1"; +const RESOURCE_EDIT_DEVELOPER_ACCESS_SCHEME: &str = "external-developer-v1"; type ResourceEditAsyncLock = tokio::sync::Mutex<()>; @@ -35,6 +37,37 @@ static RESOURCE_EDIT_PROJECT_MUTATION_LOCKS: OnceLock< std::sync::Mutex>>, > = OnceLock::new(); +#[cfg(test)] +#[derive(Clone)] +struct ResourceEditAccountBoundWriteTestBarrier { + entered: Arc, + release: Arc, +} + +#[cfg(test)] +thread_local! { + static RESOURCE_EDIT_ACCOUNT_BOUND_WRITE_TEST_BARRIER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn install_resource_edit_account_bound_write_test_barrier( + barrier: ResourceEditAccountBoundWriteTestBarrier, +) { + RESOURCE_EDIT_ACCOUNT_BOUND_WRITE_TEST_BARRIER.with(|slot| { + *slot.borrow_mut() = Some(barrier); + }); +} + +#[cfg(test)] +fn wait_resource_edit_account_bound_write_test_barrier() { + let barrier = + RESOURCE_EDIT_ACCOUNT_BOUND_WRITE_TEST_BARRIER.with(|slot| slot.borrow_mut().take()); + if let Some(barrier) = barrier { + barrier.entered.wait(); + barrier.release.wait(); + } +} + fn canonical_resource_edit_project_path(root: &Path) -> Result { fs::canonicalize(root).map_err(|error| format!("解析资源编辑项目路径失败:{error}")) } @@ -328,6 +361,8 @@ struct ResourceEditLedger { #[serde(default)] provider_request_issued_at: Option, #[serde(default)] + access_scheme: Option, + #[serde(default)] api_identity_scheme: Option, #[serde(default)] api_identity_fingerprint: Option, @@ -343,6 +378,16 @@ struct ResourceEditLedger { remote_operation_id: Option, #[serde(default)] remote_project_id: Option, + #[serde(default)] + source_upload_bucket: Option, + #[serde(default)] + source_remote_object_key: Option, + #[serde(default)] + source_upload_completed: bool, + #[serde(default)] + source_remote_asset_object_id: Option, + #[serde(default)] + source_remote_resource_id: Option, remote_resource_id: Option, remote_object_key: Option, remote_asset_object_id: Option, @@ -463,6 +508,11 @@ struct ResourceEditUploadTicket { form_fields: BTreeMap, } +struct ResourceEditRemoteStage { + value: T, + post_response_session_error: Option, +} + fn resource_edit_ledger_path(operation_id: &str) -> String { format!("{RESOURCE_EDIT_ROOT}/operations/{operation_id}.json") } @@ -757,6 +807,40 @@ fn legacy_resource_edit_request_fingerprint( Ok(sha256_hex(&payload)) } +fn legacy_remote_canonical_request_fingerprints( + input: &DeriveLocalProjectResourceInput, + source: &ResourceEditSourceSnapshot, + prompt: &str, + asset_name: &str, + ledger: &ResourceEditLedger, +) -> Result, String> { + let Some(source_asset) = source.source_asset.as_ref() else { + return Ok(None); + }; + let local_canonical_resource_id = format!("local-asset:{}", source_asset.id); + if source.canonical_resource_id != local_canonical_resource_id + || ledger.source_resource_id == source.canonical_resource_id + || ledger.source_asset_id.as_deref() != Some(source_asset.id.as_str()) + || ledger.source_path.as_deref() != source.source_path.as_deref() + || ledger.source_sha256 != source.source_sha256 + || ledger.source_media_type.as_deref() != Some(source.media_type.as_str()) + || ledger.source_asset_kind.as_deref() != Some(source.asset_kind.as_str()) + { + return Ok(None); + } + + // v1 ledgers created before local asset IDs became canonical fingerprinted the manifest's + // remote resourceId. Only an existing ledger whose complete local source identity still + // matches may replay that historical fingerprint; new requests never consult manifest remote + // IDs as editable authority. + let mut legacy_source = source.clone(); + legacy_source.canonical_resource_id = ledger.source_resource_id.clone(); + Ok(Some([ + resource_edit_request_fingerprint(input, &legacy_source, prompt, asset_name)?, + legacy_resource_edit_request_fingerprint(input, &legacy_source, prompt, asset_name)?, + ])) +} + fn read_resource_edit_ledger( root: &Path, operation_id: &str, @@ -770,6 +854,8 @@ fn read_resource_edit_ledger( } fn write_resource_edit_ledger(root: &Path, ledger: &ResourceEditLedger) -> Result<(), String> { + #[cfg(test)] + wait_resource_edit_account_bound_write_test_barrier(); write_agent_runtime_json_sidecar_with_max_bytes( root, &resource_edit_ledger_path(&ledger.operation_id), @@ -789,46 +875,96 @@ fn update_resource_edit_phase( write_resource_edit_ledger(root, ledger) } -fn bind_resource_edit_platform_owner( +fn initialize_resource_edit_access_identity( root: &Path, ledger: &mut ResourceEditLedger, + api_base_url: &str, session: Option<&PlatformSessionSnapshot>, ) -> Result<(), String> { - let Some(session) = session else { - return Ok(()); - }; - match ( - ledger.platform_owner_user_id.as_deref(), - ledger.platform_owner_api_base_url.as_deref(), - ) { - (None, None) - if ledger.remote_operation_id.is_none() && ledger.remote_object_key.is_none() => + with_frozen_resource_edit_platform_session(session, || { + if ledger.access_scheme.is_some() + || ledger.api_identity_scheme.is_some() + || ledger.api_identity_fingerprint.is_some() + || ledger.platform_owner_user_id.is_some() + || ledger.platform_owner_api_base_url.is_some() { + return Err("资源编辑首次访问主体只能写入一次".to_string()); + } + let service_fingerprint = + platform_art_generation_external_service_fingerprint(api_base_url, session); + ledger.access_scheme = Some( + if session.is_some() { + RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME + } else { + RESOURCE_EDIT_DEVELOPER_ACCESS_SCHEME + } + .to_string(), + ); + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some(service_fingerprint); + if let Some(session) = session { ledger.platform_owner_user_id = Some(session.user_id.clone()); ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); - write_resource_edit_ledger(root, ledger) } - (Some(user_id), Some(api_base_url)) - if user_id == session.user_id && api_base_url == session.api_base_url => + write_resource_edit_ledger(root, ledger) + }) +} + +fn bind_resource_edit_platform_owner( + ledger: &ResourceEditLedger, + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + with_frozen_resource_edit_platform_session(session, || { + match ( + ledger.access_scheme.as_deref(), + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + session, + ) { + ( + Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME), + Some(user_id), + Some(api_base_url), + Some(session), + ) if user_id == session.user_id && api_base_url == session.api_base_url => Ok(()), + (Some(RESOURCE_EDIT_DEVELOPER_ACCESS_SCHEME), None, None, None) => Ok(()), + _ => Err( + "authentication-required: 此资源编辑缺少完整访问主体或属于其他陶泥儿账号,当前账号不能恢复或提交" + .to_string(), + ), + } + }) +} + +fn validate_resource_edit_access_identity( + ledger: &ResourceEditLedger, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + match ( + ledger.access_scheme.as_deref(), + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + platform_session, + ) { + ( + Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME), + Some(owner_user_id), + Some(owner_api_base_url), + Some(session), + ) if owner_user_id == session.user_id.as_str() + && owner_api_base_url == session.api_base_url.as_str() => { Ok(()) } + (Some(RESOURCE_EDIT_DEVELOPER_ACCESS_SCHEME), None, None, None) => Ok(()), _ => Err( - "authentication-required: 此资源编辑属于其他陶泥儿账号,当前账号不能恢复或提交" + "authentication-required: 资源编辑访问模式、服务地址或账本 owner 与当前调用主体不一致" .to_string(), ), } } -fn ensure_frozen_resource_edit_platform_session( - session: Option<&PlatformSessionSnapshot>, -) -> Result<(), String> { - if let Some(session) = session { - validate_platform_session_snapshot(session)?; - } - Ok(()) -} - fn read_stable_resource_edit_file( root: &Path, relative_path: &str, @@ -1355,18 +1491,12 @@ async fn request_resource_edit_upload_ticket( access: &ExternalEditorBindingAccess<'_>, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, -) -> Result { + binding_key_sha256: &str, +) -> Result, String> { let bytes = source .bytes .as_ref() .ok_or_else(|| "源媒体上传缺少文件内容".to_string())?; - let file_name = source - .source_path - .as_deref() - .and_then(|path| Path::new(path).file_name()) - .and_then(|value| value.to_str()) - .map(sanitize_file_name) - .unwrap_or_else(|| "source.bin".to_string()); access.validate_frozen_session()?; let response = client .post(format!( @@ -1378,20 +1508,35 @@ async fn request_resource_edit_upload_ticket( .json(&resource_edit_upload_ticket_payload( input, source, - &file_name, + binding_key_sha256, bytes.len(), )) .send() .await - .map_err(|_| "创建源资源上传凭证失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?; + let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(editor_api_authentication_error()); } if response.status() == reqwest::StatusCode::FORBIDDEN { + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(editor_api_authorization_error()); } if !response.status().is_success() { + if response.status().is_server_error() { + return Err(format!( + "result-unknown: 创建源资源上传凭证返回 HTTP {}", + response.status().as_u16() + )); + } + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(format!( "创建源资源上传凭证失败:HTTP {}", response.status().as_u16() @@ -1400,8 +1545,10 @@ async fn request_resource_edit_upload_ticket( let payload = response .json::() .await - .map_err(|_| "解析源资源上传凭证失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| "result-unknown: 解析源资源上传凭证失败".to_string())?; + if post_response_session_error.is_none() { + post_response_session_error = access.validate_frozen_session().err(); + } let upload = external_editor_response_data(&payload) .get("upload") .or_else(|| payload.pointer("/data/upload")) @@ -1431,28 +1578,32 @@ async fn request_resource_edit_upload_ticket( .ok_or_else(|| "源资源上传凭证 formFields 必须全为字符串".to_string()) }) .collect::, _>>()?; - Ok(ResourceEditUploadTicket { - host, - bucket, - object_key, - success_action_status, - form_fields, + Ok(ResourceEditRemoteStage { + value: ResourceEditUploadTicket { + host, + bucket, + object_key, + success_action_status, + form_fields, + }, + post_response_session_error, }) } fn resource_edit_upload_ticket_payload( input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, - file_name: &str, + binding_key_sha256: &str, byte_length: usize, ) -> serde_json::Value { + let file_name = format!("source.{}", infer_file_extension(None, &source.media_type)); serde_json::json!({ "legacyPrefix": RESOURCE_EDIT_UPLOAD_LEGACY_PREFIX, "pathSegments": [ "editor", RESOURCE_EDIT_UPLOAD_NAMESPACE, input.expected_project_id.as_str(), - input.operation_id.as_str() + binding_key_sha256 ], "fileName": file_name, "contentType": source.media_type, @@ -1466,7 +1617,7 @@ async fn upload_resource_edit_source( ticket: &ResourceEditUploadTicket, source: &ResourceEditSourceSnapshot, access: &ExternalEditorBindingAccess<'_>, -) -> Result<(), String> { +) -> Result, String> { let bytes = source .bytes .as_ref() @@ -1483,13 +1634,7 @@ async fn upload_resource_edit_source( for (key, value) in &ticket.form_fields { form = form.text(key.clone(), value.clone()); } - let file_name = source - .source_path - .as_deref() - .and_then(|path| Path::new(path).file_name()) - .and_then(|value| value.to_str()) - .map(sanitize_file_name) - .unwrap_or_else(|| "source.bin".to_string()); + let file_name = format!("source.{}", infer_file_extension(None, &source.media_type)); let part = Part::bytes(bytes.clone()) .file_name(file_name) .mime_str(&source.media_type) @@ -1500,23 +1645,36 @@ async fn upload_resource_edit_source( .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传源资源失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| "result-unknown: 上传源资源未取得确定响应".to_string())?; + let post_response_session_error = access.validate_frozen_session().err(); if response.status().as_u16() != ticket.success_action_status { + if response.status().is_server_error() { + return Err(format!( + "result-unknown: 上传源资源返回 HTTP {}", + response.status().as_u16() + )); + } + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(format!( "上传源资源失败:HTTP {}", response.status().as_u16() )); } - Ok(()) + Ok(ResourceEditRemoteStage { + value: (), + post_response_session_error, + }) } async fn confirm_resource_edit_source( client: &reqwest::Client, access: &ExternalEditorBindingAccess<'_>, - ticket: &ResourceEditUploadTicket, + bucket: &str, + object_key: &str, source: &ResourceEditSourceSnapshot, -) -> Result { +) -> Result, String> { let bytes = source .bytes .as_ref() @@ -1530,8 +1688,8 @@ async fn confirm_resource_edit_source( )) .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ - "bucket": ticket.bucket, - "objectKey": ticket.object_key, + "bucket": bucket, + "objectKey": object_key, "contentType": source.media_type, "contentLength": bytes.len(), "contentHash": source.source_sha256, @@ -1540,15 +1698,30 @@ async fn confirm_resource_edit_source( })) .send() .await - .map_err(|_| "确认源资源上传失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?; + let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(editor_api_authentication_error()); } if response.status() == reqwest::StatusCode::FORBIDDEN { + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(editor_api_authorization_error()); } if !response.status().is_success() { + if response.status().is_server_error() { + return Err(format!( + "result-unknown: 确认源资源上传返回 HTTP {}", + response.status().as_u16() + )); + } + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(format!( "确认源资源上传失败:HTTP {}", response.status().as_u16() @@ -1557,17 +1730,23 @@ async fn confirm_resource_edit_source( let payload = response .json::() .await - .map_err(|_| "解析源资源确认响应失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| "result-unknown: 解析源资源确认响应失败".to_string())?; + if post_response_session_error.is_none() { + post_response_session_error = access.validate_frozen_session().err(); + } let asset_object = external_editor_response_data(&payload) .get("assetObject") .or_else(|| payload.pointer("/data/assetObject")) - .ok_or_else(|| "源资源确认响应缺少 assetObject".to_string())?; - if json_string_field(asset_object, "objectKey").as_deref() != Some(ticket.object_key.as_str()) { - return Err("源资源确认响应 objectKey 不一致".to_string()); + .ok_or_else(|| "result-unknown: 源资源确认响应缺少 assetObject".to_string())?; + if json_string_field(asset_object, "objectKey").as_deref() != Some(object_key) { + return Err("result-unknown: 源资源确认响应 objectKey 不一致".to_string()); } - json_string_field(asset_object, "assetObjectId") - .ok_or_else(|| "源资源确认响应缺少 assetObjectId".to_string()) + let asset_object_id = json_string_field(asset_object, "assetObjectId") + .ok_or_else(|| "result-unknown: 源资源确认响应缺少 assetObjectId".to_string())?; + Ok(ResourceEditRemoteStage { + value: asset_object_id, + post_response_session_error, + }) } async fn resolve_resource_edit_source_project( @@ -1584,12 +1763,13 @@ async fn resolve_resource_edit_source_project( async fn register_resource_edit_source_image( client: &reqwest::Client, access: &ExternalEditorBindingAccess<'_>, - input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, + local_asset_id: &str, + binding_key: &str, remote_project_id: &str, - ticket: &ResourceEditUploadTicket, + object_key: &str, asset_object_id: &str, -) -> Result<(String, u32, u32), String> { +) -> Result, String> { let bytes = source .bytes .as_deref() @@ -1608,9 +1788,13 @@ async fn register_resource_edit_source_image( access.api_route(&endpoint) )) .bearer_auth(access.bearer_token()) + .header( + "Idempotency-Key", + format!("game-creator-resource-{binding_key}"), + ) .json(&serde_json::json!({ - "imageSrc": format!("/{}", ticket.object_key.trim_start_matches('/')), - "objectKey": ticket.object_key, + "imageSrc": format!("/{}", object_key.trim_start_matches('/')), + "objectKey": object_key, "assetObjectId": asset_object_id, "width": decoded.width(), "height": decoded.height(), @@ -1618,20 +1802,36 @@ async fn register_resource_edit_source_image( "assetKind": source.asset_kind, "generationInputs": { "source": RESOURCE_EDIT_QUEUE_SOURCE, - "operationId": input.operation_id, + "localAssetId": local_asset_id, + "sourceSha256": source.source_sha256, }, })) .send() .await - .map_err(|_| "登记源图片项目资源失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?; + let mut post_response_session_error = access.validate_frozen_session().err(); if response.status() == reqwest::StatusCode::UNAUTHORIZED { + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(editor_api_authentication_error()); } if response.status() == reqwest::StatusCode::FORBIDDEN { + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(editor_api_authorization_error()); } if !response.status().is_success() { + if response.status().is_server_error() { + return Err(format!( + "result-unknown: 登记源图片项目资源返回 HTTP {}", + response.status().as_u16() + )); + } + if let Some(error) = post_response_session_error { + return Err(error); + } return Err(format!( "登记源图片项目资源失败:HTTP {}", response.status().as_u16() @@ -1640,12 +1840,54 @@ async fn register_resource_edit_source_image( let payload = response .json::() .await - .map_err(|_| "解析源图片项目资源响应失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| "result-unknown: 解析源图片项目资源响应失败".to_string())?; + if post_response_session_error.is_none() { + post_response_session_error = access.validate_frozen_session().err(); + } let data = external_editor_response_data(&payload); let resource_id = json_string_field(data.get("resource").unwrap_or(data), "resourceId") - .ok_or_else(|| "源图片项目资源响应缺少 resourceId".to_string())?; - Ok((resource_id, decoded.width(), decoded.height())) + .ok_or_else(|| "result-unknown: 源图片项目资源响应缺少 resourceId".to_string())?; + if !is_registered_editor_reference_id(&resource_id) { + return Err("result-unknown: 源图片项目资源响应 resourceId 无效".to_string()); + } + Ok(ResourceEditRemoteStage { + value: (resource_id, decoded.width(), decoded.height()), + post_response_session_error, + }) +} + +fn write_resource_edit_source_stage( + root: &Path, + ledger: &mut ResourceEditLedger, + stage: &str, +) -> Result<(), String> { + ledger.updated_at = unix_timestamp(); + write_resource_edit_ledger(root, ledger) + .map_err(|error| format!("result-unknown: {stage} 已成功但恢复身份落盘失败:{error}")) +} + +fn validate_resource_edit_source_recovery_state( + input: &DeriveLocalProjectResourceInput, + ledger: &ResourceEditLedger, +) -> Result<(), String> { + let has_bucket = ledger.source_upload_bucket.is_some(); + let has_object_key = ledger.source_remote_object_key.is_some(); + if has_bucket != has_object_key + || (ledger.source_upload_completed && !has_object_key) + || (ledger.source_remote_asset_object_id.is_some() && !ledger.source_upload_completed) + || (ledger.source_remote_resource_id.is_some() + && ledger.source_remote_asset_object_id.is_none()) + || (input.edit_kind != LocalProjectResourceEditKind::ImageReference + && ledger.source_remote_resource_id.is_some()) + { + return Err("result-unknown: 源资源远端恢复阶段不完整,禁止自动重放".to_string()); + } + if let Some(resource_id) = ledger.source_remote_resource_id.as_deref() { + if !is_registered_editor_reference_id(resource_id) { + return Err("result-unknown: 源图片远端 resourceId 无效,禁止自动重放".to_string()); + } + } + Ok(()) } async fn ensure_resource_edit_source_reference( @@ -1671,6 +1913,20 @@ async fn ensure_resource_edit_source_reference( &source.media_type, &source.asset_kind, )?; + let principal_key = + external_editor_project_binding_key_sha256(&input.expected_project_id, principal)?; + let binding_key = external_editor_resource_binding_key_sha256( + &input.expected_project_id, + &principal_key, + &canvas_context.project_id, + &source_identity, + )?; + let _binding_guard = + acquire_external_editor_resource_binding_singleflight(root, &binding_key, access).await?; + access.validate_frozen_session()?; + + // The first waiter may have installed the binding while this operation waited on the shared + // process/cross-process lock. Only the exact principal + project + local source identity wins. if let Some(binding) = read_external_editor_resource_binding_at( root, &input.expected_project_id, @@ -1703,37 +1959,126 @@ async fn ensure_resource_edit_source_reference( )?; } + validate_resource_edit_source_recovery_state(input, ledger)?; + let has_source_remote_state = ledger.source_remote_object_key.is_some() + || ledger.source_upload_completed + || ledger.source_remote_asset_object_id.is_some() + || ledger.source_remote_resource_id.is_some(); + if has_source_remote_state + && ledger.remote_project_id.as_deref() != Some(canvas_context.project_id.as_str()) + { + return Err( + "result-unknown: 源资源恢复身份所属 remote project 已变化,禁止自动重放".to_string(), + ); + } + ledger.remote_project_id = Some(canvas_context.project_id.clone()); + // Prepared legacy ledgers and manifest fields are not authorization evidence. If the current - // principal has no exact source binding, upload the committed local bytes again. + // principal has no exact source binding, use only locally journaled stages for this identity. if ledger.source_stable_reference.take().is_some() { write_resource_edit_ledger(root, ledger)?; } - let ticket = request_resource_edit_upload_ticket(client, access, input, source).await?; - access.validate_frozen_session()?; - upload_resource_edit_source(&ticket, source, access).await?; - access.validate_frozen_session()?; - let asset_object_id = confirm_resource_edit_source(client, access, &ticket, source).await?; - access.validate_frozen_session()?; + + let ticket = if ledger.source_upload_completed { + None + } else { + let submitted = + request_resource_edit_upload_ticket(client, access, input, source, &binding_key) + .await?; + if ledger + .source_upload_bucket + .as_deref() + .is_some_and(|value| value != submitted.value.bucket.as_str()) + || ledger + .source_remote_object_key + .as_deref() + .is_some_and(|value| value != submitted.value.object_key.as_str()) + { + return Err( + "result-unknown: 源资源上传凭证身份与已持久化阶段不一致,禁止自动重放".to_string(), + ); + } + ledger.source_upload_bucket = Some(submitted.value.bucket.clone()); + ledger.source_remote_object_key = Some(submitted.value.object_key.clone()); + write_resource_edit_source_stage(root, ledger, "源资源上传凭证")?; + if let Some(error) = submitted.post_response_session_error { + return Err(error); + } + Some(submitted.value) + }; + + if !ledger.source_upload_completed { + let ticket = ticket + .as_ref() + .ok_or_else(|| "result-unknown: 源资源上传阶段缺少有效凭证".to_string())?; + let uploaded = upload_resource_edit_source(ticket, source, access).await?; + ledger.source_upload_completed = true; + write_resource_edit_source_stage(root, ledger, "源资源上传")?; + if let Some(error) = uploaded.post_response_session_error { + return Err(error); + } + } + + let object_key = ledger + .source_remote_object_key + .clone() + .ok_or_else(|| "result-unknown: 源资源恢复阶段缺少 objectKey".to_string())?; + let bucket = ledger + .source_upload_bucket + .clone() + .ok_or_else(|| "result-unknown: 源资源恢复阶段缺少 bucket".to_string())?; + let asset_object_id = + if let Some(asset_object_id) = ledger.source_remote_asset_object_id.clone() { + asset_object_id + } else { + let confirmed = + confirm_resource_edit_source(client, access, &bucket, &object_key, source).await?; + ledger.source_remote_asset_object_id = Some(confirmed.value.clone()); + write_resource_edit_source_stage(root, ledger, "源资源确认")?; + if let Some(error) = confirmed.post_response_session_error { + return Err(error); + } + confirmed.value + }; + let (stable_reference, remote_resource_id, width, height) = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - let (resource_id, width, height) = register_resource_edit_source_image( - client, - access, - input, - source, - &canvas_context.project_id, - &ticket, - &asset_object_id, - ) - .await?; + let bytes = source + .bytes + .as_deref() + .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; + let decoded = image::load_from_memory(bytes) + .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { + resource_id + } else { + let registered = register_resource_edit_source_image( + client, + access, + source, + local_asset_id, + &binding_key, + &canvas_context.project_id, + &object_key, + &asset_object_id, + ) + .await?; + let resource_id = registered.value.0; + ledger.source_remote_resource_id = Some(resource_id.clone()); + write_resource_edit_source_stage(root, ledger, "源图片项目资源登记")?; + if let Some(error) = registered.post_response_session_error { + return Err(error); + } + resource_id + }; ( resource_id.clone(), Some(resource_id), - Some(width), - Some(height), + Some(decoded.width()), + Some(decoded.height()), ) } else { - (ticket.object_key.clone(), None, None, None) + (object_key.clone(), None, None, None) }; access.validate_frozen_session()?; let binding = new_external_editor_resource_binding( @@ -1742,7 +2087,7 @@ async fn ensure_resource_edit_source_reference( &canvas_context.project_id, &source_identity, remote_resource_id.as_deref(), - &ticket.object_key, + &object_key, &asset_object_id, width, height, @@ -1751,7 +2096,6 @@ async fn ensure_resource_edit_source_reference( write_external_editor_resource_binding_at(root, &binding)?; access.validate_frozen_session()?; ledger.source_stable_reference = Some(stable_reference.clone()); - ledger.remote_project_id = Some(canvas_context.project_id.clone()); write_resource_edit_ledger(root, ledger)?; Ok(PreparedResourceEditSourceReference { stable_reference, @@ -1881,6 +2225,11 @@ fn resource_edit_result_has_download(payload: &serde_json::Value) -> bool { || json_string_field(data, "objectKey").is_some() } +struct SubmittedResourceEditRemote { + payload: serde_json::Value, + post_response_session_error: Option, +} + fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { matches!( endpoint, @@ -1896,7 +2245,7 @@ async fn submit_resource_edit_remote( client: &reqwest::Client, access: &ExternalEditorBindingAccess<'_>, ledger: &mut ResourceEditLedger, -) -> Result { +) -> Result { let endpoint = ledger .endpoint .as_deref() @@ -1938,18 +2287,27 @@ async fn submit_resource_edit_remote( .send() .await .map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?; - access.validate_frozen_session()?; + let mut post_response_session_error = access.validate_frozen_session().err(); let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { + if let Some(error) = post_response_session_error.as_ref() { + return Err(error.clone()); + } return Err(editor_api_authentication_error()); } if status == reqwest::StatusCode::FORBIDDEN { + if let Some(error) = post_response_session_error.as_ref() { + return Err(error.clone()); + } return Err(editor_api_authorization_error()); } if status == reqwest::StatusCode::BAD_REQUEST { ledger.terminal_failure_code = Some("remote-request-bad-request".to_string()); ledger.terminal_failed_at = Some(unix_timestamp()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed)?; + if let Some(error) = post_response_session_error.as_ref() { + return Err(error.clone()); + } return Err("remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400".to_string()); } if !matches!( @@ -1961,6 +2319,9 @@ async fn submit_resource_edit_remote( ledger, ResourceEditLedgerPhase::ReconciliationRequired, )?; + if let Some(error) = post_response_session_error.as_ref() { + return Err(error.clone()); + } return Err(format!( "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", status.as_u16() @@ -1977,7 +2338,9 @@ async fn submit_resource_edit_remote( return Err("result-unknown: 资源编辑响应无法解析".to_string()); } }; - access.validate_frozen_session()?; + if post_response_session_error.is_none() { + post_response_session_error = access.validate_frozen_session().err(); + } if resource_edit_operation_id(&payload).is_none() { update_resource_edit_phase( root, @@ -1986,7 +2349,28 @@ async fn submit_resource_edit_remote( )?; return Err("result-unknown: 资源编辑已受理但响应缺少 operationId".to_string()); } - Ok(payload) + Ok(SubmittedResourceEditRemote { + payload, + post_response_session_error, + }) +} + +async fn submit_and_persist_resource_edit_remote( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + ledger: &mut ResourceEditLedger, +) -> Result<(), String> { + let submission = submit_resource_edit_remote(root, client, access, ledger).await?; + let operation_id = resource_edit_operation_id(&submission.payload) + .ok_or_else(|| "result-unknown: 资源编辑已受理但响应缺少 operationId".to_string())?; + ledger.remote_operation_id = Some(operation_id); + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; + if let Some(error) = submission.post_response_session_error { + return Err(error); + } + access.validate_frozen_session()?; + Ok(()) } async fn wait_for_resource_edit_remote( @@ -2179,9 +2563,16 @@ fn resource_edit_service_identity_snapshot_sha256( "endpoint": ledger.endpoint, "requestBodySha256": ledger.request_body_json.as_deref().map(|body| sha256_hex(body.as_bytes())), "remoteOperationId": ledger.remote_operation_id, + "remoteProjectId": ledger.remote_project_id, + "sourceUploadBucket": ledger.source_upload_bucket, + "sourceRemoteObjectKey": ledger.source_remote_object_key, + "sourceUploadCompleted": ledger.source_upload_completed, + "sourceRemoteAssetObjectId": ledger.source_remote_asset_object_id, + "sourceRemoteResourceId": ledger.source_remote_resource_id, "remoteResourceId": ledger.remote_resource_id, "remoteObjectKey": ledger.remote_object_key, "sourceStableReference": ledger.source_stable_reference, + "accessScheme": ledger.access_scheme, "apiIdentityScheme": ledger.api_identity_scheme, "apiIdentityFingerprint": ledger.api_identity_fingerprint, }), @@ -2190,15 +2581,31 @@ fn resource_edit_service_identity_snapshot_sha256( } fn resource_edit_has_service_identity_evidence(ledger: &ResourceEditLedger) -> bool { - ledger.endpoint.is_some() + ledger.access_scheme.is_some() + || ledger.platform_owner_user_id.is_some() + || ledger.platform_owner_api_base_url.is_some() + || ledger.endpoint.is_some() || ledger.request_body_json.is_some() || ledger.remote_operation_id.is_some() + || ledger.remote_project_id.is_some() + || ledger.source_upload_bucket.is_some() + || ledger.source_remote_object_key.is_some() + || ledger.source_upload_completed + || ledger.source_remote_asset_object_id.is_some() + || ledger.source_remote_resource_id.is_some() || ledger.remote_resource_id.is_some() || ledger.remote_object_key.is_some() || ledger.source_stable_reference.is_some() || ledger.phase != ResourceEditLedgerPhase::Prepared } +fn validate_resource_edit_service_identity_owner( + ledger: &ResourceEditLedger, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + validate_resource_edit_access_identity(ledger, platform_session) +} + fn public_resource_edit_service_identity_confirmation( ledger: &ResourceEditLedger, service_origin: String, @@ -2219,13 +2626,35 @@ fn prepare_resource_edit_service_identity( ledger: &mut ResourceEditLedger, api_base_url: &str, api_key: &str, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { - let fingerprint = platform_art_generation_external_service_fingerprint(api_base_url); + with_frozen_resource_edit_platform_session(platform_session, || { + prepare_resource_edit_service_identity_under_lease( + root, + ledger, + api_base_url, + api_key, + platform_session, + ) + }) +} + +fn prepare_resource_edit_service_identity_under_lease( + root: &Path, + ledger: &mut ResourceEditLedger, + api_base_url: &str, + api_key: &str, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result { + validate_resource_edit_service_identity_owner(ledger, platform_session)?; + let fingerprint = + platform_art_generation_external_service_fingerprint(api_base_url, platform_session); match classify_platform_art_generation_service_identity( ledger.api_identity_scheme.as_deref(), ledger.api_identity_fingerprint.as_deref(), api_base_url, api_key, + platform_session, ) { PlatformArtGenerationServiceIdentityMatch::Current | PlatformArtGenerationServiceIdentityMatch::LegacyVerified => { @@ -2310,13 +2739,14 @@ async fn prepare_remote_resource_edit( access: &ExternalEditorBindingAccess<'_>, principal: &ExternalEditorPrincipalIdentity, ) -> Result<(), String> { - bind_resource_edit_platform_owner(root, ledger, access.frozen_platform_session())?; + bind_resource_edit_platform_owner(ledger, access.frozen_platform_session())?; access.validate_frozen_session()?; match prepare_resource_edit_service_identity( root, ledger, access.api_base_url(), access.bearer_token(), + access.frozen_platform_session(), )? { ResourceEditServiceIdentityDecision::Ready => {} ResourceEditServiceIdentityDecision::ConfirmationRequired(_) => { @@ -2402,13 +2832,7 @@ async fn prepare_remote_resource_edit( ); write_resource_edit_ledger(root, ledger)?; } - let submission = submit_resource_edit_remote(root, &client, access, ledger).await?; - access.validate_frozen_session()?; - let operation_id = resource_edit_operation_id(&submission).ok_or_else(|| { - "result-unknown: 资源编辑已受理但响应缺少 operationId".to_string() - })?; - ledger.remote_operation_id = Some(operation_id.clone()); - update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; + submit_and_persist_resource_edit_remote(root, &client, access, ledger).await?; let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; access.validate_frozen_session()?; result @@ -2424,14 +2848,11 @@ async fn prepare_remote_resource_edit( }; let download_source = serde_json::json!({ "objectKey": object_key }); access.validate_frozen_session()?; - let read_url_route = access.api_route("/api/external/v1/assets/read-url"); - let download = resolve_canvas_resource_download_with_limit_and_route( + let download = resolve_canvas_resource_download_with_access( &client, - access.api_base_url(), - access.bearer_token(), + access, &download_source, media_read_limit(&input.edit_kind), - &read_url_route, ) .await .map_err(|_| "result-unknown: 远端资源编辑结果下载或换签失败".to_string())? @@ -3425,6 +3846,37 @@ fn commit_resource_edit_asset( commit_resource_edit_asset_internal(root, input, source, prompt, asset_name, ledger, None) } +fn with_frozen_resource_edit_platform_session( + platform_session: Option<&PlatformSessionSnapshot>, + action: impl FnOnce() -> Result, +) -> Result { + 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, + ) +} + +fn commit_resource_edit_asset_with_frozen_platform_session( + root: &Path, + input: &DeriveLocalProjectResourceInput, + source: &ResourceEditSourceSnapshot, + prompt: &str, + asset_name: &str, + ledger: &mut ResourceEditLedger, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result { + with_frozen_resource_edit_platform_session(platform_session, || { + commit_resource_edit_asset(root, input, source, prompt, asset_name, ledger) + }) +} + fn write_resource_edit_result_binding( root: &Path, input: &DeriveLocalProjectResourceInput, @@ -3781,6 +4233,18 @@ pub(crate) fn list_pending_local_project_resource_edits_at( if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } + 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()), + ) + }) + .transpose()?; let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; let entries = match fs::read_dir(&directory) { Ok(entries) => entries, @@ -3819,6 +4283,9 @@ pub(crate) fn list_pending_local_project_resource_edits_at( if !matches!( ledger.phase, ResourceEditLedgerPhase::Committed | ResourceEditLedgerPhase::Archived + ) && resource_edit_pending_is_visible_to_current_principal( + &ledger, + current_platform_session.as_ref(), ) { pending.push(PendingLocalProjectResourceEdit { operation_id: ledger.operation_id, @@ -3834,6 +4301,32 @@ pub(crate) fn list_pending_local_project_resource_edits_at( Ok(pending) } +fn resource_edit_pending_is_visible_to_current_principal( + ledger: &ResourceEditLedger, + current_platform_session: Option<&PlatformSessionSnapshot>, +) -> bool { + match ( + ledger.access_scheme.as_deref(), + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + ) { + ( + Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME), + Some(owner_user_id), + Some(owner_api_base_url), + ) => current_platform_session.is_some_and(|session| { + session.user_id == owner_user_id && session.api_base_url == owner_api_base_url + }), + (Some(RESOURCE_EDIT_DEVELOPER_ACCESS_SCHEME), None, None) => { + current_platform_session.is_none() + } + // Local-only edits have no remote principal and remain project-visible. Historical remote + // ledgers without a complete access scheme and owner cannot prove a current principal. + (None, None, None) => !ledger.edit_kind.is_remote_media(), + _ => false, + } +} + pub(crate) async fn request_resource_edit_service_identity_confirmation_at( input: RequestResourceEditServiceIdentityConfirmationInput, ) -> Result { @@ -3857,9 +4350,15 @@ pub(crate) async fn request_resource_edit_service_identity_confirmation_at( return Err("资源编辑服务身份确认对应的 operation 身份无效".to_string()); } ensure_resource_edit_phase_resumable(&ledger.phase)?; - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; - match prepare_resource_edit_service_identity(root, &mut ledger, &api_base_url, &api_key)? { + let (api_base_url, api_key, platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + match prepare_resource_edit_service_identity( + root, + &mut ledger, + &api_base_url, + &api_key, + platform_session.as_ref(), + )? { ResourceEditServiceIdentityDecision::ConfirmationRequired(confirmation) => Ok(confirmation), ResourceEditServiceIdentityDecision::Ready => { Err("当前资源编辑 operation 的服务身份已验证,无需显式确认".to_string()) @@ -3889,10 +4388,29 @@ pub(crate) async fn confirm_resource_edit_service_identity_at( if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; - let service_fingerprint = platform_art_generation_external_service_fingerprint(&api_base_url); - let service_origin = platform_art_generation_external_service_origin(&api_base_url)?; + let (api_base_url, api_key, platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + with_frozen_resource_edit_platform_session(platform_session.as_ref(), || { + confirm_resource_edit_service_identity_under_lease( + root, + &input, + &api_base_url, + &api_key, + platform_session.as_ref(), + ) + }) +} + +fn confirm_resource_edit_service_identity_under_lease( + root: &Path, + input: &ConfirmResourceEditServiceIdentityInput, + api_base_url: &str, + api_key: &str, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result { + let service_fingerprint = + platform_art_generation_external_service_fingerprint(api_base_url, platform_session); + let service_origin = platform_art_generation_external_service_origin(api_base_url)?; let mut ledger = read_resource_edit_ledger(root, &input.operation_id)? .ok_or_else(|| "资源编辑服务身份确认对应的账本不存在".to_string())?; if ledger.schema_version != RESOURCE_EDIT_SCHEMA_VERSION @@ -3903,6 +4421,7 @@ pub(crate) async fn confirm_resource_edit_service_identity_at( { return Err("资源编辑服务身份确认对应的 operation 身份已变化".to_string()); } + validate_resource_edit_service_identity_owner(&ledger, platform_session)?; ensure_resource_edit_phase_resumable(&ledger.phase)?; let confirmation = ledger .service_identity_confirmation @@ -3920,8 +4439,9 @@ pub(crate) async fn confirm_resource_edit_service_identity_at( classify_platform_art_generation_service_identity( ledger.api_identity_scheme.as_deref(), ledger.api_identity_fingerprint.as_deref(), - &api_base_url, - &api_key, + api_base_url, + api_key, + platform_session, ), PlatformArtGenerationServiceIdentityMatch::LegacyUnverified | PlatformArtGenerationServiceIdentityMatch::Unbound @@ -3961,16 +4481,52 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at( { return Err("待处置的资源编辑账本身份无效".to_string()); } - match ledger.phase { - ResourceEditLedgerPhase::Archived => {} - ResourceEditLedgerPhase::RemoteFailed => { - ledger.archived_at = Some(unix_timestamp()); - update_resource_edit_phase(root, &mut ledger, ResourceEditLedgerPhase::Archived)?; + let platform_session = match ( + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + ) { + (None, None) => None, + (Some(owner_user_id), Some(owner_api_base_url)) => { + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 此资源编辑属于已登录账号,请恢复原账号后归档".to_string() + })?; + if session.user_id != owner_user_id || session.api_base_url != owner_api_base_url { + return Err( + "authentication-required: 此资源编辑属于其他陶泥儿账号,当前账号不能归档" + .to_string(), + ); + } + Some(session) } - ResourceEditLedgerPhase::ReconciliationRequired => { - return Err("reconciliation-required: 结果未知的资源编辑不能移出恢复队列".to_string()); + _ => return Err("待处置的资源编辑账本账号身份不完整".to_string()), + }; + let mut archive = || -> Result<(), String> { + match ledger.phase { + ResourceEditLedgerPhase::Archived => {} + ResourceEditLedgerPhase::RemoteFailed => { + ledger.archived_at = Some(unix_timestamp()); + update_resource_edit_phase(root, &mut ledger, ResourceEditLedgerPhase::Archived)?; + } + ResourceEditLedgerPhase::ReconciliationRequired => { + return Err( + "reconciliation-required: 结果未知的资源编辑不能移出恢复队列".to_string(), + ); + } + _ => return Err("只有远端明确失败的资源编辑可以移出恢复队列".to_string()), } - _ => return Err("只有远端明确失败的资源编辑可以移出恢复队列".to_string()), + 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, + archive, + )?; + } else { + archive()?; } Ok(ArchiveFailedLocalProjectResourceEditResult { operation_id: ledger.operation_id, @@ -4091,6 +4647,11 @@ pub(crate) async fn derive_local_project_resource_at( legacy_resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name)?; let now = unix_timestamp(); let existing_ledger = read_resource_edit_ledger(root, &input.operation_id)?; + let new_remote_credentials = if existing_ledger.is_none() && input.edit_kind.is_remote_media() { + Some(resolve_canvas_sync_api_credentials(None, None)?) + } else { + None + }; if existing_ledger.is_none() && read_game_creator_agent_runtime_project_revision(root)?.revision != input.expected_project_revision @@ -4099,10 +4660,22 @@ pub(crate) async fn derive_local_project_resource_at( } let mut ledger = match existing_ledger { Some(ledger) => { - if !matches!( + let legacy_remote_canonical_fingerprints = + legacy_remote_canonical_request_fingerprints( + &input, + &source, + &prompt, + &asset_name, + &ledger, + )?; + let fingerprint_matches = matches!( ledger.request_fingerprint.as_str(), value if value == request_fingerprint || value == legacy_request_fingerprint - ) || ledger.idempotency_key != input.idempotency_key + ) || legacy_remote_canonical_fingerprints + .as_ref() + .is_some_and(|fingerprints| fingerprints.contains(&ledger.request_fingerprint)); + if !fingerprint_matches + || ledger.idempotency_key != input.idempotency_key || ledger.project_id != input.expected_project_id { return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string()); @@ -4111,7 +4684,7 @@ pub(crate) async fn derive_local_project_resource_at( ledger } None => { - let ledger = ResourceEditLedger { + let mut ledger = ResourceEditLedger { schema_version: RESOURCE_EDIT_SCHEMA_VERSION.to_string(), operation_id: input.operation_id.clone(), idempotency_key: input.idempotency_key.clone(), @@ -4140,6 +4713,7 @@ pub(crate) async fn derive_local_project_resource_at( prompt: prompt.clone(), asset_name: asset_name.clone(), provider_request_issued_at: None, + access_scheme: None, api_identity_scheme: None, api_identity_fingerprint: None, platform_owner_user_id: None, @@ -4150,6 +4724,11 @@ pub(crate) async fn derive_local_project_resource_at( request_body_json: None, remote_operation_id: None, remote_project_id: None, + source_upload_bucket: None, + source_remote_object_key: None, + source_upload_completed: false, + source_remote_asset_object_id: None, + source_remote_resource_id: None, remote_resource_id: None, remote_object_key: None, remote_asset_object_id: None, @@ -4165,7 +4744,19 @@ pub(crate) async fn derive_local_project_resource_at( created_at: now, updated_at: now, }; - write_resource_edit_ledger(root, &ledger)?; + if input.edit_kind.is_remote_media() { + let (api_base_url, _, platform_session) = new_remote_credentials + .as_ref() + .ok_or_else(|| "资源编辑缺少首次访问主体".to_string())?; + initialize_resource_edit_access_identity( + root, + &mut ledger, + api_base_url, + platform_session.as_ref(), + )?; + } else { + write_resource_edit_ledger(root, &ledger)?; + } ledger } }; @@ -4190,7 +4781,10 @@ pub(crate) async fn derive_local_project_resource_at( let remote_credentials = if input.edit_kind.is_remote_media() && ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { - Some(resolve_canvas_sync_api_credentials(None, None)?) + Some(match new_remote_credentials { + Some(credentials) => credentials, + None => resolve_canvas_sync_api_credentials(None, None)?, + }) } else { None }; @@ -4224,7 +4818,9 @@ pub(crate) async fn derive_local_project_resource_at( external_editor_binding_principal(&access) }) .transpose()?; - bind_resource_edit_platform_owner(root, &mut ledger, frozen_platform_session.as_ref())?; + if input.edit_kind.is_remote_media() { + bind_resource_edit_platform_owner(&ledger, frozen_platform_session.as_ref())?; + } if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { let generation_result = if input.edit_kind.is_text() { let staged = read_optional_resource_edit_staging(root, &input.operation_id)?; @@ -4293,9 +4889,15 @@ pub(crate) async fn derive_local_project_resource_at( } let project_lock = resource_edit_project_mutation_lock(root)?; let _project_guard = project_lock.lock().await; - ensure_frozen_resource_edit_platform_session(frozen_platform_session.as_ref())?; - let result = - commit_resource_edit_asset(root, &input, &source, &prompt, &asset_name, &mut ledger)?; + let result = commit_resource_edit_asset_with_frozen_platform_session( + root, + &input, + &source, + &prompt, + &asset_name, + &mut ledger, + frozen_platform_session.as_ref(), + )?; if let (Some((api_base_url, api_key, _)), Some(principal)) = (remote_credentials.as_ref(), binding_principal.as_ref()) { @@ -4327,6 +4929,7 @@ mod tests { api_key: &str, operation: impl Future, ) -> T { + let _platform_session_guard = clear_test_platform_session(); crate::assets::with_external_editor_api_credentials( crate::assets::external_editor_api_credentials_for_test( api_base_url.to_string(), @@ -4423,6 +5026,149 @@ mod tests { .expect("write resource editor media bytes"); } + fn spawn_source_binding_fixture( + listener: TcpListener, + upload_url: String, + hold_first_ticket_for_race: bool, + switch_after_first_registration: Option<(String, u64)>, + done: mpsc::Receiver<()>, + ) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set source binding fixture nonblocking"); + let object_key = "generated/source-binding.png"; + let mut requests = Vec::new(); + let mut pending_tickets = Vec::::new(); + let mut first_ticket_at = None; + let mut registration_count = 0_usize; + + loop { + let release_pending = pending_tickets.len() >= 2 + || first_ticket_at.is_some_and(|started: std::time::Instant| { + started.elapsed() >= Duration::from_millis(150) + }); + if release_pending { + for mut stream in pending_tickets.drain(..) { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url, + "bucket": "private-test-bucket", + "objectKey": object_key, + "successActionStatus": 204, + "formFields": {"key": object_key} + }}}), + ); + } + first_ticket_at = None; + } + + if done.try_recv().is_ok() { + assert!( + pending_tickets.is_empty(), + "source binding fixture stopped with pending tickets" + ); + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept source binding fixture request: {error}"), + }; + stream + .set_nonblocking(false) + .expect("restore source binding fixture stream blocking mode"); + let request = read_http_request(&mut stream); + let request_line = request.lines().next().unwrap_or_default(); + requests.push(request.clone()); + if request_line.starts_with("GET /api/external/v1/editor/projects ") + || request_line.starts_with("GET /api/editor/projects ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "source-binding-project" + }]}}), + ); + } else if request_line.starts_with("GET /api/external/v1/editor/assets/library ") + || request_line.starts_with("GET /api/editor/assets/library ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "source-binding-folder" + }]}}}), + ); + } else if request_line + .starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + || request_line.starts_with("POST /api/assets/direct-upload-tickets ") + { + if hold_first_ticket_for_race && pending_tickets.len() < 2 { + first_ticket_at.get_or_insert_with(std::time::Instant::now); + pending_tickets.push(stream); + } else { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url, + "bucket": "private-test-bucket", + "objectKey": object_key, + "successActionStatus": 204, + "formFields": {"key": object_key} + }}}), + ); + } + } else if request_line.starts_with("POST /upload-source-binding ") { + write_bytes(&mut stream, "204 No Content", "text/plain", &[]); + } else if request_line.starts_with("POST /api/external/v1/assets/objects/confirm ") + || request_line.starts_with("POST /api/assets/objects/confirm ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": object_key, + "assetObjectId": "source-binding-asset-object" + }}}), + ); + } else if request_line.contains("/projects/source-binding-project/resources ") { + registration_count += 1; + if registration_count == 1 { + if let Some((api_base_url, generation)) = + switch_after_first_registration.as_ref() + { + install_platform_session( + "source-binding-owner-b", + "source-binding-token-b", + api_base_url, + *generation, + ) + .expect("switch account after source registration"); + } + } + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-source-binding" + }}}), + ); + } else { + panic!("unexpected source binding fixture request: {request_line}"); + } + } + requests + }) + } + fn resource_editor_test_png() -> Vec { let mut output = Cursor::new(Vec::new()); image::DynamicImage::new_rgba8(2, 2) @@ -4490,6 +5236,10 @@ mod tests { prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), provider_request_issued_at: None, + access_scheme: input + .edit_kind + .is_remote_media() + .then(|| RESOURCE_EDIT_DEVELOPER_ACCESS_SCHEME.to_string()), api_identity_scheme: None, api_identity_fingerprint: None, platform_owner_user_id: None, @@ -4500,6 +5250,11 @@ mod tests { request_body_json: None, remote_operation_id: None, remote_project_id: None, + source_upload_bucket: None, + source_remote_object_key: None, + source_upload_completed: false, + source_remote_asset_object_id: None, + source_remote_resource_id: None, remote_resource_id: None, remote_object_key: None, remote_asset_object_id: None, @@ -4731,7 +5486,8 @@ mod tests { source_version: None, }; - let payload = resource_edit_upload_ticket_payload(&request, &source, "source-video.mp4", 3); + let binding_key = "b".repeat(64); + let payload = resource_edit_upload_ticket_payload(&request, &source, &binding_key, 3); assert_eq!(payload["legacyPrefix"], "generated-character-drafts"); assert_eq!( payload["pathSegments"], @@ -4739,10 +5495,10 @@ mod tests { "editor", "resource-editor-references", PROJECT_ID, - operation_id + binding_key ]) ); - assert_eq!(payload["fileName"], "source-video.mp4"); + assert_eq!(payload["fileName"], "source.mp4"); assert_eq!(payload["contentType"], "video/mp4"); assert_eq!(payload["access"], "private"); assert_eq!(payload["maxSizeBytes"], 3); @@ -5058,6 +5814,390 @@ mod tests { } } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_operations_share_first_source_binding_without_duplicate_registration() { + let directory = tempfile::tempdir().expect("create concurrent binding fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "并发源资源 binding 测试") + .expect("initialize project"); + let uploaded = upload_local_asset_at( + root, + "concurrent-source.png", + "image/png", + &resource_editor_test_png(), + ) + .expect("upload concurrent source"); + let manifest = read_existing_manifest_for_project(root).expect("read concurrent manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("concurrent source asset"); + let mut first_request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::ImageReference, + format!("asset:{}", source_asset.id), + ); + first_request.source_asset_id = Some(source_asset.id.clone()); + first_request.source_path = Some(source_asset.local_path.clone()); + first_request.source_media_type = Some(source_asset.media_type.clone()); + first_request.source_subtype = Some(source_asset.kind.clone()); + let mut second_request = first_request.clone(); + second_request.operation_id = Uuid::new_v4().to_string(); + second_request.idempotency_key = Uuid::new_v4().to_string(); + let source = resolve_resource_edit_source(root, &manifest, &first_request) + .expect("resolve concurrent source"); + let mut first_ledger = + ledger_for(&first_request, &source, ResourceEditLedgerPhase::Prepared); + let mut second_ledger = + ledger_for(&second_request, &source, ResourceEditLedgerPhase::Prepared); + write_resource_edit_ledger(root, &first_ledger).expect("write first concurrent ledger"); + write_resource_edit_ledger(root, &second_ledger).expect("write second concurrent ledger"); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind concurrent binding fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("concurrent binding address") + ); + let upload_url = format!("{base_url}/upload-source-binding"); + let access = ExternalEditorBindingAccess::new(&base_url, "source-binding-key", None) + .expect("concurrent binding access"); + let principal = + external_editor_binding_principal(&access).expect("concurrent binding principal"); + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + PROJECT_ID, + &principal, + "source-binding-project", + "source-binding-folder", + 1, + ) + .expect("concurrent project binding"), + ) + .expect("write concurrent project binding"); + let (done_sender, done_receiver) = mpsc::channel(); + let server = spawn_source_binding_fixture(listener, upload_url, true, None, done_receiver); + let client = reqwest::Client::new(); + + let (first, second) = tokio::join!( + ensure_resource_edit_source_reference( + root, + &client, + &access, + &principal, + &first_request, + &source, + &mut first_ledger, + ), + ensure_resource_edit_source_reference( + root, + &client, + &access, + &principal, + &second_request, + &source, + &mut second_ledger, + ), + ); + done_sender + .send(()) + .expect("stop concurrent source binding fixture"); + let requests = server + .join() + .expect("join concurrent source binding fixture"); + let first = first.expect("first concurrent source binding"); + let second = second.expect("second concurrent source binding"); + assert_eq!(first.stable_reference, second.stable_reference); + for endpoint in [ + "direct-upload-tickets", + "/upload-source-binding ", + "/assets/objects/confirm ", + "/projects/source-binding-project/resources ", + ] { + assert_eq!( + requests + .iter() + .filter(|request| request + .lines() + .next() + .unwrap_or_default() + .contains(endpoint)) + .count(), + 1, + "source binding side effect repeated for {endpoint}" + ); + } + let principal_key = external_editor_project_binding_key_sha256(PROJECT_ID, &principal) + .expect("concurrent source project binding key"); + let source_identity = new_external_editor_source_identity( + &source_asset.id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + ) + .expect("concurrent source identity"); + let binding_key = external_editor_resource_binding_key_sha256( + PROJECT_ID, + &principal_key, + "source-binding-project", + &source_identity, + ) + .expect("concurrent source resource binding key"); + let register_request = requests + .iter() + .find(|request| request.contains("/projects/source-binding-project/resources ")) + .expect("source resource registration request"); + assert!(register_request.to_ascii_lowercase().contains(&format!( + "idempotency-key: game-creator-resource-{binding_key}" + ))); + assert!(register_request.contains(&format!("\"localAssetId\":\"{}\"", source_asset.id))); + assert!( + register_request.contains(&format!("\"sourceSha256\":\"{}\"", source.source_sha256)) + ); + assert!(!register_request.contains(&first_request.operation_id)); + assert!(!register_request.contains(&second_request.operation_id)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn source_registration_identity_persists_before_account_switch_is_reported() { + let directory = tempfile::tempdir().expect("create source registration switch fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "源资源登记切号恢复测试") + .expect("initialize project"); + let uploaded = upload_local_asset_at( + root, + "switch-source.png", + "image/png", + &resource_editor_test_png(), + ) + .expect("upload switch source"); + let manifest = read_existing_manifest_for_project(root).expect("read switch manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("switch source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::ImageReference, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + request.source_subtype = Some(source_asset.kind.clone()); + let source = + resolve_resource_edit_source(root, &manifest, &request).expect("resolve switch source"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + write_resource_edit_ledger(root, &ledger).expect("write switch source ledger"); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind switch binding fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("switch binding address") + ); + let upload_url = format!("{base_url}/upload-source-binding"); + let _session_guard = install_test_platform_session( + "source-binding-owner-a", + "source-binding-token-a", + &base_url, + ); + let frozen_session = current_platform_session().expect("frozen source binding owner A"); + let access = ExternalEditorBindingAccess::for_platform(&frozen_session) + .expect("source binding owner A access"); + let principal = + external_editor_binding_principal(&access).expect("source binding owner A principal"); + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + PROJECT_ID, + &principal, + "source-binding-project", + "source-binding-folder", + 1, + ) + .expect("switch project binding"), + ) + .expect("write switch project binding"); + let (done_sender, done_receiver) = mpsc::channel(); + let server = spawn_source_binding_fixture( + listener, + upload_url, + false, + Some((base_url.clone(), frozen_session.generation + 1)), + done_receiver, + ); + let client = reqwest::Client::new(); + + let first_error = ensure_resource_edit_source_reference( + root, + &client, + &access, + &principal, + &request, + &source, + &mut ledger, + ) + .await + .err() + .expect("account switch after registration stops local continuation"); + let mut persisted = read_resource_edit_ledger(root, &request.operation_id) + .expect("read source registration switch ledger") + .expect("source registration switch ledger"); + let persisted_json = + serde_json::to_value(&persisted).expect("serialize source registration switch ledger"); + + install_platform_session( + "source-binding-owner-a", + "source-binding-token-a", + &base_url, + frozen_session.generation + 2, + ) + .expect("switch back to source binding owner A"); + let resumed_session = current_platform_session().expect("resumed source binding owner A"); + let resumed_access = ExternalEditorBindingAccess::for_platform(&resumed_session) + .expect("resumed source binding access"); + let resumed = ensure_resource_edit_source_reference( + root, + &client, + &resumed_access, + &principal, + &request, + &source, + &mut persisted, + ) + .await; + done_sender + .send(()) + .expect("stop source registration switch fixture"); + let requests = server + .join() + .expect("join source registration switch fixture"); + + assert!( + first_error.contains("authentication-required"), + "{first_error}" + ); + assert_eq!( + persisted_json["sourceRemoteResourceId"], + "editor-resource-source-binding" + ); + assert_eq!( + resumed + .expect("resume source binding without registration") + .stable_reference, + "editor-resource-source-binding" + ); + assert_eq!( + requests + .iter() + .filter(|request| request + .lines() + .next() + .unwrap_or_default() + .contains("/projects/source-binding-project/resources ")) + .count(), + 1, + "resume repeated source registration" + ); + } + + #[tokio::test] + async fn accepted_submission_persists_operation_before_reporting_account_switch() { + let directory = tempfile::tempdir().expect("create accepted account switch fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "受理后切号恢复句柄测试") + .expect("initialize project"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind accepted switch fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("accepted switch address") + ); + let _session_guard = + 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 server = std::thread::spawn(move || { + let mut stream = + accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0); + let request = read_http_request(&mut stream); + assert!(request.starts_with("POST /api/editor/videos/generations ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer submission-token-a")); + install_platform_session( + "submission-owner-b", + "submission-token-b", + &switch_base_url, + switch_generation, + ) + .expect("switch to owner B before returning accepted response"); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "accepted-before-account-switch", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + }); + + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:accepted-switch-video".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: request.source_resource_id.clone(), + source_path: Some("assets/accepted-switch-video.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + text: None, + source_asset: None, + source_version: None, + }; + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.endpoint = Some("/api/external/v1/editor/videos/generations".to_string()); + ledger.request_body_json = Some("{}".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write prepared switch ledger"); + let access = ExternalEditorBindingAccess::for_platform(&frozen_session) + .expect("owner A binding access"); + + let error = submit_and_persist_resource_edit_remote( + root, + &reqwest::Client::new(), + &access, + &mut ledger, + ) + .await + .expect_err("account switch after accepted response must stop local continuation"); + server.join().expect("join accepted switch fixture"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted); + assert_eq!( + ledger.remote_operation_id.as_deref(), + Some("accepted-before-account-switch") + ); + let persisted = read_resource_edit_ledger(root, &request.operation_id) + .expect("read accepted switch ledger") + .expect("accepted switch ledger"); + assert_eq!(persisted.phase, ResourceEditLedgerPhase::Accepted); + assert_eq!( + persisted.remote_operation_id.as_deref(), + Some("accepted-before-account-switch") + ); + } + #[tokio::test] async fn authentication_and_missing_status_keep_the_original_operation_ledger() { let directory = tempfile::tempdir().expect("create operation preservation fixture"); @@ -5102,7 +6242,8 @@ mod tests { submit_resource_edit_remote(root, &client, &access, &mut ledger).await }) .await - .expect_err("authentication status must fail"); + .err() + .expect("authentication status must fail"); server.join().expect("join auth fixture"); let expected_code = if status.starts_with("401") { "authentication-required" @@ -5364,6 +6505,367 @@ mod tests { assert_eq!(resumed.phase, ResourceEditLedgerPhase::Accepted); } + #[test] + fn resource_service_identity_helper_rejects_switch_without_writing_mixed_owner_fingerprint() { + let directory = tempfile::tempdir().expect("create resource identity switch fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "资源编辑切号指纹测试") + .expect("initialize project"); + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:identity-switch-video".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: request.source_resource_id.clone(), + source_path: Some("assets/identity-switch.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: None, + text: None, + source_asset: None, + source_version: None, + }; + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + let base_url = "https://dev.genarrative.world"; + let _session = crate::platform_session::install_test_platform_session( + "resource-owner-a", + "resource-token-a", + base_url, + ); + let frozen_a = current_platform_session().expect("freeze resource owner A"); + 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"); + + let error = prepare_resource_edit_service_identity( + root, + &mut ledger, + base_url, + &frozen_a.access_token, + Some(&frozen_a), + ) + .expect_err("switched owner must stop before service identity ledger mutation"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!( + ledger.platform_owner_user_id.as_deref(), + Some("resource-owner-a") + ); + assert_eq!( + ledger.api_identity_fingerprint, + Some(platform_art_generation_external_service_fingerprint( + base_url, + Some(&frozen_a), + )) + ); + } + + #[tokio::test] + async fn resource_identity_request_and_confirm_reject_current_non_owner_without_mutation() { + let directory = tempfile::tempdir().expect("create resource identity owner fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "资源编辑身份确认 owner 隔离测试") + .expect("initialize project"); + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:resource-identity-owner".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: request.source_resource_id.clone(), + source_path: Some("assets/resource-identity-owner.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: None, + text: None, + source_asset: None, + source_version: None, + }; + let api_base_url = "https://resource-identity-owner.example.test"; + let _session = install_test_platform_session( + "resource-identity-owner-a", + "resource-identity-token-a", + api_base_url, + ); + let owner_a = current_platform_session().expect("resource identity owner A"); + let owner_b = PlatformSessionSnapshot { + 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, + }; + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string()); + ledger.platform_owner_user_id = Some(owner_a.user_id.clone()); + ledger.platform_owner_api_base_url = Some(owner_a.api_base_url.clone()); + let challenge = Uuid::new_v4().to_string(); + let ledger_snapshot_sha256 = resource_edit_service_identity_snapshot_sha256(&ledger) + .expect("resource owner ledger snapshot"); + ledger.service_identity_confirmation = + Some(ResourceEditPrivateServiceIdentityConfirmation { + challenge: challenge.clone(), + service_fingerprint: platform_art_generation_external_service_fingerprint( + api_base_url, + Some(&owner_b), + ), + ledger_snapshot_sha256, + expires_at: unix_timestamp() + 60, + }); + write_resource_edit_ledger(root, &ledger).expect("write resource owner A ledger"); + let ledger_path = root.join(resource_edit_ledger_path(&request.operation_id)); + let before = fs::read(&ledger_path).expect("read resource owner A ledger"); + replace_platform_session_for_gui_owner( + &owner_b.user_id, + &owner_b.access_token, + &owner_b.api_base_url, + owner_b.generation, + ) + .expect("switch to resource non-owner B"); + + let request_error = request_resource_edit_service_identity_confirmation_at( + RequestResourceEditServiceIdentityConfirmationInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + .expect_err("non-owner B must not request resource identity confirmation"); + assert!( + request_error.contains("authentication-required"), + "{request_error}" + ); + assert_eq!( + fs::read(&ledger_path).expect("reread after rejected resource request"), + before + ); + + let confirm_error = + confirm_resource_edit_service_identity_at(ConfirmResourceEditServiceIdentityInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + remote_operation_id: None, + challenge, + }) + .await + .expect_err("non-owner B must not confirm forged resource challenge"); + assert!( + confirm_error.contains("authentication-required"), + "{confirm_error}" + ); + assert_eq!( + fs::read(&ledger_path).expect("reread after rejected resource confirm"), + before + ); + } + + #[test] + fn resource_first_ledger_write_lease_blocks_switch_and_rejects_stale_snapshot() { + let directory = tempfile::tempdir().expect("create resource owner lease fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "资源编辑 owner 写入线性化测试") + .expect("initialize project"); + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:resource-owner-lease".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: request.source_resource_id.clone(), + source_path: Some("assets/resource-owner-lease.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: None, + text: None, + source_asset: None, + source_version: None, + }; + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.access_scheme = None; + let api_base_url = "https://resource-owner-lease.example.test"; + let _session = install_test_platform_session( + "resource-lease-owner-a", + "resource-lease-token-a", + api_base_url, + ); + let frozen_a = current_platform_session().expect("freeze resource lease owner A"); + let entered = Arc::new(std::sync::Barrier::new(2)); + let release = Arc::new(std::sync::Barrier::new(2)); + let (switched_sender, switched_receiver) = mpsc::channel(); + + std::thread::scope(|scope| { + let write_entered = Arc::clone(&entered); + let write_release = Arc::clone(&release); + let first_write = scope.spawn(|| { + install_resource_edit_account_bound_write_test_barrier( + ResourceEditAccountBoundWriteTestBarrier { + entered: write_entered, + release: write_release, + }, + ); + initialize_resource_edit_access_identity( + root, + &mut ledger, + api_base_url, + Some(&frozen_a), + ) + }); + entered.wait(); + let switch = scope.spawn(|| { + replace_platform_session_for_gui_owner( + "resource-lease-owner-b", + "resource-lease-token-b", + api_base_url, + frozen_a.generation + 1, + ) + .expect("switch resource lease owner"); + switched_sender.send(()).expect("signal resource switch"); + }); + assert!(switched_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + release.wait(); + first_write + .join() + .expect("join resource first ledger write") + .expect("write resource owner A under lease"); + switch.join().expect("join resource owner switch"); + }); + switched_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("resource owner switch completes after write"); + let persisted = read_resource_edit_ledger(root, &request.operation_id) + .expect("read resource owner ledger") + .expect("resource owner ledger exists"); + assert_eq!( + persisted.platform_owner_user_id.as_deref(), + Some("resource-lease-owner-a") + ); + assert_eq!( + persisted.platform_owner_api_base_url.as_deref(), + Some(api_base_url) + ); + assert_eq!( + persisted.access_scheme.as_deref(), + Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME) + ); + + let stale_request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:stale-resource-owner-lease".to_string(), + ); + let mut stale_source = source; + stale_source.canonical_resource_id = stale_request.source_resource_id.clone(); + let mut stale_ledger = ledger_for( + &stale_request, + &stale_source, + ResourceEditLedgerPhase::Prepared, + ); + stale_ledger.access_scheme = None; + let error = initialize_resource_edit_access_identity( + root, + &mut stale_ledger, + api_base_url, + Some(&frozen_a), + ) + .expect_err("stale resource owner snapshot must fail before write"); + assert!(error.contains("authentication-required"), "{error}"); + assert!(read_resource_edit_ledger(root, &stale_request.operation_id) + .expect("read stale resource ledger") + .is_none()); + assert_eq!(stale_ledger.platform_owner_user_id, None); + } + + #[tokio::test] + async fn historical_unowned_remote_resource_retry_is_rejected_without_network_or_mutation() { + let platform_session_guard = clear_test_platform_session(); + let directory = tempfile::tempdir().expect("create unowned resource retry fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "历史未归属资源重试测试") + .expect("initialize project"); + let source_bytes = resource_editor_test_png(); + let uploaded = + upload_local_asset_at(root, "unowned-source.png", "image/png", &source_bytes) + .expect("upload unowned retry source"); + let manifest = read_existing_manifest_for_project(root).expect("read unowned manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("find unowned source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::ImageReference, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + request.source_subtype = Some(source_asset.kind.clone()); + let source = resolve_resource_edit_source(root, &manifest, &request) + .expect("resolve unowned source"); + let prompt = normalize_resource_edit_prompt(&request.edit_kind, &request.prompt) + .expect("normalize unowned prompt"); + let asset_name = + normalize_resource_edit_name(&request.asset_name).expect("normalize unowned name"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.request_fingerprint = + resource_edit_request_fingerprint(&request, &source, &prompt, &asset_name) + .expect("fingerprint unowned request"); + ledger.access_scheme = None; + ledger.api_identity_scheme = None; + ledger.api_identity_fingerprint = None; + ledger.platform_owner_user_id = None; + ledger.platform_owner_api_base_url = None; + write_resource_edit_ledger(root, &ledger).expect("write historical unowned ledger"); + let ledger_path = root.join(resource_edit_ledger_path(&request.operation_id)); + let before = fs::read(&ledger_path).expect("read historical unowned ledger bytes"); + drop(platform_session_guard); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind zero-network fixture"); + let api_base_url = format!( + "http://{}", + listener.local_addr().expect("zero-network fixture address") + ); + let _session = install_test_platform_session( + "unowned-retry-owner-b", + "unowned-retry-token-b", + &api_base_url, + ); + let error = resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }) + .await + .expect_err("account B must not claim an unowned historical resource ledger"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!( + fs::read(&ledger_path).expect("reread rejected unowned resource ledger"), + before + ); + listener + .set_nonblocking(true) + .expect("set zero-network fixture nonblocking"); + assert!(matches!( + listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + #[tokio::test] async fn submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation() { let directory = tempfile::tempdir().expect("create submission status fixture"); @@ -5431,7 +6933,8 @@ mod tests { .await }) .await - .expect_err("submission status must fail"); + .err() + .expect("submission status must fail"); server.join().expect("join submission fixture"); assert!(error.contains(expected_error), "{status}: {error}"); @@ -5551,7 +7054,8 @@ mod tests { .await }) .await - .expect_err("unproven accepted response must fail closed"); + .err() + .expect("unproven accepted response must fail closed"); server.join().expect("join accepted fixture"); assert!(error.contains(expected_error), "{damage}: {error}"); @@ -5719,18 +7223,15 @@ mod tests { .await .expect_err("terminal failure must not resume"); assert!(resume_error.contains("remote-terminal-failed")); - assert_eq!( - list_pending_local_project_resource_edits_at( - ListPendingLocalProjectResourceEditsInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: PROJECT_ID.to_string(), - }, - ) - .expect("list terminal failure") - .first() - .map(|entry| entry.phase.as_str()), - Some("remote-failed") - ); + let pending = list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect("explicit Developer remote failure remains visible to Developer mode"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].operation_id, request.operation_id); let archived = archive_failed_local_project_resource_edit_at( ArchiveFailedLocalProjectResourceEditInput { @@ -5793,6 +7294,215 @@ mod tests { assert!(reconciliation_archive_error.contains("reconciliation-required")); } + #[tokio::test] + async fn platform_owned_remote_failure_cannot_be_archived_after_account_switch() { + let directory = tempfile::tempdir().expect("create account-owned archive fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "失败账本跨账号归档测试") + .expect("initialize project"); + let api_base_url = "https://archive-owner.example.test"; + let _session_guard = + install_test_platform_session("archive-owner-a", "archive-token-a", api_base_url); + let owner_a = current_platform_session().expect("owner A session"); + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:archive-owner-video".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: request.source_resource_id.clone(), + source_path: Some("assets/archive-owner-video.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + text: None, + source_asset: None, + source_version: None, + }; + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::RemoteFailed); + ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string()); + ledger.platform_owner_user_id = Some(owner_a.user_id.clone()); + ledger.platform_owner_api_base_url = Some(owner_a.api_base_url.clone()); + ledger.terminal_failure_code = Some("remote-generation-failed".to_string()); + ledger.terminal_failed_at = Some(unix_timestamp()); + write_resource_edit_ledger(root, &ledger).expect("write owner A failure ledger"); + let ledger_path = root.join(resource_edit_ledger_path(&request.operation_id)); + let before = fs::read(&ledger_path).expect("read owner A ledger bytes"); + + install_platform_session( + "archive-owner-b", + "archive-token-b", + api_base_url, + owner_a.generation + 1, + ) + .expect("switch to owner B"); + let error = archive_failed_local_project_resource_edit_at( + ArchiveFailedLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + .expect_err("owner B must not archive owner A failure ledger"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!( + fs::read(&ledger_path).expect("reread owner A ledger bytes"), + before, + "rejected cross-account archive must not mutate the durable ledger" + ); + } + + #[test] + fn pending_resource_edits_are_filtered_by_current_platform_owner() { + let directory = tempfile::tempdir().expect("create pending owner visibility fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "资源编辑待恢复列表账号隔离测试") + .expect("initialize project"); + let api_base_url = "https://pending-owner.example.test"; + let _session = + install_test_platform_session("pending-owner-a", "pending-token-a", api_base_url); + let owner_a = current_platform_session().expect("pending owner A session"); + + let remote_source = |canonical_resource_id: String| ResourceEditSourceSnapshot { + canonical_resource_id, + source_path: Some("assets/pending-owner.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: None, + text: None, + source_asset: None, + source_version: None, + }; + let owner_a_request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:pending-owner-a".to_string(), + ); + let mut owner_a_ledger = ledger_for( + &owner_a_request, + &remote_source(owner_a_request.source_resource_id.clone()), + ResourceEditLedgerPhase::Prepared, + ); + owner_a_ledger.access_scheme = None; + initialize_resource_edit_access_identity( + root, + &mut owner_a_ledger, + api_base_url, + Some(&owner_a), + ) + .expect("write pending owner A ledger"); + + let legacy_request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:pending-legacy".to_string(), + ); + let mut legacy_ledger = ledger_for( + &legacy_request, + &remote_source(legacy_request.source_resource_id.clone()), + ResourceEditLedgerPhase::Prepared, + ); + legacy_ledger.access_scheme = None; + legacy_ledger.api_identity_scheme = None; + legacy_ledger.api_identity_fingerprint = None; + write_resource_edit_ledger(root, &legacy_ledger).expect("write unowned remote ledger"); + + let local_request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Text, + "local-asset:pending-local".to_string(), + ); + let local_source = ResourceEditSourceSnapshot { + canonical_resource_id: local_request.source_resource_id.clone(), + source_path: Some("assets/pending-local.md".to_string()), + media_type: "text/markdown".to_string(), + asset_kind: "text".to_string(), + source_sha256: "b".repeat(64), + bytes: Some(b"local".to_vec()), + text: Some("local".to_string()), + source_asset: None, + source_version: None, + }; + let local_ledger = ledger_for( + &local_request, + &local_source, + ResourceEditLedgerPhase::Prepared, + ); + write_resource_edit_ledger(root, &local_ledger).expect("write local pending ledger"); + + let visible_to_a = list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect("list pending for owner A"); + assert!(visible_to_a + .iter() + .any(|item| item.operation_id == owner_a_request.operation_id)); + assert!(visible_to_a + .iter() + .any(|item| item.operation_id == local_request.operation_id)); + assert!(!visible_to_a + .iter() + .any(|item| item.operation_id == legacy_request.operation_id)); + + replace_platform_session_for_gui_owner( + "pending-owner-b", + "pending-token-b", + api_base_url, + owner_a.generation + 1, + ) + .expect("switch to pending owner B"); + let owner_b = current_platform_session().expect("pending owner B session"); + let owner_b_request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:pending-owner-b".to_string(), + ); + let mut owner_b_ledger = ledger_for( + &owner_b_request, + &remote_source(owner_b_request.source_resource_id.clone()), + ResourceEditLedgerPhase::Prepared, + ); + owner_b_ledger.access_scheme = None; + initialize_resource_edit_access_identity( + root, + &mut owner_b_ledger, + api_base_url, + Some(&owner_b), + ) + .expect("write pending owner B ledger"); + + let visible_to_b = list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect("list pending for owner B"); + assert!(visible_to_b + .iter() + .any(|item| item.operation_id == owner_b_request.operation_id)); + assert!(visible_to_b + .iter() + .any(|item| item.operation_id == local_request.operation_id)); + assert!(!visible_to_b + .iter() + .any(|item| item.operation_id == owner_a_request.operation_id)); + assert!(!visible_to_b + .iter() + .any(|item| item.operation_id == legacy_request.operation_id)); + } + #[test] fn remote_requests_keep_resource_editor_queue_identity_and_endpoint_limits() { let directory = tempfile::tempdir().expect("create resource editor fixture"); @@ -6393,6 +8103,229 @@ mod tests { ); } + #[test] + fn platform_session_switch_waits_until_local_asset_commit_finishes() { + let directory = tempfile::tempdir().expect("create commit account switch fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "本地提交切号线性化测试") + .expect("initialize project"); + let source_bytes = resource_editor_test_png(); + let uploaded = upload_local_asset_at(root, "commit-source.png", "image/png", &source_bytes) + .expect("upload commit source image"); + let manifest = read_existing_manifest_for_project(root).expect("read commit manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("commit source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::ImageReference, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + request.source_subtype = Some(source_asset.kind.clone()); + let source = + resolve_resource_edit_source(root, &manifest, &request).expect("resolve commit source"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.staged_media_type = Some("image/png".to_string()); + ledger.staged_extension = Some("png".to_string()); + write_resource_edit_staging(root, &request.operation_id, &resource_editor_test_png()) + .expect("stage commit derivative"); + + let api_base_url = "https://resource-editor-commit.example.test"; + let _session_guard = + install_test_platform_session("commit-owner-a", "commit-token-a", api_base_url); + let frozen_session = current_platform_session().expect("frozen commit owner A session"); + ledger.access_scheme = None; + initialize_resource_edit_access_identity( + root, + &mut ledger, + api_base_url, + Some(&frozen_session), + ) + .expect("write commit owner A ledger"); + let (begin_switch_sender, begin_switch_receiver) = mpsc::channel(); + 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_thread = std::thread::spawn(move || { + begin_switch_receiver + .recv() + .expect("wait for commit critical section"); + attempted_sender + .send(()) + .expect("announce account switch attempt"); + install_platform_session( + "commit-owner-b", + "commit-token-b", + &switch_api_base_url, + switch_generation, + ) + .expect("switch to commit owner B"); + completed_sender + .send(()) + .expect("announce account switch completion"); + }); + + let committed = with_frozen_resource_edit_platform_session(Some(&frozen_session), || { + begin_switch_sender + .send(()) + .expect("start concurrent account switch"); + attempted_receiver + .recv() + .expect("wait for concurrent account switch attempt"); + assert!( + completed_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "account switch completed inside the local asset commit critical section" + ); + commit_resource_edit_asset( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + ) + }) + .expect("commit under frozen owner A session"); + completed_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("account switch completes after local commit"); + switch_thread.join().expect("join commit switch thread"); + assert!(committed.asset.is_some()); + assert_eq!( + current_platform_session() + .as_ref() + .map(|session| session.user_id.as_str()), + Some("commit-owner-b") + ); + } + + #[tokio::test] + async fn legacy_remote_canonical_ledger_resumes_from_the_same_local_asset() { + let directory = tempfile::tempdir().expect("create legacy canonical recovery fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "旧 canonical 资源编辑恢复测试") + .expect("initialize project"); + let source_bytes = resource_editor_test_png(); + let uploaded = upload_local_asset_at(root, "legacy-source.png", "image/png", &source_bytes) + .expect("upload source image"); + let mut manifest = read_existing_manifest_for_project(root).expect("read source manifest"); + let source_asset = manifest + .assets + .iter_mut() + .find(|asset| asset.id == uploaded.id) + .expect("find source image asset"); + source_asset.source.canvas_project_id = Some("legacy-owner-project".to_string()); + source_asset.source.resource_id = Some("editor-resource-legacy-owner".to_string()); + source_asset.source.asset_object_id = Some("legacy-owner-object".to_string()); + let source_asset = source_asset.clone(); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write legacy source provenance"); + + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::ImageReference, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + request.source_subtype = Some(source_asset.kind.clone()); + let current_source = resolve_resource_edit_source(root, &manifest, &request) + .expect("resolve current local source"); + assert_eq!( + current_source.canonical_resource_id, + format!("local-asset:{}", source_asset.id) + ); + let mut legacy_source = current_source.clone(); + legacy_source.canonical_resource_id = "editor-resource-legacy-owner".to_string(); + let prompt = normalize_resource_edit_prompt(&request.edit_kind, &request.prompt) + .expect("normalize prompt"); + let asset_name = normalize_resource_edit_name(&request.asset_name).expect("normalize name"); + let mut ledger = ledger_for( + &request, + &legacy_source, + ResourceEditLedgerPhase::MediaDownloaded, + ); + ledger.request_fingerprint = + resource_edit_request_fingerprint(&request, &legacy_source, &prompt, &asset_name) + .expect("legacy request fingerprint"); + for mismatch in ["asset-id", "path", "sha256", "media-type", "asset-kind"] { + let mut mismatched_ledger = ledger.clone(); + match mismatch { + "asset-id" => mismatched_ledger.source_asset_id = Some("other-asset".to_string()), + "path" => mismatched_ledger.source_path = Some("assets/other.png".to_string()), + "sha256" => mismatched_ledger.source_sha256 = "0".repeat(64), + "media-type" => { + mismatched_ledger.source_media_type = Some("image/webp".to_string()) + } + "asset-kind" => { + mismatched_ledger.source_asset_kind = Some("other-kind".to_string()) + } + _ => unreachable!(), + } + assert!( + legacy_remote_canonical_request_fingerprints( + &request, + ¤t_source, + &prompt, + &asset_name, + &mismatched_ledger, + ) + .expect("evaluate mismatched legacy identity") + .is_none(), + "legacy canonical migration accepted mismatched {mismatch}" + ); + } + ledger.staged_media_type = Some("image/png".to_string()); + ledger.staged_extension = Some("png".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write legacy resource edit ledger"); + let derivative_bytes = resource_editor_test_png(); + write_resource_edit_staging(root, &request.operation_id, &derivative_bytes) + .expect("stage recovered derivative"); + + let recovered = + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }) + .await + .expect("resume legacy canonical ledger from the same local asset"); + let derivative = recovered.asset.expect("recovered derivative asset"); + assert_ne!(derivative.id, source_asset.id); + assert_eq!( + fs::read(root.join(&source_asset.local_path)).expect("read preserved source image"), + source_bytes + ); + assert_eq!( + fs::read(root.join(&derivative.local_path)).expect("read recovered derivative"), + derivative_bytes + ); + + let replay = resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id, + }) + .await + .expect("replay committed legacy canonical ledger"); + assert_eq!( + replay.asset.as_ref().map(|asset| asset.id.as_str()), + Some(derivative.id.as_str()) + ); + } + #[tokio::test] async fn text_provider_handoff_recovers_without_network_and_commits_once() { let directory = tempfile::tempdir().expect("create text handoff fixture"); @@ -7387,6 +9320,7 @@ mod tests { #[tokio::test] async fn pending_task_video_and_version_resume_from_persisted_source_snapshots() { + let _no_platform_session = crate::platform_session::clear_test_platform_session(); let task_directory = tempfile::tempdir().expect("create task video fixture"); let task_root = task_directory.path(); init_local_game_project_at(task_root, PROJECT_ID, "任务视频恢复测试") diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 405a58e64..3a955d1dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -46,17 +46,22 @@ fn external_agent_runner_gui_owner_attachment_state( pub(super) fn register_external_agent_runner_gui_owner_attachment( state: &Mutex, config_dir: &Path, - params: ExternalAgentRunnerRequestParams, -) { + mut params: ExternalAgentRunnerRequestParams, +) -> Result<(), String> { let mut state = lock_unpoisoned(state); state.generation = state.generation.wrapping_add(1); let generation = state.generation; + params.gui_owner_session_revision = Some(generation); + if let Some(owner_epoch) = params.gui_owner_epoch.as_deref() { + write_external_agent_runner_gui_owner_claim_atomic(config_dir, owner_epoch, generation)?; + } state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { generation, config_dir: config_dir.to_path_buf(), params, attached_boot_id: None, }); + Ok(()) } pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( @@ -990,6 +995,7 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { pub(crate) fn attach_external_agent_runner_gui_owner( event_sink: &GameCreatorManifestInvalidationEventSink, + gui_owner_epoch: &str, ) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); @@ -1003,6 +1009,7 @@ pub(crate) fn attach_external_agent_runner_gui_owner( ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), + gui_owner_epoch: Some(gui_owner_epoch.to_string()), platform_user_id: platform_session .as_ref() .map(|session| session.user_id.clone()), @@ -1015,7 +1022,7 @@ pub(crate) fn attach_external_agent_runner_gui_owner( platform_auth_generation: platform_session.map(|session| session.generation), ..ExternalAgentRunnerRequestParams::default() }, - ); + )?; ensure_external_agent_runner(&config_dir).map(|_| ()) } @@ -1025,67 +1032,130 @@ pub(crate) fn install_external_agent_runner_platform_session( 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() + synchronize_external_agent_runner_platform_session_with( + || { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + remember_external_agent_runner_platform_session( + external_agent_runner_gui_owner_attachment_state(), + Some((user_id, access_token, api_base_url)), + generation, + ) + .and_then(|_| ensure_external_agent_runner(&config_dir)) + .and_then(|endpoint| { + validate_external_agent_runner_platform_session_attachment( + external_agent_runner_gui_owner_attachment_state(), + &config_dir, + &endpoint, + Some((user_id, access_token, api_base_url)), + generation, + ) + }) }, + || shutdown_external_agent_runner_at(&config_dir), ) - .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 { + synchronize_external_agent_runner_platform_session_with( + || { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + remember_external_agent_runner_platform_session( + external_agent_runner_gui_owner_attachment_state(), + None, + generation, + ) + .and_then(|_| ensure_external_agent_runner(&config_dir)) + .and_then(|endpoint| { + validate_external_agent_runner_platform_session_attachment( + external_agent_runner_gui_owner_attachment_state(), + &config_dir, + &endpoint, + None, + generation, + ) + }) + }, + || shutdown_external_agent_runner_at(&config_dir), + ) +} + +fn validate_external_agent_runner_platform_session_attachment( + state: &Mutex, + config_dir: &Path, + endpoint: &ExternalAgentRunnerEndpoint, + session: Option<(&str, &str, &str)>, + generation: u64, +) -> Result<(), String> { + let state = lock_unpoisoned(state); + let registration = state.registration.as_ref().ok_or_else(|| { + "Agent Runner 尚未建立带 owner epoch 的 GUI owner 登记,平台登录态拒绝下发".to_string() + })?; + let expected_user_id = session.map(|(user_id, _, _)| user_id); + let expected_access_token = session.map(|(_, access_token, _)| access_token); + let expected_api_base_url = session.map(|(_, _, api_base_url)| api_base_url); + if registration.config_dir != config_dir + || 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_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 + { + return Err("Agent Runner 未确认当前 GUI owner epoch 的平台登录态".to_string()); + } + Ok(()) +} + +pub(super) fn synchronize_external_agent_runner_platform_session_with( + sync_attempt: impl FnOnce() -> Result<(), String>, + fence_runner: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + let sync_result = sync_attempt(); + let Err(sync_error) = sync_result else { return Ok(()); }; - send_external_agent_runner_request( - &endpoint, - "platform.session.clear", - ExternalAgentRunnerRequestParams { - platform_auth_generation: Some(generation), - ..ExternalAgentRunnerRequestParams::default() - }, - ) - .map(|_| ()) + match fence_runner() { + Ok(()) => Err(format!( + "{sync_error};为避免旧账号继续执行,Agent Runner 已停止,后续请求将按当前账号重建" + )), + Err(fence_error) => Err(format!( + "{sync_error};阻断旧账号 Agent Runner 失败:{fence_error}" + )), + } } pub(super) fn remember_external_agent_runner_platform_session( state: &Mutex, session: Option<(&str, &str, &str)>, generation: u64, -) { +) -> Result<(), String> { + remember_external_agent_runner_platform_session_with( + state, + session, + generation, + write_external_agent_runner_gui_owner_claim_atomic, + ) +} + +pub(super) fn remember_external_agent_runner_platform_session_with( + state: &Mutex, + session: Option<(&str, &str, &str)>, + generation: u64, + write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>, +) -> Result<(), String> { let mut state = lock_unpoisoned(state); - let Some(registration) = state.registration.as_mut() else { - return; + 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 { - return; + return Ok(()); } if generation == current_generation { match session { @@ -1096,23 +1166,43 @@ pub(super) fn remember_external_agent_runner_platform_session( && registration.params.platform_api_base_url.as_deref() == Some(api_base_url) => { - return; + return Ok(()); } - Some(_) => return, + Some(_) => return Ok(()), None if registration.params.platform_user_id.is_none() && registration.params.platform_access_token.is_none() => { - return; + return Ok(()); } None => {} } } + state.generation = state.generation.wrapping_add(1); + let registration_generation = state.generation; + let claim = state.registration.as_ref().and_then(|registration| { + registration + .params + .gui_owner_epoch + .as_deref() + .map(|owner_epoch| (registration.config_dir.clone(), owner_epoch.to_string())) + }); + if let Some((config_dir, owner_epoch)) = claim { + write_claim(&config_dir, &owner_epoch, registration_generation)?; + } + let registration = state + .registration + .as_mut() + .expect("checked GUI owner registration must remain present while locked"); + registration.generation = registration_generation; + registration.attached_boot_id = 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); + registration.params.gui_owner_session_revision = Some(registration_generation); + Ok(()) } pub(super) fn validate_external_agent_runner_gui_owner_attachment_result( @@ -1405,6 +1495,8 @@ 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, + gui_owner_epoch: None, + gui_owner_session_revision: None, platform_user_id: None, platform_access_token: None, platform_api_base_url: None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index cbee001a8..a73c4a0ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,5 +1,8 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; -use crate::configure_game_creator_manifest_invalidation_event_sink; +use crate::{ + install_game_creator_manifest_invalidation_event_sink, + validate_game_creator_manifest_invalidation_event_sink, +}; use serde::Deserialize; use serde_json::json; use sha2::{Digest as _, Sha256}; @@ -92,6 +95,134 @@ pub(super) fn external_agent_runner_request_agent( Ok(agent.to_string()) } +pub(super) fn apply_external_agent_runner_gui_owner_platform_session( + state: &ExternalAgentRunnerServerState, + params: &ExternalAgentRunnerRequestParams, +) -> Result<(), String> { + apply_external_agent_runner_gui_owner_attachment(state, params, None) +} + +fn apply_external_agent_runner_gui_owner_attachment( + state: &ExternalAgentRunnerServerState, + params: &ExternalAgentRunnerRequestParams, + event_sink: Option, +) -> Result<(), String> { + let requested_epoch = params + .gui_owner_epoch + .as_deref() + .filter(|value| uuid::Uuid::parse_str(value).is_ok()) + .ok_or_else(|| "Agent Runner GUI owner 缺少有效 owner epoch".to_string())?; + let requested_revision = params + .gui_owner_session_revision + .ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?; + let config_dir = state + .gui_owner_lock_path + .parent() + .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); + let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?; + if durable_claim.owner_epoch != requested_epoch + || durable_claim.session_revision != requested_revision + { + return Err("Agent Runner GUI owner claim 已过期".to_string()); + } + let requested_claim = (requested_epoch.to_string(), requested_revision); + let replace_claim = active_claim.as_ref() != Some(&requested_claim); + let result = match ( + params.platform_user_id.as_deref(), + params.platform_access_token.as_deref(), + params.platform_api_base_url.as_deref(), + params.platform_auth_generation, + ) { + (Some(user_id), Some(access_token), Some(api_base_url), Some(generation)) => { + if replace_claim { + crate::replace_platform_session_for_gui_owner( + user_id, + access_token, + api_base_url, + generation, + ) + } else { + crate::install_platform_session_checked( + user_id, + access_token, + api_base_url, + generation, + ) + } + } + (None, None, None, Some(generation)) => { + if replace_claim { + crate::clear_platform_session_for_gui_owner(generation); + Ok(()) + } else { + crate::clear_platform_session_checked(generation) + } + } + (None, None, None, None) if replace_claim => { + crate::clear_platform_session_for_gui_owner(0); + Ok(()) + } + (None, None, None, None) => Ok(()), + _ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()), + }; + result?; + let committed_claim = match read_external_agent_runner_gui_owner_claim(config_dir) { + Ok(claim) => claim, + Err(error) => { + *active_claim = None; + crate::clear_platform_session_for_gui_owner(0); + return Err(format!( + "Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}" + )); + } + }; + if committed_claim.owner_epoch != requested_epoch + || committed_claim.session_revision != requested_revision + { + *active_claim = None; + crate::clear_platform_session_for_gui_owner(0); + return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string()); + } + if let Some(event_sink) = event_sink { + install_game_creator_manifest_invalidation_event_sink(event_sink); + } + *active_claim = Some(requested_claim); + Ok(()) +} + +pub(super) fn validate_external_agent_runner_gui_owner_claim_current( + state: &ExternalAgentRunnerServerState, +) -> Result<(), String> { + let config_dir = state + .gui_owner_lock_path + .parent() + .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); + let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir); + let matches = durable_claim.as_ref().is_ok_and(|claim| { + active_claim.as_ref() == Some(&(claim.owner_epoch.clone(), claim.session_revision)) + }); + if matches { + return Ok(()); + } + *active_claim = None; + crate::clear_platform_session_for_gui_owner(0); + match durable_claim { + Ok(_) => Err( + "authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离" + .to_string(), + ), + Err(error) => Err(format!( + "authentication-required: Agent Runner GUI owner claim 无法核验,平台登录态已隔离:{error}" + )), + } +} + +fn external_agent_runner_method_requires_current_gui_owner_claim(method: &str) -> bool { + method == "mcp.status" || method.starts_with("runtime.") +} + pub(super) fn external_agent_runner_request_session_id( request: &ExternalAgentRunnerRequest, ) -> Result, String> { @@ -406,7 +537,8 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( claim_owner: impl Fn(&Path) -> Result, ) -> ExternalAgentRunnerResponse { let fingerprint = external_agent_runner_request_fingerprint(request); - { + let use_request_cache = request.method != "runner.attach_gui_owner"; + if use_request_cache { let cache = lock_unpoisoned(&state.write_request_cache); if let Some(cached) = cache.find(&request.request_id) { if cached.fingerprint == fingerprint { @@ -466,15 +598,17 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( }; let mut cache = lock_unpoisoned(&state.write_request_cache); - if let Some(cached) = cache.find(&request.request_id) { - if cached.fingerprint == fingerprint { - return cached.response.clone(); + if use_request_cache { + if let Some(cached) = cache.find(&request.request_id) { + if cached.fingerprint == fingerprint { + return cached.response.clone(); + } + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "request-id-conflict", + "同一 requestId 不能用于不同请求", + ); } - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "request-id-conflict", - "同一 requestId 不能用于不同请求", - ); } let response = match request.method.as_str() { @@ -644,38 +778,28 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( "Agent Runner GUI owner 缺少 manifest 事件接收端".to_string() }) .and_then(|(port, token)| { - configure_game_creator_manifest_invalidation_event_sink(port, token) + validate_game_creator_manifest_invalidation_event_sink(port, token) }); - if let Err(error) = event_sink { - return ExternalAgentRunnerResponse::failure( - &request.request_id, - "event-sink-invalid", - 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, - ) { + let event_sink = match event_sink { + Ok(event_sink) => event_sink, + Err(error) => { return ExternalAgentRunnerResponse::failure( &request.request_id, - "platform-session-invalid", - error, + "event-sink-invalid", + redact_runner_secret(&error, &token), ); } + }; + if let Err(error) = apply_external_agent_runner_gui_owner_attachment( + state, + &request.params, + Some(event_sink), + ) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-invalid", + error, + ); } state.gui_owner_attached.store(true, Ordering::Release); ExternalAgentRunnerResponse::success( @@ -695,40 +819,12 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( ), } } - "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 })) + "platform.session.install" | "platform.session.clear" => { + ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-epoch-required", + "平台登录态只能通过当前 GUI owner epoch 的 runner.attach_gui_owner 同步", + ) } "runner.shutdown" | "shutdown" => { let provider_requests_interrupted = @@ -862,12 +958,14 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( "Agent Runner 不支持该方法", ), }; - cache_external_agent_runner_response_if_cacheable( - &mut cache, - &request.request_id, - &fingerprint, - &response, - ); + if use_request_cache { + cache_external_agent_runner_response_if_cacheable( + &mut cache, + &request.request_id, + &fingerprint, + &response, + ); + } response } @@ -904,6 +1002,17 @@ pub(super) fn handle_external_agent_runner_request( "Agent Runner method 无效", ); } + if state.gui_owner_attached.load(Ordering::Acquire) + && external_agent_runner_method_requires_current_gui_owner_claim(&request.method) + { + if let Err(error) = validate_external_agent_runner_gui_owner_claim_current(state) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-claim-stale", + redact_runner_secret(&error, &expected_token), + ); + } + } match request.method.as_str() { "runner.ping" => ExternalAgentRunnerResponse::success( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index 4b140ab53..bfbd56241 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -309,6 +309,38 @@ pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> Pa config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME) } +pub(super) fn external_agent_runner_gui_owner_claim_path(config_dir: &Path) -> PathBuf { + config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME) +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ExternalAgentRunnerGuiOwnerClaim { + pub(super) owner_epoch: String, + pub(super) session_revision: u64, +} + +pub(super) fn read_external_agent_runner_gui_owner_claim( + config_dir: &Path, +) -> Result { + let path = external_agent_runner_gui_owner_claim_path(config_dir); + let file = open_external_agent_runner_endpoint_file(&path)?; + validate_external_agent_runner_endpoint_metadata(&file, &path)?; + let mut content = Vec::new(); + file.take(EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES + 1) + .read_to_end(&mut content) + .map_err(|error| format!("读取 Agent Runner GUI owner claim 失败:{error}"))?; + if content.len() as u64 > EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES { + return Err("Agent Runner GUI owner claim 超过大小上限".to_string()); + } + let claim = serde_json::from_slice::(&content) + .map_err(|_| "解析 Agent Runner GUI owner claim 失败".to_string())?; + if uuid::Uuid::parse_str(&claim.owner_epoch).is_err() { + return Err("Agent Runner GUI owner claim 缺少有效 ownerEpoch".to_string()); + } + Ok(claim) +} + pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result { match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? { Some(lock) => { @@ -905,10 +937,13 @@ pub(crate) fn acquire_external_agent_runner_gui_owner_lock( else { return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string()); }; + let owner_epoch = uuid::Uuid::new_v4().to_string(); + let acquired_at = unix_millis(); let diagnostic = serde_json::to_vec(&json!({ "protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, "pid": std::process::id(), - "acquiredAt": unix_millis(), + "ownerEpoch": owner_epoch, + "acquiredAt": acquired_at, })) .map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?; file.set_len(0) @@ -921,5 +956,68 @@ pub(crate) fn acquire_external_agent_runner_gui_owner_lock( path.display() ) })?; - Ok(ExternalAgentRunnerGuiOwnerLock { _file: file }) + write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, 0)?; + Ok(ExternalAgentRunnerGuiOwnerLock { + _file: file, + owner_epoch, + }) +} + +pub(super) fn write_external_agent_runner_gui_owner_claim_atomic( + config_dir: &Path, + owner_epoch: &str, + session_revision: u64, +) -> Result<(), String> { + let path = external_agent_runner_gui_owner_claim_path(config_dir); + let content = serde_json::to_vec(&json!({ + "schemaVersion": "agent-runner-gui-owner-claim.v1", + "protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + "pid": std::process::id(), + "ownerEpoch": owner_epoch, + "sessionRevision": session_revision, + "updatedAt": unix_millis(), + })) + .map_err(|error| format!("生成 Agent Runner GUI owner claim 失败:{error}"))?; + if content.len() as u64 > EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES { + return Err("Agent Runner GUI owner claim 超过大小上限".to_string()); + } + let mut temporary = None; + for sequence in 0..16_u32 { + let candidate = config_dir.join(format!( + ".{EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME}.{}.{}.tmp", + std::process::id(), + sequence + )); + match private_create_new_file(&candidate) { + Ok(file) => { + temporary = Some((candidate, file)); + break; + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "创建 Agent Runner GUI owner claim 临时文件失败:{error}" + )); + } + } + } + let (temporary_path, mut temporary_file) = temporary + .ok_or_else(|| "创建 Agent Runner GUI owner claim 临时文件重试耗尽".to_string())?; + let write_result = temporary_file + .write_all(&content) + .and_then(|_| temporary_file.sync_all()); + drop(temporary_file); + if let Err(error) = write_result { + let _ = fs::remove_file(&temporary_path); + return Err(format!("写入 Agent Runner GUI owner claim 失败:{error}")); + } + if let Err(error) = replace_file_atomically(&temporary_path, &path) { + let _ = fs::remove_file(&temporary_path); + return Err(format!("提交 Agent Runner GUI owner claim 失败:{error}")); + } + let persisted = read_external_agent_runner_gui_owner_claim(config_dir)?; + if persisted.owner_epoch != owner_epoch || persisted.session_revision != session_revision { + return Err("Agent Runner GUI owner claim 写入后回读不一致".to_string()); + } + Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 579987f45..b76907ec3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -9,12 +9,14 @@ use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 6; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 7; 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"; pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str = "agent-runner.gui-owner.lock"; +pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str = + "agent-runner.gui-owner.claim.json"; pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock"; pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_DIAGNOSTIC_FILE_NAME: &str = "execution-owner.json"; @@ -269,6 +271,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) event_sink_token: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) gui_owner_epoch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) gui_owner_session_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) platform_user_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) platform_access_token: Option, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index 289c76e12..cde54a691 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -152,18 +152,27 @@ pub(crate) fn bind_loopback_listener_with_linux_fallback(seed: &str) -> io::Resu } } +fn external_agent_runner_watchdog_tick(state: &ExternalAgentRunnerServerState) -> bool { + if !state.gui_owner_attached.load(Ordering::Acquire) { + return false; + } + match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + Ok(true) => { + let _ = validate_external_agent_runner_gui_owner_claim_current(state); + false + } + Ok(false) | Err(_) => { + request_external_agent_runner_forced_shutdown(state); + true + } + } +} + #[cfg(test)] pub(super) fn external_agent_runner_shutdown_if_gui_owner_lost( state: &ExternalAgentRunnerServerState, ) -> Result { - if !state.gui_owner_attached.load(Ordering::Acquire) { - return Ok(false); - } - if external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path)? { - return Ok(false); - } - request_external_agent_runner_forced_shutdown(state); - Ok(true) + Ok(external_agent_runner_watchdog_tick(state)) } pub(super) fn spawn_external_agent_runner_gui_owner_watchdog( @@ -174,25 +183,10 @@ pub(super) fn spawn_external_agent_runner_gui_owner_watchdog( thread::Builder::new() .name("agent-runner-gui-owner-watchdog".to_string()) .spawn(move || loop { - if !state.gui_owner_attached.load(Ordering::Acquire) { + if !external_agent_runner_watchdog_tick(&state) { thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL); continue; } - let owner_lost = - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { - Ok(locked) => !locked, - Err(_) => true, - }; - if !owner_lost { - thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_CHECK_INTERVAL); - continue; - } - - state.draining.store(true, Ordering::Release); - state - .force_shutdown_requested - .store(true, Ordering::Release); - state.shutdown_requested.store(true, Ordering::Release); thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_OWNER_WATCHDOG_HARD_EXIT_TIMEOUT); let _ = crate::agent::shutdown_game_creator_codex_app_servers(); remove_external_agent_runner_endpoint_if_boot_matches(&endpoint_path, &boot_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index 74b0a5402..3070039b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -12,6 +12,7 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) shutdown_requested: AtomicBool, pub(super) force_shutdown_requested: AtomicBool, pub(super) gui_owner_attached: AtomicBool, + pub(super) gui_owner_platform_session_claim: Mutex>, pub(super) draining: AtomicBool, pub(super) active_connections: AtomicUsize, pub(super) known_roots: Mutex>, @@ -89,6 +90,7 @@ impl ExternalAgentRunnerServerState { shutdown_requested: AtomicBool::new(false), force_shutdown_requested: AtomicBool::new(false), gui_owner_attached: AtomicBool::new(false), + gui_owner_platform_session_claim: Mutex::new(None), draining: AtomicBool::new(false), active_connections: AtomicUsize::new(0), known_roots: Mutex::new(BTreeSet::new()), @@ -232,6 +234,13 @@ pub(super) struct ExternalAgentRunnerInstanceLock { #[derive(Debug)] pub(crate) struct ExternalAgentRunnerGuiOwnerLock { pub(super) _file: File, + pub(super) owner_epoch: String, +} + +impl ExternalAgentRunnerGuiOwnerLock { + pub(crate) fn owner_epoch(&self) -> &str { + &self.owner_epoch + } } pub(super) struct ExternalAgentRunnerProjectOwnerStorage { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 718e95266..c87180438 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -90,7 +90,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, 6); + assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 7); } fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint { @@ -569,7 +569,8 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { event_sink_token: Some(event_sink_token.clone()), ..ExternalAgentRunnerRequestParams::default() }; - register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params); + register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params) + .expect("register GUI owner attachment"); let calls = std::cell::RefCell::new(Vec::new()); let endpoint_a = test_endpoint( @@ -656,18 +657,22 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { event_sink_token: Some("f".repeat(64)), ..ExternalAgentRunnerRequestParams::default() }, - ); + ) + .expect("register GUI owner attachment"); 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); + ) + .expect("remember owner A session"); + remember_external_agent_runner_platform_session(&state, None, 5) + .expect("remember logged-out session"); remember_external_agent_runner_platform_session( &state, Some(("user-a", "late-token-a", "https://dev.genarrative.world")), 4, - ); + ) + .expect("ignore stale owner A session"); remember_external_agent_runner_platform_session( &state, Some(( @@ -676,12 +681,14 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { "https://dev.genarrative.world", )), 5, - ); + ) + .expect("ignore conflicting same-generation session"); remember_external_agent_runner_platform_session( &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 6, - ); + ) + .expect("remember latest owner B session"); let endpoint = test_endpoint( "platform-session-replay-runner-token", @@ -706,6 +713,404 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { .expect("latest platform session is replayed"); } +#[test] +fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("platform-session-dirty-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_331), + event_sink_token: Some("d".repeat(64)), + platform_user_id: Some("user-a".to_string()), + platform_access_token: Some("token-a".to_string()), + platform_api_base_url: Some("https://dev.genarrative.world".to_string()), + platform_auth_generation: Some(1), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("register GUI owner attachment"); + let endpoint = test_endpoint( + "platform-session-dirty-runner-token", + "platform-session-dirty-boot", + 31_331, + ); + 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-a")); + Ok(()) + }, + ) + .expect("attach owner A"); + + remember_external_agent_runner_platform_session(&state, None, 2) + .expect("remember logged-out session"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, params| { + assert_eq!(params.platform_auth_generation, Some(2)); + assert_eq!(params.platform_user_id, None); + assert_eq!(params.platform_access_token, None); + assert_eq!(params.platform_api_base_url, None); + Ok(()) + }, + ) + .expect("same boot reattaches the logged-out generation"); +} + +#[test] +fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("platform-session-stale-attach-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + platform_user_id: Some("user-a".to_string()), + platform_access_token: Some("token-a".to_string()), + platform_api_base_url: Some("https://dev.genarrative.world".to_string()), + platform_auth_generation: Some(1), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("register GUI owner attachment"); + let endpoint = test_endpoint( + "platform-session-stale-attach-token", + "platform-session-stale-attach-boot", + 31_332, + ); + 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-a")); + remember_external_agent_runner_platform_session( + &state, + Some(("user-b", "token-b", "https://dev.genarrative.world")), + 2, + ) + .expect("remember owner B while owner A attach is in flight"); + Ok(()) + }, + ) + .expect("stale owner A attach response returns"); + + let retries = std::cell::Cell::new(0_u32); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, params| { + retries.set(retries.get() + 1); + assert_eq!(params.platform_user_id.as_deref(), Some("user-b")); + assert_eq!(params.platform_auth_generation, Some(2)); + Ok(()) + }, + ) + .expect("new owner B remains pending after stale completion"); + assert_eq!(retries.get(), 1); +} + +#[test] +fn gui_owner_platform_session_payload_clears_runner_session() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) + .expect("acquire platform-session clear owner"); + let state = ExternalAgentRunnerServerState::new( + config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint("platform-clear-token", "platform-clear-boot", 31_333), + ); + let _session = crate::install_test_platform_session( + "runner-owner-a", + "runner-token-a", + "https://dev.genarrative.world", + ); + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_session_revision: Some(0), + platform_auth_generation: Some(2), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("explicit logged-out payload clears Runner session"); + assert_eq!(crate::current_platform_session(), None); +} + +#[test] +fn gui_owner_partial_platform_session_payload_fails_without_mutation() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) + .expect("acquire partial-session owner"); + let state = ExternalAgentRunnerServerState::new( + config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint("platform-partial-token", "platform-partial-boot", 31_334), + ); + let _session = crate::install_test_platform_session( + "runner-owner-a", + "runner-token-a", + "https://dev.genarrative.world", + ); + let before = crate::current_platform_session(); + let error = apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_session_revision: Some(0), + 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), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect_err("partial platform session must fail closed"); + assert!(error.contains("参数不完整"), "{error}"); + assert_eq!(crate::current_platform_session(), before); +} + +#[test] +fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old_epoch() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let state = ExternalAgentRunnerServerState::new( + config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint("platform-epoch-token", "platform-epoch-boot", 31_335), + ); + let _session = crate::install_test_platform_session( + "runner-owner-seed", + "runner-token-seed", + "https://dev.genarrative.world", + ); + let owner_a = acquire_external_agent_runner_gui_owner_lock(&config_dir) + .expect("acquire old GUI owner epoch"); + let owner_a_epoch = owner_a.owner_epoch().to_string(); + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner_a_epoch.clone()), + gui_owner_session_revision: Some(0), + platform_user_id: Some("runner-owner-a".to_string()), + 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), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("old GUI installs high-generation owner A"); + drop(owner_a); + + let owner_b = acquire_external_agent_runner_gui_owner_lock(&config_dir) + .expect("acquire new GUI owner epoch"); + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner_b.owner_epoch().to_string()), + gui_owner_session_revision: Some(0), + platform_user_id: Some("runner-owner-b".to_string()), + 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), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("new GUI epoch replaces higher-generation old owner"); + assert_eq!( + crate::current_platform_session().map(|session| (session.user_id, session.generation)), + Some(("runner-owner-b".to_string(), 1)) + ); + + let stale_error = apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner_a_epoch), + gui_owner_session_revision: Some(0), + platform_user_id: Some("runner-owner-a".to_string()), + 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), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect_err("old GUI epoch must not overwrite the current owner"); + assert!(stale_error.contains("claim 已过期"), "{stale_error}"); + assert_eq!( + crate::current_platform_session().map(|session| session.user_id), + Some("runner-owner-b".to_string()) + ); +} + +#[test] +fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_reattach() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let token = "platform-claim-gate-token-platform-claim-gate-token"; + let state = ExternalAgentRunnerServerState::new( + config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint(token, "platform-claim-gate-boot", 31_337), + ); + let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) + .expect("acquire claim gate owner"); + let _session = crate::install_test_platform_session( + "runner-owner-seed", + "runner-token-seed", + "https://dev.genarrative.world", + ); + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_session_revision: Some(0), + platform_user_id: Some("runner-owner-a".to_string()), + 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), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("attach owner A claim"); + state.gui_owner_attached.store(true, Ordering::Release); + + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + .expect("advance durable claim before reattach"); + assert!( + !external_agent_runner_shutdown_if_gui_owner_lost(&state) + .expect("watchdog observes a still-live GUI owner"), + "claim mismatch must isolate the platform session without stopping a live GUI owner" + ); + assert_eq!(crate::current_platform_session(), None); + let blocked = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "platform-claim-gate-1".to_string(), + token: token.to_string(), + method: "mcp.status".to_string(), + params: ExternalAgentRunnerRequestParams::default(), + }, + &state, + ); + assert!(!blocked.ok); + assert_eq!( + blocked.error.as_ref().map(|error| error.code.as_str()), + Some("platform-session-claim-stale") + ); + assert_eq!(crate::current_platform_session(), None); + + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_session_revision: Some(1), + platform_user_id: Some("runner-owner-b".to_string()), + 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), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("current durable claim reattaches owner B"); + 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)), + Some(("runner-owner-b".to_string(), 1)) + ); +} + +#[test] +fn failed_platform_session_sync_fences_runner_before_returning_error() { + let fenced = std::cell::Cell::new(false); + let error = synchronize_external_agent_runner_platform_session_with( + || Err("injected platform-session sync failure".to_string()), + || { + fenced.set(true); + Ok(()) + }, + ) + .expect_err("sync failure must be returned after fencing"); + assert!(fenced.get()); + assert!(error.contains("Agent Runner 已停止"), "{error}"); + + let fence_error = synchronize_external_agent_runner_platform_session_with( + || Err("injected platform-session sync failure".to_string()), + || Err("injected runner fence failure".to_string()), + ) + .expect_err("failed fence must report both errors"); + assert!(fence_error.contains("阻断旧账号 Agent Runner 失败")); + assert!(fence_error.contains("injected runner fence failure")); +} + +#[test] +fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) + .expect("acquire claim-write failure owner"); + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch().to_string()), + platform_user_id: Some("runner-owner-a".to_string()), + 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), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("register owner A"); + + let fenced = std::cell::Cell::new(false); + let error = synchronize_external_agent_runner_platform_session_with( + || { + remember_external_agent_runner_platform_session_with( + &state, + Some(( + "runner-owner-b", + "runner-token-b", + "https://dev.genarrative.world", + )), + 2, + |_, _, _| Err("injected durable claim write failure".to_string()), + ) + }, + || { + fenced.set(true); + Ok(()) + }, + ) + .expect_err("claim write failure must fence the Runner"); + assert!(fenced.get()); + assert!(error.contains("durable claim write failure"), "{error}"); + + let endpoint = test_endpoint( + "claim-write-failure-token-claim-write-failure-token", + "claim-write-failure-boot", + 31_336, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, params| { + assert_eq!(params.platform_user_id.as_deref(), Some("runner-owner-a")); + assert_eq!(params.platform_auth_generation, Some(1)); + Ok(()) + }, + ) + .expect("failed claim write must leave owner A registration intact"); +} + #[test] fn runner_output_redacts_camel_case_platform_access_token_fields() { let config_dir = Path::new("private-appdata"); @@ -726,7 +1131,8 @@ fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { &state, &config_dir, ExternalAgentRunnerRequestParams::default(), - ); + ) + .expect("register GUI owner attachment"); let endpoint = test_endpoint( "gui-owner-retry-token-gui-owner-retry-token", "gui-owner-retry-boot", @@ -777,7 +1183,8 @@ fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { event_sink_token: Some("c".repeat(64)), ..ExternalAgentRunnerRequestParams::default() }, - ); + ) + .expect("register GUI owner attachment"); let endpoint = test_endpoint( "missing-sink-confirmation-runner-token", "missing-sink-confirmation-boot", @@ -825,7 +1232,8 @@ fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { event_sink_token: Some("d".repeat(64)), ..ExternalAgentRunnerRequestParams::default() }, - ); + ) + .expect("register GUI owner attachment"); let endpoint = test_endpoint( "false-sink-confirmation-runner-token", "false-sink-confirmation-boot", @@ -876,7 +1284,8 @@ fn gui_owner_registration_does_not_cross_config_dirs() { event_sink_token: Some(event_sink_token.clone()), ..ExternalAgentRunnerRequestParams::default() }, - ); + ) + .expect("register GUI owner attachment"); let endpoint = test_endpoint( "gui-owner-config-token-gui-owner-config-token", "gui-owner-config-boot", @@ -955,6 +1364,8 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), + gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, }, @@ -973,6 +1384,63 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + .expect("advance owner claim revision"); + let replacement = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "gui-owner-attach-2".to_string(), + token: token.to_string(), + method: "runner.attach_gui_owner".to_string(), + params: ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_319), + event_sink_token: Some("c".repeat(64)), + gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_session_revision: Some(1), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + assert!(replacement.ok); + assert_eq!( + sink_guard.configured_sink(), + Some(crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }) + ); + + let stale_replay = handle_external_agent_runner_request( + ExternalAgentRunnerRequest { + protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, + request_id: "gui-owner-attach-1".to_string(), + token: token.to_string(), + method: "runner.attach_gui_owner".to_string(), + params: ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_318), + event_sink_token: Some("b".repeat(64)), + gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_session_revision: Some(0), + ..ExternalAgentRunnerRequestParams::default() + }, + }, + &state, + ); + assert!(!stale_replay.ok); + assert_eq!( + stale_replay.error.as_ref().map(|error| error.code.as_str()), + Some("platform-session-invalid") + ); + assert_eq!( + sink_guard.configured_sink(), + Some(crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }), + "旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端" + ); + drop(owner); assert!(external_agent_runner_shutdown_if_gui_owner_lost(&state) .expect("owner loss requests shutdown")); diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 459b16bf5..054181cd6 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -23,14 +23,17 @@ import { import { type ClientServerPreset, type ClientServerSelection, + getClientServerBaseUrl, getClientServerSelection, normalizeClientServerBaseUrl, setClientServerSelection, } from '../services/clientHttp'; import { beginPlatformSessionTransition, + beginPlatformSessionClearTransition, clearCommittedPlatformSession, commitAuthenticatedPlatformSession, + currentPlatformSessionApiBaseUrl, currentPlatformSessionGeneration, refreshPlatformSessionForGeneration, subscribePlatformSessionRefresh, @@ -112,14 +115,15 @@ export function AuthenticatedClient({ function persistServerSelection() { try { - setClientServerSelection({ + const next = setClientServerSelection({ preset: serverSelection.preset, customBaseUrl: customServerUrl, }); - return true; + setServerSelection(next); + return next; } catch (error) { setLoginStatus(error instanceof Error ? error.message : String(error)); - return false; + return null; } } @@ -140,15 +144,18 @@ export function AuthenticatedClient({ let disposed = false; async function hydrateAuth() { const hydrationGeneration = currentPlatformSessionGeneration(); + const hydrationApiBaseUrl = getClientServerBaseUrl(); try { if (!getStoredAuthAccessToken()) { - const refreshed = - await refreshPlatformSessionForGeneration(hydrationGeneration); + const refreshed = await refreshPlatformSessionForGeneration( + hydrationGeneration, + hydrationApiBaseUrl, + ); if (!refreshed) { return; } } - const user = await getCurrentClientAuthUser(); + const user = await getCurrentClientAuthUser(hydrationApiBaseUrl); if (disposed) { return; } @@ -156,6 +163,7 @@ export function AuthenticatedClient({ const committedGeneration = await commitAuthenticatedPlatformSession( user, hydrationGeneration, + hydrationApiBaseUrl, ); if (committedGeneration === null) { return; @@ -185,12 +193,14 @@ export function AuthenticatedClient({ } if (getStoredAuthAccessToken()) { try { - const refreshed = - await refreshPlatformSessionForGeneration(hydrationGeneration); + const refreshed = await refreshPlatformSessionForGeneration( + hydrationGeneration, + hydrationApiBaseUrl, + ); if (!refreshed) { return; } - const user = await getCurrentClientAuthUser(); + const user = await getCurrentClientAuthUser(hydrationApiBaseUrl); if (disposed) { return; } @@ -199,6 +209,7 @@ export function AuthenticatedClient({ await commitAuthenticatedPlatformSession( user, hydrationGeneration, + hydrationApiBaseUrl, ); if (committedGeneration === null) { return; @@ -269,9 +280,11 @@ export function AuthenticatedClient({ if (codeBusy || codeCooldownSeconds > 0) { return; } - if (!persistServerSelection()) { + const persistedSelection = persistServerSelection(); + if (!persistedSelection) { return; } + const apiBaseUrl = getClientServerBaseUrl(persistedSelection); const normalizedPhone = normalizeAuthPhoneInput(phone); if (!normalizedPhone) { setLoginStatus('请输入手机号'); @@ -280,7 +293,10 @@ export function AuthenticatedClient({ setCodeBusy(true); setLoginStatus('正在发送验证码'); try { - const response = await sendClientPhoneLoginCode(normalizedPhone); + const response = await sendClientPhoneLoginCode( + normalizedPhone, + apiBaseUrl, + ); setCodeCooldownSeconds(Math.max(0, Math.floor(response.cooldownSeconds))); setLoginStatus(`验证码已发送,${response.expiresInSeconds} 秒内有效`); } catch (error) { @@ -308,20 +324,31 @@ export function AuthenticatedClient({ setLoginStatus('请输入密码'); return; } - if (!persistServerSelection()) { + const persistedSelection = persistServerSelection(); + if (!persistedSelection) { return; } + const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection); setLoginBusy(true); setLoginStatus('正在登录'); const loginGeneration = beginPlatformSessionTransition(); try { const user = loginMode === 'code' - ? await loginClientWithPhoneCode(normalizedPhone, code) - : await loginClientWithPassword(normalizedPhone, password); + ? await loginClientWithPhoneCode( + normalizedPhone, + code, + loginApiBaseUrl, + ) + : await loginClientWithPassword( + normalizedPhone, + password, + loginApiBaseUrl, + ); const committedGeneration = await commitAuthenticatedPlatformSession( user, loginGeneration, + loginApiBaseUrl, ); if (committedGeneration === null) { return; @@ -338,16 +365,26 @@ export function AuthenticatedClient({ } async function logout() { - const logoutGeneration = beginPlatformSessionTransition(); + const logoutApiBaseUrl = currentPlatformSessionApiBaseUrl(); + const logoutGeneration = beginPlatformSessionClearTransition(); + let nativeClearError: unknown = null; try { - await logoutClientAuthSession(); + await logoutClientAuthSession(logoutApiBaseUrl); } catch { clearStoredAuthAccessToken(); } - await clearCommittedPlatformSession(logoutGeneration); + try { + await clearCommittedPlatformSession(logoutGeneration); + } catch (error) { + nativeClearError = error; + } setAuthUser(null); setAuthStatus('unauthenticated'); - setLoginStatus('已退出登录'); + setLoginStatus( + nativeClearError + ? '已退出登录;本地运行时登录态同步失败,请重启客户端后再登录' + : '已退出登录', + ); } if (authStatus === 'checking') { @@ -374,6 +411,7 @@ export function AuthenticatedClient({ 服务器 | null = null; +const clientAuthRefreshPromises = new Map>(); const CLIENT_AUTH_NETWORK_ERROR_MESSAGE = '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; @@ -126,7 +126,7 @@ async function requestAuthJson( url: string, init: RequestInit, fallbackMessage: string, - options: { skipAuth?: boolean } = {}, + options: { skipAuth?: boolean; apiBaseUrl?: string } = {}, ) { const headers = new Headers(init.headers); headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); @@ -138,11 +138,15 @@ async function requestAuthJson( } let response: Response; try { - response = await fetchClientHttp(url, { - ...init, - credentials: 'same-origin', - headers, - }); + response = await fetchClientHttp( + url, + { + ...init, + credentials: 'same-origin', + headers, + }, + { serverBaseUrl: options.apiBaseUrl }, + ); } catch (error) { throw new ClientAuthRequestError(getClientAuthNetworkErrorMessage(error), { networkError: true, @@ -158,35 +162,47 @@ async function requestAuthJson( return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); } -export async function getCurrentClientAuthUser() { +export async function getCurrentClientAuthUser( + apiBaseUrl = getClientServerBaseUrl(), +) { const response = await requestAuthJson( '/api/auth/me', { method: 'GET' }, '读取当前用户失败', + { apiBaseUrl }, ); return response.user; } -export async function refreshClientAuthAccessToken() { - if (!clientAuthRefreshPromise) { - clientAuthRefreshPromise = requestAuthJson( - '/api/auth/refresh', - { method: 'POST' }, - '刷新登录状态失败', - { skipAuth: true }, - ) - .then((response) => { - setStoredAuthAccessToken(response.token); - return response.token; - }) - .finally(() => { - clientAuthRefreshPromise = null; - }); - } - return clientAuthRefreshPromise; +export async function refreshClientAuthAccessToken( + apiBaseUrl = getClientServerBaseUrl(), +) { + const current = clientAuthRefreshPromises.get(apiBaseUrl); + if (current) return current; + const refreshPromise = requestAuthJson( + '/api/auth/refresh', + { method: 'POST' }, + '刷新登录状态失败', + { skipAuth: true, apiBaseUrl }, + ) + .then((response) => { + setStoredAuthAccessToken(response.token); + return response.token; + }) + .finally(() => { + if (clientAuthRefreshPromises.get(apiBaseUrl) === refreshPromise) { + clientAuthRefreshPromises.delete(apiBaseUrl); + } + }); + clientAuthRefreshPromises.set(apiBaseUrl, refreshPromise); + return refreshPromise; } -export async function loginClientWithPassword(phone: string, password: string) { +export async function loginClientWithPassword( + phone: string, + password: string, + apiBaseUrl = getClientServerBaseUrl(), +) { const request: AuthEntryRequest = { ...buildClientAuthPhoneInput(phone), password: password.trim(), @@ -199,13 +215,16 @@ export async function loginClientWithPassword(phone: string, password: string) { body: JSON.stringify(request), }, '登录失败', - { skipAuth: true }, + { skipAuth: true, apiBaseUrl }, ); setStoredAuthAccessToken(response.token); return response.user; } -export async function sendClientPhoneLoginCode(phone: string) { +export async function sendClientPhoneLoginCode( + phone: string, + apiBaseUrl = getClientServerBaseUrl(), +) { const request: AuthPhoneSendCodeRequest = { ...buildClientAuthPhoneInput(phone), scene: 'login', @@ -218,11 +237,15 @@ export async function sendClientPhoneLoginCode(phone: string) { body: JSON.stringify(request), }, '发送验证码失败', - { skipAuth: true }, + { skipAuth: true, apiBaseUrl }, ); } -export async function loginClientWithPhoneCode(phone: string, code: string) { +export async function loginClientWithPhoneCode( + phone: string, + code: string, + apiBaseUrl = getClientServerBaseUrl(), +) { const request: AuthPhoneLoginRequest = { ...buildClientAuthPhoneInput(phone), code: code.trim(), @@ -235,29 +258,33 @@ export async function loginClientWithPhoneCode(phone: string, code: string) { body: JSON.stringify(request), }, '登录失败', - { skipAuth: true }, + { skipAuth: true, apiBaseUrl }, ); setStoredAuthAccessToken(response.token); return response.user; } -export async function logoutClientAuthSession() { +export async function logoutClientAuthSession( + apiBaseUrl = getClientServerBaseUrl(), +) { try { if (!getStoredAuthAccessToken()) { - await refreshClientAuthAccessToken().catch(() => ''); + await refreshClientAuthAccessToken(apiBaseUrl).catch(() => ''); } try { await requestAuthJson( '/api/auth/logout', { method: 'POST' }, '退出登录失败', + { apiBaseUrl }, ); } catch { - await refreshClientAuthAccessToken().catch(() => ''); + await refreshClientAuthAccessToken(apiBaseUrl).catch(() => ''); await requestAuthJson( '/api/auth/logout', { method: 'POST' }, '退出登录失败', + { apiBaseUrl }, ); } } finally { diff --git a/apps/ai-game-creator-shell/src/services/clientHttp.ts b/apps/ai-game-creator-shell/src/services/clientHttp.ts index 619299276..d8f2a9902 100644 --- a/apps/ai-game-creator-shell/src/services/clientHttp.ts +++ b/apps/ai-game-creator-shell/src/services/clientHttp.ts @@ -163,8 +163,20 @@ export function resolveClientHttpTarget( export async function fetchClientHttp( url: string, init: RequestInit, + options: { serverBaseUrl?: string } = {}, ): Promise { - const target = resolveClientHttpTarget(url); + const currentContext = currentClientHttpContext(); + const serverBaseUrl = options.serverBaseUrl + ? normalizeClientServerBaseUrl(options.serverBaseUrl) + : undefined; + // Unit fixtures intentionally use the relative Vite transport. Real clients bind every + // auth transaction to the explicit origin captured before its first request. + const target = resolveClientHttpTarget( + url, + currentContext.mode === 'test' + ? currentContext + : { ...currentContext, serverBaseUrl }, + ); if (target.transport === 'tauri-http') { return tauriHttpFetch(target.url, init); } diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index 754e0f38c..b5402ba5d 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -25,12 +25,20 @@ type PlatformSessionRefreshListener = ( result: PlatformSessionRefreshResult, ) => void; +type PlatformSessionGenerationListener = (generation: number) => void; + let platformAuthGeneration = 0; +let platformNativeGeneration = 0; +let platformNativeGenerationFloorPromise: Promise | null = null; let committedPlatformSession: CommittedPlatformSession | null = null; +let desiredPlatformSession: CommittedPlatformSession | null = null; let platformSessionRefreshPromise: Promise | null = null; +let platformSessionNativeMutationTail: Promise = Promise.resolve(); const platformSessionRefreshListeners = new Set(); +const platformSessionGenerationListeners = + new Set(); function restoreCommittedAccessToken() { if (committedPlatformSession?.accessToken) { @@ -43,14 +51,29 @@ function restoreCommittedAccessToken() { window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); } +function restoreCurrentRendererAccessToken() { + if (!desiredPlatformSession) { + window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); + return; + } + restoreCommittedAccessToken(); +} + function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) { for (const listener of platformSessionRefreshListeners) { listener(result); } } -async function installCommittedPlatformSession( +function notifyPlatformSessionGeneration() { + for (const listener of platformSessionGenerationListeners) { + listener(platformAuthGeneration); + } +} + +async function installNativePlatformSession( session: CommittedPlatformSession, + generation: number, ) { const invoke = resolveTauriInvoke(); if (!invoke) return; @@ -58,69 +81,203 @@ async function installCommittedPlatformSession( userId: session.user.id, accessToken: session.accessToken, apiBaseUrl: session.apiBaseUrl, - generation: session.generation, + generation, }); } -async function resolvePlatformApiBaseUrl() { +async function clearNativePlatformSession(generation: number) { + const invoke = resolveTauriInvoke(); + if (!invoke) return; + await invoke('clear_platform_account_session', { generation }); +} + +function enqueuePlatformSessionNativeMutation( + operation: () => Promise, +): Promise { + const pending = platformSessionNativeMutationTail + .catch(() => undefined) + .then(operation); + platformSessionNativeMutationTail = pending.then( + () => undefined, + () => undefined, + ); + return pending; +} + +async function readNativePlatformSessionGenerationFloor() { + const invoke = resolveTauriInvoke(); + if (!invoke) return 0; + const floor = await invoke( + 'read_platform_account_session_generation', + ); + // 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 无效,请重启客户端后重试'); + } + return floor; +} + +async function reserveNativePlatformSessionGeneration() { + platformNativeGenerationFloorPromise ??= + readNativePlatformSessionGenerationFloor(); + const nativeGenerationFloor = await platformNativeGenerationFloorPromise; + platformNativeGeneration = Math.max( + platformNativeGeneration + 1, + platformAuthGeneration, + nativeGenerationFloor + 1, + ); + return platformNativeGeneration; +} + +async function reconcileNativePlatformSessionToCurrentAuthority() { + for (;;) { + const authoritativeGeneration = platformAuthGeneration; + const authoritativeSession = desiredPlatformSession + ? { ...desiredPlatformSession } + : null; + const reconciliationGeneration = + await reserveNativePlatformSessionGeneration(); + restoreCurrentRendererAccessToken(); + try { + if (authoritativeSession) { + await installNativePlatformSession( + authoritativeSession, + reconciliationGeneration, + ); + } else { + await clearNativePlatformSession(reconciliationGeneration); + } + } catch (error) { + if (platformAuthGeneration === authoritativeGeneration) { + committedPlatformSession = null; + desiredPlatformSession = null; + restoreCommittedAccessToken(); + notifyPlatformSessionGeneration(); + } + throw error; + } + if (platformAuthGeneration !== authoritativeGeneration) { + continue; + } + committedPlatformSession = authoritativeSession + ? { + ...authoritativeSession, + generation: authoritativeGeneration, + } + : null; + desiredPlatformSession = committedPlatformSession + ? { ...committedPlatformSession } + : null; + restoreCommittedAccessToken(); + notifyPlatformSessionGeneration(); + return; + } +} + +function resolvePlatformApiBaseUrl() { return getClientServerBaseUrl(); } async function commitPlatformSession( user: AuthUser, + accessToken: string, + apiBaseUrl: string, expectedGeneration: number, ): Promise { - const accessToken = getStoredAuthAccessToken(); - if (!accessToken) { - throw new Error('陶泥儿登录凭据缺失,请重新登录'); - } if (platformAuthGeneration !== expectedGeneration) { - if ( - committedPlatformSession?.user.id === user.id && - committedPlatformSession.accessToken === accessToken - ) { - return committedPlatformSession; - } - restoreCommittedAccessToken(); + restoreCurrentRendererAccessToken(); return null; } - platformAuthGeneration += 1; - const apiBaseUrl = await resolvePlatformApiBaseUrl(); - committedPlatformSession = { + const candidate: CommittedPlatformSession = { user, accessToken, apiBaseUrl, - generation: platformAuthGeneration, + generation: expectedGeneration + 1, }; - return committedPlatformSession; + // Reserve a new generation so older refresh/login work becomes stale, but keep the previous + // committed identity authoritative until Rust and Runner have accepted the candidate. + platformAuthGeneration = candidate.generation; + 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 null; + } + committedPlatformSession = candidate; + desiredPlatformSession = { ...candidate }; + restoreCommittedAccessToken(); + notifyPlatformSessionGeneration(); + return candidate; } export function currentPlatformSessionGeneration() { return platformAuthGeneration; } +export function currentPlatformSessionApiBaseUrl() { + return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); +} + export function beginPlatformSessionTransition() { platformAuthGeneration += 1; - committedPlatformSession = null; + desiredPlatformSession = committedPlatformSession + ? { ...committedPlatformSession } + : null; + notifyPlatformSessionGeneration(); + return platformAuthGeneration; +} + +export function beginPlatformSessionClearTransition() { + platformAuthGeneration += 1; + desiredPlatformSession = null; + notifyPlatformSessionGeneration(); return platformAuthGeneration; } export async function commitAuthenticatedPlatformSession( user: AuthUser, expectedGeneration: number, + apiBaseUrl = resolvePlatformApiBaseUrl(), ) { - const session = await commitPlatformSession(user, expectedGeneration); - if (!session) return null; - await installCommittedPlatformSession(session); - return session.generation; + const accessToken = getStoredAuthAccessToken(); + if (!accessToken) { + throw new Error('陶泥儿登录凭据缺失,请重新登录'); + } + return enqueuePlatformSessionNativeMutation(async () => { + const session = await commitPlatformSession( + user, + accessToken, + apiBaseUrl, + expectedGeneration, + ); + if (!session) return null; + return session.generation; + }); } export async function refreshPlatformSessionForGeneration( expectedGeneration: number, + apiBaseUrl = resolvePlatformApiBaseUrl(), ) { - const token = await refreshClientAuthAccessToken(); + const token = await refreshClientAuthAccessToken(apiBaseUrl); if (platformAuthGeneration !== expectedGeneration) { - restoreCommittedAccessToken(); + restoreCurrentRendererAccessToken(); return null; } return token; @@ -130,37 +287,64 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { if (platformSessionRefreshPromise) return platformSessionRefreshPromise; const expectedGeneration = platformAuthGeneration; + const apiBaseUrl = + committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); const expectedSessionUserId = expectedUserId?.trim() || committedPlatformSession?.user.id || ''; platformSessionRefreshPromise = (async (): Promise => { try { - const refreshed = - await refreshPlatformSessionForGeneration(expectedGeneration); + const refreshed = await refreshPlatformSessionForGeneration( + expectedGeneration, + apiBaseUrl, + ); if (!refreshed) return { status: 'stale' }; - const user = await getCurrentClientAuthUser(); + const user = await getCurrentClientAuthUser(apiBaseUrl); if ( platformAuthGeneration !== expectedGeneration || !user || (expectedSessionUserId && user.id !== expectedSessionUserId) ) { - restoreCommittedAccessToken(); + restoreCurrentRendererAccessToken(); return { status: 'stale' }; } - const session = await commitPlatformSession(user, expectedGeneration); - if (!session) return { status: 'stale' }; - await installCommittedPlatformSession(session); + const committedGeneration = await commitAuthenticatedPlatformSession( + user, + expectedGeneration, + apiBaseUrl, + ); + if (committedGeneration === null) return { status: 'stale' }; return { status: 'refreshed', user, - generation: session.generation, + generation: committedGeneration, }; } catch (error) { - if (platformAuthGeneration === expectedGeneration) { - const clearGeneration = beginPlatformSessionTransition(); - await clearCommittedPlatformSession(clearGeneration); + if (platformAuthGeneration !== expectedGeneration) { + restoreCurrentRendererAccessToken(); + return { status: 'stale' }; } - return { status: 'failed', error }; + let failure = error; + const currentOwnerUserId = committedPlatformSession?.user.id || ''; + if ( + currentOwnerUserId && + currentOwnerUserId !== expectedSessionUserId + ) { + restoreCurrentRendererAccessToken(); + return { status: 'stale' }; + } + if ( + !currentOwnerUserId || + currentOwnerUserId === expectedSessionUserId + ) { + const clearGeneration = beginPlatformSessionClearTransition(); + try { + await clearCommittedPlatformSession(clearGeneration); + } catch (clearError) { + failure = clearError; + } + } + return { status: 'failed', error: failure }; } })().then((result) => { notifyPlatformSessionRefresh(result); @@ -181,16 +365,51 @@ export function subscribePlatformSessionRefresh( }; } +export function subscribePlatformSessionGeneration( + listener: PlatformSessionGenerationListener, +) { + platformSessionGenerationListeners.add(listener); + return () => { + platformSessionGenerationListeners.delete(listener); + }; +} + export async function clearCommittedPlatformSession(generation: number) { - restoreCommittedAccessToken(); - const invoke = resolveTauriInvoke(); - if (!invoke) return; - await invoke('clear_platform_account_session', { generation }); + return enqueuePlatformSessionNativeMutation(async () => { + if (platformAuthGeneration !== generation) { + await reconcileNativePlatformSessionToCurrentAuthority(); + return; + } + desiredPlatformSession = null; + const nativeGeneration = await reserveNativePlatformSessionGeneration(); + try { + await clearNativePlatformSession(nativeGeneration); + } catch { + if (platformAuthGeneration === generation) { + desiredPlatformSession = null; + } + await reconcileNativePlatformSessionToCurrentAuthority(); + return; + } + if (platformAuthGeneration !== generation) { + await reconcileNativePlatformSessionToCurrentAuthority(); + return; + } + committedPlatformSession = null; + desiredPlatformSession = null; + restoreCommittedAccessToken(); + notifyPlatformSessionGeneration(); + }); } export function resetPlatformSessionStateForTests() { platformAuthGeneration = 0; + platformNativeGeneration = 0; + platformNativeGenerationFloorPromise = null; committedPlatformSession = null; + desiredPlatformSession = null; platformSessionRefreshPromise = null; + platformSessionNativeMutationTail = Promise.resolve(); platformSessionRefreshListeners.clear(); + platformSessionGenerationListeners.clear(); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index a0ef217ef..d3c48393d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -60,7 +60,11 @@ import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; -import { requestPlatformSessionRefresh } from '../../services/platformSession'; +import { + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, + subscribePlatformSessionGeneration, +} from '../../services/platformSession'; import UiEditorPage from '../ui-editor'; import { type ProjectManifestSnapshotMetadata, @@ -750,6 +754,9 @@ export default function ProjectDevelopmentView({ useState('idle'); const [pendingResourceEditsError, setPendingResourceEditsError] = useState(''); + const [platformSessionGeneration, setPlatformSessionGeneration] = useState( + currentPlatformSessionGeneration, + ); const [resourceRecoveryPanelOpen, setResourceRecoveryPanelOpen] = useState(false); const [pendingResourceEditActionIds, setPendingResourceEditActionIds] = @@ -824,6 +831,7 @@ export default function ProjectDevelopmentView({ const assetCanvasRouteRef = useRef(null); const resourceEditorRevisionRef = useRef(new Map()); const pendingResourceEditsReadEpochRef = useRef(0); + const platformSessionGenerationRef = useRef(platformSessionGeneration); const pendingResourceEditActionIdsRef = useRef(new Set()); const resourceRecoveryPanelEpochRef = useRef(0); const currentProjectIdentityRef = useRef({ @@ -1443,8 +1451,7 @@ export default function ProjectDevelopmentView({ } const viewport = resolveViewport(event); const category = viewport?.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!viewport || !category) { return; } @@ -1463,8 +1470,7 @@ 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; } @@ -1552,8 +1558,7 @@ export default function ProjectDevelopmentView({ .querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!category) { return; } @@ -1573,8 +1578,7 @@ export default function ProjectDevelopmentView({ ?.querySelectorAll('[data-resource-section-scroll]') .forEach((viewport) => { const category = viewport.dataset.resourceSectionScroll as - | ResourceCategory - | undefined; + ResourceCategory | undefined; if (!category) { return; } @@ -1618,6 +1622,25 @@ export default function ProjectDevelopmentView({ } }, [advanceFocusGeneration, embeddedPreviewUrl]); + useEffect( + () => + subscribePlatformSessionGeneration((generation) => { + platformSessionGenerationRef.current = generation; + pendingResourceEditsReadEpochRef.current += 1; + resourceRecoveryPanelEpochRef.current += 1; + pendingResourceEditActionIdsRef.current = new Set(); + setPendingResourceEdits([]); + setPendingResourceEditsLoadState('idle'); + setPendingResourceEditsError(''); + setPendingResourceEditActionIds(new Set()); + setPendingResourceEditActionErrors(new Map()); + setResourceEditServiceIdentityConfirmations(new Map()); + setResourceRecoveryPanelOpen(false); + setPlatformSessionGeneration(generation); + }), + [], + ); + useLayoutEffect(() => { const previousRecoveryProject = resourceRecoveryProjectIdentityRef.current; if ( @@ -1675,6 +1698,7 @@ export default function ProjectDevelopmentView({ projectPath, projectId: manifest.projectId, }; + const actionPlatformSessionGeneration = platformSessionGeneration; if (!isCurrentRecoveryProject(actionProject)) { return { status: 'stale' as const }; } @@ -1701,6 +1725,8 @@ export default function ProjectDevelopmentView({ ); if ( readEpoch !== pendingResourceEditsReadEpochRef.current || + actionPlatformSessionGeneration !== + platformSessionGenerationRef.current || !isCurrentRecoveryProject(actionProject) ) { return { status: 'stale' as const }; @@ -1712,6 +1738,8 @@ export default function ProjectDevelopmentView({ } catch (error) { if ( readEpoch !== pendingResourceEditsReadEpochRef.current || + actionPlatformSessionGeneration !== + platformSessionGenerationRef.current || !isCurrentRecoveryProject(actionProject) ) { return { status: 'stale' as const }; @@ -1721,7 +1749,12 @@ export default function ProjectDevelopmentView({ setPendingResourceEditsError(message); return { status: 'failed' as const, error: message }; } - }, [isCurrentRecoveryProject, manifest.projectId, projectPath]); + }, [ + isCurrentRecoveryProject, + manifest.projectId, + platformSessionGeneration, + projectPath, + ]); useEffect(() => { void refreshPendingResourceEdits(); @@ -2910,16 +2943,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), diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 64eb16017..03a3d7fd5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1,8 +1,15 @@ import { afterEach } from 'vitest'; -import { resetClientServerSelectionForTests } from '../../src/services/clientHttp'; +import { + AGC_DEVELOPMENT_API_BASE_URL, + AGC_RELEASE_API_BASE_URL, + resetClientServerSelectionForTests, + setClientServerSelection, +} from '../../src/services/clientHttp'; import { beginPlatformSessionTransition, + beginPlatformSessionClearTransition, + clearCommittedPlatformSession, commitAuthenticatedPlatformSession, currentPlatformSessionGeneration, requestPlatformSessionRefresh, @@ -78,7 +85,350 @@ export function registerAuthTests() { expect(screen.queryByLabelText('已登录')).toBeNull(); expect( window.localStorage.getItem('genarrative.auth.access-token.v1'), - ).toBe('phone-token'); + ).toBe(null); + }); + + it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => { + setClientServerSelection({ preset: 'dev', customBaseUrl: '' }); + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + let resolveLogin: ((response: Response) => void) | null = 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 await new Promise((resolve) => { + resolveLogin = resolve; + }); + } + 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: '登录' })); + await waitFor(() => expect(resolveLogin).not.toBeNull()); + expect( + (screen.getByLabelText('服务器') as HTMLSelectElement).disabled, + ).toBe(true); + + setClientServerSelection({ preset: 'release', customBaseUrl: '' }); + resolveLogin?.( + new Response( + JSON.stringify({ + token: 'origin-a-token', + user: { ...testAuthUser, loginMethod: 'phone' }, + created: false, + referral: null, + }), + { status: 200 }, + ), + ); + + expect(await screen.findByRole('main', { name: '已登录' })).not.toBeNull(); + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + accessToken: 'origin-a-token', + apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, + }), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'install_platform_account_session', + expect.objectContaining({ apiBaseUrl: AGC_RELEASE_API_BASE_URL }), + ); + }); + + it('reserves install and clear generations above the native floor after renderer state resets', async () => { + let nativeGenerationFloor = 57; + const mutations: Array<{ + command: string; + generation: number; + }> = []; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + if (command === 'read_platform_account_session_generation') { + return nativeGenerationFloor; + } + 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'); + } + mutations.push({ command, generation }); + nativeGenerationFloor = generation; + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + + resetPlatformSessionStateForTests(); + const installFloor = nativeGenerationFloor; + 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); + + resetPlatformSessionStateForTests(); + const clearFloor = nativeGenerationFloor; + 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( + invoke.mock.calls.filter( + ([command]) => command === 'read_platform_account_session_generation', + ), + ).toHaveLength(2); + }); + + it('keeps the previous renderer session authoritative when replacement install is rejected', async () => { + const invoke = vi.fn(async (_command: string, payload?: unknown) => { + const userId = (payload as { userId?: string } | undefined)?.userId; + if (userId === 'user-b') { + throw new Error('replacement rejected'); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const accountAGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-a-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); + const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; + const accountBGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-b-token', + ); + + await expect( + commitAuthenticatedPlatformSession(accountB, accountBGeneration), + ).rejects.toThrow('replacement rejected'); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('account-a-token'); + }); + + it('reconciles a stale native install back to the last committed renderer authority', async () => { + let resolveAccountBInstall: (() => void) | null = null; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + const userId = (payload as { userId?: string } | undefined)?.userId; + if ( + command === 'install_platform_account_session' && + userId === 'user-b' + ) { + await new Promise((resolve) => { + resolveAccountBInstall = resolve; + }); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const accountAGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-a-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); + const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; + const accountBGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-b-token', + ); + const accountBCommit = commitAuthenticatedPlatformSession( + accountB, + accountBGeneration, + ); + await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); + + beginPlatformSessionTransition(); + resolveAccountBInstall?.(); + + await expect(accountBCommit).resolves.toBeNull(); + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: testAuthUser.id, + accessToken: 'account-a-token', + }), + ); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('account-a-token'); + }); + + it('keeps a queued logout authoritative after an older native install completes', async () => { + let resolveAccountBInstall: (() => void) | null = null; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + const userId = (payload as { userId?: string } | undefined)?.userId; + if ( + command === 'install_platform_account_session' && + userId === 'user-b' + ) { + await new Promise((resolve) => { + resolveAccountBInstall = resolve; + }); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const accountAGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-a-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); + const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; + const accountBGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-b-token', + ); + const accountBCommit = commitAuthenticatedPlatformSession( + accountB, + accountBGeneration, + ); + await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); + + const logoutGeneration = beginPlatformSessionClearTransition(); + const clear = clearCommittedPlatformSession(logoutGeneration); + resolveAccountBInstall?.(); + + await expect(accountBCommit).resolves.toBeNull(); + await expect(clear).resolves.toBeUndefined(); + expect(invoke).toHaveBeenLastCalledWith( + 'clear_platform_account_session', + expect.objectContaining({ + generation: expect.any(Number), + }), + ); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe(null); + }); + + it('keeps a newer queued login valid after reconciling an older native install', async () => { + let resolveAccountBInstall: (() => void) | null = null; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + const userId = (payload as { userId?: string } | undefined)?.userId; + if ( + command === 'install_platform_account_session' && + userId === 'user-b' + ) { + await new Promise((resolve) => { + resolveAccountBInstall = resolve; + }); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const accountAGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-a-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); + + const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; + const accountBGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-b-token', + ); + const accountBCommit = commitAuthenticatedPlatformSession( + accountB, + accountBGeneration, + ); + await waitFor(() => expect(resolveAccountBInstall).not.toBeNull()); + + const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' }; + const accountCGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-c-token', + ); + const accountCCommit = commitAuthenticatedPlatformSession( + accountC, + accountCGeneration, + ); + resolveAccountBInstall?.(); + + await expect(accountBCommit).resolves.toBeNull(); + await expect(accountCCommit).resolves.not.toBeNull(); + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: 'user-c', + accessToken: 'account-c-token', + }), + ); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('account-c-token'); + }); + + it('restores renderer authority when a queued login is stale before it starts', async () => { + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + const accountAGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-a-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); + + const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; + const accountBGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-b-token', + ); + const accountBCommit = commitAuthenticatedPlatformSession( + accountB, + accountBGeneration, + ); + beginPlatformSessionTransition(); + + await expect(accountBCommit).resolves.toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('account-a-token'); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'install_platform_account_session', + ), + ).toHaveLength(1); }); it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => { @@ -147,6 +497,43 @@ export function registerAuthTests() { ); }); + it('treats a late old-account refresh failure as stale after switching accounts', 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 rejectRefresh: ((error: Error) => void) | null = null; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + if (String(input) === '/api/auth/refresh') { + return await new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + } + throw new Error(`unexpected fetch ${String(input)}`); + }, + ); + const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id); + 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); + rejectRefresh?.(new Error('late account A refresh failed')); + + await expect(staleRefresh).resolves.toEqual({ status: 'stale' }); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('account-b-token'); + }); + it('refreshes the current account once and synchronizes the replacement token', async () => { const invoke = vi.fn(async () => null); window.__TAURI__ = { core: { invoke } }; @@ -195,6 +582,62 @@ export function registerAuthTests() { }); }); + it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => { + setClientServerSelection({ preset: 'dev', customBaseUrl: '' }); + 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, + AGC_DEVELOPMENT_API_BASE_URL, + ); + + let resolveRefresh: ((response: Response) => void) | null = null; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return await new Promise((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 refresh = requestPlatformSessionRefresh(testAuthUser.id); + setClientServerSelection({ preset: 'release', customBaseUrl: '' }); + resolveRefresh?.( + new Response(JSON.stringify({ token: 'replacement-token' }), { + status: 200, + }), + ); + + await expect(refresh).resolves.toEqual( + expect.objectContaining({ status: 'refreshed' }), + ); + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + accessToken: 'replacement-token', + apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, + }), + ); + }); + it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') @@ -652,4 +1095,67 @@ export function registerAuthTests() { ), ).toHaveLength(1); }); + + it('fails the renderer closed when native session clear is rejected during logout', async () => { + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'existing-token', + ); + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string) => { + if (command === 'clear_platform_account_session') { + throw new Error('runner clear rejected'); + } + return null; + }), + }, + }; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + if (url === '/api/auth/logout') { + return new Response('', { status: 200 }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user, logout }) => + React.createElement( + 'main', + { 'aria-label': '已登录' }, + React.createElement('span', null, user.displayName), + React.createElement( + 'button', + { type: 'button', onClick: logout }, + '退出', + ), + ), + ), + ); + expect(await screen.findByLabelText('已登录')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '退出' })); + + expect(await screen.findByRole('main', { name: '登录' })).not.toBeNull(); + expect( + screen.getByText( + '已退出登录;本地运行时登录态同步失败,请重启客户端后再登录', + ), + ).not.toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe(null); + }); } diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 4cf3067aa..c345984e3 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -418,6 +418,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotentCreateKey" + } + ], "requestBody": { "required": false, "content": { @@ -439,11 +444,17 @@ } } }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, "401": { "$ref": "#/components/responses/Unauthorized" }, "403": { "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/IdempotentCreateConflict" } } } @@ -684,6 +695,9 @@ "parameters": [ { "$ref": "#/components/parameters/ProjectId" + }, + { + "$ref": "#/components/parameters/IdempotentCreateKey" } ], "requestBody": { @@ -718,6 +732,9 @@ }, "404": { "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/IdempotentCreateConflict" } } } @@ -766,6 +783,11 @@ "ExternalApiKey": [] } ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotentCreateKey" + } + ], "requestBody": { "required": true, "content": { @@ -795,6 +817,9 @@ }, "403": { "$ref": "#/components/responses/Forbidden" + }, + "409": { + "$ref": "#/components/responses/IdempotentCreateConflict" } } } @@ -1557,6 +1582,18 @@ "maxLength": 128, "pattern": "^[!-~]+$" } + }, + "IdempotentCreateKey": { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "可选的稳定创建幂等键。同一账号、接口与键使用相同规范化请求体重试时返回首次结果的当前记录,即使该记录后来已被修改;同键不同请求体返回 409。首次结果已删除时,同键重放同样返回 409 且不会重新创建。网络结果不确定时必须复用原值。", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[!-~]+$" + } } }, "responses": { @@ -1610,6 +1647,16 @@ } } }, + "IdempotentCreateConflict": { + "description": "稳定创建幂等冲突:同一 Idempotency-Key 携带不同规范化请求体,或首次创建结果已删除 / 缺少耐久回执;服务端不会重新创建或重复首次副作用", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "UpstreamError": { "description": "上游生成服务失败", "content": { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a0a10aba0..49ff5f053 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7182,8 +7182,8 @@ - 路由决策:普通 AGC 登录后直接调用固定官方 origin 下的网站现役 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`,不再要求用户配置 External Editor Base URL 或 `tnr_sk_...`。普通 release 的官方 origin 继续服从 2026-08-10 的 dev API 固定规则,本地 dev 使用配套代理。`/api/external/v1` 的 Bearer Developer API Key 契约保持独立且不接受网站 Access Token,只供第三方 Agent/CLI、独立 standalone game-chat release 与显式高级自定义模式;服务端不为普通用户自动签发、代管或下发 Developer Key。 - UI 与 standalone 例外:普通 Launcher、工作台、已认证 debug game-chat 和运行时配置页不展示或维护 External Editor URL/Key。独立 standalone game-chat release 仍绕过 `AuthenticatedClient`,只能在用户显式配置其隔离 AppData 的高级 External v1 URL/Key 后使用云端画板能力;凭据缺失时明确不可用,不得回退到普通登录态、内置 Key 或隐式公共账号。 -- 会话 CAS:WebView 登录、refresh、退出和换号通过窄 Tauri/Runner 协议同步 `userId + 短期 Access Token + authGeneration` 到 GUI/Runner 进程内存。generation 单调递增,只有不旧于当前值的安装/清除可以生效;每个业务请求在发出前冻结 `ownerUserId + authGeneration`,在响应解析、账本推进、媒体安装和正式项目提交前都重新核对。旧 generation 的迟到安装、响应或清除不得覆盖新账号,也不得把旧账号产物安装到新账号项目。 -- 凭据边界:Access Token、refresh Cookie、Authorization 和 authGeneration 不进入 AppData、项目、manifest、生成账本、Agent prompt、conversation、Agent DB、trace、日志或普通错误;durable 账本只保存非敏感 ownerUserId。Runner IPC 只允许专用 session install/clear 消息携带 Token,普通生成/恢复命令参数继续不得携带 Access Token 或 Developer Key。 +- 会话 CAS:GUI 取得 owner OS 锁时产生随机 `owner epoch`,并持久化只含 `owner epoch + session revision` 的私有 claim。WebView 登录、refresh、退出和换号先单调推进 durable session revision,再以 `runner.attach_gui_owner` 同步 `userId + 短期 Access Token + authGeneration`;Runner 只接受 epoch/revision 与 durable claim 完全一致的 attach。同一 epoch 内 generation 仍单调递增,只有不旧于当前值的安装/清除可以生效;新 GUI epoch 则可替换旧进程留下的更高 generation。每个业务请求在发出前冻结 `ownerUserId + authGeneration`,在响应解析、账本推进、媒体安装和正式项目提交前都重新核对。旧 epoch/revision/generation 的迟到 attach、响应或清除不得覆盖新账号,也不得把旧账号产物安装到新账号项目。 +- 凭据边界:Access Token、refresh Cookie、Authorization 和 authGeneration 不进入 AppData、durable claim、项目、manifest、生成账本、Agent prompt、conversation、Agent DB、trace、日志或普通错误;durable claim 只保存非敏感 epoch/revision,durable 账本只保存非敏感 ownerUserId。Token 只允许随与 durable claim 完全匹配的 `runner.attach_gui_owner` 进入 Runner 内存;旧 `platform.session.install/clear` 不再是授权入口,普通生成/恢复命令参数继续不得携带 Access Token 或 Developer Key。claim 缺失、不可读或失配时 Runner 必须立即清空平台会话并阻断 Runtime 请求;同步失败必须隔离或停止旧 Runner,不得在 GUI 已切换后继续使用旧账号权威。 - 401/refresh:普通模式首次 `401` 进入当前 authGeneration 的全局单飞 refresh;其它请求等待同一 Promise,但各自 deadline、取消和账号归属独立。refresh 成功只在原 generation/owner 仍当前时以更高 generation CAS 发布新 Token,然后每个调用最多使用原 endpoint、原始请求字节和原 `Idempotency-Key` 重试一次。refresh 失败、账号已切换或重试仍为 `401` 时停止网络并保留原 operation;`403` 表示当前账号权限不足,不触发 refresh。高级 External v1 模式只允许用户修正自己的 Developer Key,并继续原 operation。 - Durable 身份与换号:普通账本身份固定为 `official-platform-v1 + 官方 origin + ownerUserId`,高级账本身份固定为 External service origin,二者均不绑定可轮换 Token/Key。退出或换号会提升 generation、中止并脱离旧请求;新账号对旧 owner 的 `prepared/accepted/running` 账本必须零 POST、零 GET、零下载、零安装、零归档,只有重新登录同一 owner 后才能恢复。`401/403/404`、网关错误或轮询超时不能在 owner 不匹配时证明旧 operation 的终态。 - 幂等与站内响应:`prepared` 只可精确重放账本冻结的原 endpoint、原始正文和原幂等键;`accepted/running` 只查询原 operationId。Access Token/Key 轮换、refresh、重启、退出或换号都不得创建替代 operation、修改正文或重新扣费。站内生成适配 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;External v1 继续固定 `202 + operationId`,两者共享相同 owner、计费、稳定引用与本地事务门禁。 @@ -14360,7 +14360,17 @@ - 权威边界:本地项目 ID、manifest asset、正式本地文件和内容摘要属于设备上的本地项目;`canvasProjectId / resourceId / assetObjectId / objectKey` 属于具体 External Editor 服务 principal。manifest 中现有远端字段继续保留生成来源,不再承担“当前账号可编辑句柄”,本轮不修改共享 manifest schema。 - 私有投影:统一在 `.agent/runtime/external-editor-bindings/` 持久化 active binding。项目 binding 按服务 origin、平台 userId 或 Developer Key 摘要以及本地 `projectId` 分区;资源 binding 再绑定远端项目、本地 asset ID、源 SHA-256、媒体类型和 canonical asset kind。sidecar 不保存 Token、Key、Cookie、Authorization、签名上传表单、媒体正文或绝对路径。 +- binding 完整性与并发:lookup key 与稳定 payload 内容指纹分开校验,远端 project/folder/resource/object ID、object key 和尺寸的任一改写都必须失败关闭。同一 `本地项目 + principal` 的首次 binding 建立串行化覆盖复读、远端创建与安装,并发生成不得各自创建画布或留下孤儿项目。 +- binding schema 迁移:当前 project/resource sidecar 显式为 v2 且 payload fingerprint 必填。旧 v1 文档仅能由独立 `deny_unknown_fields` wire 结构命中,在 key、principal、local project/source、remote 引用、object key、时间和尺寸等现役不变量通过后计算指纹并原子回写 v2;未知字段或身份 / source / 路径篡改均失败关闭且不回写。 - 切号语义:账号 B 打开账号 A 曾生成资源的同一本地项目时,不访问、迁移或覆盖 A 的私有画布;B 从本地正式文件在自己的远端项目重新上传、confirm、登记并形成独立 binding。切回 A 时复用 A binding。项目标题只用于远端展示,不能作为同名项目的权威关联;项目改名不换 binding。 +- Runner 切号权威:Runner 协议 v7 规定 GUI owner 锁每次取得都创建新的随机 owner epoch,每次平台会话变更先推进 durable session revision claim。Runner 只能通过与该 claim 完全匹配的 `runner.attach_gui_owner` 替换会话,因此新 GUI 即使从较低 `authGeneration` 开始也能取代旧进程权威。claim 失配时立即清空 Runner 平台会话并阻断 Runtime,旧 `platform.session.install/clear` 只失败关闭而不再变更权威;GUI 与 Runner 同步失败时必须隔离或停止旧 Runner。 +- renderer / native 会话提交:renderer auth generation 只负责 UI 转换和迟到读取失效,native install / clear 使用独立只增 generation 并在同一串行队列执行。登录、refresh、当前用户查询与 native commit 共用请求前冻结的 API origin,refresh singleflight 按 origin 分区;入队前冻结 `user + token + origin`。候选账号只在 native 成功且仍属当前 auth generation 时提交;期望权威显式表示为 account 或 `null`。迟到 native 完成必须以更高 generation 对账回当前期望权威,对账失败清空 renderer committed 会话与 Token。 +- stale refresh 决策:queued commit 的 expected generation 过期时,early return 先恢复 renderer 当前 committed / desired Token。旧账号的 refresh 失败在当前 owner 已变更时只返回 `stale`,不发送会让 `AuthenticatedClient` 清空新账号的 failed 结果,也不执行新账号 native clear。 - 在途边界:资源编辑、素材画布生成和 Agent 美术生成的 `prepared / accepted / running` 账本继续锁定发起 principal。账号切换后只能停止请求并保留原 operation 供切回或对账,不得把在途副作用迁移到新账号,也不得重建正文或重复 POST。只有尚未提交远端生成的新操作可以为当前账号建立新的项目和资源 binding。 -- 集成边界:`resource_editor`、素材画布参考准备、`canvas.asset_generate` 的 art-spec 派生和直连只读恢复统一消费当前 principal binding;PR 176 rebase 后必须删除或整合其局部 canonical cache,不能保留第二套账号身份系统。External v1 路由和公开 DTO 不变,无需修改 OpenAPI。 +- 在途会话复验:提交返回 `operationId` 后先持久化 accepted 身份,再在每次 poll、download 和本地 commit 前校验冻结会话;同步本地 commit 必须持有冻结会话租约,使切号与安装线性化。Tauri 手工生成入口也必须使用稳定的 durable operation slot,不能退化成临时幂等键。切号后不得继续旧账号网络或把旧账号结果安装到本地项目。 +- 账本 owner 线性化:资源编辑与素材画布的 owner 绑定、服务身份指纹 / 挑战和确认写入,均在同一冻结 platform session 租约下完成,且账本已有 owner 必须精确匹配 `userId + api origin`。Developer Key 账本保持 `owner=null`;请求 / 确认挑战的直接命令调用也不具备跨 owner 写入权限。 +- 恢复投影:资源恢复列表持有当前 session 租约扫描,远端账本只对精确 owner `userId + api origin` 可见;未绑 owner 的 Developer / legacy 远端账本与 owner 不完整账本均失败关闭,纯本地编辑仍可见。renderer auth generation 变更同步作废恢复 read epoch,清空列表、挑战和动作状态,迟到读取不得恢复旧账号投影。 +- Direct 审计对账:图集只读恢复已完成 file + manifest 登记后,`asset.register` 追加审计一旦成功或结果未知,后续登记 / binding 失败都保留 file、manifest 和已落盘 audit,统一进入 `reconciliation-required`。禁止为了伪造原子性而回滚 manifest、删文件、重试未知审计或重新生成。 +- 账本迁移:资源 canonical 身份从历史 manifest `resourceId` 收口到本地 asset ID 时,旧账本只在 source asset、路径和 SHA-256 一致时可白名单恢复;新请求仍只信任本地 canonical 身份。 +- 集成边界:`resource_editor`、素材画布参考准备、`canvas.asset_generate` 的 art-spec 派生和直连只读恢复统一消费当前 principal binding;PR 176 rebase 后必须删除或整合其局部 canonical cache,不能保留第二套账号身份系统。External v1 的项目、素材目录和项目资源创建接口新增可选 `Idempotency-Key` 请求头并同步 OpenAPI;客户端用 binding key 派生稳定值,服务端按 `owner + 接口命名空间 + key` 生成稳定 ID,同键同正文返回原记录、同键异正文冲突。 - 验收:至少覆盖 A 生成到本地后切 B 重登记、B 请求零 A 远端 ID、重启后复用 B、切回 A 复用 A、两个同名本地项目隔离、项目改名不漂移、源 SHA 或 kind 变化重登记、A 在途任务切 B 零网络,以及 sidecar 零凭据和身份篡改失败关闭。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 655f864f0..6c48eae45 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4845,5 +4845,15 @@ - 现象:本地资源文件仍存在,但账号 A 生成后切到账号 B,快速编辑、GIF/视频派生、art-spec 下游生成或直连恢复提示画布不存在、无权限或资源不属于当前项目;原请求重试仍失败。 - 原因:本地 manifest 只有一组 `source.canvasProjectId / resourceId / assetObjectId`,旧实现把它同时当作历史来源和当前账号可编辑引用。新的 operation 虽已绑定 B,却会把 A 的 project/resource/object 身份配合 B Token 发出;另一部分链路又把 A 的画布 ID 与 B 按标题选出的项目严格比较。 - 处理:manifest 远端字段只保留历史 provenance。所有远端编辑和派生先解析 `.agent/runtime/external-editor-bindings/` 中当前 principal 的项目/资源 binding;不存在时从本地正式文件按 asset ID、SHA-256、媒体类型和 canonical kind 在当前账号重登记。禁止按标题、manifest ID 或其他账号 committed ledger 自动采纳远端对象。A 的在途 operation 继续留在 A,B 只能开始自己的新操作。 -- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 localProjectId 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Developer Key fixture 不能替代平台账号隔离证据。 +- 并发与升级陷阱:首次 binding 不能用无锁的“先查后建”,否则两个 generation 会各自创建远端项目/目录;进程锁只能压住当前存活客户端,远端创建还必须携带由 binding key 派生的稳定 `Idempotency-Key`,关闭“响应已返回但 sidecar 未落盘”时重启重复创建的窗口。canonical ID 变更也不能只改新请求指纹;必须用本地 asset、路径和内容摘要白名单恢复旧 accepted/committed 账本,避免升级后已付费结果永久无法恢复。 +- binding wire 升级陷阱:在仍标 v1 的 struct 上直接新增必填指纹,会让所有已有本地项目的 binding 反序列化失败,重现“画布项目丢失”。必须升为新 schema,用独立严格 legacy wire 只迁移完整通过现役不变量校验的旧文档;不能用 default 缺失字段或在身份校验前回写。 +- 在途切号陷阱:只在首次 POST 前校验 session 不足以锁定 principal。获得 `operationId` 后必须先落盘,后续每次 poll、download 和本地 commit 都要复验原 session;同步 commit 的“先校验再写入”仍有切号竞态,必须用会话租约把校验与本地安装线性化。手工 Tauri 入口若使用临时幂等键,也会在 202 后切号时丢失恢复身份。切号后继续用 A Token 轮询或安装 A 结果同样是账号边界缺失。 +- renderer / native 两阶段陷阱:只用一个 generation 同时表示 UI 转换和 native CAS,或在 Rust 确认前先替换 committed Token,会让迟到 install / clear 把新账号覆盖回旧账号。正确做法是 auth generation 与 native 只增 generation 分离,所有 native mutation 串行,入队前冻结 token + origin + user,并持续以显式 account / `null` 期望权威对账迟到完成。对账失败不能恢复未确认候选会话,而要清空 committed 会话与 Token。 +- auth origin 竞态陷阱:只在登录前持久化服务器选择不等于冻结事务 origin;请求 A 在途时若 UI 改为 B,返回的 A Token 可能被安装到 B origin。登录、hydrate 与 refresh 必须在首个请求前冻结 origin,让 HTTP 链与 native commit 共用该快照;同时按 origin 隔离 refresh singleflight,不能让 A 的 Promise 被 B 复用。 +- stale refresh 陷阱:请求在入队前写入候选 Token 后,若 queued commit 直接因 generation 过期返回却不恢复 renderer authority,本地请求会继续携带错账号 Token。另外,A refresh 失败晚于 B 登录成功时,若仍向全局发布 failed,`AuthenticatedClient` 会把 B 误登出。所有 stale early return 先恢复当前 committed / desired Token;旧 owner 的迟到 refresh failure 只返回 `stale`,不 clear、不发布 failed。 +- 账本写入租约陷阱:只在进入命令时读一次当前账号,仍可能在 owner 绑定、服务身份指纹 / 挑战或确认落盘前切到 B,从而向 A 账本写入 B 身份。这些写入必须持同一冻结 platform session 租约并精确校验 ledger owner;公开的 request / confirm 命令也必须自身完成 owner 门禁,不能依赖调用方曾经走过恢复流程。 +- 恢复列表泄漏陷阱:项目相同不代表账本对当前账号可见。如果列表只按 project / phase 扫描,切 B 后会展示 A 的 operation,迟到回包还可能把已清空的列表重新写回。扫描必须持当前 session 租约并按完整 owner `userId + api origin` 过滤;未绑 / 不完整的远端账本隐藏,纯本地编辑保留。renderer 还要用 auth generation 使恢复 read epoch 失效并清空相关操作状态。 +- 追加审计回滚陷阱:Direct 恢复中 `asset.register` 是 append-only 审计,返回错误不能证明 append 未持久。若 file / manifest 已落盘且审计成功或结果未知,删文件或回滚 manifest 会创造“审计已存在、资源却消失”的第二种不一致,重试还可能复制审计或重新扣费。应保留 file + manifest + audit 现场并标记 `reconciliation-required`;后续 binding 失败也使用同一语义。 +- Runner 跨 GUI 陷阱:WebView 的 `authGeneration` 会随 GUI 进程重启从较低值重新开始,但 busy Runner 可能仍持有旧 GUI 的高 generation。只用 generation CAS 会把新账号安装误判为过期;只把 OS owner 锁当作授权,或保留独立 `platform.session.install/clear` 入口,又会让 Runner 在单次 IPC 丢失后继续使用旧账号。Runner 协议 v7 由 owner 锁创建随机 epoch,每次会话变更先推进 durable revision claim,并且只允许与 claim 完全匹配的 `runner.attach_gui_owner` 安装会话。Runner 要持续比对 claim,失配立即清空平台会话并拒绝 Runtime 请求;GUI 同步失败还要隔离或停止旧 Runner,不能只向前端报错。 +- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 localProjectId 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Runner 回归还要覆盖高 generation 旧 epoch 被低 generation 新 epoch 正确替换、迟到旧 epoch/revision attach 失败关闭、claim 改写或同步失败后旧 Runner 零 Runtime 请求。Developer Key fixture 不能替代平台账号隔离证据。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs`、`project/resource_editor.rs`、`project/asset_canvas/generation.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index edddd254e..5ad1d1026 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -397,7 +397,15 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 - 后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;普通已认证客户端通过固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*` 调用平台,独立 standalone game-chat/显式高级模式才通过 `/api/external/v1/*` 与自备 Developer Key 调用。生成前按当前服务 principal 与本地 `projectId` 的私有 binding 创建或复用远端画布项目和素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 用图片生成 `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,用图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions` 调用图标 spritesheet 生成,产出真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把调用模式、相对 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;普通模式还冻结 `official-platform-v1 + 官方 origin + ownerUserId`,高级模式冻结 External service origin,两者均不保存 Token 或 Key。`accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许使用原 endpoint、原始 JSON 字节与同一键恢复 POST,不得重建远端项目/目录、重组正文或分配新键。普通模式站内响应兼容 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;高级 External v1 固定 `202 + operationId`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。轮询超时、临时鉴权或网关错误只保留原 operation 并在恢复时继续;结果损坏、状态损坏、透明派生失败或远端完成后的本地提交失败进入对账。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局。旧正式图不合格时只返回 `needs-repair`;Supervisor 认领后由原 owner 使用 `replaceExisting=true` 原位替换。凭据不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 - External Editor 的远端项目和资源属于服务 principal,不属于本地项目的跨账号身份。`.agent/manifest.json` 中 `source.canvasProjectId / resourceId / assetObjectId` 只记录生成来源,不得作为当前登录账号的 active editable binding。客户端在 `.agent/runtime/external-editor-bindings/` 私有保存账号级投影:项目键至少包含服务 origin、principal 和本地 `projectId`,资源键再包含远端项目、本地 asset ID、源文件 SHA-256、媒体类型与 canonical asset kind;值保存该 principal 下的远端 project/folder/resource/object 身份。Token 轮换继续复用同一 principal binding,账号、服务、源字节或 canonical kind 变化必须重新建立;同名本地项目和项目改名不得参与权威匹配。 +- binding 的 lookup key 与稳定 payload 内容指纹分开校验;远端 project/folder/resource/object ID、object key 或尺寸被单独改写时必须失败关闭。同一本地项目和 principal 的首次项目 binding 建立必须串行化覆盖“复读 binding → 必要的远端创建 → 原子安装 binding”,不得因并发任务创建孤儿画布。External v1 项目、素材目录和项目资源创建请求携带由 binding key 派生的稳定 `Idempotency-Key`;服务端在 owner 与接口命名空间内生成稳定 ID,并允许同键同正文安全重试,使远端响应后、本地 sidecar 落盘前崩溃也不会重复创建。 +- 当前 binding wire schema 为 v2,payload fingerprint 必填。已落盘的 v1 project/resource JSON 只能经独立严格 wire 结构读取,在 lookup key、principal、本地 source、远端字段、对象键、时间和尺寸等不变量全部通过后重算指纹并原子回写 v2。不得用 `serde(default)` 把未知或篡改文档混入迁移。 - 已完成的本地正式资源以本地文件和 manifest asset 身份为权威。切换账号后,当前账号没有资源 binding 时,必须从本地字节上传、confirm 并登记到当前账号的远端项目后再编辑或派生;任何请求都不得把旧账号的 `canvasProjectId / resourceId / objectKey` 与新账号 Token 组合。新绑定不得覆盖历史来源,也不得迁移或删除旧账号远端对象。已经 `prepared / accepted / running` 的生成或编辑 operation 仍冻结原 principal、原正文和原 operation;切换账号只能停止恢复并保留对账证据,不能改绑、重放或重复扣费。 +- 远端提交返回 `operationId` 后先持久化 accepted 身份,再在每次 poll、download 和本地 commit 前复验冻结会话;同步本地 commit 持有冻结会话租约,使切号只能发生在完整安装前或安装后。Tauri 手工生成同样使用稳定的 durable operation slot,不得使用进程内临时幂等键。历史资源编辑账本从 manifest 远端 resource ID 迁移到本地 asset ID 时,只允许在本地 asset、路径和内容摘要仍一致时兼容旧指纹,新请求不得恢复信任 manifest 远端 ID。 +- renderer 中的 auth generation 只用于标记当前 UI 身份转换并使迟到读取失效;发给 Rust / Runner 的 native generation 是另一条只增不减的序列,不得用可重置的 renderer generation 直接充当 native CAS。所有 native install / clear 串行入队;登录、refresh、当前用户查询和 native commit 必须共用首个请求前冻结的同一 API origin,refresh singleflight 也按 origin 隔离。提交入队前冻结候选 `accessToken + api origin + user` 快照,并显式记录期望权威是具体账号还是 `null`。候选快照只在 native 确认成功且 auth generation 仍当前时成为 committed 会话;迟到的 install / clear 完成必须用更高 native generation 把 Rust / Runner 对账回当前期望权威,对账失败则清空 renderer 的 committed 会话与 Token 并失败关闭。 +- queued commit 发现 expected auth generation 已过期时,必须在 stale early return 前把 renderer Token 恢复到当前 committed / desired authority,不得留下入队前冻结的候选 Token。旧账号 refresh 的迟到失败若已不属于当前账号,只能投影为 `stale`;不得进入当前账号的 refresh-failed / clear 链路,也不得使 `AuthenticatedClient` 登出已成功切换的新账号。 +- 资源编辑与素材画布账本的 platform owner 绑定、服务身份指纹 / 确认挑战和确认结果写入,必须全程持有同一冻结 platform session 租约,并要求账本已有 owner 与该 `userId + api origin` 精确一致;直接调用确认命令也不得越过 owner 校验。Developer Key 路径保持 `owner=null` 且不伪造 platform session 租约。 +- 资源恢复列表是当前 principal 的私有投影:列举期间持有当前 platform session 租约,远端账本只在 owner `userId + api origin` 完整且精确匹配时可见;未绑 owner 的 Developer / legacy 远端账本和 owner 不完整账本失败关闭,纯本地编辑仍对项目可见。renderer 在 auth generation 变更时立即使恢复读取 epoch 失效、清空旧列表 / 挑战 / 在途动作并关闭面板,迟到回包不得重新投影旧账号条目。 +- Direct 只读恢复中,本地文件与 manifest 登记成功后,`asset.register` 追加审计的结果若为成功或无法判定,都必须保留当前 file + manifest + 已追加的 audit 现场;后续账号资源 binding 安装失败同样保留这些可证事实。该路径统一返回 `reconciliation-required`,禁止回滚 manifest / 删除文件,也禁止在无法证明审计未落盘时重试登记或重新生成。 - 2026-08-03 durable 恢复补充,2026-08-04 扩展 `prepared`:`prepared / accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源;`prepared` 只原样恢复同一逻辑 POST,`accepted` 只查询既有 operation。不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住恢复。显式“继续/恢复”复用原 action、pending、provider batch 与生成账本身份,不创建 successor 请求或新幂等键。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 - 生成账本必须绑定调用模式与服务身份:普通模式为固定官方 origin + ownerUserId,高级 External v1 模式为显式 service origin;Access Token 或 Developer Key 只负责当前请求授权,轮换不得改变已有 operation 的归属。退出或换号提升单调账号 generation,中止并脱离旧请求;旧 owner 账本在新 owner 下禁止 POST、GET、下载、安装和归档,只有重新登录同一 owner 后才能恢复。绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。首次请求结果已经未知后,鉴权或其它非成功响应不能证明原请求未入队,必须保留原账本。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。 @@ -408,6 +416,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。 - 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 - 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。GUI 客户端还必须把完整 `runner.attach_gui_owner` 参数作为绑定规范化 AppData 的进程内登记保存;`ensure_external_agent_runner` 无论复用既有 endpoint 还是启动新 Runner,都要在把 endpoint 交给 Runtime 写请求前按新 `bootId` 补登记。同一登记 generation 在同一 boot 上幂等,补登记失败不得记录成功 boot 且本次 `ensure` 失败关闭;未建立 GUI 登记的普通 CLI 不执行该重放。OS owner 锁与 watchdog 已成立只代表进程受 GUI 生命周期约束,不能替代事件 sink 等进程内附加能力的逐 boot 恢复。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 +- 2026-08-23 Runner 协议 v7 GUI owner 会话权威补充:GUI 取得 owner OS 锁时产生随机 `owner epoch`,并在私有 AppData 持久化只含 `owner epoch + session revision` 的 claim;每次登录、refresh、退出或换号都必须先单调推进 durable session revision,再同步 Runner。`runner.attach_gui_owner` 是 Runner 接受平台会话快照的唯一授权入口;只有 attach 携带的 epoch/revision 与 durable claim 完全一致才可安装或清除会话,新 GUI epoch 可替换旧进程留下的高 `authGeneration`,不用可在新 WebView 重置的 generation 猜测进程所有权。Runner 在 claim 缺失、不可读或与当前 attach 身份失配时立即清空进程内平台会话,并阻断除重新 attach 及必要管理请求以外的 Runtime 工作;旧 `platform.session.install/clear` 协议不再是授权入口。GUI 会话同步未得到完整 attach 确认时本地变更必须失败,并隔离或停止旧 Runner;即使进程终止失败,claim 失配门禁也不允许旧账号继续发起 Runtime 请求。claim 不保存 Access Token,Token 只随当次受保护的 attach IPC 进入 Runner 内存。 - 2026-08-05 GUI owner attachment 确认补充:登记参数必须保存 GUI manifest 事件接收端的真实 `event_sink_port` 与 `event_sink_token`,不得借用 actionId 等无关字段作为测试替身。每次 attach RPC 只有同时返回 `attached=true` 与 `eventSinkAttached=true` 才能把当前 `bootId` 标记为已登记;`eventSinkAttached` 缺失、为 false 或普通 RPC 失败都保持当前 boot 待重试。sink token 只留在私有进程内登记和 RPC 参数中,不进入日志、错误文本或公共状态。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 @@ -1125,7 +1134,7 @@ game-project/ - 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用现有编辑器项目、统一生成队列、资产上传/换签、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。 - 生成面板和确认摘要只操作本地状态;用户点击独立“确认生成”后才通过 `canvas.asset_generate` confirm 权限边界。本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,取消确认则直接丢弃且零 External 调用;一旦提交,重复点击、重启和未知结果保持原身份,不能自动确认或换键重扣。 -- 主站网页画布与普通 AI 游戏创作 Tauri 客户端统一使用当前平台登录态调用固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`。网站 Access Token 延续现有 WebView 客户端存储与刷新 Cookie 机制;登录成功或刷新后,AGC 只把 `userId + 短期 Access Token + 单调 authGeneration` 的当前快照同步到 GUI/Runner 进程内存,退出或换号以更高 generation 清除旧快照。Token 不写 Rust AppData 配置、项目、账本或日志。`/api/external/v1/*` 继续只供第三方开发者和独立 standalone game-chat/高级自定义模式使用自备 `tnr_sk_...`,不自动签发或下发 Developer API Key。 +- 主站网页画布与普通 AI 游戏创作 Tauri 客户端统一使用当前平台登录态调用固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`。网站 Access Token 延续现有 WebView 客户端存储与刷新 Cookie 机制;GUI 层的同一 `owner epoch` 内仍使用单调 `authGeneration` 约束请求与迟到回包,但每次登录、refresh、退出或换号先推进 durable session revision,再由与 claim 完全匹配的 `runner.attach_gui_owner` 把 `userId + 短期 Access Token + authGeneration` 快照安装到 Runner 进程内存。新 GUI epoch 不继承旧进程 generation;claim 失配时 Runner 清空会话并阻断 Runtime,不得保留旧账号快照。Token 不写 Rust AppData 配置、durable claim、项目、账本或日志。`/api/external/v1/*` 继续只供第三方开发者和独立 standalone game-chat/高级自定义模式使用自备 `tnr_sk_...`,不自动签发或下发 Developer API Key。 - 每个网络动作在发出前冻结 `ownerUserId + authGeneration`,并在响应解析、账本推进、媒体安装和正式提交前复核当前账号。旧 generation 的迟到响应或清除不得覆盖新账号。普通模式首次 `401` 时,每个 generation 只允许一个共享 refresh;其它请求等待它,但保留各自 deadline/取消。refresh 成功后以更高 generation CAS 安装新 Token,并仅用原 endpoint、原始请求字节和原 `Idempotency-Key` 重试一次;refresh 失败、账号切换或再次 `401` 时停止网络并保留账本,`403` 不触发 refresh。 - 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。普通账本身份固定为 `official-platform-v1 + 官方 origin + ownerUserId`,高级账本身份固定为 External service origin;均不绑定或保存 Token/Key。换号后旧 owner 账本零网络、零安装,只有重新登录同一 owner 后才能恢复。`accepted/running` 只 GET 原 operation,`prepared` 只精确重放冻结 endpoint、原始请求字节和原幂等键。草稿/manifest/event/日志只投影安全阶段与本地身份。 - 固定状态顺序为 `generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected`。远端 completed 后必须先验证稳定引用,再经 `/assets/read-url` 下载并写入阶段三 staging;随后用账本中的固定本地 commit 身份进入既有 journal/ledger 事务。正式提交失败只恢复本地事务,不重新生成或下载。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 231a073ee..86c10467b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -755,6 +755,15 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 时间:`completed_at_micros` 必须为正数并固化为 receipt 完成时间;object/resource/asset/binding/canvas 候选的原时间字段与它一起进入 commit SHA-256,重放必须复用原 prepared commit 而不得重新取时。queue job 完成时间与完成事件仍使用 SpacetimeDB `ctx.timestamp`,不信任调用方时钟。 - 索引:主键 `operation_key`;`by_editor_generation_operation_owner(owner_user_id, operation_id)` 仅用于受控定位和诊断,不允许同 owner 跨 operation kind 复用同一 operation ID。 +### `editor_idempotent_create_receipt` + +- Rust 结构体:`EditorIdempotentCreateReceipt` +- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` +- 说明:图片画布工程、素材文件夹和工程资源首次创建的私有 durable receipt。主键 `receipt_key` 由认证 owner、接口 namespace 与 API 根据 `Idempotency-Key` 派生的请求记录 ID 做 domain-separated SHA-256 得到;`request_sha256` 覆盖完整规范化 create payload,但不包含每次重试都会变化的请求时间。首次业务行、副作用与 receipt 必须在同一 SpacetimeDB 事务中提交,主键唯一约束负责并发仲裁。 +- 重放:同 owner、namespace、key 与同一原始规范化正文重放时,按 receipt 的 `result_record_id` 返回当前业务行,因此工程改名、目录更新或资源元数据后仍不会把当前可变行误判为请求正文漂移;同键异正文返回 `409`。receipt 不随业务行删除,首次结果已删除时重放统一返回 `409` 并拒绝重建;receipt 缺失但请求稳定 ID 已存在同样失败关闭,不能补造 receipt 或重复首次副作用。 +- 索引:主键 `receipt_key`;`by_editor_idempotent_create_receipt_owner(owner_user_id, namespace, request_record_id)` 仅用于受控诊断。表为 private,不作为工程、目录或资源 read model。 +- 真实事务门禁:运行 `npm run check:editor-idempotency-procedures`,在隔离的 SpacetimeDB 2.7.0 hotfix3 standalone 中发布当前模块,验证工程、素材文件夹和工程资源的同正文重放、异正文冲突、并发仲裁、删除后失败关闭,以及业务行与 private receipt 一一对应且无孤儿;源码字符串断言不能替代该门禁。 + ### `editor_generation_runtime_identity_rotation` - Rust 结构体:`EditorGenerationRuntimeIdentityRotation` diff --git a/package.json b/package.json index 427b98227..2e691c481 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "check:wechat-virtual-payment-reconcile": "node scripts/check-wechat-virtual-payment-reconcile.mjs", "check:api-server-env": "node scripts/check-api-server-env.mjs", "check:admin-account-procedures": "node scripts/check-admin-account-procedures.mjs", + "check:editor-idempotency-procedures": "node scripts/spacetime-editor-idempotency-smoke.mjs", "check:spacetime-runtime-access": "node scripts/check-spacetime-runtime-access.mjs", "check:module-runtime-artifact": "node scripts/check-module-runtime-artifact.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", diff --git a/scripts/spacetime-editor-idempotency-smoke.mjs b/scripts/spacetime-editor-idempotency-smoke.mjs new file mode 100644 index 000000000..e34c7ba8d --- /dev/null +++ b/scripts/spacetime-editor-idempotency-smoke.mjs @@ -0,0 +1,757 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { chmod, mkdtemp, rm } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +import { createSpacetimeWebIdentity } from './spacetime-migration-common.mjs'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const database = 'editor-idempotency-smoke'; +const expectedSpacetimeVersion = '2.7.0'; +const expectedSpacetimeCommit = 'd220349adb7af7eefa810eb08a185609356b83f6'; +const commandTimeoutMs = 5 * 60 * 1000; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function appendOutput(current, chunk) { + const next = `${current}${chunk}`; + return next.length <= 24_000 ? next : next.slice(-24_000); +} + +export function sanitizeDiagnostic(value, sensitiveValues = []) { + let sanitized = String(value); + for (const sensitiveValue of sensitiveValues) { + if (typeof sensitiveValue === 'string' && sensitiveValue.length > 0) { + sanitized = sanitized.split(sensitiveValue).join('[redacted]'); + } + } + return sanitized; +} + +export function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timeout = setTimeout(() => { + child.kill('SIGKILL'); + reject( + new Error( + sanitizeDiagnostic( + `${command} timed out after ${options.timeoutMs ?? commandTimeoutMs}ms`, + options.sensitiveValues, + ), + ), + ); + }, options.timeoutMs ?? commandTimeoutMs); + + child.stdout.on('data', (chunk) => { + stdout = appendOutput(stdout, chunk.toString()); + }); + child.stderr.on('data', (chunk) => { + stderr = appendOutput(stderr, chunk.toString()); + }); + child.on('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on('exit', (code, signal) => { + clearTimeout(timeout); + const diagnostic = sanitizeDiagnostic( + `${stdout}\n${stderr}`.trim(), + options.sensitiveValues, + ); + if (signal) { + reject(new Error(`${command} exited via ${signal}: ${diagnostic}`)); + } else if (code !== 0) { + reject(new Error(`${command} exited with ${code}: ${diagnostic}`)); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +export function parseProcedureOutput(stdout, procedureName = 'procedure') { + let value; + try { + value = JSON.parse(String(stdout).trim()); + } catch (error) { + throw new Error( + `${procedureName} stdout is not JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + assert( + Array.isArray(value), + `${procedureName} returned an invalid result shape.`, + ); + if (value.length === 4) { + const folder = decodeOption(value[1]); + const library = decodeOption(value[2]); + return { + ok: value[0], + record: folder ?? library, + errorMessage: decodeOption(value[3]), + }; + } + assert( + value.length === 3, + `${procedureName} returned an invalid result shape.`, + ); + return { + ok: value[0], + record: decodeOption(value[1]), + errorMessage: decodeOption(value[2]), + }; +} + +export function parseSqlRows(stdout, queryLabel = 'SQL') { + let payload; + try { + payload = JSON.parse(String(stdout).trim()); + } catch (error) { + throw new Error( + `${queryLabel} stdout is not JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + assert( + Array.isArray(payload) && + payload.length === 1 && + Array.isArray(payload[0]?.rows), + `${queryLabel} returned an invalid result shape.`, + ); + return payload[0].rows; +} + +function decodeOption(value) { + if (Array.isArray(value) && value.length === 2 && value[0] === 0) { + return value[1]; + } + if (Array.isArray(value) && value.length === 2 && value[0] === 1) { + return null; + } + if (value === null || value === undefined) { + return null; + } + throw new Error(`Unexpected SATS option: ${JSON.stringify(value)}`); +} + +async function reservePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert( + address && typeof address === 'object', + 'Failed to reserve a local port.', + ); + const port = address.port; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return port; +} + +function startStandalone(dataDir, port) { + const child = spawn( + 'spacetime', + [ + 'start', + '--in-memory', + '--data-dir', + dataDir, + '--listen-addr', + `127.0.0.1:${port}`, + '--non-interactive', + ], + { + cwd: repoRoot, + env: process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let output = ''; + child.stdout.on('data', (chunk) => { + output = appendOutput(output, chunk.toString()); + }); + child.stderr.on('data', (chunk) => { + output = appendOutput(output, chunk.toString()); + }); + return { child, output: () => output }; +} + +async function waitForStandalone(serverUrl, processState) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (processState.child.exitCode !== null) { + throw new Error( + `SpacetimeDB exited during startup: ${processState.output().trim()}`, + ); + } + try { + const response = await fetch(`${serverUrl}/v1/ping`); + if (response.ok) { + return; + } + } catch { + // Startup is still in progress. + } + await delay(200); + } + throw new Error( + `Timed out waiting for SpacetimeDB: ${processState.output().trim()}`, + ); +} + +export async function stopStandalone(child) { + if (child.exitCode !== null) { + return; + } + child.kill('SIGTERM'); + const exited = await Promise.race([ + once(child, 'exit').then(() => true), + delay(5_000).then(() => false), + ]); + if (!exited && child.exitCode === null) { + child.kill('SIGKILL'); + await once(child, 'exit'); + } +} + +function cliPrefix(configPath) { + return ['--config-path', configPath]; +} + +async function callProcedure(configPath, serverUrl, procedureName, input) { + const result = await runCommand('spacetime', [ + ...cliPrefix(configPath), + 'call', + '--server', + serverUrl, + '--yes', + '--no-config', + database, + procedureName, + JSON.stringify(input), + ]); + return parseProcedureOutput(result.stdout, procedureName); +} + +async function sqlRows(configPath, serverUrl, query, queryLabel) { + const result = await runCommand('spacetime', [ + ...cliPrefix(configPath), + 'sql', + '--server', + serverUrl, + '--yes', + '--no-config', + '--format', + 'json', + database, + query, + ]); + return parseSqlRows(result.stdout, queryLabel); +} + +function projectInput(projectId, title, nowMicros = 1_000) { + return { + project_id: projectId, + owner_user_id: 'receipt-owner', + title, + now_micros: nowMicros, + }; +} + +function resourceInput(overrides = {}) { + const none = [1, []]; + const some = (value) => [0, value]; + return { + resource_id: 'resource-a', + project_id: 'project-a', + owner_user_id: 'receipt-owner', + asset_object_id: none, + image_src: '/generated/editor/resource-a.png', + object_key: none, + width: 32, + height: 32, + source_type: 'generated', + prompt: some('receipt smoke'), + actual_prompt: none, + model: some('smoke'), + provider: some('smoke'), + task_id: some('receipt-smoke-task'), + source_resource_id: none, + asset_kind: some('icon'), + generation_inputs_json: none, + updated_at_micros: 1_000, + image_sequence_frames_json: none, + image_sequence_duration_ms: none, + ...overrides, + }; +} + +function folderInput(overrides = {}) { + return { + folder_id: 'folder-a', + owner_user_id: 'receipt-owner', + label: 'Folder A', + sort_order: 100, + now_micros: 1_000, + ...overrides, + }; +} + +function assertOk(result, label) { + assert(result.ok === true, `${label} failed: ${result.errorMessage}`); + assert(result.record !== null, `${label} did not return a record.`); +} + +function assertBodyConflict(result, label) { + assert(result.ok === false, `${label} unexpectedly succeeded.`); + assert( + result.errorMessage === + '幂等创建冲突:同一 Idempotency-Key 的创建正文不一致', + `${label} returned an unexpected error: ${result.errorMessage}`, + ); +} + +function assertDeletedConflict(result, recordLabel) { + assert( + result.ok === false, + `${recordLabel} replay unexpectedly recreated the record.`, + ); + assert( + typeof result.errorMessage === 'string' && + result.errorMessage.includes('已删除'), + `${recordLabel} replay returned an unexpected error: ${result.errorMessage}`, + ); +} + +async function runSmoke(configPath, serverUrl) { + const projectA = projectInput('project-a', 'First'); + assertOk( + await callProcedure( + configPath, + serverUrl, + 'create_editor_project_and_return', + projectA, + ), + 'project first create', + ); + assertOk( + await callProcedure( + configPath, + serverUrl, + 'create_editor_project_and_return', + { ...projectA, now_micros: 2_000 }, + ), + 'project exact replay', + ); + assertBodyConflict( + await callProcedure( + configPath, + serverUrl, + 'create_editor_project_and_return', + { ...projectA, title: 'Drifted' }, + ), + 'project drift replay', + ); + + const resourceA = resourceInput(); + assertOk( + await callProcedure( + configPath, + serverUrl, + 'create_editor_project_resource_and_return', + resourceA, + ), + 'resource first create', + ); + assertOk( + await callProcedure( + configPath, + serverUrl, + 'create_editor_project_resource_and_return', + { ...resourceA, updated_at_micros: 2_000 }, + ), + 'resource exact replay', + ); + assertBodyConflict( + await callProcedure( + configPath, + serverUrl, + 'create_editor_project_resource_and_return', + { ...resourceA, width: 64 }, + ), + 'resource drift replay', + ); + + const folderA = folderInput(); + assertOk( + await callProcedure( + configPath, + serverUrl, + 'create_editor_asset_folder_and_return', + folderA, + ), + 'asset folder first create', + ); + assertOk( + await callProcedure( + configPath, + serverUrl, + 'create_editor_asset_folder_and_return', + { ...folderA, now_micros: 2_000 }, + ), + 'asset folder exact replay', + ); + assertBodyConflict( + await callProcedure( + configPath, + serverUrl, + 'create_editor_asset_folder_and_return', + { ...folderA, label: 'Drifted Folder' }, + ), + 'asset folder drift replay', + ); + + const sameRaceInput = projectInput('project-same-race', 'Same Race'); + const sameRace = await Promise.all([ + callProcedure( + configPath, + serverUrl, + 'create_editor_project_and_return', + sameRaceInput, + ), + callProcedure( + configPath, + serverUrl, + 'create_editor_project_and_return', + sameRaceInput, + ), + ]); + sameRace.forEach((result, index) => + assertOk(result, `same-body race ${index + 1}`), + ); + + const conflictRace = await Promise.all([ + callProcedure( + configPath, + serverUrl, + 'create_editor_project_and_return', + projectInput('project-conflict-race', 'Race A'), + ), + callProcedure( + configPath, + serverUrl, + 'create_editor_project_and_return', + projectInput('project-conflict-race', 'Race B'), + ), + ]); + assert( + conflictRace.filter((result) => result.ok).length === 1, + 'Different-body race did not commit exactly one request.', + ); + assert( + conflictRace.filter((result) => !result.ok).length === 1, + 'Different-body race did not reject exactly one request.', + ); + assertBodyConflict( + conflictRace.find((result) => !result.ok), + 'different-body race loser', + ); + + const projectsBeforeDelete = await sqlRows( + configPath, + serverUrl, + 'SELECT project_id, owner_user_id, title FROM editor_project', + 'project rows before delete', + ); + const resourcesBeforeDelete = await sqlRows( + configPath, + serverUrl, + 'SELECT resource_id, project_id, owner_user_id FROM editor_project_resource', + 'resource rows before delete', + ); + const receiptsBeforeDelete = await sqlRows( + configPath, + serverUrl, + 'SELECT owner_user_id, namespace, request_record_id, result_record_id FROM editor_idempotent_create_receipt', + 'receipt rows before delete', + ); + const foldersBeforeDelete = await sqlRows( + configPath, + serverUrl, + 'SELECT folder_id, owner_user_id, system_default FROM editor_asset_folder', + 'asset folder rows before delete', + ); + assert( + projectsBeforeDelete.length === 3, + 'Unexpected project count before deletion.', + ); + assert( + resourcesBeforeDelete.length === 1, + 'Unexpected resource count before deletion.', + ); + assert( + receiptsBeforeDelete.length === 5, + 'Unexpected receipt count before deletion.', + ); + const customFoldersBeforeDelete = foldersBeforeDelete.filter( + ([, , systemDefault]) => !systemDefault, + ); + assert( + customFoldersBeforeDelete.length === 1, + 'Unexpected custom folder count before deletion.', + ); + for (const [projectId, ownerUserId] of projectsBeforeDelete) { + assert( + ownerUserId === 'receipt-owner', + `Project ${projectId} has the wrong owner.`, + ); + assert( + receiptsBeforeDelete.some( + ([owner, namespace, requestId, resultId]) => + owner === ownerUserId && + namespace === 'editor-project' && + requestId === projectId && + resultId === projectId, + ), + `Project ${projectId} is missing its durable receipt.`, + ); + } + for (const [resourceId, projectId, ownerUserId] of resourcesBeforeDelete) { + assert( + projectsBeforeDelete.some(([candidate]) => candidate === projectId), + `Resource ${resourceId} is orphaned from project ${projectId}.`, + ); + assert( + receiptsBeforeDelete.some( + ([owner, namespace, requestId, resultId]) => + owner === ownerUserId && + namespace === 'editor-project-resource' && + requestId === resourceId && + resultId === resourceId, + ), + `Resource ${resourceId} is missing its durable receipt.`, + ); + } + for (const [folderId, ownerUserId] of customFoldersBeforeDelete) { + assert( + receiptsBeforeDelete.some( + ([owner, namespace, requestId, resultId]) => + owner === ownerUserId && + namespace === 'editor-asset-folder' && + requestId === folderId && + resultId === folderId, + ), + `Asset folder ${folderId} is missing its durable receipt.`, + ); + } + + const deleteResult = await callProcedure( + configPath, + serverUrl, + 'delete_editor_project_and_return', + { project_id: 'project-a', owner_user_id: 'receipt-owner' }, + ); + assertOk(deleteResult, 'project delete'); + assertDeletedConflict( + await callProcedure( + configPath, + serverUrl, + 'create_editor_project_and_return', + projectA, + ), + 'project', + ); + assertDeletedConflict( + await callProcedure( + configPath, + serverUrl, + 'create_editor_project_resource_and_return', + resourceA, + ), + 'resource', + ); + const deleteFolderResult = await callProcedure( + configPath, + serverUrl, + 'delete_editor_asset_folder_and_return', + { + folder_id: 'folder-a', + owner_user_id: 'receipt-owner', + updated_at_micros: 3_000, + }, + ); + assertOk(deleteFolderResult, 'asset folder delete'); + assertDeletedConflict( + await callProcedure( + configPath, + serverUrl, + 'create_editor_asset_folder_and_return', + folderA, + ), + 'asset folder', + ); + + const projectsAfterDelete = await sqlRows( + configPath, + serverUrl, + 'SELECT project_id FROM editor_project', + 'project rows after delete', + ); + const resourcesAfterDelete = await sqlRows( + configPath, + serverUrl, + 'SELECT resource_id FROM editor_project_resource', + 'resource rows after delete', + ); + const receiptsAfterDelete = await sqlRows( + configPath, + serverUrl, + 'SELECT owner_user_id, namespace, request_record_id, result_record_id FROM editor_idempotent_create_receipt', + 'receipt rows after delete', + ); + const foldersAfterDelete = await sqlRows( + configPath, + serverUrl, + 'SELECT folder_id, system_default FROM editor_asset_folder', + 'asset folder rows after delete', + ); + assert( + projectsAfterDelete.length === 2 && + projectsAfterDelete.every(([projectId]) => projectId !== 'project-a'), + 'Deleted project was recreated or another project was lost.', + ); + assert( + resourcesAfterDelete.length === 0, + 'Project deletion left an orphan resource.', + ); + assert( + foldersAfterDelete.length === 1 && foldersAfterDelete[0][1] === true, + 'Asset folder deletion removed the default folder or left a custom-folder orphan.', + ); + assert( + receiptsAfterDelete.length === receiptsBeforeDelete.length, + 'Deletion removed or duplicated a durable receipt.', + ); +} + +export async function main() { + let tempDir; + let standalone; + let loginToken = ''; + try { + const version = await runCommand('spacetime', ['--version'], { + timeoutMs: 30_000, + }); + assert( + version.stdout.includes( + `spacetimedb tool version ${expectedSpacetimeVersion}`, + ), + `Expected SpacetimeDB ${expectedSpacetimeVersion}, got: ${version.stdout.trim()}`, + ); + assert( + version.stdout.includes(`Commit: ${expectedSpacetimeCommit}`), + `Expected SpacetimeDB commit ${expectedSpacetimeCommit}, got: ${version.stdout.trim()}`, + ); + + tempDir = await mkdtemp( + path.join(os.tmpdir(), 'genarrative-editor-idempotency-smoke-'), + ); + const port = await reservePort(); + const serverUrl = `http://127.0.0.1:${port}`; + standalone = startStandalone(path.join(tempDir, 'data'), port); + await waitForStandalone(serverUrl, standalone); + + const identity = await createSpacetimeWebIdentity({ database, serverUrl }); + loginToken = identity.token; + const configPath = path.join(tempDir, 'cli.toml'); + await runCommand( + 'spacetime', + [...cliPrefix(configPath), 'login', '--token', loginToken], + { sensitiveValues: [loginToken] }, + ); + await chmod(configPath, 0o600); + + const wasmPath = + process.env.GENARRATIVE_EDITOR_IDEMPOTENCY_SMOKE_WASM?.trim(); + const publishArgs = [ + ...cliPrefix(configPath), + 'publish', + database, + '--server', + serverUrl, + '--yes=all', + '--no-config', + ]; + if (wasmPath) { + publishArgs.push('--bin-path', path.resolve(repoRoot, wasmPath)); + } else { + publishArgs.push('--module-path', 'server-rs/crates/spacetime-module'); + } + await runCommand('spacetime', publishArgs, { + env: { + ...process.env, + CARGO_TARGET_DIR: path.join(tempDir, 'cargo-target'), + }, + sensitiveValues: [loginToken], + }); + + await runSmoke(configPath, serverUrl); + console.log( + '[editor-idempotency-smoke] Passed exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.', + ); + } catch (error) { + const standaloneOutput = sanitizeDiagnostic( + standalone?.output().trim() ?? '', + [loginToken], + ); + console.error( + `[editor-idempotency-smoke] Failed: ${sanitizeDiagnostic( + error instanceof Error ? error.message : String(error), + [loginToken], + )}`, + ); + if (standaloneOutput) { + console.error(standaloneOutput); + } + process.exitCode = 1; + } finally { + if (standalone) { + await stopStandalone(standalone.child); + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + } +} + +if ( + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]) +) { + await main(); +} diff --git a/scripts/spacetime-editor-idempotency-smoke.test.ts b/scripts/spacetime-editor-idempotency-smoke.test.ts new file mode 100644 index 000000000..cc6819746 --- /dev/null +++ b/scripts/spacetime-editor-idempotency-smoke.test.ts @@ -0,0 +1,83 @@ +import { spawn } from 'node:child_process'; + +import { describe, expect, it } from 'vitest'; + +import { + parseProcedureOutput, + parseSqlRows, + sanitizeDiagnostic, + stopStandalone, +} from './spacetime-editor-idempotency-smoke.mjs'; + +describe('SpacetimeDB editor idempotency smoke parsers', () => { + it('parses successful and failed procedure SATS results', () => { + expect( + parseProcedureOutput( + '[true,[0,["project-a","owner-a"]],[1,[]]]', + 'create_editor_project_and_return', + ), + ).toEqual({ + ok: true, + record: ['project-a', 'owner-a'], + errorMessage: null, + }); + expect( + parseProcedureOutput( + '[false,[1,[]],[0,"幂等创建冲突"]]', + 'create_editor_project_and_return', + ), + ).toEqual({ + ok: false, + record: null, + errorMessage: '幂等创建冲突', + }); + expect( + parseProcedureOutput( + '[true,[1,[]],[0,["receipt-owner",[],[]]],[1,[]]]', + 'delete_editor_asset_folder_and_return', + ), + ).toEqual({ + ok: true, + record: ['receipt-owner', [], []], + errorMessage: null, + }); + }); + + it('parses only the rows from one JSON SQL statement', () => { + const stdout = JSON.stringify([ + { + schema: { elements: [{ name: 'project_id' }] }, + rows: [['project-a'], ['project-b']], + total_duration_micros: 12, + }, + ]); + + expect(parseSqlRows(stdout, 'projects')).toEqual([ + ['project-a'], + ['project-b'], + ]); + expect(() => parseSqlRows('[]', 'projects')).toThrow( + 'invalid result shape', + ); + }); + + it('redacts an isolated CLI token from diagnostics', () => { + expect(sanitizeDiagnostic('failed token-private', ['token-private'])).toBe( + 'failed [redacted]', + ); + }); +}); + +describe('SpacetimeDB editor idempotency smoke cleanup', () => { + it('terminates a live standalone-shaped child process', async () => { + const child = spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1000)'], + { stdio: 'ignore' }, + ); + + await stopStandalone(child); + + expect(child.exitCode !== null || child.signalCode !== null).toBe(true); + }); +}); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 6cd850652..4247f223c 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2036,6 +2036,7 @@ fn parse_editor_timestamp_micros(value: &str) -> Option { pub async fn create_editor_project( State(state): State, + headers: HeaderMap, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, @@ -2046,11 +2047,22 @@ pub async fn create_editor_project( .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| EDITOR_PROJECT_DEFAULT_TITLE.to_string()); + let owner_user_id = authenticated.claims().user_id().to_string(); + let project_id = optional_editor_idempotency_key(&headers)? + .map(|key| { + editor_idempotent_create_id( + EDITOR_PROJECT_ID_PREFIX, + &owner_user_id, + "internal-editor-project", + key, + ) + }) + .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX)); let project = state .spacetime_client() .create_editor_project(EditorProjectCreateRecordInput { - project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), + project_id, + owner_user_id, title, now_micros, }) @@ -2175,6 +2187,7 @@ pub async fn delete_editor_project( pub async fn create_editor_project_resource( State(state): State, Path(project_id): Path, + headers: HeaderMap, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, @@ -2188,12 +2201,23 @@ pub async fn create_editor_project_resource( serialize_editor_image_sequence_frames(payload.image_sequence_frames)?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let resource_id = optional_editor_idempotency_key(&headers)? + .map(|key| { + editor_idempotent_create_id( + EDITOR_RESOURCE_ID_PREFIX, + &owner_user_id, + "internal-editor-project-resource", + key, + ) + }) + .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX)); let resource = state .spacetime_client() .create_editor_project_resource(EditorProjectResourceCreateRecordInput { - resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX), + resource_id, project_id, - owner_user_id: authenticated.claims().user_id().to_string(), + owner_user_id, asset_object_id: normalize_optional_string(payload.asset_object_id), image_src, object_key, @@ -2273,15 +2297,27 @@ pub async fn get_editor_asset_library( pub async fn create_editor_asset_folder( State(state): State, + headers: HeaderMap, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { + let owner_user_id = current_owner_user_id(&authenticated); + let folder_id = optional_editor_idempotency_key(&headers)? + .map(|key| { + editor_idempotent_create_id( + EDITOR_ASSET_FOLDER_ID_PREFIX, + &owner_user_id, + "internal-editor-asset-folder", + key, + ) + }) + .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX)); let folder = state .spacetime_client() .create_editor_asset_folder(EditorAssetFolderCreateRecordInput { - folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX), - owner_user_id: current_owner_user_id(&authenticated), + folder_id, + owner_user_id, label: payload.label, sort_order: payload.sort_order.unwrap_or(100), now_micros: current_utc_micros(), @@ -5725,6 +5761,23 @@ pub(crate) fn optional_editor_idempotency_key( Ok(Some(value)) } +pub(crate) fn editor_idempotent_create_id( + prefix: &str, + owner_user_id: &str, + namespace: &str, + idempotency_key: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"genarrative-editor-idempotent-create-v1\0"); + hasher.update(owner_user_id.as_bytes()); + hasher.update(b"\0"); + hasher.update(namespace.as_bytes()); + hasher.update(b"\0"); + hasher.update(idempotency_key.as_bytes()); + let digest = format!("{:x}", hasher.finalize()); + format!("{prefix}{}", &digest[..32]) +} + pub(crate) async fn enqueue_editor_image_edit_for_owner( state: &AppState, request_context: &RequestContext, @@ -19824,6 +19877,77 @@ mod tests { assert!(optional_editor_idempotency_key(&headers).is_err()); } + #[test] + fn editor_idempotent_create_id_is_stable_and_scoped() { + let first = editor_idempotent_create_id( + EDITOR_RESOURCE_ID_PREFIX, + "owner-a", + "internal-editor-project-resource", + "request-123", + ); + assert_eq!( + first, + editor_idempotent_create_id( + EDITOR_RESOURCE_ID_PREFIX, + "owner-a", + "internal-editor-project-resource", + "request-123", + ) + ); + assert!(first.starts_with(EDITOR_RESOURCE_ID_PREFIX)); + assert_eq!(first.len(), EDITOR_RESOURCE_ID_PREFIX.len() + 32); + assert_ne!( + first, + editor_idempotent_create_id( + EDITOR_RESOURCE_ID_PREFIX, + "owner-b", + "internal-editor-project-resource", + "request-123", + ) + ); + assert_ne!( + first, + editor_idempotent_create_id( + EDITOR_RESOURCE_ID_PREFIX, + "owner-a", + "internal-editor-asset-folder", + "request-123", + ) + ); + assert_ne!( + first, + editor_idempotent_create_id( + EDITOR_RESOURCE_ID_PREFIX, + "owner-a", + "internal-editor-project-resource", + "request-456", + ) + ); + assert_ne!( + first, + editor_idempotent_create_id( + EDITOR_RESOURCE_ID_PREFIX, + "owner-a", + "external-editor-project-resource", + "request-123", + ), + "站内 Bearer 与 External API key 接口不能共享同一幂等 ID 域" + ); + } + + #[test] + fn editor_idempotent_create_conflicts_map_to_http_409() { + for message in [ + "幂等创建冲突:同一 Idempotency-Key 的创建正文不一致", + "幂等创建冲突:首次创建的图片画布工程已删除,拒绝重建", + "幂等创建冲突:创建回执已被并发请求占用", + ] { + let error = + map_editor_project_error(SpacetimeClientError::Procedure(message.to_string())); + assert_eq!(error.status_code(), StatusCode::CONFLICT, "{message}"); + } + } + #[test] fn editor_image_edit_request_rejects_legacy_and_unknown_fields() { for field in [ diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index d8c142186..e52daf493 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -38,12 +38,14 @@ use crate::{ EditorProjectPayload, EditorProjectResourcePayload, EditorUiDesignAssetExtractionRequest, current_utc_micros, editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record, editor_asset_payload_from_record, - editor_project_payload_from_record, editor_project_resource_payload_from_record, - enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner, + editor_idempotent_create_id, editor_project_payload_from_record, + editor_project_resource_payload_from_record, enqueue_editor_image_edit_for_owner, + enqueue_editor_image_generation_for_owner, enqueue_editor_ui_design_asset_extraction_for_owner, ensure_generic_editor_image_generation_contract, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, - parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs, + optional_editor_idempotency_key, parse_editor_generation_json_payload, + sanitize_editor_untrusted_generation_inputs, save_editor_project_layout_with_revision_and_get, serialize_editor_generation_inputs, }, external_api_auth::ExternalApiPrincipal, @@ -257,16 +259,28 @@ pub async fn openapi_json() -> Response { pub async fn create_external_editor_project( State(state): State, + headers: HeaderMap, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_PROJECT)?; + let owner_user_id = principal.owner_user_id().to_string(); + let project_id = optional_editor_idempotency_key(&headers)? + .map(|key| { + editor_idempotent_create_id( + EDITOR_PROJECT_ID_PREFIX, + &owner_user_id, + "external-editor-project", + key, + ) + }) + .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX)); let project = state .spacetime_client() .create_editor_project(EditorProjectCreateRecordInput { - project_id: build_prefixed_uuid_id(EDITOR_PROJECT_ID_PREFIX), - owner_user_id: principal.owner_user_id().to_string(), + project_id, + owner_user_id, title: normalize_project_title(payload.title), now_micros: current_utc_micros(), }) @@ -493,16 +507,28 @@ pub async fn get_external_editor_asset_library( pub async fn create_external_editor_asset_folder( State(state): State, + headers: HeaderMap, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let owner_user_id = principal.owner_user_id().to_string(); + let folder_id = optional_editor_idempotency_key(&headers)? + .map(|key| { + editor_idempotent_create_id( + EDITOR_ASSET_FOLDER_ID_PREFIX, + &owner_user_id, + "external-editor-asset-folder", + key, + ) + }) + .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX)); let folder = state .spacetime_client() .create_editor_asset_folder(EditorAssetFolderCreateRecordInput { - folder_id: build_prefixed_uuid_id(EDITOR_ASSET_FOLDER_ID_PREFIX), - owner_user_id: principal.owner_user_id().to_string(), + folder_id, + owner_user_id, label: payload.label, sort_order: payload.sort_order.unwrap_or(100), now_micros: current_utc_micros(), @@ -681,6 +707,7 @@ pub async fn delete_external_editor_asset( pub async fn create_external_editor_project_resource( State(state): State, Path(project_id): Path, + headers: HeaderMap, Extension(request_context): Extension, Extension(principal): Extension, Json(payload): Json, @@ -694,12 +721,23 @@ pub async fn create_external_editor_project_resource( serialize_external_editor_image_sequence_frames(payload.image_sequence_frames)?; let object_key = normalize_optional_string(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; + let owner_user_id = principal.owner_user_id().to_string(); + let resource_id = optional_editor_idempotency_key(&headers)? + .map(|key| { + editor_idempotent_create_id( + EDITOR_RESOURCE_ID_PREFIX, + &owner_user_id, + "external-editor-project-resource", + key, + ) + }) + .unwrap_or_else(|| build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX)); let resource = state .spacetime_client() .create_editor_project_resource(EditorProjectResourceCreateRecordInput { - resource_id: build_prefixed_uuid_id(EDITOR_RESOURCE_ID_PREFIX), + resource_id, project_id, - owner_user_id: principal.owner_user_id().to_string(), + owner_user_id, asset_object_id: normalize_optional_string(payload.asset_object_id), image_src, object_key, @@ -1616,6 +1654,28 @@ mod tests { } } + #[test] + fn external_project_create_rejects_an_invalid_optional_idempotency_header() { + let mut headers = HeaderMap::new(); + headers.insert( + IDEMPOTENCY_KEY_HEADER, + HeaderValue::from_static("contains space"), + ); + let error = optional_editor_idempotency_key(&headers) + .expect_err("External project create 必须拒绝非法可选幂等键"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + + let source = include_str!("external_editor_api.rs"); + let handler = source + .split_once("pub async fn create_external_editor_project(") + .and_then(|(_, tail)| { + tail.split_once("pub async fn list_external_editor_projects(") + .map(|(body, _)| body) + }) + .expect("external project create handler"); + assert!(handler.contains("optional_editor_idempotency_key(&headers)?")); + } + #[tokio::test] async fn external_generation_submission_is_accepted_with_poll_contract() { let response = external_generation_accepted_response( @@ -1898,6 +1958,43 @@ mod tests { .get("post") .is_some() ); + for path in [ + "/api/external/v1/editor/projects", + "/api/external/v1/editor/projects/{projectId}/resources", + "/api/external/v1/editor/assets/folders", + ] { + assert_eq!( + parsed["paths"][path]["post"]["responses"]["409"]["$ref"], + "#/components/responses/IdempotentCreateConflict", + "{path} 必须公开专用稳定创建冲突" + ); + assert!( + parsed["paths"][path]["post"]["parameters"] + .as_array() + .is_some_and(|parameters| { + parameters.iter().any(|parameter| { + parameter.get("$ref").and_then(Value::as_str) + == Some("#/components/parameters/IdempotentCreateKey") + }) + }), + "{path} 必须公开可选的稳定创建幂等键" + ); + } + assert_eq!( + parsed["components"]["parameters"]["IdempotentCreateKey"]["required"], + false + ); + assert!( + parsed["components"]["parameters"]["IdempotentCreateKey"]["description"] + .as_str() + .is_some_and(|description| description.contains("已删除")) + ); + assert!( + parsed["paths"]["/api/external/v1/editor/projects"]["post"]["responses"] + .get("400") + .is_some(), + "External project create 必须声明非法 Idempotency-Key 的 400" + ); assert!( parsed["paths"] .get("/api/external/v1/editor/projects/recent") diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index b3ad9e6a1..b9e6e2dad 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -309,6 +309,8 @@ pub mod editor_generation_runtime_identity_rotation_table; pub mod editor_generation_runtime_identity_rotation_type; pub mod editor_generation_target_preflight_input_type; pub mod editor_generation_target_preflight_result_type; +pub mod editor_idempotent_create_receipt_table; +pub mod editor_idempotent_create_receipt_type; pub mod editor_image_asset_kind_cleanup_input_type; pub mod editor_image_asset_kind_cleanup_procedure_result_type; pub mod editor_pixel_art_canvas_completion_input_type; @@ -1166,6 +1168,8 @@ pub use editor_generation_runtime_identity_rotation_table::*; pub use editor_generation_runtime_identity_rotation_type::EditorGenerationRuntimeIdentityRotation; pub use editor_generation_target_preflight_input_type::EditorGenerationTargetPreflightInput; pub use editor_generation_target_preflight_result_type::EditorGenerationTargetPreflightResult; +pub use editor_idempotent_create_receipt_table::*; +pub use editor_idempotent_create_receipt_type::EditorIdempotentCreateReceipt; pub use editor_image_asset_kind_cleanup_input_type::EditorImageAssetKindCleanupInput; pub use editor_image_asset_kind_cleanup_procedure_result_type::EditorImageAssetKindCleanupProcedureResult; pub use editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput; @@ -1879,6 +1883,7 @@ pub struct DbUpdate { editor_generation_pricing_config: __sdk::TableUpdate, editor_generation_runtime_identity_rotation: __sdk::TableUpdate, + editor_idempotent_create_receipt: __sdk::TableUpdate, editor_project: __sdk::TableUpdate, editor_project_resource: __sdk::TableUpdate, editor_showcase_asset: __sdk::TableUpdate, @@ -2153,6 +2158,11 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { table_update, )?, ), + "editor_idempotent_create_receipt" => { + db_update.editor_idempotent_create_receipt.append( + editor_idempotent_create_receipt_table::parse_table_update(table_update)?, + ) + } "editor_project" => db_update .editor_project .append(editor_project_table::parse_table_update(table_update)?), @@ -2735,6 +2745,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.editor_generation_runtime_identity_rotation, ) .with_updates_by_pk(|row| &row.rotation_id); + diff.editor_idempotent_create_receipt = cache + .apply_diff_to_table::( + "editor_idempotent_create_receipt", + &self.editor_idempotent_create_receipt, + ) + .with_updates_by_pk(|row| &row.receipt_key); diff.editor_project = cache .apply_diff_to_table::("editor_project", &self.editor_project) .with_updates_by_pk(|row| &row.project_id); @@ -3359,6 +3375,9 @@ impl __sdk::DbUpdate for DbUpdate { "editor_generation_runtime_identity_rotation" => db_update .editor_generation_runtime_identity_rotation .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "editor_idempotent_create_receipt" => db_update + .editor_idempotent_create_receipt + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "editor_project" => db_update .editor_project .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3786,6 +3805,9 @@ impl __sdk::DbUpdate for DbUpdate { "editor_generation_runtime_identity_rotation" => db_update .editor_generation_runtime_identity_rotation .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "editor_idempotent_create_receipt" => db_update + .editor_idempotent_create_receipt + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "editor_project" => db_update .editor_project .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4119,6 +4141,7 @@ pub struct AppliedDiff<'r> { editor_generation_pricing_config: __sdk::TableAppliedDiff<'r, EditorGenerationPricingConfig>, editor_generation_runtime_identity_rotation: __sdk::TableAppliedDiff<'r, EditorGenerationRuntimeIdentityRotation>, + editor_idempotent_create_receipt: __sdk::TableAppliedDiff<'r, EditorIdempotentCreateReceipt>, editor_project: __sdk::TableAppliedDiff<'r, EditorProject>, editor_project_resource: __sdk::TableAppliedDiff<'r, EditorProjectResource>, editor_showcase_asset: __sdk::TableAppliedDiff<'r, EditorShowcaseAsset>, @@ -4468,6 +4491,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.editor_generation_runtime_identity_rotation, event, ); + callbacks.invoke_table_row_callbacks::( + "editor_idempotent_create_receipt", + &self.editor_idempotent_create_receipt, + event, + ); callbacks.invoke_table_row_callbacks::( "editor_project", &self.editor_project, @@ -5151,19 +5179,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle { /// either a [`DbConnection`] or an [`EventContext`] and operate on either. pub trait RemoteDbContext: __sdk::DbContext< - DbView = RemoteTables, - Reducers = RemoteReducers, - SubscriptionBuilder = __sdk::SubscriptionBuilder, -> + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + > { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, - > RemoteDbContext for Ctx +> RemoteDbContext for Ctx { } @@ -5606,6 +5634,7 @@ impl __sdk::SpacetimeModule for RemoteModule { editor_generation_operation_table::register_table(client_cache); editor_generation_pricing_config_table::register_table(client_cache); editor_generation_runtime_identity_rotation_table::register_table(client_cache); + editor_idempotent_create_receipt_table::register_table(client_cache); editor_project_table::register_table(client_cache); editor_project_resource_table::register_table(client_cache); editor_showcase_asset_table::register_table(client_cache); @@ -5746,6 +5775,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "editor_generation_operation", "editor_generation_pricing_config", "editor_generation_runtime_identity_rotation", + "editor_idempotent_create_receipt", "editor_project", "editor_project_resource", "editor_showcase_asset", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_idempotent_create_receipt_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_idempotent_create_receipt_table.rs new file mode 100644 index 000000000..679f1043e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_idempotent_create_receipt_table.rs @@ -0,0 +1,238 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::editor_idempotent_create_receipt_type::EditorIdempotentCreateReceipt; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `editor_idempotent_create_receipt`. +/// +/// Obtain a handle from the [`EditorIdempotentCreateReceiptTableAccess::editor_idempotent_create_receipt`] method on [`super::RemoteTables`], +/// like `ctx.db.editor_idempotent_create_receipt()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_idempotent_create_receipt().on_insert(...)`. +pub struct EditorIdempotentCreateReceiptTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `editor_idempotent_create_receipt`. +pub struct EditorIdempotentCreateReceiptTableAccessor; + +impl __sdk::TableAccessor for EditorIdempotentCreateReceiptTableAccessor { + type Row = EditorIdempotentCreateReceipt; + type Handle<'db> = EditorIdempotentCreateReceiptTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.editor_idempotent_create_receipt() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `editor_idempotent_create_receipt`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait EditorIdempotentCreateReceiptTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`EditorIdempotentCreateReceiptTableHandle`], which mediates access to the table `editor_idempotent_create_receipt`. + fn editor_idempotent_create_receipt(&self) -> EditorIdempotentCreateReceiptTableHandle<'_>; +} + +impl EditorIdempotentCreateReceiptTableAccess for super::RemoteTables { + fn editor_idempotent_create_receipt(&self) -> EditorIdempotentCreateReceiptTableHandle<'_> { + EditorIdempotentCreateReceiptTableHandle { + imp: self + .imp + .get_table::("editor_idempotent_create_receipt"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct EditorIdempotentCreateReceiptInsertCallbackId(__sdk::CallbackId); +pub struct EditorIdempotentCreateReceiptDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for EditorIdempotentCreateReceiptTableHandle<'ctx> { + type Row = EditorIdempotentCreateReceipt; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for EditorIdempotentCreateReceiptTableHandle<'ctx> { + type Row = EditorIdempotentCreateReceipt; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = EditorIdempotentCreateReceiptInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorIdempotentCreateReceiptInsertCallbackId { + EditorIdempotentCreateReceiptInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorIdempotentCreateReceiptInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = EditorIdempotentCreateReceiptDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorIdempotentCreateReceiptDeleteCallbackId { + EditorIdempotentCreateReceiptDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorIdempotentCreateReceiptDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for EditorIdempotentCreateReceiptTableHandle<'ctx> { + type InsertCallbackId = EditorIdempotentCreateReceiptInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorIdempotentCreateReceiptInsertCallbackId { + EditorIdempotentCreateReceiptInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorIdempotentCreateReceiptInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for EditorIdempotentCreateReceiptTableHandle<'ctx> { + type DeleteCallbackId = EditorIdempotentCreateReceiptDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorIdempotentCreateReceiptDeleteCallbackId { + EditorIdempotentCreateReceiptDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorIdempotentCreateReceiptDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct EditorIdempotentCreateReceiptUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for EditorIdempotentCreateReceiptTableHandle<'ctx> { + type UpdateCallbackId = EditorIdempotentCreateReceiptUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorIdempotentCreateReceiptUpdateCallbackId { + EditorIdempotentCreateReceiptUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorIdempotentCreateReceiptUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for EditorIdempotentCreateReceiptTableHandle<'ctx> { + type UpdateCallbackId = EditorIdempotentCreateReceiptUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorIdempotentCreateReceiptUpdateCallbackId { + EditorIdempotentCreateReceiptUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorIdempotentCreateReceiptUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `receipt_key` unique index on the table `editor_idempotent_create_receipt`, +/// which allows point queries on the field of the same name +/// via the [`EditorIdempotentCreateReceiptReceiptKeyUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_idempotent_create_receipt().receipt_key().find(...)`. +pub struct EditorIdempotentCreateReceiptReceiptKeyUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> EditorIdempotentCreateReceiptTableHandle<'ctx> { + /// Get a handle on the `receipt_key` unique index on the table `editor_idempotent_create_receipt`. + pub fn receipt_key(&self) -> EditorIdempotentCreateReceiptReceiptKeyUnique<'ctx> { + EditorIdempotentCreateReceiptReceiptKeyUnique { + imp: self.imp.get_unique_constraint::("receipt_key"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> EditorIdempotentCreateReceiptReceiptKeyUnique<'ctx> { + /// Find the subscribed row whose `receipt_key` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("editor_idempotent_create_receipt"); + _table.add_unique_constraint::("receipt_key", |row| &row.receipt_key); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `EditorIdempotentCreateReceipt`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait editor_idempotent_create_receiptQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `EditorIdempotentCreateReceipt`. + fn editor_idempotent_create_receipt( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl editor_idempotent_create_receiptQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_idempotent_create_receipt( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_idempotent_create_receipt") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_idempotent_create_receipt_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_idempotent_create_receipt_type.rs new file mode 100644 index 000000000..fe264dbac --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_idempotent_create_receipt_type.rs @@ -0,0 +1,67 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorIdempotentCreateReceipt { + pub receipt_key: String, + pub owner_user_id: String, + pub namespace: String, + pub request_record_id: String, + pub result_record_id: String, + pub request_sha_256: String, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for EditorIdempotentCreateReceipt { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `EditorIdempotentCreateReceipt`. +/// +/// Provides typed access to columns for query building. +pub struct EditorIdempotentCreateReceiptCols { + pub receipt_key: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub namespace: __sdk::__query_builder::Col, + pub request_record_id: __sdk::__query_builder::Col, + pub result_record_id: __sdk::__query_builder::Col, + pub request_sha_256: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for EditorIdempotentCreateReceipt { + type Cols = EditorIdempotentCreateReceiptCols; + fn cols(table_name: &'static str) -> Self::Cols { + EditorIdempotentCreateReceiptCols { + receipt_key: __sdk::__query_builder::Col::new(table_name, "receipt_key"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + namespace: __sdk::__query_builder::Col::new(table_name, "namespace"), + request_record_id: __sdk::__query_builder::Col::new(table_name, "request_record_id"), + result_record_id: __sdk::__query_builder::Col::new(table_name, "result_record_id"), + request_sha_256: __sdk::__query_builder::Col::new(table_name, "request_sha_256"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `EditorIdempotentCreateReceipt`. +/// +/// Provides typed access to indexed columns for query building. +pub struct EditorIdempotentCreateReceiptIxCols { + pub receipt_key: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for EditorIdempotentCreateReceipt { + type IxCols = EditorIdempotentCreateReceiptIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + EditorIdempotentCreateReceiptIxCols { + receipt_key: __sdk::__query_builder::IxCol::new(table_name, "receipt_key"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for EditorIdempotentCreateReceipt {} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 02b15313e..86eaae1f6 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -86,6 +86,9 @@ const EDITOR_GENERATION_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; const EDITOR_GENERATION_SOUND_EFFECT_MODEL_VIDU: &str = "audio1.0"; const EDITOR_GENERATION_SOUND_EFFECT_MODEL_ELEVENLABS: &str = "eleven_text_to_sound_v2"; const EDITOR_GENERATION_BACKGROUND_MUSIC_MODEL: &str = "chirp-v5"; +const EDITOR_IDEMPOTENT_CREATE_NAMESPACE_PROJECT: &str = "editor-project"; +const EDITOR_IDEMPOTENT_CREATE_NAMESPACE_RESOURCE: &str = "editor-project-resource"; +const EDITOR_IDEMPOTENT_CREATE_NAMESPACE_ASSET_FOLDER: &str = "editor-asset-folder"; const EDITOR_GENERATION_VIDEO_MODELS: [&str; 6] = [ "seedance2.0-fast", "seedance2.0", @@ -500,6 +503,27 @@ pub struct EditorGenerationOperation { completed_at: Timestamp, } +// 私有 durable receipt 固化创建请求的原始规范化正文与首次结果绑定。 +// 业务行后续允许更新;receipt 本身不可更新或随业务删除,以便结果删除后重放失败关闭。 +#[spacetimedb::table( + accessor = editor_idempotent_create_receipt, + index( + accessor = by_editor_idempotent_create_receipt_owner, + btree(columns = [owner_user_id, namespace, request_record_id]) + ) +)] +#[derive(Clone)] +pub struct EditorIdempotentCreateReceipt { + #[primary_key] + receipt_key: String, + owner_user_id: String, + namespace: String, + request_record_id: String, + result_record_id: String, + request_sha256: String, + created_at: Timestamp, +} + #[derive(Clone, Debug, PartialEq, SpacetimeType)] pub struct EditorProjectViewportSnapshot { pub x: f64, @@ -2211,15 +2235,193 @@ pub fn rotate_editor_generation_runtime_service_identity_and_return( } } +fn replay_editor_idempotent_create( + ctx: &ReducerContext, + owner_user_id: &str, + namespace: &str, + request_record_id: &str, + request_sha256: &str, +) -> Result, String> { + let receipt_key = + editor_idempotent_create_receipt_key(owner_user_id, namespace, request_record_id); + let Some(receipt) = ctx + .db + .editor_idempotent_create_receipt() + .receipt_key() + .find(&receipt_key) + else { + return Ok(None); + }; + if receipt.owner_user_id != owner_user_id + || receipt.namespace != namespace + || receipt.request_record_id != request_record_id + { + return Err("幂等创建冲突:创建回执身份与当前请求不一致".to_string()); + } + if receipt.request_sha256 != request_sha256 { + return Err("幂等创建冲突:同一 Idempotency-Key 的创建正文不一致".to_string()); + } + Ok(Some(receipt.result_record_id)) +} + +fn persist_editor_idempotent_create_receipt( + ctx: &ReducerContext, + owner_user_id: &str, + namespace: &str, + request_record_id: &str, + result_record_id: &str, + request_sha256: &str, +) -> Result<(), String> { + let receipt_key = + editor_idempotent_create_receipt_key(owner_user_id, namespace, request_record_id); + ctx.db + .editor_idempotent_create_receipt() + .try_insert(EditorIdempotentCreateReceipt { + receipt_key, + owner_user_id: owner_user_id.to_string(), + namespace: namespace.to_string(), + request_record_id: request_record_id.to_string(), + result_record_id: result_record_id.to_string(), + request_sha256: request_sha256.to_string(), + created_at: ctx.timestamp, + }) + .map_err(|_| "幂等创建冲突:创建回执已被并发请求占用".to_string())?; + Ok(()) +} + +fn editor_idempotent_create_receipt_key( + owner_user_id: &str, + namespace: &str, + request_record_id: &str, +) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"genarrative-editor-idempotent-create-receipt-v1\0"); + update_editor_create_hash_text(&mut hasher, owner_user_id); + update_editor_create_hash_text(&mut hasher, namespace); + update_editor_create_hash_text(&mut hasher, request_record_id); + format!("{:x}", hasher.finalize()) +} + +fn editor_project_create_request_sha256( + input: &EditorProjectCreateInput, +) -> Result { + let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; + let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; + let title = normalize_title(&input.title); + let mut hasher = editor_create_request_hasher(EDITOR_IDEMPOTENT_CREATE_NAMESPACE_PROJECT); + update_editor_create_hash_text(&mut hasher, project_id.as_str()); + update_editor_create_hash_text(&mut hasher, owner_user_id.as_str()); + update_editor_create_hash_text(&mut hasher, title.as_str()); + Ok(format!("{:x}", hasher.finalize())) +} + +fn editor_asset_folder_create_request_sha256( + input: &EditorAssetFolderCreateInput, +) -> Result { + let folder_id = normalize_required(&input.folder_id, "editor_asset_folder.folder_id")?; + let owner_user_id = + normalize_required(&input.owner_user_id, "editor_asset_folder.owner_user_id")?; + let label = normalize_asset_label(&input.label); + let mut hasher = editor_create_request_hasher(EDITOR_IDEMPOTENT_CREATE_NAMESPACE_ASSET_FOLDER); + update_editor_create_hash_text(&mut hasher, folder_id.as_str()); + update_editor_create_hash_text(&mut hasher, owner_user_id.as_str()); + update_editor_create_hash_text(&mut hasher, label.as_str()); + update_editor_create_hash_bytes(&mut hasher, &input.sort_order.to_le_bytes()); + Ok(format!("{:x}", hasher.finalize())) +} + +fn editor_project_resource_create_request_sha256( + input: &EditorProjectResourceCreateInput, +) -> Result { + let resource = normalize_editor_project_resource_create(input.clone())?; + let mut hasher = editor_create_request_hasher(EDITOR_IDEMPOTENT_CREATE_NAMESPACE_RESOURCE); + update_editor_create_hash_text(&mut hasher, resource.resource_id.as_str()); + update_editor_create_hash_text(&mut hasher, resource.project_id.as_str()); + update_editor_create_hash_text(&mut hasher, resource.owner_user_id.as_str()); + update_editor_create_hash_optional_text(&mut hasher, resource.asset_object_id.as_deref()); + update_editor_create_hash_text(&mut hasher, resource.image_src.as_str()); + update_editor_create_hash_optional_text(&mut hasher, resource.object_key.as_deref()); + update_editor_create_hash_bytes(&mut hasher, &resource.width.to_le_bytes()); + update_editor_create_hash_bytes(&mut hasher, &resource.height.to_le_bytes()); + update_editor_create_hash_text(&mut hasher, resource.source_type.as_str()); + update_editor_create_hash_optional_text(&mut hasher, resource.prompt.as_deref()); + update_editor_create_hash_optional_text(&mut hasher, resource.actual_prompt.as_deref()); + update_editor_create_hash_optional_text(&mut hasher, resource.model.as_deref()); + update_editor_create_hash_optional_text(&mut hasher, resource.provider.as_deref()); + update_editor_create_hash_optional_text(&mut hasher, resource.task_id.as_deref()); + update_editor_create_hash_optional_text(&mut hasher, resource.source_resource_id.as_deref()); + update_editor_create_hash_optional_text(&mut hasher, resource.asset_kind.as_deref()); + update_editor_create_hash_optional_text( + &mut hasher, + resource.generation_inputs_json.as_deref(), + ); + update_editor_create_hash_optional_text( + &mut hasher, + resource.image_sequence_frames_json.as_deref(), + ); + match resource.image_sequence_duration_ms { + Some(value) => { + update_editor_create_hash_bytes(&mut hasher, &[1]); + update_editor_create_hash_bytes(&mut hasher, &value.to_le_bytes()); + } + None => update_editor_create_hash_bytes(&mut hasher, &[0]), + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn editor_create_request_hasher(namespace: &str) -> Sha256 { + let mut hasher = Sha256::new(); + hasher.update(b"genarrative-editor-idempotent-create-request-v1\0"); + update_editor_create_hash_text(&mut hasher, namespace); + hasher +} + +fn update_editor_create_hash_text(hasher: &mut Sha256, value: &str) { + update_editor_create_hash_bytes(hasher, value.as_bytes()); +} + +fn update_editor_create_hash_optional_text(hasher: &mut Sha256, value: Option<&str>) { + match value { + Some(value) => { + update_editor_create_hash_bytes(hasher, &[1]); + update_editor_create_hash_text(hasher, value); + } + None => update_editor_create_hash_bytes(hasher, &[0]), + } +} + +fn update_editor_create_hash_bytes(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value); +} + fn create_editor_project( ctx: &ReducerContext, input: EditorProjectCreateInput, ) -> Result { + let request_sha256 = editor_project_create_request_sha256(&input)?; let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; let title = normalize_title(&input.title); let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + if let Some(result_project_id) = replay_editor_idempotent_create( + ctx, + owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_PROJECT, + project_id.as_str(), + request_sha256.as_str(), + )? { + let project = ctx + .db + .editor_project() + .project_id() + .find(&result_project_id) + .filter(|project| project.owner_user_id == owner_user_id) + .ok_or_else(|| "幂等创建冲突:首次创建的图片画布工程已删除,拒绝重建".to_string())?; + return build_project_snapshot(ctx, project.project_id.as_str()); + } + if ctx .db .editor_project() @@ -2227,7 +2429,7 @@ fn create_editor_project( .find(&project_id) .is_some() { - return Err("图片画布工程已存在".to_string()); + return Err("幂等创建冲突:图片画布工程已存在但缺少创建回执".to_string()); } ctx.db.editor_project().insert(EditorProject { @@ -2242,6 +2444,14 @@ fn create_editor_project( updated_at: now, }); ensure_default_canvas(ctx, project_id.as_str(), owner_user_id.as_str(), None, now)?; + persist_editor_idempotent_create_receipt( + ctx, + owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_PROJECT, + project_id.as_str(), + project_id.as_str(), + request_sha256.as_str(), + )?; build_project_snapshot(ctx, project_id.as_str()) } @@ -2613,7 +2823,39 @@ fn create_editor_project_resource( ctx: &ReducerContext, input: EditorProjectResourceCreateInput, ) -> Result { - let resource = prepare_editor_project_resource(ctx, input)?; + let request_sha256 = editor_project_resource_create_request_sha256(&input)?; + let resource = normalize_editor_project_resource_create(input)?; + if let Some(result_resource_id) = replay_editor_idempotent_create( + ctx, + resource.owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_RESOURCE, + resource.resource_id.as_str(), + request_sha256.as_str(), + )? { + let existing = ctx + .db + .editor_project_resource() + .resource_id() + .find(&result_resource_id) + .filter(|existing| existing.owner_user_id == resource.owner_user_id) + .ok_or_else(|| "幂等创建冲突:首次创建的画布资源已删除,拒绝重建".to_string())?; + return Ok(resource_snapshot_from_row(existing)); + } + + require_owned_project( + ctx, + resource.project_id.as_str(), + resource.owner_user_id.as_str(), + )?; + if ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource.resource_id) + .is_some() + { + return Err("幂等创建冲突:画布资源已存在但缺少创建回执".to_string()); + } if let Some(existing_resource) = find_reusable_project_resource_for_input( ctx, resource.project_id.as_str(), @@ -2631,19 +2873,27 @@ fn create_editor_project_resource( resource.image_sequence_duration_ms, resource.updated_at, )?; + persist_editor_idempotent_create_receipt( + ctx, + resource.owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_RESOURCE, + resource.resource_id.as_str(), + existing_resource.resource_id.as_str(), + request_sha256.as_str(), + )?; return Ok(resource_snapshot_from_row(existing_resource)); } - if ctx - .db - .editor_project_resource() - .resource_id() - .find(&resource.resource_id) - .is_some() - { - return Err("画布资源已存在".to_string()); - } let resource_id = resource.resource_id.clone(); + let owner_user_id = resource.owner_user_id.clone(); ctx.db.editor_project_resource().insert(resource); + persist_editor_idempotent_create_receipt( + ctx, + owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_RESOURCE, + resource_id.as_str(), + resource_id.as_str(), + request_sha256.as_str(), + )?; ctx.db .editor_project_resource() @@ -2653,9 +2903,77 @@ fn create_editor_project_resource( .ok_or_else(|| "画布资源创建失败".to_string()) } +fn create_or_reuse_editor_spritesheet_project_resource( + ctx: &ReducerContext, + input: EditorProjectResourceCreateInput, +) -> Result { + let request_sha256 = editor_project_resource_create_request_sha256(&input)?; + let resource = normalize_editor_project_resource_create(input.clone())?; + if let Some(result_resource_id) = replay_editor_idempotent_create( + ctx, + resource.owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_RESOURCE, + resource.resource_id.as_str(), + request_sha256.as_str(), + )? { + if result_resource_id != resource.resource_id { + return Err("图集切片项目资源回执未指向稳定资源 ID".to_string()); + } + let existing = ctx + .db + .editor_project_resource() + .resource_id() + .find(&result_resource_id) + .filter(|existing| { + existing.owner_user_id == resource.owner_user_id + && editor_generation_project_resource_matches(existing, &input) + }) + .ok_or_else(|| "图集切片项目资源回执对应记录已删除或发生漂移".to_string())?; + return Ok(resource_snapshot_from_row(existing)); + } + + require_owned_project( + ctx, + resource.project_id.as_str(), + resource.owner_user_id.as_str(), + )?; + if let Some(existing) = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource.resource_id) + { + if !editor_generation_project_resource_matches(&existing, &input) { + return Err("图集切片项目资源稳定 ID 已被其他内容占用".to_string()); + } + persist_editor_idempotent_create_receipt( + ctx, + resource.owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_RESOURCE, + resource.resource_id.as_str(), + resource.resource_id.as_str(), + request_sha256.as_str(), + )?; + return Ok(resource_snapshot_from_row(existing)); + } + create_editor_project_resource(ctx, input) +} + fn prepare_editor_project_resource( ctx: &ReducerContext, input: EditorProjectResourceCreateInput, +) -> Result { + let resource = normalize_editor_project_resource_create(input)?; + require_owned_project( + ctx, + resource.project_id.as_str(), + resource.owner_user_id.as_str(), + )?; + Ok(resource) +} + +fn normalize_editor_project_resource_create( + input: EditorProjectResourceCreateInput, ) -> Result { let resource_id = normalize_required(&input.resource_id, "editor_project_resource.resource_id")?; @@ -2664,7 +2982,6 @@ fn prepare_editor_project_resource( &input.owner_user_id, "editor_project_resource.owner_user_id", )?; - require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; let image_src = normalize_required(&input.image_src, "editor_project_resource.image_src")?; let source_type = normalize_required(&input.source_type, "editor_project_resource.source_type")?; @@ -3002,7 +3319,7 @@ fn persist_editor_spritesheet_slice_batch( .project_resource .map(|mut resource| { resource.asset_object_id = Some(asset_object.asset_object_id.clone()); - create_editor_project_resource(ctx, resource) + create_or_reuse_editor_spritesheet_project_resource(ctx, resource) }) .transpose()?; let asset = item @@ -5390,9 +5707,27 @@ fn create_editor_asset_folder( ctx: &ReducerContext, input: EditorAssetFolderCreateInput, ) -> Result { + let request_sha256 = editor_asset_folder_create_request_sha256(&input)?; let folder_id = normalize_required(&input.folder_id, "editor_asset_folder.folder_id")?; let owner_user_id = normalize_required(&input.owner_user_id, "editor_asset_folder.owner_user_id")?; + let label = normalize_asset_label(&input.label); + if let Some(result_folder_id) = replay_editor_idempotent_create( + ctx, + owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_ASSET_FOLDER, + folder_id.as_str(), + request_sha256.as_str(), + )? { + let existing = ctx + .db + .editor_asset_folder() + .folder_id() + .find(&result_folder_id) + .filter(|existing| existing.owner_user_id == owner_user_id) + .ok_or_else(|| "幂等创建冲突:首次创建的素材文件夹已删除,拒绝重建".to_string())?; + return Ok(asset_folder_snapshot_from_row(existing)); + } if ctx .db .editor_asset_folder() @@ -5400,20 +5735,28 @@ fn create_editor_asset_folder( .find(&folder_id) .is_some() { - return Err("素材文件夹已存在".to_string()); + return Err("幂等创建冲突:素材文件夹已存在但缺少创建回执".to_string()); } let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); ensure_default_asset_folder(ctx, owner_user_id.as_str(), now)?; ctx.db.editor_asset_folder().insert(EditorAssetFolder { folder_id: folder_id.clone(), - owner_user_id, - label: normalize_asset_label(&input.label), + owner_user_id: owner_user_id.clone(), + label, sort_order: input.sort_order, collapsed: false, system_default: false, created_at: now, updated_at: now, }); + persist_editor_idempotent_create_receipt( + ctx, + owner_user_id.as_str(), + EDITOR_IDEMPOTENT_CREATE_NAMESPACE_ASSET_FOLDER, + folder_id.as_str(), + folder_id.as_str(), + request_sha256.as_str(), + )?; ctx.db .editor_asset_folder() .folder_id() @@ -17535,6 +17878,42 @@ mod tests { )); } + #[test] + fn legacy_spritesheet_project_resource_without_receipt_replays_exact_content() { + let input = spritesheet_slice_item(0) + .project_resource + .expect("project resource input"); + let existing = normalize_editor_project_resource_create(input.clone()) + .expect("normalize legacy project resource"); + assert!(editor_generation_project_resource_matches( + &existing, &input + )); + let mut drifted = input.clone(); + drifted.width += 1; + assert!(!editor_generation_project_resource_matches( + &existing, &drifted + )); + + let source = include_str!("editor_project_storage.rs"); + let replay_helper = source + .split_once("fn create_or_reuse_editor_spritesheet_project_resource(") + .and_then(|(_, tail)| { + tail.split_once("fn prepare_editor_project_resource(") + .map(|(body, _)| body) + }) + .expect("spritesheet project-resource replay helper"); + assert!(replay_helper.contains("editor_generation_project_resource_matches")); + assert!(replay_helper.contains("persist_editor_idempotent_create_receipt")); + let batch = source + .split_once("fn persist_editor_spritesheet_slice_batch(") + .and_then(|(_, tail)| { + tail.split_once("struct EditorPixelArtResultPersisted") + .map(|(body, _)| body) + }) + .expect("spritesheet batch persistence"); + assert!(batch.contains("create_or_reuse_editor_spritesheet_project_resource")); + } + #[test] fn editor_layout_accepts_json_up_to_two_mib() { let payload = format!( @@ -21389,4 +21768,150 @@ mod tests { ) .expect("active structured generation-dialog must remain saveable after cleanup"); } + + #[test] + fn editor_create_receipt_fingerprints_cover_the_normalized_body_not_request_time() { + let project = EditorProjectCreateInput { + project_id: " proj-stable ".to_string(), + owner_user_id: " owner-1 ".to_string(), + title: " 原始标题 ".to_string(), + now_micros: 1, + }; + let mut replayed_project = project.clone(); + replayed_project.now_micros = 2; + assert_eq!( + editor_project_create_request_sha256(&project).expect("project fingerprint"), + editor_project_create_request_sha256(&replayed_project) + .expect("replayed project fingerprint") + ); + replayed_project.title = "变更标题".to_string(); + assert_ne!( + editor_project_create_request_sha256(&project).expect("project fingerprint"), + editor_project_create_request_sha256(&replayed_project) + .expect("drifted project fingerprint") + ); + + let folder = EditorAssetFolderCreateInput { + folder_id: " folder-stable ".to_string(), + owner_user_id: " owner-1 ".to_string(), + label: " 原始目录 ".to_string(), + sort_order: 100, + now_micros: 1, + }; + let mut replayed_folder = folder.clone(); + replayed_folder.now_micros = 2; + assert_eq!( + editor_asset_folder_create_request_sha256(&folder).expect("folder fingerprint"), + editor_asset_folder_create_request_sha256(&replayed_folder) + .expect("replayed folder fingerprint") + ); + replayed_folder.sort_order = 101; + assert_ne!( + editor_asset_folder_create_request_sha256(&folder).expect("folder fingerprint"), + editor_asset_folder_create_request_sha256(&replayed_folder) + .expect("drifted folder fingerprint") + ); + + let resource = editor_generation_result_input().items[0] + .project_resource + .clone() + .expect("resource input"); + let mut replayed_resource = resource.clone(); + replayed_resource.updated_at_micros += 1; + assert_eq!( + editor_project_resource_create_request_sha256(&resource).expect("resource fingerprint"), + editor_project_resource_create_request_sha256(&replayed_resource) + .expect("replayed resource fingerprint") + ); + replayed_resource.width += 1; + assert_ne!( + editor_project_resource_create_request_sha256(&resource).expect("resource fingerprint"), + editor_project_resource_create_request_sha256(&replayed_resource) + .expect("drifted resource fingerprint") + ); + } + + #[test] + fn editor_create_receipts_are_transactional_and_fail_closed_after_deletion() { + let source = include_str!("editor_project_storage.rs"); + let table = source + .split_once("pub struct EditorIdempotentCreateReceipt") + .map(|(_, tail)| tail) + .expect("durable create receipt table"); + assert!(table.contains("request_sha256: String")); + assert!(table.contains("result_record_id: String")); + + for function_name in [ + "\nfn create_editor_project(", + "\nfn create_editor_project_resource(", + "\nfn create_editor_asset_folder(", + ] { + let body = source + .split_once(function_name) + .map(|(_, tail)| tail) + .expect("create function"); + let end = body.find("\n}\n").expect("create function end"); + let body = &body[..end]; + assert!(body.contains("replay_editor_idempotent_create")); + assert!(body.contains("persist_editor_idempotent_create_receipt")); + assert!(body.contains("已删除")); + } + + let replay = source + .split_once("fn replay_editor_idempotent_create(") + .and_then(|(_, tail)| { + tail.split_once("fn persist_editor_idempotent_create_receipt(") + .map(|(body, _)| body) + }) + .expect("replay helper"); + assert!(replay.contains("request_sha256")); + assert!(replay.contains("result_record_id")); + + let persist = source + .split_once("fn persist_editor_idempotent_create_receipt(") + .map(|(_, tail)| tail) + .expect("persist helper"); + assert!(persist.contains("try_insert")); + } + + #[test] + fn editor_resource_create_receipt_fingerprint_lists_every_normalized_create_field() { + let source = include_str!("editor_project_storage.rs"); + let fingerprint = source + .split_once("fn editor_project_resource_create_request_sha256(") + .and_then(|(_, tail)| { + tail.split_once("fn editor_create_request_hasher(") + .map(|(body, _)| body) + }) + .expect("resource create fingerprint"); + for field in [ + "resource.resource_id", + "resource.project_id", + "resource.owner_user_id", + "resource.asset_object_id", + "resource.image_src", + "resource.object_key", + "resource.width", + "resource.height", + "resource.source_type", + "resource.prompt", + "resource.actual_prompt", + "resource.model", + "resource.provider", + "resource.task_id", + "resource.source_resource_id", + "resource.asset_kind", + "resource.generation_inputs_json", + "resource.image_sequence_frames_json", + "resource.image_sequence_duration_ms", + ] { + assert!( + fingerprint.contains(field), + "resource create fingerprint 缺少 {field}" + ); + } + assert!(!fingerprint.contains("updated_at")); + assert!(!fingerprint.contains("created_at")); + assert!(!fingerprint.contains("public_showcase_enabled")); + } } diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 0939bd94b..4b6993bf7 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -266,6 +266,7 @@ macro_rules! migration_tables { editor_generation_pricing_config, editor_generation_runtime_identity_rotation, editor_generation_operation, + editor_idempotent_create_receipt, puzzle_agent_session, puzzle_background_compile_task, puzzle_agent_message, -- 2.52.0 From d6a3d635deb5cdfae26f8f11aa1bf2f1104a4f98 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 24 Aug 2026 02:29:37 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=BB=E5=88=86?= =?UTF-8?q?=E6=94=AF=E8=9E=8D=E5=90=88=E5=90=8E=E7=9A=84=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 Direct 不确定结果与普通失败的补偿分流 补齐角色动画源图片账号绑定复用 升级 External Editor v3 恢复测试身份 同步账号作用域画布生成测试契约 --- .../src-tauri/src/agent/direct_runtime.rs | 156 +++++++++++++++-- .../src/agent/generation/canvas_generation.rs | 163 ++++++++++++++++-- .../src/agent/runtime_driver/recovery_scan.rs | 5 +- .../src-tauri/src/project/resource_editor.rs | 127 +++++++++++++- .../src/tests/collaboration/delegation.rs | 7 +- .../src-tauri/src/tests/mod.rs | 116 ++++++++++++- .../src-tauri/src/tests/project.rs | 37 +++- 7 files changed, 561 insertions(+), 50 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index b76042096..368886a8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1625,6 +1625,12 @@ fn begin_direct_taonier_regeneration_compensation_at( } } +fn direct_taonier_generation_failure_needs_reconciliation(error: &str) -> bool { + platform_art_generation_error_needs_reconciliation(error) + || error.starts_with(PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX) + || error.starts_with("reconciliation-required:") +} + fn direct_taonier_regeneration_stage_failure( root: &Path, workflow: Option<&mut DirectTaonierRegenerationWorkflow>, @@ -1632,10 +1638,7 @@ fn direct_taonier_regeneration_stage_failure( local_path: &str, error: String, ) -> String { - if platform_art_generation_error_needs_reconciliation(&error) - || error.starts_with(PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX) - || error.starts_with("reconciliation-required:") - { + if direct_taonier_generation_failure_needs_reconciliation(&error) { // A remote side effect or local asset.register may already be durable. Keep the new // file, manifest, workflow, and stage ledger together for exact recovery; restoring the // old package here would erase the only local proof and allow a duplicate paid request. @@ -1661,6 +1664,32 @@ fn direct_taonier_regeneration_stage_failure( direct_taonier_regeneration_failure(root, Some(rollback), error) } +fn direct_taonier_art_generation_failure(asset_label: &str, error: String) -> String { + if direct_taonier_generation_failure_needs_reconciliation(&error) { + // Keep reconciliation markers at byte zero. The regeneration stage owns the + // compensation decision and must be able to distinguish an uncertain durable + // side effect from an ordinary pre-commit failure. + return error; + } + format!("陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}") +} + +fn direct_taonier_strict_spritesheet_stage_failure( + root: &Path, + rollback: Option<&DirectTaonierRegenerationRollback>, + error: String, +) -> String { + if direct_taonier_generation_failure_needs_reconciliation(&error) { + // The strict transaction or its remote request may already be durable. Preserve the + // transaction marker, files, manifest, workflow, and retained stage ledger together. + return error; + } + // The v4 rollback contract intentionally snapshots only art-spec/background. A strict + // spritesheet failure must restore those frozen replacements without trying to register the + // spritesheet itself as a rollback asset. + direct_taonier_regeneration_failure(root, rollback, error) +} + fn direct_taonier_art_generation_outcome( warning: Option, slice_warning: Option, @@ -2713,11 +2742,7 @@ async fn generate_direct_taonier_art_asset_at( ], }); } - Err(error) => { - return Err(format!( - "陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}" - )); - } + Err(error) => return Err(direct_taonier_art_generation_failure(asset_label, error)), }; if generated.asset.local_path != output_path { return Err(format!( @@ -3059,7 +3084,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( "art.spritesheet", "正在生成核心图集并切分四类运行时素材", ); - let outcome = generate_direct_taonier_art_asset_at( + let outcome = match generate_direct_taonier_art_asset_at( root, prompt, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, @@ -3070,9 +3095,16 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( mode.regenerates_existing(), ) .await - .map_err(|error| { - direct_taonier_regeneration_failure(root, regeneration_rollback.as_ref(), error) - })?; + { + Ok(outcome) => outcome, + Err(error) => { + return Err(direct_taonier_strict_spritesheet_stage_failure( + root, + regeneration_rollback.as_ref(), + error, + )); + } + }; warnings.extend(outcome.warnings); slice_warnings.extend(outcome.slice_warnings); if !direct_taonier_strict_art_package_is_valid(root) { @@ -5728,7 +5760,14 @@ mod tests { "source": context.source, "actionId": context.action_id, "actionFingerprint": context.action_fingerprint, - "externalConfigurationFingerprint": "retained-service-fingerprint", + "externalConfigurationFingerprint": + platform_art_generation_external_service_fingerprint( + "http://127.0.0.1:38201", + None, + ), + "accessScheme": "external-developer-v1", + "externalServiceOrigin": "http://127.0.0.1:38201", + "platformOwnerUserId": null, "endpoint": "/api/external/v1/editor/images/generations", "canvasName": "retained-direct-canvas", "generationPrompt": "retained direct art spec", @@ -6805,7 +6844,14 @@ mod tests { "source": context.source, "actionId": context.action_id, "actionFingerprint": context.action_fingerprint, - "externalConfigurationFingerprint": "test-service-fingerprint", + "externalConfigurationFingerprint": + platform_art_generation_external_service_fingerprint( + "http://127.0.0.1:38201", + None, + ), + "accessScheme": "external-developer-v1", + "externalServiceOrigin": "http://127.0.0.1:38201", + "platformOwnerUserId": null, "endpoint": "/api/external/v1/editor/icon-spritesheets/generations", "canvasName": "taonier-test-canvas", "generationPrompt": "测试严格图集", @@ -7456,8 +7502,10 @@ mod tests { .expect("record background replacement"); persist_direct_taonier_regeneration_rollback_at(root.path(), &mut workflow, &rollback) .expect("persist rollback anchors"); + begin_direct_taonier_strict_spritesheet_commit_at(root.path(), &mut workflow) + .expect("enter strict spritesheet stage"); - let error = direct_taonier_regeneration_failure( + let error = direct_taonier_strict_spritesheet_stage_failure( root.path(), Some(&rollback), "模拟 strict spritesheet 失败".to_string(), @@ -7485,6 +7533,21 @@ mod tests { .expect("previous entry") ); } + let resumed = read_direct_taonier_regeneration_workflow_at(root.path()) + .expect("read compensated workflow") + .expect("same invocation remains resumable"); + assert_eq!( + resumed.state, + DirectTaonierRegenerationWorkflowState::InProgress + ); + assert!(!resumed.strict_spritesheet_pending); + assert!(!direct_taonier_regeneration_has_replacement_anchor( + &resumed + )); + assert!( + !direct_taonier_regeneration_workflow_requires_recovery_at(root.path()) + .expect("ordinary strict failure is fully compensated") + ); } #[test] @@ -7646,6 +7709,67 @@ mod tests { ); } + #[test] + fn generation_reconciliation_markers_reach_strict_stage_without_compensation() { + for marker in [ + format!("{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} operation result unknown"), + format!("{PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX} source preserved"), + format!("{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} asset.register unknown"), + "reconciliation-required: binding install unknown".to_string(), + ] { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at( + root.path(), + "direct-strict-stage-reconciliation", + "严格图集结果待核对", + ) + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + let mut workflow = prepare_direct_taonier_regeneration_workflow_at( + root.path(), + "严格图集结果待核对", + "client-turn-strict-reconciliation", + ) + .expect("prepare workflow"); + let rollback = workflow.rollback.clone(); + begin_direct_taonier_strict_spritesheet_commit_at(root.path(), &mut workflow) + .expect("enter strict spritesheet stage"); + let replacement = tiny_replacement_png(32, 64, 192); + replace_direct_taonier_art_asset_fixture( + root.path(), + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + "uncertain-committed-spritesheet", + vec!["taonier-resource-icon-spec".to_string()], + &replacement, + ); + + let propagated = + direct_taonier_art_generation_failure("陶泥儿首版核心游戏美术图集", marker.clone()); + assert_eq!(propagated, marker); + let error = direct_taonier_strict_spritesheet_stage_failure( + root.path(), + rollback.as_ref(), + propagated, + ); + + assert_eq!(error, marker); + assert_eq!( + std::fs::read(root.path().join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH)) + .expect("preserved uncertain spritesheet"), + replacement + ); + let retained = read_direct_taonier_regeneration_workflow_at(root.path()) + .expect("read workflow") + .expect("workflow remains"); + assert_eq!( + retained.state, + DirectTaonierRegenerationWorkflowState::InProgress + ); + assert!(retained.strict_spritesheet_pending); + } + } + #[test] fn failed_art_spec_stage_after_local_commit_is_discovered_and_restored() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 1ade732cb..53fd53274 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -9725,8 +9725,16 @@ mod canvas_generation_tests { action_id: "post-202-action".to_string(), action_fingerprint: "post-202-fingerprint".to_string(), }; + let current_prompt = "账号 A 已提交的生成请求"; + let current_options = PlatformArtAssetGenerationOptions { + asset_kind: "game-art".to_string(), + asset_label: "账号隔离测试".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let generation_prompt = + build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); let request_body = serde_json::json!({ - "prompt": "账号 A 已提交的生成请求", + "prompt": generation_prompt, "kind": "spec", "projectId": "post-202-project", "assetFolderId": "post-202-folder", @@ -9743,7 +9751,7 @@ mod canvas_generation_tests { &runtime_context, "/api/external/v1/editor/images/generations", "202 后切换账号", - "账号 A 已提交的生成请求", + &generation_prompt, &request_body, &ledger_access, ) @@ -9752,13 +9760,9 @@ mod canvas_generation_tests { let error = generate_platform_art_asset_with_runtime_options_at( root, - "调用输入不得覆盖持久化请求", + current_prompt, &[], - &PlatformArtAssetGenerationOptions { - asset_kind: "game-art".to_string(), - asset_label: "账号隔离测试".to_string(), - ..PlatformArtAssetGenerationOptions::default() - }, + ¤t_options, false, &runtime_context, ) @@ -9852,6 +9856,7 @@ mod canvas_generation_tests { "resource": { "resourceId": "persisted-resource-1", "projectId": "persisted-canvas-project", + "objectKey": "generated/accepted-recovery.png", "imageSrc": server_download_url }, "warning": { @@ -9874,6 +9879,24 @@ mod canvas_generation_tests { stream .write_all(response.as_bytes()) .expect("write accepted operation response"); + } else if request.starts_with( + "GET /api/assets/read-url?objectKey=generated%2Faccepted-recovery.png ", + ) { + let body = serde_json::json!({ + "read": { + "objectKey": "generated/accepted-recovery.png", + "signedUrl": server_download_url + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write accepted recovery read URL"); } else if request.starts_with("GET /download.png ") { let headers = format!( "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", @@ -9986,12 +10009,14 @@ mod canvas_generation_tests { .collect::>(); assert_eq!( requests.len(), - 2, + 3, "accepted recovery must only poll and download" ); assert!(requests[0] .starts_with("GET /api/runtime/external-generation/jobs/accepted-operation-1 ")); - assert!(requests[1].starts_with("GET /download.png ")); + assert!(requests[1] + .starts_with("GET /api/assets/read-url?objectKey=generated%2Faccepted-recovery.png ")); + assert!(requests[2].starts_with("GET /download.png ")); assert!(requests.iter().all(|request| !request.starts_with("POST "))); assert!(requests .iter() @@ -10166,6 +10191,38 @@ mod canvas_generation_tests { let binding_access = ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None) .expect("prepare changed-reference binding access"); + let manifest = read_manifest_for_project(root).expect("read changed-reference manifest"); + let source = manifest + .assets + .iter() + .find(|asset| asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH) + .expect("registered changed-reference art spec"); + let source_bytes = + fs::read(root.join(&source.local_path)).expect("read changed-reference art spec bytes"); + let principal = external_editor_binding_principal(&binding_access) + .expect("derive changed-reference principal"); + let source_identity = new_external_editor_source_identity( + &source.id, + &format!("{:x}", Sha256::digest(&source_bytes)), + &source.media_type, + &source.kind, + ) + .expect("derive changed-reference source identity"); + let binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + "same-canvas-project", + &source_identity, + Some("current-art-spec"), + "generated/current-art-spec.png", + "current-art-spec-object", + Some(1), + Some(1), + unix_timestamp(), + ) + .expect("create changed-reference current account binding"); + write_external_editor_resource_binding_at(root, &binding) + .expect("persist changed-reference current account binding"); let (state, created) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -10382,14 +10439,18 @@ mod canvas_generation_tests { let ledger_access = ExternalEditorBindingAccess::for_developer(&base_url, "accepted-failure-key") .expect("create accepted failure ledger access"); + let current_prompt = "失败恢复提示词"; + let current_options = PlatformArtAssetGenerationOptions::default(); + let generation_prompt = + build_platform_art_asset_prompt(current_prompt, &[], ¤t_options); let (state, _) = prepare_platform_art_generation_runtime_state( root, &runtime_context, "/api/external/v1/editor/images/generations", "失败恢复画布", - "失败恢复提示词", + &generation_prompt, &serde_json::json!({ - "prompt": "失败恢复提示词", + "prompt": generation_prompt, "kind": "spec", "projectId": "failed-canvas-project", "assetFolderId": "failed-asset-folder", @@ -10409,9 +10470,9 @@ mod canvas_generation_tests { async { match request_platform_art_asset_with_runtime_options_at( root, - "不得重新提交", + current_prompt, &[], - &PlatformArtAssetGenerationOptions::default(), + ¤t_options, Some(&runtime_context), ) .await @@ -10486,6 +10547,7 @@ mod canvas_generation_tests { "resource": { "resourceId": "recovered-resource", "projectId": "test-canvas-project", + "objectKey": "generated/recovered-art-spec.png", "imageSrc": image_url }, "taskId": "recovered-task", @@ -10502,6 +10564,24 @@ mod canvas_generation_tests { stream .write_all(response.as_bytes()) .expect("write accepted scan operation response"); + } else if request.starts_with( + "GET /api/assets/read-url?objectKey=generated%2Frecovered-art-spec.png ", + ) { + let body = serde_json::json!({ + "read": { + "objectKey": "generated/recovered-art-spec.png", + "signedUrl": image_url + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write accepted scan read URL response"); } else if request.starts_with("GET /artifact.png ") { let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", @@ -10539,8 +10619,52 @@ mod canvas_generation_tests { &base_url, ); let run_id = "accepted-recovery-scan-run"; - setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id) - .expect("setup accepted generation recovery state"); + let pending = + setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id) + .expect("setup accepted generation recovery state"); + let runtime_context = platform_art_generation_runtime_context_from_pending(&pending); + remove_platform_art_generation_runtime_state_at( + root, + &runtime_context.agent_id, + &runtime_context.run_id, + ) + .expect("replace stale accepted generation fixture"); + let options = PlatformArtAssetGenerationOptions { + output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "icon-spec".to_string(), + asset_label: "游戏统一视觉规范图".to_string(), + replace_existing: false, + }; + let prompt = "恢复已受理视觉规范图"; + let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); + let (_, _, frozen_platform_session) = + resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account"); + let frozen_platform_session = + frozen_platform_session.expect("accepted recovery scan must use a platform account"); + let ledger_access = ExternalEditorBindingAccess::for_platform(&frozen_platform_session) + .expect("create accepted recovery scan access"); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "durable-test-canvas", + &generation_prompt, + &serde_json::json!({ + "prompt": generation_prompt, + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "referenceImageSrcs": [] + }), + &ledger_access, + ) + .expect("write current accepted generation fixture"); + assert!(created); + mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 0) + .expect("accept current generation fixture"); resume_game_creator_agent_background_tasks_at(root) .expect("resume accepted generation through recovery scan"); @@ -10548,6 +10672,9 @@ mod canvas_generation_tests { .recv_timeout(Duration::from_secs(3)) .expect("operation GET after recovery scan"); let second = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("read URL GET after recovery scan"); + let third = request_receiver .recv_timeout(Duration::from_secs(3)) .expect("download GET after recovery scan"); for _ in 0..100 { @@ -10566,7 +10693,9 @@ mod canvas_generation_tests { server.join().expect("join accepted scan fixture"); assert!(first.starts_with("GET /api/runtime/external-generation/jobs/test-operation-id ")); - assert!(second.starts_with("GET /artifact.png ")); + assert!(second + .starts_with("GET /api/assets/read-url?objectKey=generated%2Frecovered-art-spec.png ")); + assert!(third.starts_with("GET /artifact.png ")); assert!(request_receiver .recv_timeout(Duration::from_millis(100)) .is_err()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index d775df5ae..045ed4484 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -1619,7 +1619,10 @@ mod orphaned_external_generation_recovery_tests { "source": "direct-codex", "actionId": "direct-taonier-art-spec", "actionFingerprint": "direct-taonier-art-v1:icon-spec:art-spec", - "externalConfigurationFingerprint": "test-service", + "externalConfigurationFingerprint": "c".repeat(64), + "accessScheme": "external-developer-v1", + "externalServiceOrigin": "https://editor.example.test", + "platformOwnerUserId": null, "endpoint": "/api/external/v1/editor/images/generations", "canvasName": "Direct TaoNier", "generationPrompt": "Direct art spec", diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index c64edf659..ec018e5d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -2031,7 +2031,8 @@ async fn ensure_resource_edit_source_reference( LocalProjectResourceEditKind::ImageReference => binding .remote_resource_id .filter(|reference| is_registered_editor_reference_id(reference)), - LocalProjectResourceEditKind::Video => Some(binding.object_key), + LocalProjectResourceEditKind::Video + | LocalProjectResourceEditKind::CharacterAnimation => Some(binding.object_key), _ => return Err("当前资源类型不需要远端源 binding".to_string()), }; if let Some(stable_reference) = stable_reference { @@ -6269,6 +6270,130 @@ mod tests { assert!(!register_request.contains(&second_request.operation_id)); } + #[tokio::test] + async fn character_animation_reuses_exact_source_binding_object_key() { + let directory = tempfile::tempdir().expect("create character animation binding fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "角色动画源 binding 复用测试") + .expect("initialize project"); + let uploaded = upload_local_asset_at( + root, + "character-source.png", + "image/png", + &resource_editor_test_png(), + ) + .expect("upload character source"); + let manifest = read_existing_manifest_for_project(root).expect("read character manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("character source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::CharacterAnimation, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + request.source_subtype = Some(source_asset.kind.clone()); + let source = resolve_resource_edit_source(root, &manifest, &request) + .expect("resolve character source"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + write_resource_edit_ledger(root, &ledger).expect("write character animation ledger"); + + let listener = + TcpListener::bind("127.0.0.1:0").expect("bind character animation binding fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("character binding address") + ); + let upload_url = format!("{base_url}/unused-upload"); + let access = + ExternalEditorBindingAccess::new(&base_url, "character-animation-binding-key", None) + .expect("character binding access"); + let principal = + external_editor_binding_principal(&access).expect("character binding principal"); + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + PROJECT_ID, + &principal, + "source-binding-project", + "source-binding-folder", + 1, + ) + .expect("character project binding"), + ) + .expect("write character project binding"); + let source_identity = new_external_editor_source_identity( + &source_asset.id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + ) + .expect("character source identity"); + write_external_editor_resource_binding_at( + root, + &new_external_editor_resource_binding( + PROJECT_ID, + &principal, + "source-binding-project", + &source_identity, + None, + "generated/character-source.png", + "character-source-object", + None, + None, + 1, + ) + .expect("character resource binding"), + ) + .expect("write character resource binding"); + let (done_sender, done_receiver) = mpsc::channel(); + let server = spawn_source_binding_fixture(listener, upload_url, false, None, done_receiver); + + let prepared = ensure_resource_edit_source_reference( + root, + &reqwest::Client::new(), + &access, + &principal, + &request, + &source, + &mut ledger, + ) + .await + .expect("reuse character source binding without remote mutation"); + done_sender + .send(()) + .expect("stop character animation binding fixture"); + let requests = server + .join() + .expect("join character animation binding fixture"); + assert_eq!(prepared.stable_reference, "generated/character-source.png"); + assert_eq!( + ledger.source_stable_reference.as_deref(), + Some("generated/character-source.png") + ); + assert_eq!( + ledger.remote_project_id.as_deref(), + Some("source-binding-project") + ); + assert_eq!( + requests.len(), + 2, + "cache hit must only revalidate project and folder" + ); + assert!(requests.iter().all(|request| { + let line = request.lines().next().unwrap_or_default(); + line.starts_with("GET /api/external/v1/editor/projects ") + || line.starts_with("GET /api/external/v1/editor/assets/library ") + })); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn source_registration_identity_persists_before_account_switch_is_reported() { let directory = tempfile::tempdir().expect("create source registration switch fixture"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index a4e05512f..4695fd7ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1621,6 +1621,11 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房") .expect("project init"); register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + bind_canvas_visual_asset_fixture_to_current_editor( + &root, + "assets/art-spec.png", + "canvas-project-1", + ); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -1806,7 +1811,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r }); let mut saw_generation_request = false; - for _ in 0..3 { + for _ in 0..5 { let captured = request_receiver .recv_timeout(Duration::from_secs(15)) .expect("canvas request before generation response"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index e936da8ad..628dec413 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -225,7 +225,7 @@ fn spritesheet_slice_test_png_bytes(index: usize) -> Vec { [64, 128, 240, 255], [224, 96, 224, 255], ]; - rgba_test_png_bytes(&[colors[index]]) + rgba_test_png_bytes(&[[0, 0, 0, 0], colors[index]]) } fn rgba_test_png_bytes(pixels: &[[u8; 4]]) -> Vec { @@ -3006,7 +3006,7 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( serde_json::json!({ "name": name, "imageSrc": format!("/generated/canvas/spritesheet-slice-{index}.png"), - "width": 1, + "width": 2, "height": 1, "resource": { "resourceId": format!("slice-resource-{index}"), @@ -3014,7 +3014,7 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( "imageSrc": format!("/generated/canvas/spritesheet-slice-{index}.png"), "objectKey": format!("generated/canvas/spritesheet-slice-{index}.png"), "assetObjectId": format!("slice-asset-object-{index}"), - "width": 1, + "width": 2, "height": 1, "sourceType": "generated", "taskId": "task-1", @@ -3136,11 +3136,39 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( { assert!(normalized_request.contains("authorization: bearer ")); ("200 OK", "application/json", projects_body.as_bytes().to_vec()) + } else if request.starts_with("POST /api/external/v1/editor/projects ") + || request.starts_with("POST /api/editor/projects ") + { + assert!(normalized_request.contains("authorization: bearer ")); + assert!(normalized_request.contains("idempotency-key: game-creator-project-")); + ( + "200 OK", + "application/json", + serde_json::json!({ + "data": { "project": { "projectId": "canvas-project-1" } } + }) + .to_string() + .into_bytes(), + ) } else if request.starts_with("GET /api/external/v1/editor/assets/library ") || request.starts_with("GET /api/editor/assets/library ") { assert!(normalized_request.contains("authorization: bearer ")); ("200 OK", "application/json", library_body.as_bytes().to_vec()) + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") + || request.starts_with("POST /api/editor/assets/folders ") + { + assert!(normalized_request.contains("authorization: bearer ")); + assert!(normalized_request.contains("idempotency-key: game-creator-folder-")); + ( + "200 OK", + "application/json", + serde_json::json!({ + "data": { "folder": { "folderId": "folder-1" } } + }) + .to_string() + .into_bytes(), + ) } else if request.starts_with("GET /api/external/v1/editor/projects/canvas-project-1 ") || request.starts_with("GET /api/editor/projects/canvas-project-1 ") { @@ -3298,7 +3326,7 @@ fn spawn_mock_external_canvas_api_server() -> String { fn spawn_mock_external_canvas_generation_api_server( request_sender: Option>, ) -> String { - spawn_mock_external_canvas_api_server_with_capture(16, request_sender) + spawn_mock_external_canvas_api_server_with_capture(20, request_sender) } fn spawn_mock_external_canvas_generation_api_server_with_gate( @@ -3306,7 +3334,7 @@ fn spawn_mock_external_canvas_generation_api_server_with_gate( generation_response_gate: mpsc::Receiver<()>, ) -> String { spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( - 16, + 20, Some(request_sender), Some(generation_response_gate), ) @@ -3319,7 +3347,7 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { listener.local_addr().expect("mock canvas api addr") ); std::thread::spawn(move || { - for index in 0..3 { + for index in 0..5 { let (mut stream, _) = listener.accept().expect("mock canvas api accept"); let mut request_buffer = [0_u8; 8192]; let read_len = stream.read(&mut request_buffer).unwrap_or(0); @@ -3345,6 +3373,19 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { ) } 1 => { + assert!( + request.starts_with("POST /api/external/v1/editor/projects ") + || request.starts_with("POST /api/editor/projects ") + ); + ( + "200 OK", + serde_json::json!({ + "data": { "project": { "projectId": "canvas-project-1" } } + }) + .to_string(), + ) + } + 2 => { assert!( request.starts_with("GET /api/external/v1/editor/assets/library ") || request.starts_with("GET /api/editor/assets/library ") @@ -3360,6 +3401,19 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String { .to_string(), ) } + 3 => { + assert!( + request.starts_with("POST /api/external/v1/editor/assets/folders ") + || request.starts_with("POST /api/editor/assets/folders ") + ); + ( + "200 OK", + serde_json::json!({ + "data": { "folder": { "folderId": "folder-1" } } + }) + .to_string(), + ) + } _ => { assert!( request.starts_with("POST /api/external/v1/editor/images/generations ") @@ -3434,6 +3488,56 @@ fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &st .expect("register canvas visual asset fixture"); } +fn bind_canvas_visual_asset_fixture_to_current_editor( + root: &Path, + local_path: &str, + remote_project_id: &str, +) { + let manifest = read_manifest_for_project(root).expect("read visual binding fixture manifest"); + let source = manifest + .assets + .iter() + .find(|asset| asset.local_path == local_path) + .expect("registered visual binding fixture"); + let bytes = fs::read(root.join(&source.local_path)).expect("read visual binding fixture bytes"); + let (api_base_url, api_key, frozen_platform_session) = + resolve_canvas_sync_api_credentials(None, None).expect("resolve visual fixture account"); + let frozen_platform_session = frozen_platform_session + .as_ref() + .expect("visual binding fixture requires a platform account"); + let access = + ExternalEditorBindingAccess::new(&api_base_url, &api_key, Some(frozen_platform_session)) + .expect("create visual binding fixture access"); + let principal = external_editor_binding_principal(&access) + .expect("derive visual binding fixture principal"); + let source_identity = new_external_editor_source_identity( + &source.id, + &format!("{:x}", Sha256::digest(&bytes)), + &source.media_type, + &source.kind, + ) + .expect("derive visual binding fixture source identity"); + let binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + remote_project_id, + &source_identity, + source.source.resource_id.as_deref(), + &format!("fixtures/{}.png", source.id), + source + .source + .asset_object_id + .as_deref() + .expect("visual fixture asset object id"), + Some(1), + Some(1), + unix_timestamp(), + ) + .expect("create visual resource binding fixture"); + write_external_editor_resource_binding_at(root, &binding) + .expect("persist visual resource binding fixture"); +} + fn ui_prototype_checks_fixture(passed: bool) -> serde_json::Value { serde_json::json!({ "informationHud": passed, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 1167a8440..06b7ecbcf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -351,7 +351,7 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re assert!( validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") .expect_err("stale UI reference must fail") - .contains("未精确引用当前统一视觉规范图") + .contains("未绑定当前统一视觉规范图的本地内容身份") ); let ui = manifest @@ -402,7 +402,7 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re assert!( validate_manifest_required_visual_asset(&root, &manifest, task_id) .unwrap_err() - .contains("未精确引用当前统一视觉规范图"), + .contains("未绑定当前统一视觉规范图的本地内容身份"), "{task_id} must become stale after art-spec resource changes" ); } @@ -658,6 +658,11 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { ); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + bind_canvas_visual_asset_fixture_to_current_editor( + &root, + "assets/art-spec.png", + "canvas-project-1", + ); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -846,7 +851,9 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { assert_eq!( canvas_requests .iter() - .filter(|request| request.starts_with("POST /api/editor/")) + .filter(|request| { + request.starts_with("POST /api/editor/icon-spritesheets/generations ") + }) .count(), 1, "queued/running polling must not submit generation again" @@ -911,6 +918,13 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { ) .expect("write runtime config"); let _config_guard = use_test_runtime_config_dir(config_dir.to_path_buf()); + if root.join("assets/art-spec.png").is_file() { + bind_canvas_visual_asset_fixture_to_current_editor( + root, + "assets/art-spec.png", + "canvas-project-1", + ); + } request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options) .await .expect("prepare canonical visual request"); @@ -920,7 +934,10 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { .recv_timeout(Duration::from_secs(2)) .expect("canvas api request") }) - .find(|request| request.starts_with("POST ")) + .find(|request| { + request.starts_with("POST /api/editor/images/generations ") + || request.starts_with("POST /api/editor/icon-spritesheets/generations ") + }) .expect("generation request") } @@ -994,6 +1011,11 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房") .expect("project init"); register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + bind_canvas_visual_asset_fixture_to_current_editor( + &root, + "assets/art-spec.png", + "canvas-project-1", + ); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -1035,7 +1057,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m }); let mut saw_generation_request = false; - for _ in 0..3 { + for _ in 0..5 { let captured = request_receiver .recv_timeout(Duration::from_secs(15)) .expect("canvas request before generation response"); @@ -1155,9 +1177,8 @@ async fn generate_local_game_draft_fails_after_max_passes_without_final_artifact .map(|path| path.replace('\\', "/")) .collect::>(); assert!( - artifact_paths - .iter() - .any(|path| path == ".agent/passes/pass-3/game.html"), + artifact_paths.iter().any(|path| path.as_str() + == format!(".agent/passes/pass-{GAME_CREATOR_AGENT_LOOP_MAX_PASSES}/game.html")), "unexpected max-pass artifact paths: {artifact_paths:?}" ); assert!(!trace["steps"] -- 2.52.0 From 09486f142dde5b7d1531032274d570582d5f3635 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 24 Aug 2026 02:39:54 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=AF=BC=E5=85=A5=E6=8E=92=E5=BA=8F=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按仓库规范排序账号会话运行时代码导入 同步账号会话测试的导入顺序 --- apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx | 2 +- apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 054181cd6..6a9322506 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -29,8 +29,8 @@ import { setClientServerSelection, } from '../services/clientHttp'; import { - beginPlatformSessionTransition, beginPlatformSessionClearTransition, + beginPlatformSessionTransition, clearCommittedPlatformSession, commitAuthenticatedPlatformSession, currentPlatformSessionApiBaseUrl, diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 03a3d7fd5..fb2ce862c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -7,8 +7,8 @@ import { setClientServerSelection, } from '../../src/services/clientHttp'; import { - beginPlatformSessionTransition, beginPlatformSessionClearTransition, + beginPlatformSessionTransition, clearCommittedPlatformSession, commitAuthenticatedPlatformSession, currentPlatformSessionGeneration, -- 2.52.0