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 464cca425..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 @@ -99,10 +99,16 @@ 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, task_id: String, + reference_resource_ids: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -516,7 +522,6 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) else { return Ok(false); @@ -527,8 +532,7 @@ fn direct_taonier_spritesheet_matches_retained_stage_result_at( "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 Ok(false); }; @@ -1621,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>, @@ -1628,6 +1638,12 @@ fn direct_taonier_regeneration_stage_failure( local_path: &str, error: String, ) -> String { + 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. + return error; + } let Some(rollback) = rollback.as_mut() else { return error; }; @@ -1648,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, @@ -1846,8 +1888,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| { @@ -1857,15 +1898,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; } @@ -1882,7 +1914,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 @@ -1911,10 +1948,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, @@ -1922,7 +1996,6 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool { "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) else { return false; @@ -1933,8 +2006,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; }; @@ -1949,7 +2021,6 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) else { return false; @@ -1963,8 +2034,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; }; @@ -2015,7 +2085,6 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) else { return Vec::new(); @@ -2264,25 +2333,168 @@ 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, ) -> 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 { + 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, + task_id: art_spec.task_id.clone(), + reference_resource_ids: Vec::new(), + }; 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())); @@ -2291,6 +2503,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")) @@ -2299,7 +2512,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") @@ -2308,9 +2522,12 @@ 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, "恢复的陶泥儿 art-spritesheet", @@ -2322,19 +2539,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", - &[], - Some(&art_spec.canvas_project_id), - ) - .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()); @@ -2356,17 +2576,13 @@ 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(art_spec.canvas_project_id.clone()), - resource_id: Some(resource_id), - asset_object_id: Some(asset_object_id), + canvas_project_id: Some(remote_art_spec.canvas_project_id.clone()), + 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") .or_else(|| json_string_field(resource, "prompt")), @@ -2375,13 +2591,22 @@ 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); - } + )?; + 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", @@ -2483,7 +2708,6 @@ async fn generate_direct_taonier_art_asset_at( "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) .ok_or_else(|| { @@ -2518,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!( @@ -2647,7 +2867,6 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) }) @@ -2659,8 +2878,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() }); @@ -2726,7 +2944,6 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( "icon-spec", "/api/external/v1/editor/images/generations", "spec", - &[], None, ) .ok_or_else(|| { @@ -2745,8 +2962,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() { @@ -2868,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, @@ -2879,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) { @@ -5537,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", @@ -6061,10 +6291,16 @@ 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(), task_id: "taonier-task-icon-spec".to_string(), + reference_resource_ids: Vec::new(), } } @@ -6191,6 +6427,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" => ( @@ -6386,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": "测试严格图集", @@ -7037,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(), @@ -7066,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] @@ -7175,6 +7657,119 @@ mod tests { } } + #[test] + fn uncertain_art_spec_registration_preserves_new_file_and_workflow_for_reconciliation() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at( + root.path(), + "direct-package-uncertain-registration", + "规范图登记结果未知", + ) + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + let mut workflow = prepare_direct_taonier_regeneration_workflow_at( + root.path(), + "规范图登记结果未知", + "client-turn-uncertain-registration", + ) + .expect("prepare workflow"); + let mut rollback = workflow.rollback.clone(); + let replacement = tiny_replacement_png(32, 64, 192); + replace_direct_taonier_art_asset_fixture( + root.path(), + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "uncertain-committed-spec", + Vec::new(), + &replacement, + ); + let uncertain = + format!("{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} asset.register 追加结果未知"); + + let error = direct_taonier_regeneration_stage_failure( + root.path(), + Some(&mut workflow), + &mut rollback, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + uncertain.clone(), + ); + + assert_eq!(error, uncertain); + assert_eq!( + std::fs::read(root.path().join(DIRECT_CODEX_ART_SPEC_ASSET_PATH)) + .expect("preserved replacement spec"), + replacement + ); + assert_eq!( + read_direct_taonier_regeneration_workflow_at(root.path()) + .expect("read workflow") + .expect("workflow remains") + .state, + DirectTaonierRegenerationWorkflowState::InProgress + ); + } + + #[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"); @@ -7367,6 +7962,99 @@ mod tests { ); } + #[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 d95085b21..969071c6b 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 76ed865a4..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 @@ -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, @@ -14,6 +11,7 @@ use super::external_generation_state::{ retain_platform_art_generation_runtime_accepted_result, 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; @@ -30,6 +28,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 +811,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 +863,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 +908,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 +985,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 +1027,8 @@ async fn resume_prepared_external_generation_at( ) }, )?; + post_submit_session?; + post_body_session?; Ok(generated) } ExternalGenerationInitialResponse::AsyncSubmission(submission) => { @@ -759,8 +1047,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 } } } @@ -768,94 +1057,221 @@ 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 canvas_name = manifest.name.trim().chars().take(80).collect::(); - let canvas_name = if canvas_name.is_empty() { + let local_project_id = manifest.project_id.trim(); + if local_project_id.is_empty() { + return Err( + "本地项目 manifest 缺少 projectId,无法建立 External Editor 账号绑定".to_string(), + ); + } + 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 }; - let projects_payload = external_editor_json_request( - client - .get(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/projects") - )) - .bearer_auth(api_key), - "读取外部画布项目", - ) - .await?; - 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")); - let project_id = match project_id { - Some(project_id) => project_id, - None => { - let payload = external_editor_json_request( - client - .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/projects") - )) - .bearer_auth(api_key) - .json(&serde_json::json!({ "title": canvas_name })), - "创建外部画布项目", - ) - .await?; - external_editor_response_data(&payload) - .get("project") - .and_then(|project| json_string_field(project, "projectId")) - .ok_or_else(|| "创建外部画布项目响应缺少 projectId".to_string())? + 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 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!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/assets/library") - )) - .bearer_auth(api_key), - "读取外部素材库", - ) - .await?; - 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 = match asset_folder_id { - Some(folder_id) => folder_id, - None => { - let payload = external_editor_json_request( - client - .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders") - )) - .bearer_auth(api_key) - .json(&serde_json::json!({ "label": canvas_name })), - "创建外部素材库目录", - ) - .await?; - external_editor_response_data(&payload) - .get("folder") - .and_then(|folder| json_string_field(folder, "folderId")) - .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())? + 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()) + }) { + 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(); + 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)?; + remove_external_canvas_binding_partial(root, &binding_key)?; + access.validate_frozen_session()?; Ok(ExternalCanvasGenerationContext { project_id, @@ -913,6 +1329,57 @@ 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, + ) + } +} + #[derive(Clone)] struct PlatformArtSliceManifestRegistration { local_path: String, @@ -925,6 +1392,7 @@ struct PlatformArtSliceManifestRegistration { } pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { + platform_session_fence: Option, requested_output_path: Option, replacement_fingerprint: Option, download: CanvasResourceDownload, @@ -953,10 +1421,35 @@ 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() + } } -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)?; @@ -980,22 +1473,230 @@ 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() - }) + 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。 + 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()) + .header( + "Idempotency-Key", + format!("game-creator-resource-{resource_binding_key}"), + ) + .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()) + .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, + "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?; + 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), + "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)?; + post_response_session?; + Ok(remote_resource_id) } fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { @@ -1151,10 +1852,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, @@ -1189,10 +1908,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), ) @@ -1287,16 +2005,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 @@ -1312,17 +2032,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 @@ -1471,6 +2284,22 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( 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; @@ -1490,6 +2319,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( 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", @@ -1511,6 +2341,7 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( Ok(generated) } +#[cfg(test)] pub(in crate::agent) async fn request_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -1550,21 +2381,19 @@ 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( - 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() @@ -1597,65 +2426,76 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at options.asset_kind.as_str(), "ui-prototype" | "game-background" | "art-spritesheet" ) { - let current_reference = - canonical_art_spec_reference_at(root, &snapshot.canvas_project_id).map_err( - |error| { - format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化派生请求的当前规范图身份:{error}" - ) - }, - )?; + let current_reference = canonical_art_spec_reference_at( + root, + &client, + &binding_access, + &snapshot.canvas_project_id, + ) + .await + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化派生请求的当前规范图身份:{error}" + ) + })?; if snapshot.reference_resource_ids != [current_reference] { return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); } } - 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" => { if let Some(result) = platform_art_generation_runtime_retained_result(&state) { - result + Ok(result) } else { - let submission = platform_art_generation_runtime_submission_payload(&state) - .map_err(|error| { - format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}") - })?; - let result = wait_for_external_generation_result( - &client, - &api_base_url, - &api_key, - &submission, - ) - .await?; - retain_platform_art_generation_runtime_accepted_result(root, state, &result) - .map_err(|error| { - format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 异步生成完成结果无法绑定到 operationId:{error}" + match platform_art_generation_runtime_submission_payload(&state).map_err( + |error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"), + ) { + Ok(submission) => match wait_for_external_generation_result_with_access( + &client, + &binding_access, + &submission, + ) + .await + { + Ok(result) => retain_platform_art_generation_runtime_accepted_result( + root, state, &result, ) - })?; - result + .map(|_| result) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 异步生成完成结果无法绑定到 operationId:{error}" + ) + }), + Err(error) => Err(error), + }, + 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, @@ -1672,20 +2512,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() @@ -1743,7 +2592,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!( @@ -1769,6 +2618,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, @@ -1778,9 +2628,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( @@ -1807,14 +2661,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, @@ -1828,6 +2684,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) => { @@ -1850,13 +2708,20 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } else { None }; - let result = 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?; + let result = resolve_standalone_platform_art_generation_result( + root, + runtime_context, + wait_for_external_generation_result_with_access( + &client, + &binding_access, + &submission, + ) + .await, + )?; if let Some(state) = accepted_state { retain_platform_art_generation_runtime_accepted_result(root, state, &result) .map_err(|error| { @@ -1915,10 +2780,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) = @@ -1946,8 +2815,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, @@ -2002,7 +2870,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, @@ -2045,6 +2915,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, @@ -2061,6 +2932,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, @@ -6134,7 +7006,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, @@ -6657,6 +7531,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!({ @@ -6711,7 +7657,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 { @@ -6732,6 +7678,986 @@ 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"); + 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}"); + } + } + + #[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) } @@ -6805,10 +8731,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, @@ -6977,10 +8904,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, @@ -7168,10 +9099,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, @@ -7179,7 +9109,7 @@ mod canvas_generation_tests { "持久化画布名", &durable_generation_prompt, &request_body, - &configuration_fingerprint, + &ledger_access, ) .expect("prepare recovery ledger"); assert!(created); @@ -7292,10 +9222,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, @@ -7303,7 +9232,7 @@ mod canvas_generation_tests { "持久化画布名", "持久化的生成提示词", &request_body, - &fingerprint, + &ledger_access, ) .expect("prepare auth recovery ledger"); assert!(created); @@ -7319,12 +9248,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, ) @@ -7427,10 +9358,9 @@ mod canvas_generation_tests { "assetFolderId": "retained-folder", "referenceImageSrcs": [] }); - let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( - &base_url, - "retained-runtime-key", - ); + let binding_access = + ExternalEditorBindingAccess::new(&base_url, "retained-runtime-key", None) + .expect("prepare retained binding access"); let (state, created) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -7438,7 +9368,7 @@ mod canvas_generation_tests { "retained-canvas", &generation_prompt, &request_body, - &configuration_fingerprint, + &binding_access, ) .expect("prepare retained stage ledger"); assert!(created); @@ -7584,6 +9514,292 @@ 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 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": generation_prompt, + "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 后切换账号", + &generation_prompt, + &request_body, + &ledger_access, + ) + .expect("prepare post-202 runtime ledger"); + assert!(created); + + let error = generate_platform_art_asset_with_runtime_options_at( + root, + current_prompt, + &[], + ¤t_options, + 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"); @@ -7640,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": { @@ -7654,6 +9871,24 @@ 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, + ); + 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(), @@ -7661,12 +9896,12 @@ mod canvas_generation_tests { ); stream .write_all(response.as_bytes()) - .expect("write accepted operation response"); + .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", - 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)) @@ -7674,9 +9909,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)) @@ -7713,10 +9948,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, @@ -7724,7 +9961,7 @@ mod canvas_generation_tests { "持久化画布名", &durable_generation_prompt, &request_body, - &configuration_fingerprint, + &ledger_access, ) .expect("prepare accepted recovery ledger"); assert!(created); @@ -7772,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() @@ -7820,10 +10059,9 @@ mod canvas_generation_tests { let options = PlatformArtAssetGenerationOptions::default(); let old_generation_prompt = build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &[], &options); - let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( - &base_url, - "changed-intent-key", - ); + let binding_access = + ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None) + .expect("prepare changed-intent binding access"); let (state, created) = prepare_platform_art_generation_runtime_state( root, &runtime_context, @@ -7837,7 +10075,7 @@ mod canvas_generation_tests { "assetFolderId": "old-asset-folder", "referenceImageSrcs": [] }), - &configuration_fingerprint, + &binding_access, ) .expect("prepare old intent ledger"); assert!(created); @@ -7950,10 +10188,41 @@ mod canvas_generation_tests { }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); - let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( - &base_url, - "changed-reference-key", - ); + 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, @@ -7967,7 +10236,7 @@ mod canvas_generation_tests { "assetFolderId": "same-folder", "referenceImageSrcs": ["previous-art-spec"] }), - &configuration_fingerprint, + &binding_access, ) .expect("prepare old reference ledger"); assert!(created); @@ -8043,8 +10312,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, @@ -8058,7 +10330,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) @@ -8085,7 +10357,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 @@ -8164,24 +10436,27 @@ 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 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", "referenceImageSrcs": [] }), - &configuration_fingerprint, + &ledger_access, ) .expect("prepare accepted failure ledger"); mark_platform_art_generation_runtime_accepted(root, state, "failed-operation", 0) @@ -8195,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 @@ -8272,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", @@ -8280,6 +10556,24 @@ 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, + ); + 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(), @@ -8287,12 +10581,12 @@ mod canvas_generation_tests { ); stream .write_all(response.as_bytes()) - .expect("write accepted scan operation response"); + .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", - 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)) @@ -8325,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"); @@ -8334,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 { @@ -8352,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()); @@ -8595,19 +10938,184 @@ 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")); 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!({ @@ -8707,6 +11215,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") @@ -9031,6 +11540,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 4e815aaed..bfc4e6413 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, @@ -138,15 +266,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 { @@ -183,6 +348,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() { @@ -191,7 +357,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 { @@ -221,25 +388,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(()) } @@ -247,13 +451,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)?; } @@ -265,12 +472,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 @@ -373,8 +629,9 @@ 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)?; // The prepared sidecar is the authority that allocates the billable // Idempotency-Key. Hold the cross-process project lock across the complete @@ -385,12 +642,15 @@ pub(super) fn prepare_platform_art_generation_runtime_state( "canvas.asset_generate.runtime.claim", )?; 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()); } @@ -407,7 +667,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(), @@ -814,10 +1077,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, @@ -831,7 +1100,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()); @@ -846,10 +1115,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, @@ -863,7 +1138,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()); @@ -933,6 +1208,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( @@ -945,6 +1269,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); @@ -954,7 +1279,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", @@ -965,6 +1290,7 @@ mod external_generation_state_tests { Some(¤t), base_url, "rotated-key", + None, ), PlatformArtGenerationServiceIdentityMatch::Current ); @@ -974,6 +1300,7 @@ mod external_generation_state_tests { Some(&legacy), base_url, "original-key", + None, ), PlatformArtGenerationServiceIdentityMatch::LegacyVerified ); @@ -983,6 +1310,7 @@ mod external_generation_state_tests { Some(&legacy), base_url, "rotated-key", + None, ), PlatformArtGenerationServiceIdentityMatch::LegacyUnverified ); @@ -995,6 +1323,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-"); @@ -1008,7 +1366,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", @@ -1021,34 +1380,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") @@ -1056,6 +1418,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, @@ -1121,10 +1562,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, @@ -1133,7 +1571,7 @@ mod external_generation_state_tests { "generation-ledger-canvas", "生成统一视觉规范图", &request_body, - &configuration_fingerprint, + &access, ) .expect("prepare generation ledger"); assert!(created); @@ -1173,24 +1611,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"), @@ -1204,7 +1635,7 @@ mod external_generation_state_tests { "generation-ledger-canvas", "生成统一视觉规范图", &request_body, - &configuration_fingerprint, + &access, ) .expect("reload generation ledger"); assert!(!created_again); @@ -1277,7 +1708,7 @@ mod external_generation_state_tests { "generation-ledger-canvas", "生成统一视觉规范图", &serde_json::json!({"prompt": "different request"}), - &configuration_fingerprint, + &access, ) .is_err()); @@ -1305,10 +1736,6 @@ 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 barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); let root = std::sync::Arc::new(root.to_path_buf()); let handles = (0..2) @@ -1317,9 +1744,9 @@ mod external_generation_state_tests { let root = root.clone(); let context = context.clone(); let request_body = request_body.clone(); - let configuration_fingerprint = configuration_fingerprint.clone(); std::thread::spawn(move || { barrier.wait(); + let access = developer_access("https://editor.example.test", "test-api-key"); prepare_platform_art_generation_runtime_state( &root, &context, @@ -1327,7 +1754,7 @@ mod external_generation_state_tests { "concurrent-generation-canvas", "并发生成统一视觉规范图", &request_body, - &configuration_fingerprint, + &access, ) .expect("claim generation ledger") }) @@ -1363,10 +1790,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", @@ -1381,7 +1805,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( @@ -1449,7 +1873,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 fe7a41d53..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 @@ -444,12 +444,10 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at( return Ok(true); } } - let generation_directory = root.join(".agent/runtime/canvas-generation-requests"); - if durable_agent_runtime_recovery_directory_has_entries(&generation_directory) - && !generation_directory_only_contains_retained_direct_taonier_states_at(root) - .unwrap_or(false) - { - 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); @@ -490,87 +488,211 @@ fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> boo false } -fn generation_directory_only_contains_retained_direct_taonier_states_at( - root: &Path, -) -> Result { - let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?; - let agent_entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => return Err(format!("读取 External Editor 生成账本目录失败:{error}")), - }; - let expected_agent_component = - agent_runtime_confirmation_path_component("direct-codex-art", "agent"); - let mut found = false; - for agent_entry in agent_entries { - let agent_entry = agent_entry.map_err(|error| format!("遍历生成账本目录失败:{error}"))?; - let metadata = fs::symlink_metadata(agent_entry.path()) - .map_err(|error| format!("读取生成账本目录元数据失败:{error}"))?; - if metadata.file_type().is_symlink() - || !metadata.is_dir() - || agent_entry.file_name().to_str() != Some(expected_agent_component.as_str()) - { - return Ok(false); - } - for entry in fs::read_dir(agent_entry.path()) - .map_err(|error| format!("读取 Direct 生成账本目录失败:{error}"))? - { - let entry = entry.map_err(|error| format!("遍历 Direct 生成账本失败:{error}"))?; - let metadata = fs::symlink_metadata(entry.path()) - .map_err(|error| format!("读取 Direct 生成账本元数据失败:{error}"))?; - if metadata.file_type().is_symlink() - || !metadata.is_file() - || metadata.len() > 256 * 1024 - { - return Ok(false); - } - let payload = fs::read(entry.path()) - .map_err(|error| format!("读取 Direct 生成账本失败:{error}"))?; - let payload = serde_json::from_slice::(&payload) - .map_err(|error| format!("解析 Direct 生成账本失败:{error}"))?; - if payload - .get("schemaVersion") - .and_then(serde_json::Value::as_str) - != Some(PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION) - || payload.get("agentId").and_then(serde_json::Value::as_str) - != Some("direct-codex-art") - { - return Ok(false); - } - let Some(run_id) = payload.get("runId").and_then(serde_json::Value::as_str) else { - return Ok(false); - }; - let expected_file_name = format!( - "{}.json", - agent_runtime_confirmation_path_component(run_id, "run") - ); - let expected_backup_name = format!(".{expected_file_name}.previous"); - let Some(file_name) = entry.file_name().to_str().map(str::to_string) else { - return Ok(false); - }; - if file_name != expected_file_name && file_name != expected_backup_name { - return Ok(false); - } - if !direct_taonier_regeneration_workflow_retains_stage_ledger_at( - root, - "direct-codex-art", - run_id, - )? { - return Ok(false); - } - found = true; - } - } - Ok(found) +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 cleanup_orphaned_platform_art_generation_runtime_states_at( +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}", @@ -645,7 +767,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}" )); @@ -672,10 +798,38 @@ 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) { + if agent_id != "direct-codex-art" + || direct_taonier_regeneration_workflow_retains_stage_ledger_at( + root, agent_id, run_id, + )? + { + continue; + } + // A valid Direct stage identity without the exact v4 workflow owner is still + // recoverable Agent work. Keep it visible so cleanup fails closed rather than + // silently treating a paid generation as a standalone manual request. + identities.insert((agent_id.to_string(), run_id.to_string())); + 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) { @@ -1465,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", @@ -1533,6 +1690,108 @@ mod orphaned_external_generation_recovery_tests { assert!(stage_path.is_file()); } + #[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 d15eaadc1..119521634 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, @@ -1073,6 +1073,30 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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()) @@ -1884,7 +1911,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"); @@ -1897,6 +1924,7 @@ mod tests { } bytes.extend_from_slice(&buffer[..read]); } + String::from_utf8_lossy(&bytes).into_owned() } #[test] @@ -2109,4 +2137,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 d1d5f7a57..e6167542d 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 eb7b6ba5b..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, @@ -237,6 +378,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::*; @@ -321,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!( @@ -386,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/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index f8846b851..0f44a9fa4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -89,6 +89,410 @@ const PREVIEW_REQUEST_MAX_HEADER_BYTES: usize = 32 * 1024; const PREVIEW_REQUEST_MAX_HEADER_LINES: usize = 100; const PREVIEW_RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_millis(250); const PREVIEW_RESPONSE_DRAIN_MAX_BYTES: usize = 32 * 1024; +const PREVIEW_FIT_BRIDGE_PATH: &str = "/__genarrative/local-preview-fit.js"; +const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => { + const messageType = 'genarrative.local-preview-size.v1'; + const maxContentProbes = 512; + let ready = document.readyState === 'complete'; + let frame = 0; + let lastPublishedSize = ''; + const elementLayoutSamples = { width: new WeakMap(), height: new WeakMap() }; + const previousRootLayoutSample = {}; + const previousBodyLayoutSample = {}; + const rootIndependentLayout = { width: false, height: false }; + const contentResolutionState = { width: { axis: 'width' }, height: { axis: 'height' } }; + const resetLayoutCouplingSamples = (axis) => { + elementLayoutSamples[axis] = new WeakMap(); + previousRootLayoutSample[axis] = undefined; + previousBodyLayoutSample[axis] = undefined; + }; + const resolveViewportCoupling = ( + extent, viewport, previousExtent, previousViewport, previousCoupled, + ) => { + if (extent === undefined || previousExtent === undefined || previousViewport === undefined) { + return false; + } + const viewportDelta = viewport - previousViewport; + if (Math.abs(viewportDelta) < 1) { + return Math.abs(extent - previousExtent) < 1 ? previousCoupled : false; + } + const extentDelta = extent - previousExtent; + return Math.abs(extentDelta) >= 1 && extentDelta * viewportDelta > 0; + }; + const previewStyleExtent = (value) => { + const extent = Number.parseFloat(value || ''); + return Number.isFinite(extent) ? extent : undefined; + }; + const previewTypedStyleValue = (element, property) => { + try { + return element?.computedStyleMap?.().get(property)?.toString().trim().toLowerCase(); + } catch { + return undefined; + } + }; + const previewTypedMinimumCreatesExtent = (value) => { + if (value === undefined || value === 'auto' || value === 'none') return false; + const extent = Number.parseFloat(value); + return Number.isFinite(extent) ? extent > 0 : true; + }; + const rootAxisMirrorsBody = (axis, root, body, rootExtent, bodyExtent) => { + if (!root || !body) return false; + const sameExtent = Math.abs(rootExtent - bodyExtent) <= 1; + const sizeProperty = axis === 'width' ? 'width' : 'height'; + const minimumProperty = axis === 'width' ? 'min-width' : 'min-height'; + const typedSize = previewTypedStyleValue(root, sizeProperty); + const typedMinimum = previewTypedStyleValue(root, minimumProperty); + if (typedSize !== undefined || typedMinimum !== undefined) { + rootIndependentLayout[axis] = (typedSize !== undefined && typedSize !== 'auto') + || previewTypedMinimumCreatesExtent(typedMinimum); + } else if (!sameExtent) { + rootIndependentLayout[axis] = true; + } + return sameExtent && !rootIndependentLayout[axis]; + }; + const previewStyleUsesViewport = (value) => ( + (value || '').includes('%') + || /\b(?:v[wh]|vmin|vmax|sv[wh]|lv[wh]|dv[wh]|vi|vb|svi|svb|lvi|lvb|dvi|dvb)\b/i.test(value || '') + ); + const previewViewportStyleBaseline = (value, viewport) => { + const normalized = (value || '').trim().toLowerCase(); + const amount = Number.parseFloat(normalized); + if (!Number.isFinite(amount) || normalized.includes('calc(')) return undefined; + if (normalized.endsWith('%')) return viewport * amount / 100; + if (/^-?\d*\.?\d+(?:d?v[wh]|s?v[wh]|l?v[wh])$/.test(normalized)) { + return viewport * amount / 100; + } + return undefined; + }; + const sampleAxisViewportCoupling = ( + edge, sizeStyle, minimumStyle, viewport, previous, + ) => { + const size = previewStyleExtent(sizeStyle); + const minimum = previewStyleExtent(minimumStyle); + const sizeUsesViewport = previewStyleUsesViewport(sizeStyle); + const minimumUsesViewport = previewStyleUsesViewport(minimumStyle); + const edgeCoupled = resolveViewportCoupling( + edge, viewport, previous?.edge, previous?.viewport, previous?.edgeCoupled || false, + ); + const sizeCoupled = sizeUsesViewport || resolveViewportCoupling( + size, viewport, previous?.size, previous?.viewport, + previous?.sizeUsesViewport && !sizeUsesViewport ? false : previous?.sizeCoupled || false, + ); + const minimumCoupled = minimumUsesViewport || resolveViewportCoupling( + minimum, viewport, previous?.minimum, previous?.viewport, + previous?.minimumUsesViewport && !minimumUsesViewport + ? false + : previous?.minimumCoupled || false, + ); + const edgeBaseline = edgeCoupled + ? previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge + : undefined; + const sizeBaseline = sizeCoupled + ? previous?.sizeBaseline + ?? previous?.baseline + ?? previous?.size + ?? previewViewportStyleBaseline(sizeStyle, viewport) + ?? size + : undefined; + const minimumBaseline = minimumCoupled + ? previous?.minimumBaseline + ?? previous?.baseline + ?? previous?.minimum + ?? previewViewportStyleBaseline(minimumStyle, viewport) + ?? minimum + : undefined; + const baseline = Math.max( + 0, edgeBaseline || 0, sizeBaseline || 0, minimumBaseline || 0, + ) || undefined; + return { + edge, + size, + minimum, + viewport, + edgeCoupled, + sizeCoupled, + minimumCoupled, + edgeBaseline, + sizeBaseline, + minimumBaseline, + sizeUsesViewport, + minimumUsesViewport, + baseline, + coupled: edgeCoupled || sizeCoupled || minimumCoupled, + }; + }; + const sampleElementViewportCoupling = ( + element, widthExtent, heightExtent, viewportWidth, viewportHeight, + ) => { + const style = window.getComputedStyle(element); + const width = sampleAxisViewportCoupling( + widthExtent, style.width, style.minWidth, viewportWidth, + elementLayoutSamples.width.get(element), + ); + const height = sampleAxisViewportCoupling( + heightExtent, style.height, style.minHeight, viewportHeight, + elementLayoutSamples.height.get(element), + ); + elementLayoutSamples.width.set(element, width); + elementLayoutSamples.height.set(element, height); + return { + width: width.coupled, + height: height.coupled, + widthBaseline: width.baseline, + heightBaseline: height.baseline, + }; + }; + const sampleDocumentViewportCoupling = ( + previousLayoutSample, widthExtent, heightExtent, style, viewportWidth, viewportHeight, + ) => { + const width = sampleAxisViewportCoupling( + widthExtent, style?.width, style?.minWidth, viewportWidth, + previousLayoutSample.width, + ); + const height = sampleAxisViewportCoupling( + heightExtent, style?.height, style?.minHeight, viewportHeight, + previousLayoutSample.height, + ); + previousLayoutSample.width = width; + previousLayoutSample.height = height; + return { + width: width.coupled, + height: height.coupled, + widthBaseline: width.baseline, + heightBaseline: height.baseline, + }; + }; + const measureContentBounds = (body, viewportWidth, viewportHeight) => { + if (!body) return { width: 0, height: 0, truncated: false }; + const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); + let width = 0; + let height = 0; + let widthBaseline = 0; + let heightBaseline = 0; + let visited = 0; + let element = walker.nextNode(); + while (element && visited < maxContentProbes) { + visited += 1; + const rect = element.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) { + const widthExtent = rect.right + window.scrollX; + const heightExtent = rect.bottom + window.scrollY; + const coupling = sampleElementViewportCoupling( + element, widthExtent, heightExtent, viewportWidth, viewportHeight, + ); + if (!coupling.width) width = Math.max(width, widthExtent); + if (!coupling.height) height = Math.max(height, heightExtent); + widthBaseline = Math.max(widthBaseline, coupling.widthBaseline || 0); + heightBaseline = Math.max(heightBaseline, coupling.heightBaseline || 0); + } + element = walker.nextNode(); + } + return { + width: Math.ceil(Math.max(0, width)), + height: Math.ceil(Math.max(0, height)), + widthBaseline: Math.ceil(Math.max(0, widthBaseline)), + heightBaseline: Math.ceil(Math.max(0, heightBaseline)), + truncated: element !== null, + }; + }; + const resolveIntrinsicExtent = (bodyExtent, bodyCoupled, contentExtent, truncated) => ( + truncated || !bodyCoupled + ? Math.max(bodyExtent, contentExtent) + : contentExtent + ); + const resolveContentExtent = ( + state, viewportExtent, scrollExtent, intrinsicExtent, probedExtent, truncated, + coupledBaseline, + ) => { + const viewportChanged = state.viewport !== undefined + && Math.abs(viewportExtent - state.viewport) >= 1; + const hostAppliedPrevious = state.resolved !== undefined + && Math.abs(viewportExtent - state.resolved) <= 1; + const nativeReset = viewportChanged && !hostAppliedPrevious; + if (nativeReset) { + state.responsive = false; + state.responsiveFloor = undefined; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + resetLayoutCouplingSamples(state.axis); + } + const viewportDelta = state.viewport === undefined ? 0 : viewportExtent - state.viewport; + const scrollDelta = state.scroll === undefined ? 0 : scrollExtent - state.scroll; + const responsiveScroll = !nativeReset + && hostAppliedPrevious + && Math.abs(viewportDelta) >= 1 + && Math.abs(scrollDelta) >= 1 + && viewportDelta * scrollDelta > 0; + const candidateContradicted = !state.responsive + && (state.responsiveCandidateCount || 0) > 0 + && ( + (viewportChanged && hostAppliedPrevious && !responsiveScroll) + || ( + Math.abs(scrollDelta) >= 1 + && (state.responsiveCandidateDirection || 0) * scrollDelta < 0 + ) + ); + if (responsiveScroll && !state.responsive) { + const candidateFloor = truncated + ? Math.max(coupledBaseline || 0, state.resolved || 1) + : coupledBaseline || state.resolved; + if (truncated) { + state.responsive = true; + state.responsiveFloor = candidateFloor; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } else { + if ((state.responsiveCandidateCount || 0) === 0) { + state.responsiveCandidateDirection = Math.sign(viewportDelta); + } + state.responsiveCandidateCount = (state.responsiveCandidateCount || 0) + 1; + state.responsiveCandidateFloor = state.responsiveCandidateFloor || candidateFloor; + if (state.responsiveCandidateCount >= 2) { + state.responsive = true; + state.responsiveFloor = state.responsiveCandidateFloor; + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } + } + } else if (candidateContradicted) { + state.responsiveCandidateCount = 0; + state.responsiveCandidateFloor = undefined; + state.responsiveCandidateDirection = 0; + } + const resolved = state.responsive + ? Math.max(state.responsiveFloor || 1, truncated ? probedExtent : intrinsicExtent) + : (scrollExtent > viewportExtent ? scrollExtent : intrinsicExtent); + if (state.responsive && truncated) { + state.responsiveFloor = Math.max(state.responsiveFloor || 1, resolved); + } + state.viewport = viewportExtent; + state.scroll = scrollExtent; + state.resolved = resolved; + return resolved; + }; + const publish = () => { + frame = 0; + if (!ready || window.parent === window) return; + const root = document.documentElement; + const body = document.body; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const rootRect = root?.getBoundingClientRect(); + const rootStyle = root ? window.getComputedStyle(root) : null; + const bodyRect = body?.getBoundingClientRect(); + const bodyStyle = body ? window.getComputedStyle(body) : null; + const horizontalMargin = (Number.parseFloat(bodyStyle?.marginLeft || '0') || 0) + + (Number.parseFloat(bodyStyle?.marginRight || '0') || 0); + const verticalMargin = (Number.parseFloat(bodyStyle?.marginTop || '0') || 0) + + (Number.parseFloat(bodyStyle?.marginBottom || '0') || 0); + const intrinsicBodyWidth = Math.ceil(Math.max(body?.offsetWidth || 0, bodyRect?.width || 0) + horizontalMargin); + const intrinsicBodyHeight = Math.ceil(Math.max(body?.offsetHeight || 0, bodyRect?.height || 0) + verticalMargin); + const intrinsicRootWidth = Math.ceil(Math.max( + root?.offsetWidth || 0, rootRect?.width || 0, (rootRect?.right || 0) + window.scrollX, + )); + const intrinsicRootHeight = Math.ceil(Math.max( + root?.offsetHeight || 0, rootRect?.height || 0, (rootRect?.bottom || 0) + window.scrollY, + )); + const rootWidthMirrorsBody = rootAxisMirrorsBody( + 'width', root, body, intrinsicRootWidth, intrinsicBodyWidth, + ); + const rootHeightMirrorsBody = rootAxisMirrorsBody( + 'height', root, body, intrinsicRootHeight, intrinsicBodyHeight, + ); + const rootCoupling = sampleDocumentViewportCoupling( + previousRootLayoutSample, + intrinsicRootWidth, intrinsicRootHeight, rootStyle, viewportWidth, viewportHeight, + ); + const bodyCoupling = sampleDocumentViewportCoupling( + previousBodyLayoutSample, + intrinsicBodyWidth, intrinsicBodyHeight, bodyStyle, viewportWidth, viewportHeight, + ); + const contentBounds = measureContentBounds(body, viewportWidth, viewportHeight); + const coupledWidthBaseline = Math.max( + rootWidthMirrorsBody ? 0 : rootCoupling.widthBaseline || 0, + bodyCoupling.widthBaseline || 0, + contentBounds.widthBaseline || 0, + ); + const coupledHeightBaseline = Math.max( + rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0, + bodyCoupling.heightBaseline || 0, + contentBounds.heightBaseline || 0, + ); + const intrinsicBodyContentWidth = resolveIntrinsicExtent( + intrinsicBodyWidth, bodyCoupling.width, + contentBounds.width, contentBounds.truncated, + ); + const intrinsicBodyContentHeight = resolveIntrinsicExtent( + intrinsicBodyHeight, bodyCoupling.height, + contentBounds.height, contentBounds.truncated, + ); + const intrinsicContentWidth = Math.max( + coupledWidthBaseline, + resolveIntrinsicExtent( + intrinsicRootWidth, rootCoupling.width || rootWidthMirrorsBody, + intrinsicBodyContentWidth, contentBounds.truncated, + ), + ); + const intrinsicContentHeight = Math.max( + coupledHeightBaseline, + resolveIntrinsicExtent( + intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody, + intrinsicBodyContentHeight, contentBounds.truncated, + ), + ); + const scrollWidth = Math.ceil(Math.max(root?.scrollWidth || 0, body?.scrollWidth || 0)); + const scrollHeight = Math.ceil(Math.max(root?.scrollHeight || 0, body?.scrollHeight || 0)); + const size = { + contentWidth: Math.max( + 1, resolveContentExtent( + contentResolutionState.width, viewportWidth, scrollWidth, intrinsicContentWidth, + contentBounds.width, contentBounds.truncated, coupledWidthBaseline, + ), + ), + contentHeight: Math.max( + 1, resolveContentExtent( + contentResolutionState.height, viewportHeight, scrollHeight, intrinsicContentHeight, + contentBounds.height, contentBounds.truncated, coupledHeightBaseline, + ), + ), + viewportWidth, + viewportHeight, + }; + const sizeKey = `${size.contentWidth}:${size.contentHeight}:${size.viewportWidth}:${size.viewportHeight}`; + if (sizeKey === lastPublishedSize) return; + lastPublishedSize = sizeKey; + window.parent.postMessage({ type: messageType, ...size }, '*'); + }; + const schedule = () => { + if (frame === 0) frame = window.requestAnimationFrame(publish); + }; + const activate = () => { + ready = true; + const style = document.createElement('style'); + style.dataset.genarrativePreviewFit = 'true'; + style.textContent = 'html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none}'; + (document.head || document.documentElement).appendChild(style); + schedule(); + if (typeof ResizeObserver === 'function') { + const resizeObserver = new ResizeObserver(schedule); + resizeObserver.observe(document.documentElement); + if (document.body) resizeObserver.observe(document.body); + } + document.addEventListener('load', schedule, true); + document.fonts?.ready.then(schedule); + window.addEventListener('resize', schedule, { passive: true }); + window.setInterval(() => { + if (!document.hidden) schedule(); + }, 500); + }; + if (ready) activate(); + else window.addEventListener('load', activate, { once: true }); +})();"#; +const PREVIEW_FIT_BRIDGE_TAG: &str = + r#""#; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PreviewListenerAcceptDisposition { @@ -560,6 +964,20 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) ); } + if url_path.split('?').next() == Some(PREVIEW_FIT_BRIDGE_PATH) { + let body = if is_head { + Vec::new() + } else { + PREVIEW_FIT_BRIDGE_SCRIPT.as_bytes().to_vec() + }; + return http_response( + "200 OK", + "text/javascript; charset=utf-8", + &body, + PREVIEW_FIT_BRIDGE_SCRIPT.len(), + ); + } + let file_path = match resolve_preview_path(root, url_path) { Ok(path) => path, Err(_) => { @@ -574,11 +992,419 @@ pub(crate) fn build_preview_response(root: &Path, method: &str, url_path: &str) return http_response("404 Not Found", "text/plain", body, b"not found".len()); } }; + let body = if content_type(&file_path).starts_with("text/html") { + inject_preview_fit_bridge(body) + } else { + body + }; let content_length = body.len(); let body = if is_head { Vec::new() } else { body }; http_response("200 OK", content_type(&file_path), &body, content_length) } +fn inject_preview_fit_bridge(body: Vec) -> Vec { + let html = match String::from_utf8(body) { + Ok(html) => html, + Err(error) => return error.into_bytes(), + }; + let scan = scan_preview_fit_bridge_html(&html); + if scan.has_bridge_script { + return html.into_bytes(); + } + let insertion = scan.body_end.or(scan.html_end).unwrap_or(scan.safe_append); + let mut output = String::with_capacity(html.len() + PREVIEW_FIT_BRIDGE_TAG.len()); + output.push_str(&html[..insertion]); + output.push_str(PREVIEW_FIT_BRIDGE_TAG); + output.push_str(&html[insertion..]); + output.into_bytes() +} + +#[derive(Clone, Copy, Debug)] +struct PreviewFitBridgeHtmlScan { + has_bridge_script: bool, + body_end: Option, + html_end: Option, + safe_append: usize, +} + +#[derive(Clone, Copy, Debug)] +struct PreviewHtmlTag { + start: usize, + name_start: usize, + name_end: usize, + end: usize, + closing: bool, +} + +fn scan_preview_fit_bridge_html(html: &str) -> PreviewFitBridgeHtmlScan { + let bytes = html.as_bytes(); + let mut scan = PreviewFitBridgeHtmlScan { + has_bridge_script: false, + body_end: None, + html_end: None, + safe_append: bytes.len(), + }; + let mut cursor = 0; + let mut template_depth = 0_u32; + let mut outer_template_start = None; + + while let Some(relative_start) = bytes[cursor..].iter().position(|byte| *byte == b'<') { + let tag_start = cursor + relative_start; + if bytes[tag_start..].starts_with(b"") { + return Some(cursor + 3); + } + if bytes.get(cursor..cursor + 4) == Some(b"--!>") { + return Some(cursor + 4); + } + cursor += 1; + } + None +} + +fn preview_html_doctype_at(bytes: &[u8], start: usize) -> bool { + let name_start = start + 2; + let name_end = name_start + b"doctype".len(); + bytes + .get(name_start..name_end) + .is_some_and(|name| name.eq_ignore_ascii_case(b"doctype")) +} + +fn preview_html_doctype_end(bytes: &[u8], start: usize) -> Option { + bytes + .get(start..)? + .iter() + .position(|byte| *byte == b'>') + .map(|relative_end| start + relative_end + 1) +} + +fn preview_html_tag_at(bytes: &[u8], start: usize) -> Option { + if bytes.get(start) != Some(&b'<') { + return None; + } + let mut cursor = start + 1; + let closing = bytes.get(cursor) == Some(&b'/'); + if closing { + cursor += 1; + } + let name_start = cursor; + if !bytes.get(cursor).is_some_and(u8::is_ascii_alphabetic) { + return None; + } + cursor += 1; + while bytes + .get(cursor) + .is_some_and(|byte| !preview_html_space(*byte) && !matches!(*byte, b'/' | b'>')) + { + cursor += 1; + } + let name_end = cursor; + let end = preview_html_markup_end(bytes, cursor)?; + Some(PreviewHtmlTag { + start, + name_start, + name_end, + end, + closing, + }) +} + +fn preview_html_markup_end(bytes: &[u8], mut cursor: usize) -> Option { + let mut quote = None; + while let Some(byte) = bytes.get(cursor).copied() { + if let Some(expected) = quote { + if byte == expected { + quote = None; + } + } else if matches!(byte, b'\'' | b'"') { + quote = Some(byte); + } else if byte == b'>' { + return Some(cursor + 1); + } + cursor += 1; + } + None +} + +fn preview_html_tag_name_is(bytes: &[u8], tag: PreviewHtmlTag, expected: &[u8]) -> bool { + bytes[tag.name_start..tag.name_end].eq_ignore_ascii_case(expected) +} + +fn preview_html_raw_text_name(bytes: &[u8], tag: PreviewHtmlTag) -> Option<&'static [u8]> { + [ + b"script".as_slice(), + b"style".as_slice(), + b"title".as_slice(), + b"textarea".as_slice(), + b"xmp".as_slice(), + b"iframe".as_slice(), + b"noembed".as_slice(), + b"noframes".as_slice(), + b"noscript".as_slice(), + ] + .into_iter() + .find(|name| preview_html_tag_name_is(bytes, tag, name)) +} + +fn preview_html_raw_text_close( + bytes: &[u8], + mut cursor: usize, + name: &[u8], +) -> Option<(usize, usize)> { + while cursor < bytes.len() { + let relative_start = bytes[cursor..].iter().position(|byte| *byte == b'<')?; + let close_start = cursor + relative_start; + let name_start = close_start + 2; + let name_end = name_start.checked_add(name.len())?; + if bytes.get(close_start + 1) == Some(&b'/') + && bytes + .get(name_start..name_end) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(name)) + && bytes + .get(name_end) + .is_some_and(|byte| preview_html_space(*byte) || matches!(*byte, b'/' | b'>')) + { + let end = preview_html_markup_end(bytes, name_end)?; + return Some((close_start, end)); + } + cursor = close_start + 1; + } + None +} + +fn preview_html_tag_has_bridge_src(bytes: &[u8], tag: PreviewHtmlTag) -> bool { + let mut cursor = tag.name_end; + let limit = tag.end.saturating_sub(1); + let mut first_src = None; + let mut has_type = false; + let mut has_nomodule = false; + while cursor < limit { + while cursor < limit && preview_html_space(bytes[cursor]) { + cursor += 1; + } + if cursor >= limit || bytes[cursor] == b'/' { + break; + } + let attribute_start = cursor; + while cursor < limit + && !preview_html_space(bytes[cursor]) + && !matches!(bytes[cursor], b'=' | b'/' | b'>') + { + cursor += 1; + } + if cursor == attribute_start { + cursor += 1; + continue; + } + let attribute_end = cursor; + let is_src = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"src"); + let is_type = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"type"); + let is_nomodule = bytes[attribute_start..attribute_end].eq_ignore_ascii_case(b"nomodule"); + if is_type { + has_type = true; + } + if is_nomodule { + has_nomodule = true; + } + while cursor < limit && preview_html_space(bytes[cursor]) { + cursor += 1; + } + if cursor >= limit || bytes[cursor] != b'=' { + if is_src && first_src.is_none() { + first_src = Some(false); + } + continue; + } + cursor += 1; + while cursor < limit && preview_html_space(bytes[cursor]) { + cursor += 1; + } + let (value_start, value_end) = if cursor < limit && matches!(bytes[cursor], b'\'' | b'"') { + let quote = bytes[cursor]; + cursor += 1; + let value_start = cursor; + while cursor < limit && bytes[cursor] != quote { + cursor += 1; + } + let value_end = cursor; + if cursor < limit { + cursor += 1; + } + (value_start, value_end) + } else { + let value_start = cursor; + while cursor < limit && !preview_html_space(bytes[cursor]) && bytes[cursor] != b'>' { + cursor += 1; + } + (value_start, cursor) + }; + if is_src && first_src.is_none() { + first_src = Some(bytes[value_start..value_end] == *PREVIEW_FIT_BRIDGE_PATH.as_bytes()); + } + } + first_src == Some(true) && !has_type && !has_nomodule +} + +fn preview_html_space(byte: u8) -> bool { + matches!(byte, b'\t' | b'\n' | 0x0c | b'\r' | b' ') +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|candidate| candidate == needle) +} + pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result { let path = url_path.split('?').next().unwrap_or("/"); let decoded = percent_decode_path(path).ok_or_else(|| "预览路径非法".to_string())?; 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/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 f36ceb74f..44ddd69b6 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, @@ -312,6 +347,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 +382,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 +401,44 @@ 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) + }) +} + +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, } @@ -371,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(), ), } @@ -414,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)] @@ -437,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, }); @@ -446,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() @@ -454,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, @@ -474,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 @@ -672,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, @@ -745,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), @@ -1057,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"), @@ -1166,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, @@ -1186,25 +1409,10 @@ 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) } -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 +1431,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 +1440,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 +1467,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}")), @@ -1349,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") @@ -1465,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 @@ -1476,6 +1677,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) @@ -1487,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, @@ -1494,10 +1703,11 @@ 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, + "source": "game-creator-account-binding", + "localAssetId": material.local_asset_id, + "sourceSha256": material.sha256, }, })), api_mode, @@ -1519,6 +1729,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 +1737,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 +1802,62 @@ async fn ensure_reference_states( ledger.reference_states.len() - 1 } }; + + 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, + &principal, + &project_binding.remote_project_id, + source_identity, + ) + .map_err(|_| ReferencePreparationError::Internal)? + { + if install_registered_reference_binding(root, ledger, index, binding)? { + 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)?; + 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()); @@ -1575,21 +1884,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, @@ -1597,11 +1912,41 @@ async fn ensure_reference_states( &material, &object_key, &asset_object_id, + resource_binding_key + .as_deref() + .ok_or(ReferencePreparationError::Internal)?, ) - .await? + .await?; + registered_reference } else { - object_key + object_key.clone() }; + if let Some(source_identity) = source_identity.as_ref() { + 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)?; + 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); ledger.reference_states[index].upload_bucket = None; @@ -1611,15 +1956,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 +1982,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, @@ -1642,11 +1997,41 @@ async fn ensure_reference_states( &material, &ticket.object_key, &asset_object_id, + resource_binding_key + .as_deref() + .ok_or(ReferencePreparationError::Internal)?, ) - .await? + .await?; + registered_reference } else { - ticket.object_key + ticket.object_key.clone() }; + if let Some(source_identity) = source_identity.as_ref() { + 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)?; + 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); ledger.reference_states[index].upload_bucket = None; @@ -1681,9 +2066,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( @@ -1964,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) { @@ -2045,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( @@ -2052,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( @@ -2088,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, @@ -2106,12 +2498,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 +2525,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" { @@ -2181,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); @@ -2206,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 = @@ -2215,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 @@ -2250,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 @@ -2298,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, @@ -2330,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, @@ -2546,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, @@ -2590,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()), }; @@ -2669,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 @@ -2679,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; @@ -2741,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 @@ -2754,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() @@ -2770,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 @@ -2800,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, @@ -2816,6 +3246,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()); @@ -3056,6 +3511,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), @@ -3095,6 +3551,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(); @@ -3174,7 +3640,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(); @@ -3343,7 +3808,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(); @@ -3463,6 +3928,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 @@ -3505,7 +3979,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] @@ -3557,6 +4031,539 @@ 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"; + 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, &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(), + 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, &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(), + 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"; @@ -3567,6 +4574,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 @@ -3824,6 +4840,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"; @@ -4105,33 +5401,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(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - 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(); @@ -4182,6 +5451,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, @@ -4376,10 +5646,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; @@ -4422,8 +5698,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( @@ -4445,10 +5724,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())); @@ -4459,7 +5740,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") @@ -4468,13 +5749,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!( @@ -4558,22 +5837,21 @@ mod tests { "fresh-login-token", &base_url, ); - let recovered = recover_asset_canvas_generations_at( + let platform_session = crate::platform_session::current_platform_session() + .expect("login recovery platform session"); + write_test_external_editor_project_binding( 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"); - server.join().expect("join login recovery server"); - assert_eq!( - recovered.result.resumed_generation_ids, - vec![input.generation_id.clone()] + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "login-recovery-project", + "login-recovery-folder", ); + 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"); let resumed = read_generation_ledger(directory.path(), &input.generation_id) .expect("read resumed login ledger") .expect("resumed login ledger exists"); @@ -4688,6 +5966,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, @@ -4711,17 +6000,264 @@ 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()); } + #[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 new file mode 100644 index 000000000..391bb6c10 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -0,0 +1,1965 @@ +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; +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. +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(()) + } +} + +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 { + 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) payload_fingerprint_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, +} + +// 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 { + 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) payload_fingerprint_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(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> { + 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, +} + +// 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 { + 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", + ) +} + +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, + 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 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(), + remote_asset_folder_id: remote_asset_folder_id.to_string(), + 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) +} + +#[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 mut 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, + )?, + payload_fingerprint_sha256: String::new(), + 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, + }; + binding.payload_fingerprint_sha256 = + external_editor_resource_binding_payload_fingerprint_sha256(&binding)?; + 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 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(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 + || binding.principal != *principal + { + return Err("External Editor 账号项目 binding 与当前身份不匹配".to_string()); + } + if migrated_from_v1 { + write_external_editor_project_binding_at(root, &binding)?; + } + 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 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(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 + || binding.principal_key_sha256 != principal_key + || binding.remote_project_id != remote_project_id + || binding.source != *source + { + 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, +) -> 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()); + } + 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(()) +} + +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()); + } + 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(()) +} + +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") + } + + 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); + 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 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)); + 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 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"); + 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 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); + 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/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 35cbc0294..2e2a4cdcd 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}")) } @@ -291,6 +324,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 { @@ -348,6 +386,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, @@ -361,6 +401,18 @@ struct ResourceEditLedger { endpoint: Option, request_body_json: Option, 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, #[serde(default)] @@ -493,6 +545,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") } @@ -662,63 +719,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 { @@ -848,6 +848,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, @@ -861,6 +895,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), @@ -880,46 +916,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, @@ -963,14 +1049,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 { @@ -1511,44 +1590,55 @@ 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 { + 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!( - "{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, - &file_name, + binding_key_sha256, bytes.len(), )) .send() .await - .map_err(|_| "创建源资源上传凭证失败".to_string())?; + .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,7 +1647,10 @@ async fn request_resource_edit_upload_ticket( let payload = response .json::() .await - .map_err(|_| "解析源资源上传凭证失败".to_string())?; + .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")) @@ -1587,28 +1680,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, @@ -1621,67 +1718,80 @@ fn resource_edit_upload_ticket_payload( async fn upload_resource_edit_source( ticket: &ResourceEditUploadTicket, source: &ResourceEditSourceSnapshot, - api_base_url: &str, -) -> Result<(), String> { + 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()); } - 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) .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())?; + .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, - api_base_url: &str, - api_key: &str, - ticket: &ResourceEditUploadTicket, + access: &ExternalEditorBindingAccess<'_>, + bucket: &str, + object_key: &str, source: &ResourceEditSourceSnapshot, -) -> Result { +) -> Result, String> { let bytes = 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, + "bucket": bucket, + "objectKey": object_key, "contentType": source.media_type, "contentLength": bytes.len(), "contentHash": source.source_sha256, @@ -1690,14 +1800,30 @@ async fn confirm_resource_edit_source( })) .send() .await - .map_err(|_| "确认源资源上传失败".to_string())?; + .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() @@ -1706,70 +1832,71 @@ async fn confirm_resource_edit_source( let payload = response .json::() .await - .map_err(|_| "解析源资源确认响应失败".to_string())?; + .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( 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, - input: &DeriveLocalProjectResourceInput, + access: &ExternalEditorBindingAccess<'_>, source: &ResourceEditSourceSnapshot, - ticket: &ResourceEditUploadTicket, + local_asset_id: &str, + binding_key: &str, + remote_project_id: &str, + object_key: &str, asset_object_id: &str, -) -> Result { +) -> Result, 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()) + .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(), @@ -1777,19 +1904,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())?; + .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() @@ -1798,84 +1942,268 @@ async fn register_resource_edit_source_image( let payload = response .json::() .await - .map_err(|_| "解析源图片项目资源响应失败".to_string())?; + .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); - 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(|| "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( 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, + )?; + 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, + 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 + | LocalProjectResourceEditKind::CharacterAnimation => 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, + )?; + } + + 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, use only locally journaled stages for this identity. + if ledger.source_stable_reference.take().is_some() { write_resource_edit_ledger(root, ledger)?; } - if let Some(asset) = source.source_asset.as_ref() { - if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - if let Some(reference) = asset - .source - .resource_id + + 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() - .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)? + .is_some_and(|value| value != submitted.value.object_key.as_str()) { - ledger.source_stable_reference = Some(reference.clone()); - write_resource_edit_ledger(root, ledger)?; - return Ok(reference); + 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 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 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 = - 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()); + 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 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(decoded.width()), + Some(decoded.height()), + ) + } else { + (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(), + &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()); 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 { @@ -2092,6 +2420,11 @@ fn resource_edit_result_has_download(payload: &serde_json::Value) -> bool { || json_string_field(data, "previewVideoPath").is_some() } +struct SubmittedResourceEditRemote { + payload: serde_json::Value, + post_response_session_error: Option, +} + fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { matches!( endpoint, @@ -2106,10 +2439,9 @@ 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 { +) -> Result { let endpoint = ledger .endpoint .as_deref() @@ -2137,29 +2469,41 @@ 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())?; + .map_err(|error| format!("result-unknown: 资源编辑请求已发出但未取得确定响应:{error}"))?; + 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!( @@ -2171,6 +2515,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() @@ -2187,6 +2534,9 @@ async fn submit_resource_edit_remote( return Err("result-unknown: 资源编辑响应无法解析".to_string()); } }; + 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, @@ -2195,14 +2545,34 @@ 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( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ledger: &mut ResourceEditLedger, ) -> Result { let operation_id = ledger @@ -2210,8 +2580,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; @@ -2219,13 +2590,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()); } @@ -2246,6 +2620,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") => { @@ -2509,9 +2884,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, }), @@ -2520,15 +2902,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, @@ -2549,13 +2947,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 => { @@ -2637,19 +3057,18 @@ 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(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(_) => { return Err("service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原资源编辑 operation".to_string()) @@ -2681,13 +3100,11 @@ async fn prepare_remote_resource_edit( } } 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::CharacterAnimation @@ -2696,53 +3113,34 @@ async fn prepare_remote_resource_edit( { 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)?; } if ledger.endpoint.is_none() || ledger.request_body_json.is_none() { - let canvas_context = if matches!( + let canvas_context = if let Some(prepared) = prepared_source.as_ref() { + Some(prepared.canvas_context.clone()) + } else if matches!( input.edit_kind, LocalProjectResourceEditKind::CharacterAnimation | LocalProjectResourceEditKind::Video | LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic ) { - Some( - prepare_external_canvas_generation_context( - root, - &client, - &api_base_url, - &api_key, - ) - .await?, - ) + Some(prepare_external_canvas_generation_context(root, &client, access).await?) } else { None }; @@ -2751,9 +3149,26 @@ 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()), canvas_context.as_ref(), )?; + 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) @@ -2761,18 +3176,9 @@ 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 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)?; + 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 }; let identity = extract_resource_edit_remote_identity(&generated, input.edit_kind)?; @@ -2810,17 +3216,17 @@ async fn prepare_remote_resource_edit( }) } }; - let download = resolve_canvas_resource_download_with_limit( + access.validate_frozen_session()?; + let download = resolve_canvas_resource_download_with_access( &client, - &api_base_url, - &api_key, + access, &download_source, media_read_limit(&input.edit_kind), ) .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)?; @@ -3829,6 +4235,98 @@ 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, + 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, @@ -4126,6 +4624,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, @@ -4164,6 +4674,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, @@ -4193,6 +4706,32 @@ pub(crate) fn local_project_resource_edit_warnings_at( Ok((ledger.remote_warnings, ledger.remote_slice_warnings)) } +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 { @@ -4216,9 +4755,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()) @@ -4248,10 +4793,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 @@ -4262,6 +4826,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 @@ -4279,8 +4844,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 @@ -4320,16 +4886,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, @@ -4451,6 +5053,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 @@ -4459,10 +5066,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()); @@ -4471,7 +5090,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(), @@ -4501,6 +5120,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, @@ -4510,6 +5130,12 @@ pub(crate) async fn derive_local_project_resource_at( endpoint: None, 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_legacy_public_path: None, @@ -4531,7 +5157,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 } }; @@ -4553,12 +5191,49 @@ 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(match new_remote_credentials { + Some(credentials) => credentials, + None => resolve_canvas_sync_api_credentials(None, None)?, + }) } else { None }; - bind_resource_edit_platform_owner(root, &mut ledger, frozen_platform_session.as_ref())?; + 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()?; + 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)?; @@ -4587,6 +5262,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, @@ -4594,7 +5277,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 { @@ -4616,15 +5302,36 @@ 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_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()) + { + 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; @@ -4635,6 +5342,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(), @@ -4731,6 +5439,157 @@ 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) + .write_to(&mut output, image::ImageFormat::Png) + .expect("encode resource editor PNG"); + output.into_inner() + } + fn input( root: &Path, operation_id: String, @@ -4792,6 +5651,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, @@ -4801,6 +5664,12 @@ mod tests { endpoint: None, 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_legacy_public_path: None, @@ -5041,7 +5910,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"], @@ -5049,10 +5919,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); @@ -5075,59 +5945,807 @@ 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"); + 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 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}"); + } + } + }); + + 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(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, "派生视频二次编辑测试") + init_local_game_project_at(root, PROJECT_ID, "并发源资源 binding 测试") .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 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("source asset"); - let mut prior_input = input( + .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] + 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"); + 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, - format!("asset:{}", source_asset.id), + "local-asset:accepted-switch-video".to_string(), ); - 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"); + 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()), + generation_mode: request.generation_mode, + source_width: None, + source_height: None, + 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 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( + let error = submit_and_persist_resource_edit_remote( root, &reqwest::Client::new(), - "http://127.0.0.1:9", - "unused-key", - ¤t_input, - &source, - &mut current, + &access, + &mut ledger, ) .await - .expect("reuse committed object key without upload"); - - assert_eq!(reference, "generated/stable-video.mp4"); - assert_ne!(reference, "asset-object-must-not-be-used"); + .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!( - current.source_stable_reference.as_deref(), - Some(reference.as_str()) + 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") ); } @@ -5173,11 +6791,13 @@ 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"); + .err() + .expect("authentication status must fail"); server.join().expect("join auth fixture"); let expected_code = if status.starts_with("401") { "authentication-required" @@ -5206,8 +6826,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"); @@ -5238,8 +6859,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"); @@ -5429,14 +7051,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"); @@ -5444,6 +7062,376 @@ 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, + generation_mode: request.generation_mode, + source_width: None, + source_height: 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, + generation_mode: request.generation_mode, + source_width: None, + source_height: 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, + generation_mode: request.generation_mode, + source_width: None, + source_height: 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"); @@ -5508,17 +7496,14 @@ 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"); + .err() + .expect("submission status must fail"); server.join().expect("join submission fixture"); assert!(error.contains(expected_error), "{status}: {error}"); @@ -5635,17 +7620,14 @@ 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"); + .err() + .expect("unproven accepted response must fail closed"); server.join().expect("join accepted fixture"); assert!(error.contains(expected_error), "{damage}: {error}"); @@ -5784,14 +7766,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"); @@ -5819,18 +7796,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 { @@ -5893,6 +7867,224 @@ 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()), + generation_mode: request.generation_mode, + source_width: None, + source_height: None, + 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, + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_width: None, + source_height: 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()), + generation_mode: local_request.generation_mode, + source_width: None, + source_height: None, + 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"); @@ -6202,7 +8394,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..9 { + for request_index in 0..11 { let mut stream = accept_resource_editor_fixture_connection( &listener, "External video fixture", @@ -6214,23 +8406,33 @@ 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 == "GET /api/external/v1/editor/projects HTTP/1.1" { + if request_line.starts_with("GET /api/external/v1/editor/projects ") { write_json( &mut stream, "200 OK", - serde_json::json!({"data": {"projects": [{ - "projectId": "canvas-project-video", - "title": "External 视频编辑测试" - }]}}), + serde_json::json!({"data": {"projects": []}}), ); - } else if request_line == "GET /api/external/v1/editor/assets/library HTTP/1.1" { + } else if request_line.starts_with("POST /api/external/v1/editor/projects ") { write_json( &mut stream, "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "canvas-folder-video", - "label": "External 视频编辑测试" - }]}}}), + 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 ") @@ -6273,8 +8475,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("canvas-project-video")); - assert!(request.contains("canvas-folder-video")); + assert!(request.contains("external-video-project")); assert!(request.contains("canvasCompletion")); write_json( &mut stream, @@ -6344,7 +8545,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(), 9); + assert_eq!(requests.len(), 11); assert!(requests.iter().all(|request| { !request.starts_with("POST /api/editor/") && !request.starts_with("POST /api/assets/") @@ -6363,7 +8564,7 @@ mod tests { .expect("completed video ledger"); assert_eq!( persisted.remote_canvas_project_id.as_deref(), - Some("canvas-project-video") + Some("external-video-project") ); assert_ne!(derivative.id, source_asset.id); assert_eq!( @@ -6685,6 +8886,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"); @@ -7679,6 +10103,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-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..e5a2a8f99 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"] @@ -3440,6 +3461,28 @@ fn local_preview_server_serves_game_index() { assert!(response.contains("200 OK"), "{response}"); assert!(response.contains("还没有生成游戏")); + assert!( + response.contains("/__genarrative/local-preview-fit.js"), + "{response}" + ); + + let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); + stream + .write_all(b"GET /__genarrative/local-preview-fit.js HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n") + .expect("fit bridge request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("fit bridge response"); + assert!(response.contains("200 OK"), "{response}"); + assert!( + response.contains("Content-Type: text/javascript; charset=utf-8"), + "{response}" + ); + assert!( + response.contains("genarrative.local-preview-size.v1"), + "{response}" + ); let mut stream = TcpStream::connect(("127.0.0.1", preview.port)).expect("preview connect"); stream @@ -3458,6 +3501,929 @@ fn local_preview_server_serves_game_index() { fs::remove_dir_all(root).ok(); } +fn local_preview_html_response_body(root: &Path, html: &str) -> String { + fs::create_dir_all(root.join("game")).expect("game dir"); + fs::write(root.join("game/index.html"), html).expect("write preview html"); + let response = build_preview_response(root, "GET", "/"); + let header_end = response + .windows(b"\r\n\r\n".len()) + .position(|window| window == b"\r\n\r\n") + .expect("complete preview response headers") + + b"\r\n\r\n".len(); + String::from_utf8(response[header_end..].to_vec()).expect("UTF-8 preview HTML") +} + +#[test] +fn local_preview_fit_bridge_only_publishes_changed_layout_measurements() { + let root = unique_project_path(); + let response = String::from_utf8(build_preview_response( + &root, + "GET", + "/__genarrative/local-preview-fit.js", + )) + .expect("fit bridge response"); + + assert!( + response.contains("let lastPublishedSize = ''"), + "{response}" + ); + assert!( + response.contains("if (sizeKey === lastPublishedSize) return"), + "{response}" + ); + assert!( + response.contains("new ResizeObserver(schedule)"), + "{response}" + ); + assert!( + response.contains("document.addEventListener('load', schedule, true)"), + "{response}" + ); + assert!( + response.contains("document.fonts?.ready.then(schedule)"), + "{response}" + ); + assert!( + response.contains("const maxContentProbes = 512"), + "{response}" + ); + assert!( + response.contains("if (!document.hidden) schedule()"), + "{response}" + ); + assert!(response.contains("}, 500)"), "{response}"); + assert!(response.contains("truncated || !bodyCoupled"), "{response}"); + assert!( + response.contains("extentDelta * viewportDelta > 0"), + "{response}" + ); + assert!( + response.contains("previous?.edgeBaseline ?? previous?.baseline ?? previous?.edge ?? edge"), + "{response}" + ); + assert!( + response.contains("previous?.sizeBaseline\n ?? previous?.baseline"), + "{response}" + ); + assert!( + response.contains("previous?.minimumBaseline\n ?? previous?.baseline"), + "{response}" + ); + assert!( + response.contains("element, widthExtent, heightExtent, viewportWidth, viewportHeight"), + "{response}" + ); + assert!( + response.contains("heightExtent, style.height, style.minHeight, viewportHeight"), + "{response}" + ); + assert!( + response.contains("truncated: element !== null"), + "{response}" + ); + assert!( + response.contains("intrinsicBodyWidth, bodyCoupling.width,"), + "{response}" + ); + assert!( + response.contains("const rootRect = root?.getBoundingClientRect()"), + "{response}" + ); + assert!( + response.contains("intrinsicRootHeight, rootCoupling.height || rootHeightMirrorsBody,"), + "{response}" + ); + assert!(response.contains("rootHeightMirrorsBody"), "{response}"); + assert!(response.contains("rootIndependentLayout"), "{response}"); + assert!(response.contains("computedStyleMap?.()"), "{response}"); + assert!( + response.contains("rootHeightMirrorsBody ? 0 : rootCoupling.heightBaseline || 0"), + "{response}" + ); + assert!( + response.contains("contentResolutionState.width, viewportWidth, scrollWidth"), + "{response}" + ); + assert!( + response.contains("contentResolutionState.height, viewportHeight, scrollHeight"), + "{response}" + ); + assert!( + response.contains("? Math.max(coupledBaseline || 0, state.resolved || 1)"), + "{response}" + ); + assert!( + response.contains("resetLayoutCouplingSamples(state.axis)"), + "{response}" + ); + assert!( + response.contains("state.responsiveCandidateCount >= 2"), + "{response}" + ); + assert!( + response.contains("} else if (candidateContradicted)"), + "{response}" + ); + assert!( + response.contains("viewportChanged && !hostAppliedPrevious"), + "{response}" + ); + assert!(!response.contains("MutationObserver"), "{response}"); + assert!(!response.contains("childList: true"), "{response}"); + + let resolve_intrinsic_extent = + |body: u32, body_coupled: bool, content: u32, truncated: bool| { + if truncated || !body_coupled { + body.max(content) + } else { + content + } + }; + let resolve_document_extent = |root: u32, + root_coupled: bool, + root_mirrors_body: bool, + body: u32, + body_coupled: bool, + content: u32, + truncated: bool| { + let body_and_content = resolve_intrinsic_extent(body, body_coupled, content, truncated); + resolve_intrinsic_extent( + root, + root_coupled || root_mirrors_body, + body_and_content, + truncated, + ) + }; + let resolve_document_baseline = + |root_baseline: i32, root_mirrors_body: bool, body_baseline: i32, content_baseline: i32| { + (if root_mirrors_body { 0 } else { root_baseline }) + .max(body_baseline) + .max(content_baseline) + }; + let root_mirrors_body = |same_extent: bool, + typed_size: Option<&str>, + typed_minimum: Option<&str>, + independent_latch: &mut bool| { + let typed_available = typed_size.is_some() || typed_minimum.is_some(); + if typed_available { + let size_is_independent = typed_size.is_some_and(|value| value != "auto"); + let minimum_is_independent = typed_minimum + .is_some_and(|value| value != "auto" && value != "0px" && value != "0%"); + *independent_latch = size_is_independent || minimum_is_independent; + } else if !same_extent { + *independent_latch = true; + } + same_extent && !*independent_latch + }; + let resolve_viewport_coupling = |extent: i32, + viewport: i32, + previous_extent: i32, + previous_viewport: i32, + previous_coupled: bool| { + let viewport_delta = viewport - previous_viewport; + if viewport_delta.abs() < 1 { + if (extent - previous_extent).abs() < 1 { + previous_coupled + } else { + false + } + } else { + let extent_delta = extent - previous_extent; + extent_delta.abs() >= 1 && extent_delta * viewport_delta > 0 + } + }; + let fixed_body_edge_coupled = resolve_viewport_coupling(2_000, 2_000, 2_000, 700, false); + assert!(!fixed_body_edge_coupled); + let viewport_min_height_coupled = resolve_viewport_coupling(2_000, 2_000, 700, 700, false); + assert!(viewport_min_height_coupled); + let bottom_edge_coupled = resolve_viewport_coupling(2_000, 2_000, 700, 700, false); + assert!(bottom_edge_coupled); + assert!(resolve_viewport_coupling(2_000, 2_000, 2_000, 2_000, true)); + assert!(!resolve_viewport_coupling(1_000, 2_000, 2_000, 2_000, true)); + assert_eq!(resolve_intrinsic_extent(2_000, true, 1_000, false), 1_000); + assert_eq!(resolve_intrinsic_extent(2_000, true, 0, false), 0); + assert_eq!(resolve_intrinsic_extent(2_000, true, 0, false).max(1), 1); + assert_eq!(resolve_intrinsic_extent(2_000, false, 1_000, false), 2_000); + assert_eq!(resolve_intrinsic_extent(2_000, false, 2_000, false), 2_000); + assert_eq!(resolve_intrinsic_extent(10_000, true, 5_120, true), 10_000); + assert_eq!( + resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false), + 2_000, + "a fixed documentElement extent must survive body/descendant aggregation" + ); + assert_eq!( + resolve_document_extent(2_000, false, true, 2_000, true, 1_000, false), + 1_000, + "a documentElement extent mirrored from a responsive body must not become a sticky floor" + ); + assert_eq!( + resolve_document_extent(2_000, false, true, 2_000, false, 1_000, false), + 2_000, + "equal fixed root/body extents must be preserved by the non-coupled body" + ); + let mut typed_root_independent = false; + assert!(!root_mirrors_body( + true, + Some("2000px"), + Some("0px"), + &mut typed_root_independent, + )); + assert_eq!( + resolve_document_extent(2_000, false, false, 2_000, true, 1_000, false), + 2_000, + "a fixed root equal to a coupled body must remain independently authoritative" + ); + let mut typed_root_auto = false; + assert!(root_mirrors_body( + true, + Some("auto"), + Some("0px"), + &mut typed_root_auto, + )); + let mut fallback_root_independent = false; + assert!(root_mirrors_body( + true, + None, + None, + &mut fallback_root_independent, + )); + assert!(!root_mirrors_body( + false, + None, + None, + &mut fallback_root_independent, + )); + assert!(!root_mirrors_body( + true, + None, + None, + &mut fallback_root_independent, + )); + + #[derive(Default)] + struct ResolutionState { + viewport: Option, + scroll: Option, + resolved: Option, + responsive_floor: Option, + responsive: bool, + responsive_candidate_count: u8, + responsive_candidate_floor: Option, + responsive_candidate_direction: i32, + } + let resolve_content_extent = |state: &mut ResolutionState, + viewport: i32, + scroll: i32, + intrinsic: i32, + probed: i32, + truncated: bool, + coupled_baseline: Option| { + let viewport_changed = state + .viewport + .is_some_and(|previous| (viewport - previous).abs() >= 1); + let host_applied_previous = state + .resolved + .is_some_and(|previous| (viewport - previous).abs() <= 1); + let native_reset = viewport_changed && !host_applied_previous; + if native_reset { + state.responsive = false; + state.responsive_floor = None; + state.responsive_candidate_count = 0; + state.responsive_candidate_floor = None; + state.responsive_candidate_direction = 0; + } + let viewport_delta = state.viewport.map_or(0, |previous| viewport - previous); + let scroll_delta = state.scroll.map_or(0, |previous| scroll - previous); + let responsive_scroll = !native_reset + && host_applied_previous + && viewport_delta.abs() >= 1 + && scroll_delta.abs() >= 1 + && viewport_delta * scroll_delta > 0; + let candidate_contradicted = !state.responsive + && state.responsive_candidate_count > 0 + && ((viewport_changed && host_applied_previous && !responsive_scroll) + || (scroll_delta.abs() >= 1 + && state.responsive_candidate_direction * scroll_delta < 0)); + if responsive_scroll && !state.responsive { + let candidate_floor = if truncated { + Some( + coupled_baseline + .unwrap_or(0) + .max(state.resolved.unwrap_or(1)), + ) + } else { + coupled_baseline.or(state.resolved) + }; + if truncated { + state.responsive = true; + state.responsive_floor = candidate_floor; + state.responsive_candidate_count = 0; + state.responsive_candidate_floor = None; + state.responsive_candidate_direction = 0; + } else { + if state.responsive_candidate_count == 0 { + state.responsive_candidate_direction = viewport_delta.signum(); + } + state.responsive_candidate_count += 1; + state.responsive_candidate_floor = + state.responsive_candidate_floor.or(candidate_floor); + if state.responsive_candidate_count >= 2 { + state.responsive = true; + state.responsive_floor = state.responsive_candidate_floor; + state.responsive_candidate_count = 0; + state.responsive_candidate_floor = None; + state.responsive_candidate_direction = 0; + } + } + } else if candidate_contradicted { + state.responsive_candidate_count = 0; + state.responsive_candidate_floor = None; + state.responsive_candidate_direction = 0; + } + let resolved = if state.responsive { + state + .responsive_floor + .unwrap_or(1) + .max(if truncated { probed } else { intrinsic }) + } else if scroll > viewport { + scroll + } else { + intrinsic + }; + if state.responsive && truncated { + state.responsive_floor = Some(state.responsive_floor.unwrap_or(1).max(resolved)); + } + state.viewport = Some(viewport); + state.scroll = Some(scroll); + state.resolved = Some(resolved); + resolved + }; + + let mut proportional_vh = ResolutionState::default(); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 700, 840, 840, 0, false, None), + 840 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 840, 1_008, 0, 0, false, Some(840),), + 1_008 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 840, 1_008, 0, 0, false, Some(840),), + 1_008 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 1_008, 1_210, 0, 0, false, Some(840),), + 840 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 840, 1_008, 840, 840, false, Some(840),), + 840 + ); + + let mut calculated_vh = ResolutionState::default(); + assert_eq!( + resolve_content_extent(&mut calculated_vh, 700, 900, 900, 0, false, None), + 900 + ); + assert_eq!( + resolve_content_extent(&mut calculated_vh, 900, 1_100, 0, 0, false, Some(900),), + 1_100 + ); + assert_eq!( + resolve_content_extent(&mut calculated_vh, 1_100, 1_300, 0, 0, false, Some(900),), + 900 + ); + + let mut fixed = ResolutionState::default(); + assert_eq!( + resolve_content_extent(&mut fixed, 700, 2_000, 2_000, 2_000, false, None), + 2_000 + ); + assert_eq!( + resolve_content_extent(&mut fixed, 2_000, 2_000, 2_000, 2_000, false, None), + 2_000 + ); + + let mut fixed_root = ResolutionState::default(); + let native_fixed_root = + resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false) as i32; + assert_eq!( + resolve_content_extent( + &mut fixed_root, + 700, + 2_000, + native_fixed_root, + 1_000, + false, + None, + ), + 2_000 + ); + let fitted_fixed_root = + resolve_document_extent(2_000, false, false, 1_000, false, 1_000, false) as i32; + assert_eq!( + resolve_content_extent( + &mut fixed_root, + 2_000, + 2_000, + fitted_fixed_root, + 1_000, + false, + None, + ), + 2_000, + "fitting the viewport must not discard a fixed documentElement extent" + ); + + let mut fixed_grows_during_fit = ResolutionState::default(); + assert_eq!( + resolve_content_extent( + &mut fixed_grows_during_fit, + 700, + 2_000, + 2_000, + 2_000, + false, + None, + ), + 2_000 + ); + assert_eq!( + resolve_content_extent( + &mut fixed_grows_during_fit, + 2_000, + 2_500, + 0, + 0, + false, + Some(2_000), + ), + 2_500 + ); + assert_eq!( + resolve_content_extent( + &mut fixed_grows_during_fit, + 2_500, + 2_500, + 2_500, + 2_500, + false, + None, + ), + 2_500 + ); + + assert_eq!( + resolve_content_extent( + &mut proportional_vh, + 840, + 1_500, + 1_500, + 1_500, + false, + Some(840), + ), + 1_500 + ); + assert_eq!( + resolve_content_extent( + &mut proportional_vh, + 1_500, + 1_800, + 1_500, + 1_500, + false, + Some(840), + ), + 1_500 + ); + assert_eq!( + resolve_content_extent( + &mut proportional_vh, + 1_500, + 1_800, + 500, + 500, + false, + Some(840), + ), + 840 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 800, 960, 960, 960, false, None,), + 960 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 960, 1_152, 0, 0, false, None,), + 1_152 + ); + assert_eq!( + resolve_content_extent(&mut proportional_vh, 1_152, 1_382, 0, 0, false, Some(960),), + 960 + ); + + let mut min_height = ResolutionState::default(); + assert_eq!( + resolve_content_extent(&mut min_height, 700, 2_000, 2_000, 2_000, false, None), + 2_000 + ); + assert_eq!( + resolve_content_extent( + &mut min_height, + 2_000, + 2_000, + 2_000, + 2_000, + false, + Some(700), + ), + 2_000 + ); + assert_eq!( + resolve_content_extent( + &mut min_height, + 2_000, + 2_000, + 1_000, + 1_000, + false, + Some(700), + ), + 1_000 + ); + assert_eq!( + resolve_content_extent( + &mut min_height, + 1_000, + 1_000, + 1_000, + 1_000, + false, + Some(700), + ), + 1_000 + ); + assert_eq!( + resolve_content_extent(&mut min_height, 1_000, 1_000, 700, 500, false, Some(700),), + 700 + ); + assert_eq!( + resolve_content_extent(&mut min_height, 700, 700, 700, 500, false, Some(700),), + 700 + ); + + let mut mirrored_root_min_height = ResolutionState::default(); + let native_mirrored_intrinsic = + resolve_document_extent(2_000, false, true, 2_000, false, 2_000, false) as i32; + assert_eq!( + resolve_content_extent( + &mut mirrored_root_min_height, + 700, + 2_000, + native_mirrored_intrinsic, + 2_000, + false, + None, + ), + 2_000 + ); + let fitted_mirrored_baseline = resolve_document_baseline(0, true, 700, 0); + let fitted_mirrored_intrinsic = fitted_mirrored_baseline + .max(resolve_document_extent(2_000, false, true, 2_000, true, 2_000, false) as i32); + assert_eq!( + resolve_content_extent( + &mut mirrored_root_min_height, + 2_000, + 2_000, + fitted_mirrored_intrinsic, + 2_000, + false, + Some(fitted_mirrored_baseline), + ), + 2_000 + ); + let shrunk_mirrored_intrinsic = fitted_mirrored_baseline + .max(resolve_document_extent(2_000, false, true, 2_000, true, 1_000, false) as i32); + assert_eq!( + resolve_content_extent( + &mut mirrored_root_min_height, + 2_000, + 2_000, + shrunk_mirrored_intrinsic, + 1_000, + false, + Some(fitted_mirrored_baseline), + ), + 1_000 + ); + let host_applied_mirrored_baseline = resolve_document_baseline(2_000, true, 700, 0); + let host_applied_mirrored_intrinsic = host_applied_mirrored_baseline + .max(resolve_document_extent(1_000, true, true, 1_000, true, 1_000, false) as i32); + assert_eq!( + resolve_content_extent( + &mut mirrored_root_min_height, + 1_000, + 1_000, + host_applied_mirrored_intrinsic, + 1_000, + false, + Some(host_applied_mirrored_baseline), + ), + 1_000, + "applying the shrink must not restore the mirrored root's old fitted baseline" + ); + + let mut truncated_responsive = ResolutionState::default(); + assert_eq!( + resolve_content_extent( + &mut truncated_responsive, + 700, + 10_000, + 10_000, + 5_120, + false, + None, + ), + 10_000 + ); + assert_eq!( + resolve_content_extent( + &mut truncated_responsive, + 10_000, + 12_000, + 5_120, + 5_120, + true, + Some(700), + ), + 10_000 + ); + assert_eq!( + resolve_content_extent( + &mut truncated_responsive, + 10_000, + 12_000, + 1_000, + 1_000, + true, + Some(700), + ), + 10_000 + ); +} + +#[test] +fn local_preview_bridge_injection_uses_real_html_context_and_supports_omitted_end_tags() { + let bridge = r#""#; + let cases = [ + ( + "missing body and html end tags", + "
game
", + format!("
game
{bridge}"), + ), + ( + "doctype greater-than ends the token even inside a public identifier quote", + " y\">
game
", + format!( + " {bridge} y\">
game
" + ), + ), + ( + "malformed doctype quote before script body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "public identifier remainder contains a script body decoy", + " y\">
game
", + format!( + " y\">
game
{bridge}" + ), + ), + ( + "doctype keyword accepts a missing whitespace parse error", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "script string body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "script double escaped body decoy", + "
x

after

", + format!( + "
x
{bridge}

after

" + ), + ), + ( + "trailing comment body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "comment end bang before script body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "template body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "raw text body decoy", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ( + "case insensitive real end tags", + "
game
", + format!( + "
game
{bridge}" + ), + ), + ]; + + for (label, input, expected) in cases { + let root = unique_project_path(); + let actual = local_preview_html_response_body(&root, input); + assert_eq!(actual, expected, "{label}"); + assert_eq!(actual.matches(bridge).count(), 1, "{label}"); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn local_preview_bridge_injection_is_idempotent_and_ignores_inert_path_decoys() { + let bridge = r#""#; + let root = unique_project_path(); + let first = local_preview_html_response_body( + &root, + "
game
", + ); + assert_eq!(first.matches(bridge).count(), 1, "{first}"); + assert!(first.contains(&format!("
game
{bridge}"))); + + let second = local_preview_html_response_body(&root, &first); + assert_eq!(second, first); + assert_eq!(second.matches(bridge).count(), 1, "{second}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn local_preview_bridge_injection_uses_the_first_duplicate_src_attribute() { + let bridge = r#""#; + let root = unique_project_path(); + let actual = local_preview_html_response_body( + &root, + "
game
", + ); + + assert!(actual.contains(&format!("
game
{bridge}"))); + assert_eq!(actual.matches(bridge).count(), 1, "{actual}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn local_preview_bridge_injection_ignores_inert_script_src_decoys() { + let bridge = r#""#; + let cases = [ + ( + "non executable type", + "", + ), + ( + "nomodule classic script", + "", + ), + ]; + + for (label, decoy) in cases { + let root = unique_project_path(); + let actual = local_preview_html_response_body( + &root, + &format!("{decoy}
game
"), + ); + assert!( + actual.contains(&format!("
game
{bridge}")), + "{label}" + ); + assert_eq!( + actual + .matches("/__genarrative/local-preview-fit.js") + .count(), + 2, + "{label}" + ); + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn local_preview_bridge_injection_stays_outside_unclosed_inert_html_contexts() { + let bridge = r#""#; + let cases = [ + ( + "comment", + "
game