diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 8516a8f69..f0f64a28c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -565,21 +565,70 @@ async fn recover_direct_taonier_spritesheet_read_only_at( root: &Path, art_spec: &DirectTaonierArtAssetIdentity, ) -> Result { - let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; + let (api_base_url, api_key, frozen_platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + let access = ExternalEditorBindingAccess::new( + &api_base_url, + &api_key, + frozen_platform_session.as_ref(), + )?; + let manifest = read_manifest(&root.join(".agent/manifest.json"))?; + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH) + .ok_or_else(|| "陶泥儿规范图 manifest 记录缺失".to_string())?; + let source_path = resolve_local_project_path(root, &source_asset.local_path)?; + let source_bytes = + fs::read(source_path).map_err(|error| format!("读取陶泥儿规范图失败:{error}"))?; + let source_identity = new_external_editor_source_identity( + &source_asset.id, + &format!("{:x}", Sha256::digest(&source_bytes)), + &source_asset.media_type, + &source_asset.kind, + )?; + let principal = external_editor_binding_principal(&access)?; + let Some(project_binding) = + read_external_editor_project_binding_at(root, &manifest.project_id, &principal)? + else { + // 当前账号从未建立远端项目。后续正式生成路径会从本地规范图重新登记;这里不得 + // 用当前 token 查询 manifest 中历史账号的 canvasProjectId。 + return Ok(false); + }; + let Some(spec_binding) = read_external_editor_resource_binding_at( + root, + &manifest.project_id, + &principal, + &project_binding.remote_project_id, + &source_identity, + )? + else { + return Ok(false); + }; + let Some(remote_spec_resource_id) = spec_binding.remote_resource_id.clone() else { + return Ok(false); + }; + let remote_art_spec = DirectTaonierArtAssetIdentity { + resource_id: remote_spec_resource_id, + asset_object_id: spec_binding.asset_object_id, + canvas_project_id: project_binding.remote_project_id, + }; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) .build() .map_err(|error| format!("创建陶泥儿只读资源恢复客户端失败:{error}"))?; - let route = resolve_platform_editor_api_route(&format!( + let route = access.api_route(&format!( "/api/external/v1/editor/projects/{}", - percent_encode_query_component(&art_spec.canvas_project_id) + percent_encode_query_component(&remote_art_spec.canvas_project_id) )); + access.validate_frozen_session()?; let response = client .get(format!("{api_base_url}{route}")) .bearer_auth(&api_key) .send() .await .map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?; + access.validate_frozen_session()?; let status = response.status(); if !status.is_success() { return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); @@ -596,7 +645,8 @@ async fn recover_direct_taonier_spritesheet_read_only_at( .get("resources") .and_then(serde_json::Value::as_array) .ok_or_else(|| "陶泥儿画布资源响应缺少 resources".to_string())?; - let Some(resource) = direct_recoverable_spritesheet_resource(resources, art_spec)? else { + let Some(resource) = direct_recoverable_spritesheet_resource(resources, &remote_art_spec)? + else { return Ok(false); }; let resource_id = json_string_field(resource, "resourceId") @@ -608,6 +658,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource) .await? .ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?; + access.validate_frozen_session()?; let validated = validate_platform_art_png_bytes_with_limits( &download.bytes, "恢复的陶泥儿 art-spritesheet", @@ -626,7 +677,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( "/api/external/v1/editor/images/generations", "spec", &[], - Some(&art_spec.canvas_project_id), + None, ) .ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?; if current_art_spec != *art_spec { @@ -661,9 +712,9 @@ async fn recover_direct_taonier_spritesheet_read_only_at( "platform-art-recovery", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: Some(art_spec.canvas_project_id.clone()), + canvas_project_id: Some(remote_art_spec.canvas_project_id.clone()), resource_id: Some(resource_id), - asset_object_id: Some(asset_object_id), + asset_object_id: Some(asset_object_id.clone()), task_id: Some(task_id), prompt: json_string_field(resource, "actualPrompt") .or_else(|| json_string_field(resource, "prompt")), @@ -672,12 +723,36 @@ async fn recover_direct_taonier_spritesheet_read_only_at( "/api/external/v1/editor/icon-spritesheets/generations".to_string(), ), generation_kind: Some("icon-spritesheet".to_string()), - reference_resource_ids: vec![art_spec.resource_id.clone()], + reference_resource_ids: vec![remote_art_spec.resource_id.clone()], }, ); - if let Err(error) = register { - let _ = std::fs::remove_file(&output); - return Err(error); + let registered_asset = match register { + Ok(registered_asset) => registered_asset, + Err(error) => { + let _ = std::fs::remove_file(&output); + return Err(error); + } + }; + if let Some(object_key) = json_string_field(resource, "objectKey") { + let recovered_source = new_external_editor_source_identity( + ®istered_asset.id, + &validated.content_sha256, + "image/png", + "art-spritesheet", + )?; + let recovered_binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + &remote_art_spec.canvas_project_id, + &recovered_source, + json_string_field(resource, "resourceId").as_deref(), + &object_key, + &asset_object_id, + Some(validated.width), + Some(validated.height), + unix_timestamp(), + )?; + write_external_editor_resource_binding_at(root, &recovered_binding)?; } emit_direct_game_creator_progress( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 0a1c36e55..87c884942 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -13,6 +13,7 @@ use super::external_generation_state::{ PlatformArtGenerationRuntimeState, }; use super::*; +use reqwest::multipart::{Form, Part}; const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); const EXTERNAL_GENERATION_SUBMIT_TIMEOUT: Duration = EXTERNAL_GENERATION_POLL_TIMEOUT; @@ -767,48 +768,69 @@ async fn resume_prepared_external_generation_at( pub(crate) async fn prepare_external_canvas_generation_context( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ) -> Result { let manifest = read_existing_manifest_for_project(root)?; + let local_project_id = manifest.project_id.trim(); + if local_project_id.is_empty() { + return Err( + "本地项目 manifest 缺少 projectId,无法建立 External Editor 账号绑定".to_string(), + ); + } let canvas_name = manifest.name.trim().chars().take(80).collect::(); let canvas_name = if canvas_name.is_empty() { "未命名游戏原型".to_string() } else { canvas_name }; + access.validate_frozen_session()?; + let principal = external_editor_binding_principal(access)?; + let existing_binding = + read_external_editor_project_binding_at(root, local_project_id, &principal)?; let projects_payload = external_editor_json_request( client .get(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/projects") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/projects") )) - .bearer_auth(api_key), + .bearer_auth(access.bearer_token()), "读取外部画布项目", ) .await?; + access.validate_frozen_session()?; let projects = external_editor_response_data(&projects_payload) .get("projects") .and_then(serde_json::Value::as_array) .ok_or_else(|| "外部画布项目响应缺少 projects".to_string())?; - let project_id = projects - .iter() - .find(|project| json_string_field(project, "title").as_deref() == Some(&canvas_name)) - .and_then(|project| json_string_field(project, "projectId")); + // 项目标题只用于展示,不再参与身份匹配。同名项目、账号切换或本地改名都必须 + // 依赖本地 projectId + 当前账号 principal 的私有 binding。 + let project_id = existing_binding.as_ref().and_then(|binding| { + projects + .iter() + .any(|project| { + json_string_field(project, "projectId").as_deref() + == Some(binding.remote_project_id.as_str()) + }) + .then(|| binding.remote_project_id.clone()) + }); let project_id = match project_id { Some(project_id) => project_id, None => { + access.validate_frozen_session()?; let payload = external_editor_json_request( client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/projects") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/projects") )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ "title": canvas_name })), "创建外部画布项目", ) .await?; + access.validate_frozen_session()?; external_editor_response_data(&payload) .get("project") .and_then(|project| json_string_field(project, "projectId")) @@ -819,36 +841,46 @@ pub(crate) async fn prepare_external_canvas_generation_context( let library_payload = external_editor_json_request( client .get(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/assets/library") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/assets/library") )) - .bearer_auth(api_key), + .bearer_auth(access.bearer_token()), "读取外部素材库", ) .await?; + access.validate_frozen_session()?; let folders = external_editor_response_data(&library_payload) .get("library") .and_then(|library| library.get("folders")) .and_then(serde_json::Value::as_array) .ok_or_else(|| "外部素材库响应缺少 library.folders".to_string())?; - let asset_folder_id = folders - .iter() - .find(|folder| json_string_field(folder, "label").as_deref() == Some(&canvas_name)) - .and_then(|folder| json_string_field(folder, "folderId")); + let asset_folder_id = existing_binding.as_ref().and_then(|binding| { + folders + .iter() + .any(|folder| { + json_string_field(folder, "folderId").as_deref() + == Some(binding.remote_asset_folder_id.as_str()) + }) + .then(|| binding.remote_asset_folder_id.clone()) + }); let asset_folder_id = match asset_folder_id { Some(folder_id) => folder_id, None => { + access.validate_frozen_session()?; let payload = external_editor_json_request( client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/assets/folders") )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ "label": canvas_name })), "创建外部素材库目录", ) .await?; + access.validate_frozen_session()?; external_editor_response_data(&payload) .get("folder") .and_then(|folder| json_string_field(folder, "folderId")) @@ -856,6 +888,23 @@ pub(crate) async fn prepare_external_canvas_generation_context( } }; + access.validate_frozen_session()?; + let now = unix_timestamp(); + let mut binding = new_external_editor_project_binding( + local_project_id, + &principal, + &project_id, + &asset_folder_id, + now, + )?; + if let Some(existing_binding) = existing_binding.as_ref().filter(|binding| { + binding.remote_project_id == project_id && binding.remote_asset_folder_id == asset_folder_id + }) { + binding.created_at = existing_binding.created_at; + } + write_external_editor_project_binding_at(root, &binding)?; + access.validate_frozen_session()?; + Ok(ExternalCanvasGenerationContext { project_id, asset_folder_id, @@ -942,8 +991,18 @@ impl PreparedPlatformArtAssetGeneration { } } -fn canonical_art_spec_reference_at( +struct CanonicalArtSpecUploadTicket { + host: String, + bucket: String, + object_key: String, + success_action_status: u16, + form_fields: BTreeMap, +} + +async fn canonical_art_spec_reference_at( root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, expected_canvas_project_id: &str, ) -> Result { let manifest = read_manifest_for_project(root)?; @@ -967,22 +1026,199 @@ fn canonical_art_spec_reference_at( .to_string(), ); } - if source.source.canvas_project_id.as_deref() != Some(expected_canvas_project_id) { - return Err( - "assets/art-spec.png 不属于当前 External Editor 画布项目,不能作为派生视觉资产参考源" - .to_string(), - ); + let bytes = + fs::read(&source_path).map_err(|error| format!("读取派生视觉资产规范图失败:{error}"))?; + let decoded = image::load_from_memory(&bytes) + .map_err(|_| "派生视觉资产规范图不是可解析图片".to_string())?; + let principal = external_editor_binding_principal(access)?; + let source_identity = new_external_editor_source_identity( + &source.id, + &format!("{:x}", Sha256::digest(&bytes)), + &source.media_type, + &source.kind, + )?; + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &manifest.project_id, + &principal, + expected_canvas_project_id, + &source_identity, + )? { + return binding + .remote_resource_id + .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } - source - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .ok_or_else(|| { - "assets/art-spec.png 缺少 External Editor 项目资源 ID,不能伪造规范图引用".to_string() - }) + + // manifest 中的远端 ID 只保留生成来源。当前账号没有 binding 时,必须从本地正式 + // 文件重新上传并登记,不能用当前 token 探测或发送历史账号的 project/resource ID。 + access.validate_frozen_session()?; + let ticket_payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/direct-upload-tickets") + )) + .bearer_auth(access.bearer_token()) + .json(&serde_json::json!({ + "legacyPrefix": "generated-character-drafts", + "pathSegments": [ + "editor", + "account-scoped-bindings", + manifest.project_id.as_str(), + source_identity.source_sha256.as_str() + ], + "fileName": "art-spec.png", + "contentType": source.media_type, + "access": "private", + "maxSizeBytes": bytes.len(), + "successActionStatus": 204, + })), + "创建当前账号规范图上传凭证", + ) + .await?; + access.validate_frozen_session()?; + let upload = external_editor_response_data(&ticket_payload) + .get("upload") + .or_else(|| ticket_payload.pointer("/data/upload")) + .ok_or_else(|| "当前账号规范图上传凭证缺少 upload".to_string())?; + let ticket = CanonicalArtSpecUploadTicket { + host: json_string_field(upload, "host") + .or_else(|| json_string_field(upload, "endpoint")) + .ok_or_else(|| "当前账号规范图上传凭证缺少 host".to_string())?, + bucket: json_string_field(upload, "bucket") + .ok_or_else(|| "当前账号规范图上传凭证缺少 bucket".to_string())?, + object_key: json_string_field(upload, "objectKey") + .ok_or_else(|| "当前账号规范图上传凭证缺少 objectKey".to_string())?, + success_action_status: upload + .get("successActionStatus") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u16::try_from(value).ok()) + .filter(|value| matches!(value, 200 | 201 | 204)) + .ok_or_else(|| "当前账号规范图上传凭证 successActionStatus 无效".to_string())?, + form_fields: upload + .get("formFields") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "当前账号规范图上传凭证缺少 formFields".to_string())? + .iter() + .map(|(key, value)| { + value + .as_str() + .map(|value| (key.clone(), value.to_string())) + .ok_or_else(|| "当前账号规范图上传凭证 formFields 必须全为字符串".to_string()) + }) + .collect::, _>>()?, + }; + let upload_url = + validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) + .map_err(|_| "当前账号规范图上传地址不安全".to_string())?; + let upload_client = + build_external_asset_download_client(&upload_url, access.api_base_url(), true) + .await + .map_err(|_| "无法创建当前账号规范图上传客户端".to_string())?; + access.validate_frozen_session()?; + let form = ticket + .form_fields + .iter() + .fold(Form::new(), |form, (key, value)| { + form.text(key.clone(), value.clone()) + }); + let part = Part::bytes(bytes.clone()) + .file_name("art-spec.png") + .mime_str(&source.media_type) + .map_err(|_| "规范图媒体类型不能用于上传".to_string())?; + let upload_response = upload_client + .post(upload_url) + .multipart(form.part("file", part)) + .send() + .await + .map_err(|_| "上传当前账号规范图失败".to_string())?; + if upload_response.status().as_u16() != ticket.success_action_status { + return Err(format!( + "上传当前账号规范图失败:HTTP {}", + upload_response.status().as_u16() + )); + } + access.validate_frozen_session()?; + let confirm_payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/objects/confirm") + )) + .bearer_auth(access.bearer_token()) + .json(&serde_json::json!({ + "bucket": ticket.bucket, + "objectKey": ticket.object_key, + "contentType": source.media_type, + "contentLength": bytes.len(), + "contentHash": source_identity.source_sha256, + "assetKind": source.kind, + "accessPolicy": "private", + })), + "确认当前账号规范图上传", + ) + .await?; + access.validate_frozen_session()?; + let asset_object = external_editor_response_data(&confirm_payload) + .get("assetObject") + .or_else(|| confirm_payload.pointer("/data/assetObject")) + .ok_or_else(|| "当前账号规范图确认响应缺少 assetObject".to_string())?; + if json_string_field(asset_object, "objectKey").as_deref() != Some(ticket.object_key.as_str()) { + return Err("当前账号规范图确认响应 objectKey 不一致".to_string()); + } + let asset_object_id = json_string_field(asset_object, "assetObjectId") + .ok_or_else(|| "当前账号规范图确认响应缺少 assetObjectId".to_string())?; + let resource_payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route(&format!( + "/api/external/v1/editor/projects/{}/resources", + percent_encode_query_component(expected_canvas_project_id) + )) + )) + .bearer_auth(access.bearer_token()) + .json(&serde_json::json!({ + "imageSrc": format!("/{}", ticket.object_key.trim_start_matches('/')), + "objectKey": ticket.object_key, + "assetObjectId": asset_object_id, + "width": decoded.width(), + "height": decoded.height(), + "sourceType": "uploaded", + "assetKind": source.kind, + "generationInputs": { + "source": "game-creator-account-binding", + "localAssetId": source.id, + }, + })), + "登记当前账号规范图项目资源", + ) + .await?; + access.validate_frozen_session()?; + let resource_data = external_editor_response_data(&resource_payload); + let remote_resource_id = json_string_field( + resource_data.get("resource").unwrap_or(resource_data), + "resourceId", + ) + .ok_or_else(|| "当前账号规范图项目资源响应缺少 resourceId".to_string())?; + let binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + expected_canvas_project_id, + &source_identity, + Some(&remote_resource_id), + &ticket.object_key, + &asset_object_id, + Some(decoded.width()), + Some(decoded.height()), + unix_timestamp(), + )?; + write_external_editor_resource_binding_at(root, &binding)?; + access.validate_frozen_session()?; + Ok(remote_resource_id) } fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { @@ -1401,8 +1637,13 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } else { None }; - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; + let (api_base_url, api_key, frozen_platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + let binding_access = ExternalEditorBindingAccess::new( + &api_base_url, + &api_key, + frozen_platform_session.as_ref(), + )?; let persisted_runtime_state = persisted_runtime_state .map(|state| { migrate_platform_art_generation_external_configuration( @@ -1484,20 +1725,29 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) } else { let canvas_context = - prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key) - .await?; + prepare_external_canvas_generation_context(root, &client, &binding_access).await?; let generation_kind = match options.asset_kind.as_str() { "ui-prototype" => "ui-design", "art-spritesheet" => "icon-spritesheet", _ => "spec", }; let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; - let canonical_reference = matches!( + let canonical_reference = if matches!( options.asset_kind.as_str(), "ui-prototype" | "game-background" | "art-spritesheet" - ) - .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) - .transpose()?; + ) { + Some( + canonical_art_spec_reference_at( + root, + &client, + &binding_access, + &canvas_context.project_id, + ) + .await?, + ) + } else { + None + }; let (endpoint, request_body) = if is_canonical_art_spritesheet { let reference_id = canonical_reference .as_deref() @@ -1581,6 +1831,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::to_string(&request_body) .expect("External Editor request body Value must serialize") }); + binding_access.validate_frozen_session()?; let response = submit_external_generation_request( &submit_client, &api_base_url, @@ -6098,6 +6349,145 @@ mod canvas_generation_tests { .expect("expected request body separator") } + #[tokio::test] + async fn external_canvas_context_is_scoped_by_account_and_local_project_id() { + let temporary = tempfile::tempdir().expect("create account binding project"); + let root = temporary.path(); + init_local_game_project_at(root, "local-binding-project", "同名远端画布") + .expect("init account binding project"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind account binding fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut project_created = BTreeMap::::new(); + let mut folder_created = BTreeMap::::new(); + for _ in 0..10 { + let (mut stream, _) = listener.accept().expect("accept binding request"); + let request = read_test_http_request(&mut stream); + let account = match test_request_header(&request, "authorization") { + "Bearer token-a" => "a", + "Bearer token-b" => "b", + unexpected => panic!("unexpected authorization {unexpected}"), + }; + let response = if request.starts_with("GET /api/editor/projects ") { + let projects = if project_created.get(account).copied().unwrap_or(false) { + vec![serde_json::json!({ + "projectId": format!("remote-project-{account}"), + "title": "服务端标题不参与绑定", + })] + } else { + Vec::new() + }; + serde_json::json!({"data": {"projects": projects}}) + } else if request.starts_with("POST /api/editor/projects ") { + project_created.insert(account.to_string(), true); + serde_json::json!({"data": {"project": { + "projectId": format!("remote-project-{account}") + }}}) + } else if request.starts_with("GET /api/editor/assets/library ") { + let folders = if folder_created.get(account).copied().unwrap_or(false) { + vec![serde_json::json!({ + "folderId": format!("remote-folder-{account}"), + "label": "服务端目录标题不参与绑定", + })] + } else { + Vec::new() + }; + serde_json::json!({"data": {"library": {"folders": folders}}}) + } else if request.starts_with("POST /api/editor/assets/folders ") { + folder_created.insert(account.to_string(), true); + serde_json::json!({"data": {"folder": { + "folderId": format!("remote-folder-{account}") + }}}) + } else { + panic!("unexpected binding request: {request}"); + }; + request_sender + .send((account.to_string(), request)) + .expect("capture binding request"); + let body = response.to_string(); + let wire = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(wire.as_bytes()) + .expect("write binding response"); + } + }); + let client = reqwest::Client::new(); + + let context_a = { + let _session = crate::platform_session::install_test_platform_session( + "user-a", "token-a", &base_url, + ); + let (api_base_url, api_key, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("resolve account A"); + let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, frozen.as_ref()) + .expect("create account A access"); + prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("prepare account A context") + }; + let context_b = { + let _session = crate::platform_session::install_test_platform_session( + "user-b", "token-b", &base_url, + ); + let (api_base_url, api_key, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("resolve account B"); + let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, frozen.as_ref()) + .expect("create account B access"); + prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("prepare account B context") + }; + + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest(&manifest_path).expect("read manifest before rename"); + manifest.name = "本地项目已改名".to_string(); + write_manifest(&manifest_path, &manifest).expect("rename local project"); + let context_a_after_rename = { + let _session = crate::platform_session::install_test_platform_session( + "user-a", "token-a", &base_url, + ); + let (api_base_url, api_key, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("resolve account A again"); + let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, frozen.as_ref()) + .expect("create account A access again"); + prepare_external_canvas_generation_context(root, &client, &access) + .await + .expect("reuse account A context after rename") + }; + server.join().expect("join account binding fixture"); + + assert_eq!(context_a.project_id, "remote-project-a"); + assert_eq!(context_a.asset_folder_id, "remote-folder-a"); + assert_eq!(context_b.project_id, "remote-project-b"); + assert_eq!(context_b.asset_folder_id, "remote-folder-b"); + assert_eq!(context_a_after_rename.project_id, context_a.project_id); + assert_eq!( + context_a_after_rename.asset_folder_id, + context_a.asset_folder_id + ); + let requests = request_receiver.try_iter().collect::>(); + assert_eq!(requests.len(), 10); + assert_eq!( + requests + .iter() + .filter(|(account, request)| { + account == "a" && request.starts_with("POST /api/editor/projects ") + }) + .count(), + 1, + "rename must not create a title-keyed duplicate project" + ); + for (_, request) in requests.iter().filter(|(account, _)| account == "b") { + assert!(!request.contains("remote-project-a"), "{request}"); + assert!(!request.contains("remote-folder-a"), "{request}"); + } + } + fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { rgba_test_png_with_quality(alpha, CompressionType::Fast, FilterType::Adaptive) } @@ -7527,13 +7917,17 @@ mod canvas_generation_tests { .all(|description| description.chars().count() <= 200)); } - #[test] - fn derived_visual_asset_waits_for_registered_art_spec() { + #[tokio::test] + async fn derived_visual_asset_waits_for_registered_art_spec() { let temporary = tempfile::tempdir().expect("create art dependency project"); let root = temporary.path(); init_local_game_project_at(root, "art-dependency", "美术依赖测试").expect("init project"); + let client = reqwest::Client::new(); + let access = ExternalEditorBindingAccess::new("http://127.0.0.1:1", "test-key", None) + .expect("create test binding access"); - let error = canonical_art_spec_reference_at(root, "canvas-project") + let error = canonical_art_spec_reference_at(root, &client, &access, "canvas-project") + .await .expect_err("missing art spec must block derived visual generation"); assert!(error.contains("先完成并登记 assets/art-spec.png")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index c6df5a8a7..576347c80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1065,7 +1065,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit( .await } -async fn resolve_canvas_resource_download_with_limit_and_route( +pub(crate) async fn resolve_canvas_resource_download_with_limit_and_route( _client: &reqwest::Client, api_base_url: &str, bearer_token: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index eb7b6ba5b..a028d2c08 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -237,6 +237,27 @@ pub(crate) fn install_test_platform_session( } } +/// Holds the shared test-session lock while presenting an explicit logged-out +/// state. Tests that assert missing-login behavior must use this guard so they +/// cannot observe a platform session installed by another parallel test. +#[cfg(test)] +pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard { + let isolation = PLATFORM_SESSION_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::mem::take(&mut *current); + *current = PlatformSessionState::default(); + drop(current); + TestPlatformSessionGuard { + _isolation: isolation, + previous: Some(previous), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 4a6b6d6bc..4ffe94674 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -8,6 +8,7 @@ mod asset_canvas; mod checkpoint; mod conversation; mod export; +mod external_editor_bindings; mod filesystem; mod manifest; mod memory; @@ -21,6 +22,7 @@ pub(crate) use asset_canvas::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; +pub(crate) use external_editor_bindings::*; pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index edc0b8b95..001590c50 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -312,6 +312,8 @@ struct NormalizedGenerationRequest<'a> { struct ReferenceMaterial { stable_reference: Option, + local_asset_id: Option, + asset_kind: Option, bytes: Option>, media_type: Option, file_name: Option, @@ -345,7 +347,10 @@ impl ReferencePreparationError { } fn from_http_error(error: &str, fallback: Self) -> Self { - if error.contains("HTTP 401") { + if error.contains("HTTP 401") + || error.contains("authentication-required") + || error.contains("登录态已变化") + { Self::AuthenticationRequired } else if error.contains("HTTP 403") { Self::PermissionDenied @@ -361,6 +366,14 @@ impl From for ReferencePreparationError { } } +fn validate_reference_binding_access( + access: &ExternalEditorBindingAccess<'_>, +) -> Result<(), ReferencePreparationError> { + access.validate_frozen_session().map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) + }) +} + struct CanvasGenerationApiMode { api_key: String, } @@ -1190,21 +1203,6 @@ fn validate_and_prepare_ledger( Ok(ledger) } -fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Option { - asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - && !value.starts_with("task:") - }) - .map(str::to_string) -} - fn is_registered_editor_reference_id(value: &str) -> bool { value.starts_with("editor-resource-") || value.starts_with("editor-asset-") } @@ -1223,21 +1221,6 @@ fn reference_material_at( || asset_canvas_source_resource_id(asset) == resource_id || asset.source.resource_id.as_deref() == Some(resource_id) }) { - if let Some(stable_reference) = stable_manifest_reference(asset).filter(|reference| { - ledger.intent != AssetCanvasIntent::Refine - || ledger.source_resource_id.as_deref() != Some(resource_id) - || is_registered_editor_reference_id(reference) - }) { - return Ok(ReferenceMaterial { - stable_reference: Some(stable_reference), - bytes: None, - media_type: None, - file_name: None, - sha256: None, - width: None, - height: None, - }); - } let (bytes, width, height) = open_and_validate_image_file( &resolve_local_project_path(root, &asset.local_path)?, &asset.media_type, @@ -1247,6 +1230,8 @@ fn reference_material_at( let sha256 = asset_canvas_sha256(&bytes); return Ok(ReferenceMaterial { stable_reference: None, + local_asset_id: Some(asset.id.clone()), + asset_kind: Some(asset.kind.clone()), bytes: Some(bytes), media_type: Some(asset.media_type.clone()), file_name: Some(format!("reference-{sha256}.{extension}")), @@ -1272,6 +1257,8 @@ fn reference_material_at( let sha256 = asset_canvas_sha256(&bytes); Ok(ReferenceMaterial { stable_reference: None, + local_asset_id: None, + asset_kind: None, bytes: Some(bytes), media_type: Some(media_type), file_name: Some(format!("reference-{sha256}.{extension}")), @@ -1476,6 +1463,10 @@ async fn register_uploaded_image_reference( let height = material .height .ok_or(ReferencePreparationError::MaterialInvalid)?; + let asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); let endpoint = format!( "/api/external/v1/editor/projects/{}/resources", percent_encode_query_component(&context.project_id) @@ -1494,7 +1485,7 @@ async fn register_uploaded_image_reference( "width": width, "height": height, "sourceType": "uploaded", - "assetKind": ledger.asset_kind, + "assetKind": asset_kind, "generationInputs": { "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, "operationId": ledger.generation_id, @@ -1519,6 +1510,7 @@ async fn ensure_reference_states( client: &reqwest::Client, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result<(), ReferencePreparationError> { set_private_phase( root, @@ -1526,10 +1518,52 @@ async fn ensure_reference_states( GenerationLedgerPhase::ReferencesPreparing, None, )?; + let binding_access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session) + .map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) + })?; + validate_reference_binding_access(&binding_access)?; + let principal = external_editor_binding_principal(&binding_access) + .map_err(|_| ReferencePreparationError::Internal)?; + let canvas_context = ledger + .canvas_context + .as_ref() + .ok_or(ReferencePreparationError::Internal)?; + let project_binding = + read_external_editor_project_binding_at(root, &ledger.project_id, &principal) + .map_err(|_| ReferencePreparationError::Internal)? + .ok_or(ReferencePreparationError::Internal)?; + if project_binding.remote_project_id != canvas_context.project_id + || project_binding.remote_asset_folder_id != canvas_context.asset_folder_id + { + return Err(ReferencePreparationError::Internal); + } + let requested = ledger.requested_reference_resource_ids.clone(); for resource_id in requested { let material = reference_material_at(root, ledger, &resource_id) .map_err(|_| ReferencePreparationError::MaterialInvalid)?; + let source_identity = match ( + material.local_asset_id.as_deref(), + material.sha256.as_deref(), + material.media_type.as_deref(), + material.asset_kind.as_deref(), + ) { + (Some(local_asset_id), Some(source_sha256), Some(media_type), Some(asset_kind)) => { + Some( + new_external_editor_source_identity( + local_asset_id, + source_sha256, + media_type, + asset_kind, + ) + .map_err(|_| ReferencePreparationError::MaterialInvalid)?, + ) + } + (None, _, _, None) => None, + _ => return Err(ReferencePreparationError::MaterialInvalid), + }; let index = match ledger .reference_states .iter() @@ -1549,6 +1583,33 @@ async fn ensure_reference_states( ledger.reference_states.len() - 1 } }; + + if let Some(source_identity) = source_identity.as_ref() { + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + ) + .map_err(|_| ReferencePreparationError::Internal)? + { + if let Some(remote_resource_id) = binding.remote_resource_id { + ledger.reference_states[index].stable_reference = Some(remote_resource_id); + ledger.reference_states[index].asset_object_id = Some(binding.asset_object_id); + ledger.reference_states[index].upload_bucket = None; + ledger.reference_states[index].upload_object_key = None; + ledger.reference_states[index].upload_completed = true; + write_generation_ledger(root, ledger)?; + continue; + } + } + // A manifest resourceId and a stable reference left in an older operation ledger are + // only origin provenance. Neither proves that the current account owns the remote + // resource, so a cache miss must fall back to the local formal file. + ledger.reference_states[index].stable_reference = None; + write_generation_ledger(root, ledger)?; + } if ledger.reference_states[index].stable_reference.is_some() { let requires_registered_source = ledger.intent == AssetCanvasIntent::Refine && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); @@ -1575,21 +1636,27 @@ async fn ensure_reference_states( ledger.reference_states[index].upload_bucket.clone(), ledger.reference_states[index].upload_object_key.clone(), ) { - if let Some(asset_object_id) = try_confirm_uploaded_reference( + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); + validate_reference_binding_access(&binding_access)?; + let confirmed_asset_object_id = try_confirm_uploaded_reference( client, api_base_url, api_mode, &bucket, &object_key, &material, - &ledger.asset_kind, + source_asset_kind, ) - .await? - { - let stable_reference = if ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) - { - register_uploaded_image_reference( + .await; + validate_reference_binding_access(&binding_access)?; + let confirmed_asset_object_id = confirmed_asset_object_id?; + if let Some(asset_object_id) = confirmed_asset_object_id { + let stable_reference = if source_identity.is_some() { + validate_reference_binding_access(&binding_access)?; + let registered_reference = register_uploaded_image_reference( client, api_base_url, api_mode, @@ -1598,10 +1665,38 @@ async fn ensure_reference_states( &object_key, &asset_object_id, ) - .await? + .await; + validate_reference_binding_access(&binding_access)?; + registered_reference? } else { - object_key + object_key.clone() }; + if let Some(source_identity) = source_identity.as_ref() { + validate_reference_binding_access(&binding_access)?; + let binding = new_external_editor_resource_binding( + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + Some(&stable_reference), + &object_key, + &asset_object_id, + Some( + material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + Some( + material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + unix_timestamp(), + ) + .map_err(|_| ReferencePreparationError::Internal)?; + write_external_editor_resource_binding_at(root, &binding) + .map_err(|_| ReferencePreparationError::Internal)?; + } ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; @@ -1611,15 +1706,25 @@ async fn ensure_reference_states( continue; } } - let ticket = - request_upload_ticket(client, api_base_url, api_mode, ledger, &material).await?; + validate_reference_binding_access(&binding_access)?; + let ticket = request_upload_ticket(client, api_base_url, api_mode, ledger, &material).await; + validate_reference_binding_access(&binding_access)?; + let ticket = ticket?; ledger.reference_states[index].upload_bucket = Some(ticket.bucket.clone()); ledger.reference_states[index].upload_object_key = Some(ticket.object_key.clone()); ledger.reference_states[index].upload_completed = false; write_generation_ledger(root, ledger)?; - upload_reference(&ticket, &material, api_base_url).await?; + validate_reference_binding_access(&binding_access)?; + let upload_result = upload_reference(&ticket, &material, api_base_url).await; + validate_reference_binding_access(&binding_access)?; + upload_result?; ledger.reference_states[index].upload_completed = true; write_generation_ledger(root, ledger)?; + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); + validate_reference_binding_access(&binding_access)?; let asset_object_id = try_confirm_uploaded_reference( client, api_base_url, @@ -1627,14 +1732,14 @@ async fn ensure_reference_states( &ticket.bucket, &ticket.object_key, &material, - &ledger.asset_kind, + source_asset_kind, ) - .await? - .ok_or(ReferencePreparationError::ConfirmFailed)?; - let stable_reference = if ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) - { - register_uploaded_image_reference( + .await; + validate_reference_binding_access(&binding_access)?; + let asset_object_id = asset_object_id?.ok_or(ReferencePreparationError::ConfirmFailed)?; + let stable_reference = if source_identity.is_some() { + validate_reference_binding_access(&binding_access)?; + let registered_reference = register_uploaded_image_reference( client, api_base_url, api_mode, @@ -1643,10 +1748,38 @@ async fn ensure_reference_states( &ticket.object_key, &asset_object_id, ) - .await? + .await; + validate_reference_binding_access(&binding_access)?; + registered_reference? } else { - ticket.object_key + ticket.object_key.clone() }; + if let Some(source_identity) = source_identity.as_ref() { + validate_reference_binding_access(&binding_access)?; + let binding = new_external_editor_resource_binding( + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + Some(&stable_reference), + &ticket.object_key, + &asset_object_id, + Some( + material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + Some( + material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + unix_timestamp(), + ) + .map_err(|_| ReferencePreparationError::Internal)?; + write_external_editor_resource_binding_at(root, &binding) + .map_err(|_| ReferencePreparationError::Internal)?; + } ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; @@ -1681,9 +1814,14 @@ async fn prepare_canvas_generation_context( client: &reqwest::Client, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { - prepare_external_canvas_generation_context(root, client, api_base_url, api_mode.bearer_token()) - .await + let access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; + access.validate_frozen_session()?; + let context = prepare_external_canvas_generation_context(root, client, &access).await; + access.validate_frozen_session()?; + context } fn build_generation_request_snapshot( @@ -2106,12 +2244,17 @@ async fn reconcile_generation( &client, api_base_url, api_mode, + platform_session, ) .await { Ok(context) => context, Err(error) => { - let code = if error.contains("HTTP 401") || error.contains("HTTP 403") { + let code = if error.contains("HTTP 401") + || error.contains("HTTP 403") + || error.contains("authentication-required") + || error.contains("登录态已变化") + { "authentication-required" } else { "platform-service-configuration" @@ -2128,8 +2271,15 @@ async fn reconcile_generation( ledger.canvas_context = Some(context.into()); write_generation_ledger(root, &mut ledger)?; } - if let Err(error) = - ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await + if let Err(error) = ensure_reference_states( + root, + &mut ledger, + &client, + api_base_url, + api_mode, + platform_session, + ) + .await { let code = error.code(); if code == "authentication-required" { @@ -2816,6 +2966,31 @@ mod tests { .await } + fn write_test_external_editor_project_binding( + root: &Path, + local_project_id: &str, + api_base_url: &str, + bearer_token: &str, + platform_session: Option<&PlatformSessionSnapshot>, + remote_project_id: &str, + remote_asset_folder_id: &str, + ) { + let access = ExternalEditorBindingAccess::new(api_base_url, bearer_token, platform_session) + .expect("build test External Editor binding access"); + let principal = external_editor_binding_principal(&access) + .expect("build test External Editor principal"); + let binding = new_external_editor_project_binding( + local_project_id, + &principal, + remote_project_id, + remote_asset_folder_id, + unix_timestamp(), + ) + .expect("build test External Editor project binding"); + write_external_editor_project_binding_at(root, &binding) + .expect("persist test External Editor project binding"); + } + fn test_png_with_color(color: [u8; 4]) -> Vec { let image = RgbaImage::from_pixel(4, 3, Rgba(color)); let mut output = Cursor::new(Vec::new()); @@ -3093,6 +3268,16 @@ mod tests { "http://{}", listener.local_addr().expect("generation address") ); + let api_key = "phase-five-secret-api-key"; + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + api_key, + None, + "remote-project", + "remote-folder", + ); let signed_url = format!("{base_url}/generated.png"); let server_signed_url = signed_url.clone(); let png = test_png(); @@ -3172,7 +3357,6 @@ mod tests { } } }); - let api_key = "phase-five-secret-api-key"; let private_prompt = "只允许进入私有账本的完整提示词正文"; let input = generation_input(directory.path(), project_id, &draft, private_prompt); let mut progress = Vec::new(); @@ -3461,6 +3645,15 @@ mod tests { } } }); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + "refine-private-api-key", + None, + "remote-project", + "remote-folder", + ); let execution = with_test_external_editor_credentials(&base_url, "refine-private-api-key", async { generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await @@ -3555,6 +3748,300 @@ mod tests { })); } + #[tokio::test] + async fn manifest_reference_binding_is_account_scoped_and_source_identity_sensitive() { + let project_id = "asset-canvas-account-reference-binding"; + let project_name = "素材画布账号参考资源绑定测试"; + let (directory, draft, source_png) = + create_refine_generation_fixture(project_id, project_name); + let source_sha256 = asset_canvas_sha256(&source_png); + let source_identity = new_external_editor_source_identity( + "source-character", + &source_sha256, + "image/png", + "illustration", + ) + .expect("build source identity"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind account binding server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("account binding address") + ); + let upload_url = format!("{base_url}/oss-upload"); + + let principal_a = { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-a", + "reference-token-a", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("account A session snapshot"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account A binding access"); + let principal = + external_editor_binding_principal(&access).expect("account A principal"); + let project_binding = new_external_editor_project_binding( + project_id, + &principal, + "remote-project-a", + "remote-folder-a", + unix_timestamp(), + ) + .expect("account A project binding"); + write_external_editor_project_binding_at(directory.path(), &project_binding) + .expect("persist account A project binding"); + let resource_binding = new_external_editor_resource_binding( + project_id, + &principal, + "remote-project-a", + &source_identity, + Some("editor-resource-account-a"), + "accounts/a/source.png", + "asset-object-account-a", + Some(4), + Some(3), + unix_timestamp(), + ) + .expect("account A resource binding"); + write_external_editor_resource_binding_at(directory.path(), &resource_binding) + .expect("persist account A resource binding"); + principal + }; + + let different_sha_identity = new_external_editor_source_identity( + "source-character", + &asset_canvas_sha256(&test_png_with_color([39, 132, 76, 255])), + "image/png", + "illustration", + ) + .expect("build changed SHA identity"); + let different_kind_identity = new_external_editor_source_identity( + "source-character", + &source_sha256, + "image/png", + "character-art", + ) + .expect("build changed kind identity"); + assert!(read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_a, + "remote-project-a", + &different_sha_identity, + ) + .expect("read changed SHA binding") + .is_none()); + assert!(read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_a, + "remote-project-a", + &different_kind_identity, + ) + .expect("read changed kind binding") + .is_none()); + + let server_source_png_len = source_png.len(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set account binding server nonblocking"); + for request_index in 0..4 { + let mut stream = accept_generation_fixture_connection( + &listener, + "account binding fixture", + request_index, + ); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture account binding request"); + if request.starts_with("POST /api/assets/direct-upload-tickets ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url.clone(), + "bucket": "account-b-private-bucket", + "objectKey": "accounts/b/source.png", + "successActionStatus": 204, + "maxSizeBytes": server_source_png_len, + "formFields": { + "key": "accounts/b/source.png", + "success_action_status": "204", + "Content-Type": "image/png" + } + }}}), + ); + } else if request.starts_with("POST /oss-upload ") { + write_empty_response(&mut stream, "204 No Content"); + } else if request.starts_with("POST /api/assets/objects/confirm ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": "accounts/b/source.png", + "assetObjectId": "asset-object-account-b" + }}}), + ); + } else if request + .starts_with("POST /api/editor/projects/remote-project-b/resources ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-account-b", + "projectId": "remote-project-b", + "objectKey": "accounts/b/source.png", + "assetObjectId": "asset-object-account-b" + }}}), + ); + } else { + panic!("unexpected account binding request: {request}"); + } + } + }); + + let mut account_b_input = generation_input( + directory.path(), + project_id, + &draft, + "账号 B 使用账号隔离后的源图片", + ); + account_b_input.asset_kind = "ui-design".to_string(); + let mut account_b_ledger = validate_and_prepare_ledger(directory.path(), &account_b_input) + .expect("prepare account B ledger"); + account_b_ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project-b".to_string(), + asset_folder_id: "remote-folder-b".to_string(), + canvas_name: project_name.to_string(), + }); + account_b_ledger.platform_owner_user_id = Some("reference-owner-b".to_string()); + account_b_ledger.platform_owner_api_base_url = Some(base_url.clone()); + write_generation_ledger(directory.path(), &mut account_b_ledger) + .expect("persist account B ledger context"); + + let principal_b = { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-b", + "reference-token-b", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("account B session snapshot"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account B binding access"); + let principal = + external_editor_binding_principal(&access).expect("account B principal"); + let project_binding = new_external_editor_project_binding( + project_id, + &principal, + "remote-project-b", + "remote-folder-b", + unix_timestamp(), + ) + .expect("account B project binding"); + write_external_editor_project_binding_at(directory.path(), &project_binding) + .expect("persist account B project binding"); + ensure_reference_states( + directory.path(), + &mut account_b_ledger, + &reqwest::Client::new(), + &base_url, + &CanvasGenerationApiMode { + api_key: session.access_token.clone(), + }, + Some(&session), + ) + .await + .expect("account B re-registers the local manifest reference"); + principal + }; + server.join().expect("join account binding server"); + + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 4); + assert!(requests.iter().all(|request| { + !request.contains("remote-project-a") + && !request.contains("editor-resource-account-a") + && !request.contains("asset-object-account-a") + && !request.contains("accounts/a/source.png") + })); + let register_request = requests + .iter() + .find(|request| { + request.starts_with("POST /api/editor/projects/remote-project-b/resources ") + }) + .expect("account B registration request"); + assert!(register_request.contains("\"assetKind\":\"illustration\"")); + assert!(!register_request.contains("\"assetKind\":\"ui-design\"")); + assert_eq!( + account_b_ledger.resolved_reference_ids, + vec!["editor-resource-account-b".to_string()] + ); + let account_b_binding = read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_b, + "remote-project-b", + &source_identity, + ) + .expect("read account B resource binding") + .expect("account B resource binding exists"); + assert_eq!( + account_b_binding.remote_resource_id.as_deref(), + Some("editor-resource-account-b") + ); + + let account_a_input = generation_input( + directory.path(), + project_id, + &draft, + "切回账号 A 复用 A 自己的源图片绑定", + ); + let mut account_a_ledger = validate_and_prepare_ledger(directory.path(), &account_a_input) + .expect("prepare account A reuse ledger"); + account_a_ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project-a".to_string(), + asset_folder_id: "remote-folder-a".to_string(), + canvas_name: project_name.to_string(), + }); + account_a_ledger.platform_owner_user_id = Some("reference-owner-a".to_string()); + account_a_ledger.platform_owner_api_base_url = Some(base_url.clone()); + write_generation_ledger(directory.path(), &mut account_a_ledger) + .expect("persist account A reuse ledger context"); + { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-a", + "reference-token-a", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("restored account A session snapshot"); + ensure_reference_states( + directory.path(), + &mut account_a_ledger, + &reqwest::Client::new(), + &base_url, + &CanvasGenerationApiMode { + api_key: session.access_token.clone(), + }, + Some(&session), + ) + .await + .expect("account A reuses its own binding without remote traffic"); + } + assert_eq!( + account_a_ledger.resolved_reference_ids, + vec!["editor-resource-account-a".to_string()] + ); + } + #[tokio::test] async fn reference_ticket_failure_is_stage_specific_and_never_submits_generation() { let project_id = "asset-canvas-ticket-failure-project"; @@ -3565,6 +4052,15 @@ mod tests { "http://{}", listener.local_addr().expect("ticket failure address") ); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + "ticket-failure-private-api-key", + None, + "remote-project", + "remote-folder", + ); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { listener @@ -4103,31 +4599,6 @@ mod tests { #[test] fn refine_snapshot_uses_source_once_and_preserves_local_lineage_identity() { - let mut source_asset = GameCreationAppAssetManifestEntry { - id: "source".to_string(), - kind: "illustration".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: None, - resource_id: Some("local-asset:source".to_string()), - asset_object_id: Some("asset-object-source".to_string()), - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }; - assert_eq!(stable_manifest_reference(&source_asset), None); - source_asset.source.resource_id = Some("resource-source".to_string()); - assert_eq!( - stable_manifest_reference(&source_asset).as_deref(), - Some("resource-source") - ); - let body_json = serde_json::json!({"prompt": "private"}).to_string(); let source_resource_id = "local-asset:source".to_string(); let now = asset_canvas_now(); @@ -4441,10 +4912,12 @@ mod tests { let (directory, draft) = create_generation_fixture(project_id, "阶段五缺失配置测试"); let prompt = "配置缺失时也不能公开的正文"; let input = generation_input(directory.path(), project_id, &draft, prompt); + let no_session = crate::platform_session::clear_test_platform_session(); let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) .await .err() .expect("missing configuration must fail"); + drop(no_session); assert!(error.contains("陶泥儿登录态缺失")); assert!(!error.contains(prompt)); assert!(!error.contains(&directory.path().to_string_lossy().into_owned())); @@ -4554,6 +5027,17 @@ mod tests { "fresh-login-token", &base_url, ); + let platform_session = crate::platform_session::current_platform_session() + .expect("login recovery platform session"); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "login-recovery-project", + "login-recovery-folder", + ); let recovered = recover_asset_canvas_generations_at( directory.path(), &RecoverAssetCanvasGenerationsInput { @@ -4684,6 +5168,17 @@ mod tests { "session-token", &base_url, ); + let platform_session = crate::platform_session::current_platform_session() + .expect("session gate platform session"); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "remote-project", + "remote-folder", + ); let input = generation_input( directory.path(), project_id, @@ -4707,14 +5202,14 @@ mod tests { .err() .expect("changed session must stop before submit"); server.join().expect("join session gate server"); - assert!(error.contains("登录态已变化")); + assert_eq!(error, "陶泥儿登录已失效,请重新登录"); let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 2); assert!(requests.iter().all(|request| request.starts_with("GET "))); let ledger = read_generation_ledger(directory.path(), &generation_id) .expect("read session gate ledger") .expect("session gate ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::Prepared); + assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); assert!(ledger.operation_id.is_none()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs new file mode 100644 index 000000000..9d6778dcf --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -0,0 +1,1150 @@ +use super::*; + +const EXTERNAL_EDITOR_BINDING_ROOT: &str = ".agent/runtime/external-editor-bindings"; +const EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION: &str = + "game-creator-external-editor-project-binding.v1"; +const EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION: &str = + "game-creator-external-editor-resource-binding.v1"; +const EXTERNAL_EDITOR_BINDING_MAX_BYTES: usize = 64 * 1024; +const EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS: usize = 512; +const EXTERNAL_EDITOR_BINDING_MAX_ORIGIN_CHARS: usize = 2_048; +const EXTERNAL_EDITOR_BINDING_MAX_OBJECT_KEY_CHARS: usize = 2_048; +const EXTERNAL_EDITOR_BINDING_MAX_MEDIA_TYPE_CHARS: usize = 255; +const EXTERNAL_EDITOR_BINDING_MAX_ASSET_KIND_CHARS: usize = 128; +const EXTERNAL_EDITOR_PLATFORM_SCHEME: &str = "official-platform-v1"; +const EXTERNAL_EDITOR_DEVELOPER_SCHEME: &str = "external-developer-v1"; + +/// In-memory authorization for resolving an account-scoped binding. This type deliberately does +/// not implement `Serialize` or `Debug`: the bearer token must never enter a project sidecar, +/// diagnostic, event, or formatted error. +pub(crate) struct ExternalEditorBindingAccess<'a> { + api_base_url: &'a str, + bearer_token: &'a str, + frozen_platform_session: Option<&'a PlatformSessionSnapshot>, +} + +impl<'a> ExternalEditorBindingAccess<'a> { + pub(crate) fn for_platform( + frozen_platform_session: &'a PlatformSessionSnapshot, + ) -> Result { + Self::new( + &frozen_platform_session.api_base_url, + &frozen_platform_session.access_token, + Some(frozen_platform_session), + ) + } + + pub(crate) fn for_developer( + api_base_url: &'a str, + developer_api_key: &'a str, + ) -> Result { + Self::new(api_base_url, developer_api_key, None) + } + + pub(crate) fn new( + api_base_url: &'a str, + bearer_token: &'a str, + frozen_platform_session: Option<&'a PlatformSessionSnapshot>, + ) -> Result { + let access = Self { + api_base_url, + bearer_token, + frozen_platform_session, + }; + validate_external_editor_binding_access_shape(&access)?; + Ok(access) + } + + pub(crate) fn api_base_url(&self) -> &str { + self.api_base_url + } + + pub(crate) fn bearer_token(&self) -> &str { + self.bearer_token + } + + pub(crate) fn frozen_platform_session(&self) -> Option<&PlatformSessionSnapshot> { + self.frozen_platform_session + } + + pub(crate) fn api_route(&self, external_route: &str) -> String { + if self.frozen_platform_session.is_none() { + return external_route.to_string(); + } + if let Some(suffix) = external_route.strip_prefix("/api/external/v1/editor/") { + return format!("/api/editor/{suffix}"); + } + if let Some(suffix) = external_route.strip_prefix("/api/external/v1/assets/") { + return format!("/api/assets/{suffix}"); + } + external_route.to_string() + } + + pub(crate) fn generation_status_route(&self, operation_id: &str) -> String { + let operation_id = percent_encode_query_component(operation_id); + if self.frozen_platform_session.is_some() { + format!("/api/runtime/external-generation/jobs/{operation_id}") + } else { + format!("/api/external/v1/generations/{operation_id}") + } + } + + /// Call before and after every awaited remote action and immediately before installing a + /// binding. Developer-key mode has no process-global account generation to compare. + pub(crate) fn validate_frozen_session(&self) -> Result<(), String> { + validate_external_editor_binding_access_shape(self)?; + if let Some(session) = self.frozen_platform_session { + validate_platform_session_snapshot(session)?; + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExternalEditorPrincipalIdentity { + pub(crate) scheme: String, + pub(crate) service_origin: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) owner_user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) credential_sha256: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExternalEditorProjectBinding { + pub(crate) schema_version: String, + pub(crate) binding_key_sha256: String, + pub(crate) project_id: String, + pub(crate) principal: ExternalEditorPrincipalIdentity, + pub(crate) remote_project_id: String, + pub(crate) remote_asset_folder_id: String, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExternalEditorSourceIdentity { + pub(crate) local_asset_id: String, + pub(crate) source_sha256: String, + pub(crate) media_type: String, + pub(crate) canonical_asset_kind: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ExternalEditorResourceBinding { + pub(crate) schema_version: String, + pub(crate) binding_key_sha256: String, + pub(crate) project_id: String, + pub(crate) principal_key_sha256: String, + pub(crate) remote_project_id: String, + pub(crate) source: ExternalEditorSourceIdentity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) remote_resource_id: Option, + pub(crate) object_key: String, + pub(crate) asset_object_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) height: Option, + pub(crate) created_at: u64, + pub(crate) verified_at: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectBindingKeyMaterial<'a> { + project_id: &'a str, + principal: &'a ExternalEditorPrincipalIdentity, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ResourceBindingKeyMaterial<'a> { + project_id: &'a str, + principal_key_sha256: &'a str, + remote_project_id: &'a str, + source: &'a ExternalEditorSourceIdentity, +} + +pub(crate) fn external_editor_binding_principal( + access: &ExternalEditorBindingAccess<'_>, +) -> Result { + validate_external_editor_binding_access_shape(access)?; + let service_origin = normalize_external_editor_binding_service_origin(access.api_base_url)?; + let principal = if let Some(session) = access.frozen_platform_session { + ExternalEditorPrincipalIdentity { + scheme: EXTERNAL_EDITOR_PLATFORM_SCHEME.to_string(), + service_origin, + owner_user_id: Some(session.user_id.clone()), + credential_sha256: None, + } + } else { + ExternalEditorPrincipalIdentity { + scheme: EXTERNAL_EDITOR_DEVELOPER_SCHEME.to_string(), + service_origin, + owner_user_id: None, + credential_sha256: Some(external_editor_binding_sha256( + access.bearer_token.as_bytes(), + )), + } + }; + validate_external_editor_principal(&principal)?; + Ok(principal) +} + +pub(crate) fn external_editor_project_binding_key_sha256( + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, +) -> Result { + validate_bounded_identity( + project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_external_editor_principal(principal)?; + sha256_serialized( + &ProjectBindingKeyMaterial { + project_id, + principal, + }, + "External Editor 项目 binding key", + ) +} + +pub(crate) fn external_editor_resource_binding_key_sha256( + project_id: &str, + principal_key_sha256: &str, + remote_project_id: &str, + source: &ExternalEditorSourceIdentity, +) -> Result { + validate_bounded_identity( + project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_sha256(principal_key_sha256, "External Editor principal key")?; + validate_bounded_identity( + remote_project_id, + "External Editor remote projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_external_editor_source_identity(source)?; + sha256_serialized( + &ResourceBindingKeyMaterial { + project_id, + principal_key_sha256, + remote_project_id, + source, + }, + "External Editor resource binding key", + ) +} + +pub(crate) fn new_external_editor_source_identity( + local_asset_id: &str, + source_sha256: &str, + media_type: &str, + canonical_asset_kind: &str, +) -> Result { + let source = ExternalEditorSourceIdentity { + local_asset_id: local_asset_id.to_string(), + source_sha256: source_sha256.to_string(), + media_type: media_type.to_string(), + canonical_asset_kind: canonical_asset_kind.to_string(), + }; + validate_external_editor_source_identity(&source)?; + Ok(source) +} + +pub(crate) fn new_external_editor_project_binding( + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + remote_asset_folder_id: &str, + now: u64, +) -> Result { + let binding = ExternalEditorProjectBinding { + schema_version: EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION.to_string(), + binding_key_sha256: external_editor_project_binding_key_sha256(project_id, principal)?, + project_id: project_id.to_string(), + principal: principal.clone(), + remote_project_id: remote_project_id.to_string(), + remote_asset_folder_id: remote_asset_folder_id.to_string(), + created_at: now, + updated_at: now, + }; + validate_external_editor_project_binding(&binding)?; + Ok(binding) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn new_external_editor_resource_binding( + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + source: &ExternalEditorSourceIdentity, + remote_resource_id: Option<&str>, + object_key: &str, + asset_object_id: &str, + width: Option, + height: Option, + now: u64, +) -> Result { + let principal_key_sha256 = external_editor_project_binding_key_sha256(project_id, principal)?; + let binding = ExternalEditorResourceBinding { + schema_version: EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION.to_string(), + binding_key_sha256: external_editor_resource_binding_key_sha256( + project_id, + &principal_key_sha256, + remote_project_id, + source, + )?, + project_id: project_id.to_string(), + principal_key_sha256, + remote_project_id: remote_project_id.to_string(), + source: source.clone(), + remote_resource_id: remote_resource_id.map(str::to_string), + object_key: object_key.to_string(), + asset_object_id: asset_object_id.to_string(), + width, + height, + created_at: now, + verified_at: now, + }; + validate_external_editor_resource_binding(&binding)?; + Ok(binding) +} + +pub(crate) fn read_external_editor_project_binding_at( + root: &Path, + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, +) -> Result, String> { + let binding_key = external_editor_project_binding_key_sha256(project_id, principal)?; + let binding = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &external_editor_project_binding_relative_path(&binding_key), + "External Editor 账号项目 binding", + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )?; + let Some(binding) = binding else { + return Ok(None); + }; + validate_external_editor_project_binding(&binding)?; + if binding.binding_key_sha256 != binding_key + || binding.project_id != project_id + || binding.principal != *principal + { + return Err("External Editor 账号项目 binding 与当前身份不匹配".to_string()); + } + Ok(Some(binding)) +} + +pub(crate) fn write_external_editor_project_binding_at( + root: &Path, + binding: &ExternalEditorProjectBinding, +) -> Result<(), String> { + validate_external_editor_project_binding(binding)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &external_editor_project_binding_relative_path(&binding.binding_key_sha256), + "External Editor 账号项目 binding", + binding, + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )?; + let persisted = + read_external_editor_project_binding_at(root, &binding.project_id, &binding.principal)? + .ok_or_else(|| "External Editor 账号项目 binding 写入后缺失".to_string())?; + if persisted != *binding { + return Err("External Editor 账号项目 binding 写入后回读不一致".to_string()); + } + Ok(()) +} + +pub(crate) fn read_external_editor_resource_binding_at( + root: &Path, + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + source: &ExternalEditorSourceIdentity, +) -> Result, String> { + let principal_key = external_editor_project_binding_key_sha256(project_id, principal)?; + let binding_key = external_editor_resource_binding_key_sha256( + project_id, + &principal_key, + remote_project_id, + source, + )?; + let binding = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &external_editor_resource_binding_relative_path(&principal_key, &binding_key), + "External Editor 账号资源 binding", + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )?; + let Some(binding) = binding else { + return Ok(None); + }; + validate_external_editor_resource_binding(&binding)?; + if binding.binding_key_sha256 != binding_key + || binding.project_id != project_id + || binding.principal_key_sha256 != principal_key + || binding.remote_project_id != remote_project_id + || binding.source != *source + { + return Err("External Editor 账号资源 binding 与当前身份不匹配".to_string()); + } + Ok(Some(binding)) +} + +pub(crate) fn write_external_editor_resource_binding_at( + root: &Path, + binding: &ExternalEditorResourceBinding, +) -> Result<(), String> { + validate_external_editor_resource_binding(binding)?; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &external_editor_resource_binding_relative_path( + &binding.principal_key_sha256, + &binding.binding_key_sha256, + ), + "External Editor 账号资源 binding", + binding, + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )?; + let persisted = + read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &external_editor_resource_binding_relative_path( + &binding.principal_key_sha256, + &binding.binding_key_sha256, + ), + "External Editor 账号资源 binding", + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + )? + .ok_or_else(|| "External Editor 账号资源 binding 写入后缺失".to_string())?; + validate_external_editor_resource_binding(&persisted)?; + if persisted != *binding { + return Err("External Editor 账号资源 binding 写入后回读不一致".to_string()); + } + Ok(()) +} + +pub(crate) fn invalidate_external_editor_resource_binding_at( + root: &Path, + project_id: &str, + principal: &ExternalEditorPrincipalIdentity, + remote_project_id: &str, + source: &ExternalEditorSourceIdentity, +) -> Result { + if read_external_editor_resource_binding_at( + root, + project_id, + principal, + remote_project_id, + source, + )? + .is_none() + { + return Ok(false); + } + let principal_key = external_editor_project_binding_key_sha256(project_id, principal)?; + let binding_key = external_editor_resource_binding_key_sha256( + project_id, + &principal_key, + remote_project_id, + source, + )?; + let path = resolve_local_project_path( + root, + &external_editor_resource_binding_relative_path(&principal_key, &binding_key), + )?; + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + remove_agent_runtime_json_sidecar_backup(&backup_path, "External Editor 账号资源 binding")?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("External Editor 账号资源 binding 必须是普通文件".to_string()) + } + Ok(_) => fs::remove_file(&path) + .map(|_| true) + .map_err(|error| format!("删除 External Editor 账号资源 binding 失败:{error}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), + Err(error) => Err(format!( + "读取 External Editor 账号资源 binding 元数据失败:{error}" + )), + } +} + +fn external_editor_project_binding_relative_path(binding_key_sha256: &str) -> String { + format!("{EXTERNAL_EDITOR_BINDING_ROOT}/projects/{binding_key_sha256}.json") +} + +fn external_editor_resource_binding_relative_path( + principal_key_sha256: &str, + binding_key_sha256: &str, +) -> String { + format!( + "{EXTERNAL_EDITOR_BINDING_ROOT}/resources/{principal_key_sha256}/{binding_key_sha256}.json" + ) +} + +fn validate_external_editor_binding_access_shape( + access: &ExternalEditorBindingAccess<'_>, +) -> Result<(), String> { + let service_origin = normalize_external_editor_binding_service_origin(access.api_base_url)?; + if access.bearer_token.trim() != access.bearer_token + || access.bearer_token.is_empty() + || access.bearer_token.len() > 16 * 1024 + || access.bearer_token.chars().any(char::is_control) + { + return Err("External Editor binding 凭据格式无效".to_string()); + } + if let Some(session) = access.frozen_platform_session { + let session_origin = + normalize_external_editor_binding_service_origin(&session.api_base_url)?; + if service_origin != session_origin + || access.bearer_token != session.access_token + || session.user_id.trim() != session.user_id + || session.user_id.is_empty() + || session.user_id.len() > 256 + || session.user_id.chars().any(char::is_control) + { + return Err("External Editor binding 与冻结账号快照不匹配".to_string()); + } + } + Ok(()) +} + +fn normalize_external_editor_binding_service_origin(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + let parsed = reqwest::Url::parse(value) + .map_err(|_| "External Editor binding 服务地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err("External Editor binding 服务地址必须是纯 HTTP(S) origin".to_string()); + } + let origin = parsed.origin().ascii_serialization(); + if origin == "null" || origin.len() > EXTERNAL_EDITOR_BINDING_MAX_ORIGIN_CHARS { + return Err("External Editor binding 服务 origin 无效".to_string()); + } + Ok(origin) +} + +fn validate_external_editor_principal( + principal: &ExternalEditorPrincipalIdentity, +) -> Result<(), String> { + if normalize_external_editor_binding_service_origin(&principal.service_origin)? + != principal.service_origin + { + return Err("External Editor principal 服务 origin 不是规范值".to_string()); + } + match principal.scheme.as_str() { + EXTERNAL_EDITOR_PLATFORM_SCHEME => { + let owner_user_id = principal + .owner_user_id + .as_deref() + .ok_or_else(|| "External Editor 平台 principal 缺少 ownerUserId".to_string())?; + validate_bounded_identity(owner_user_id, "ownerUserId", 256)?; + if principal.credential_sha256.is_some() { + return Err("External Editor 平台 principal 不能保存凭据摘要".to_string()); + } + } + EXTERNAL_EDITOR_DEVELOPER_SCHEME => { + if principal.owner_user_id.is_some() { + return Err("External Editor Developer principal 不能保存 ownerUserId".to_string()); + } + validate_sha256( + principal.credential_sha256.as_deref().ok_or_else(|| { + "External Editor Developer principal 缺少凭据身份摘要".to_string() + })?, + "External Editor Developer 凭据身份摘要", + )?; + } + _ => return Err("External Editor principal scheme 无效".to_string()), + } + Ok(()) +} + +fn validate_external_editor_project_binding( + binding: &ExternalEditorProjectBinding, +) -> Result<(), String> { + if binding.schema_version != EXTERNAL_EDITOR_PROJECT_BINDING_SCHEMA_VERSION { + return Err("External Editor 账号项目 binding schema 无效".to_string()); + } + validate_external_editor_principal(&binding.principal)?; + validate_bounded_identity( + &binding.project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_bounded_identity( + &binding.remote_project_id, + "External Editor remote projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_bounded_identity( + &binding.remote_asset_folder_id, + "External Editor remote assetFolderId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + let expected = + external_editor_project_binding_key_sha256(&binding.project_id, &binding.principal)?; + if binding.binding_key_sha256 != expected + || binding.created_at == 0 + || binding.updated_at < binding.created_at + { + return Err("External Editor 账号项目 binding 身份无效".to_string()); + } + Ok(()) +} + +fn validate_external_editor_source_identity( + source: &ExternalEditorSourceIdentity, +) -> Result<(), String> { + validate_bounded_identity( + &source.local_asset_id, + "本地 assetId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_sha256(&source.source_sha256, "本地资源 SHA-256")?; + validate_bounded_identity( + &source.media_type, + "本地资源媒体类型", + EXTERNAL_EDITOR_BINDING_MAX_MEDIA_TYPE_CHARS, + )?; + if !source.media_type.contains('/') + || source.media_type.starts_with(['/', '\\']) + || source.media_type.contains("://") + { + return Err("本地资源媒体类型无效".to_string()); + } + validate_bounded_identity( + &source.canonical_asset_kind, + "本地资源规范用途", + EXTERNAL_EDITOR_BINDING_MAX_ASSET_KIND_CHARS, + ) +} + +fn validate_external_editor_resource_binding( + binding: &ExternalEditorResourceBinding, +) -> Result<(), String> { + if binding.schema_version != EXTERNAL_EDITOR_RESOURCE_BINDING_SCHEMA_VERSION { + return Err("External Editor 账号资源 binding schema 无效".to_string()); + } + validate_bounded_identity( + &binding.project_id, + "本地 projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_sha256( + &binding.principal_key_sha256, + "External Editor principal key", + )?; + validate_bounded_identity( + &binding.remote_project_id, + "External Editor remote projectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_external_editor_source_identity(&binding.source)?; + if let Some(remote_resource_id) = binding.remote_resource_id.as_deref() { + validate_bounded_identity( + remote_resource_id, + "External Editor remote resourceId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + } + validate_bounded_identity( + &binding.asset_object_id, + "External Editor assetObjectId", + EXTERNAL_EDITOR_BINDING_MAX_ID_CHARS, + )?; + validate_stable_object_key(&binding.object_key)?; + match (binding.width, binding.height) { + (Some(width), Some(height)) if width > 0 && height > 0 => {} + (None, None) => {} + _ => return Err("External Editor 账号资源 binding 尺寸无效".to_string()), + } + if binding.created_at == 0 || binding.verified_at < binding.created_at { + return Err("External Editor 账号资源 binding 元数据无效".to_string()); + } + let expected = external_editor_resource_binding_key_sha256( + &binding.project_id, + &binding.principal_key_sha256, + &binding.remote_project_id, + &binding.source, + )?; + if binding.binding_key_sha256 != expected { + return Err("External Editor 账号资源 binding key 无效".to_string()); + } + Ok(()) +} + +fn validate_stable_object_key(value: &str) -> Result<(), String> { + validate_bounded_identity( + value, + "External Editor objectKey", + EXTERNAL_EDITOR_BINDING_MAX_OBJECT_KEY_CHARS, + )?; + if Path::new(value).is_absolute() + || value.starts_with(['/', '\\']) + || value.contains(['?', '#', '\\']) + || value.contains("://") + || value + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err("External Editor objectKey 必须是稳定相对对象键".to_string()); + } + Ok(()) +} + +fn validate_bounded_identity(value: &str, label: &str, max_chars: usize) -> Result<(), String> { + if value.trim() != value + || value.is_empty() + || value.chars().count() > max_chars + || value.chars().any(char::is_control) + || external_editor_binding_looks_like_absolute_path(value) + { + return Err(format!("{label} 无效")); + } + Ok(()) +} + +fn external_editor_binding_looks_like_absolute_path(value: &str) -> bool { + let bytes = value.as_bytes(); + Path::new(value).is_absolute() + || value.starts_with("\\\\") + || value.starts_with("~/") + || value.starts_with("~\\") + || value.contains("://") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')) +} + +fn validate_sha256(value: &str, label: &str) -> Result<(), String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!("{label} 必须是小写 SHA-256")); + } + Ok(()) +} + +fn external_editor_binding_sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn sha256_serialized(value: &T, label: &str) -> Result { + serde_json::to_vec(value) + .map(|bytes| external_editor_binding_sha256(&bytes)) + .map_err(|error| format!("序列化 {label} 失败:{error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn platform_session(user_id: &str, token: &str, generation: u64) -> PlatformSessionSnapshot { + PlatformSessionSnapshot { + user_id: user_id.to_string(), + access_token: token.to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + generation, + } + } + + fn platform_principal(session: &PlatformSessionSnapshot) -> ExternalEditorPrincipalIdentity { + let access = ExternalEditorBindingAccess::for_platform(session).expect("platform access"); + external_editor_binding_principal(&access).expect("platform principal") + } + + fn source_identity() -> ExternalEditorSourceIdentity { + new_external_editor_source_identity("asset:hero", &"1".repeat(64), "image/png", "character") + .expect("source identity") + } + + #[test] + fn account_bindings_are_isolated_while_token_rotation_reuses_identity() { + let account_a = platform_session("user-a", "token-a", 1); + let account_a_refreshed = platform_session("user-a", "token-a-refreshed", 2); + let account_b = platform_session("user-b", "token-b", 3); + let principal_a = platform_principal(&account_a); + let principal_a_refreshed = platform_principal(&account_a_refreshed); + let principal_b = platform_principal(&account_b); + + assert_eq!(principal_a, principal_a_refreshed); + assert_ne!(principal_a, principal_b); + assert_eq!( + external_editor_project_binding_key_sha256("local-project", &principal_a) + .expect("account A key"), + external_editor_project_binding_key_sha256("local-project", &principal_a_refreshed,) + .expect("refreshed account A key") + ); + assert_ne!( + external_editor_project_binding_key_sha256("local-project", &principal_a) + .expect("account A key"), + external_editor_project_binding_key_sha256("local-project", &principal_b) + .expect("account B key") + ); + } + + #[test] + fn project_store_separates_accounts_and_local_project_ids() { + let directory = tempfile::tempdir().expect("temp project"); + let principal_a = platform_principal(&platform_session("user-a", "token-a", 1)); + let principal_b = platform_principal(&platform_session("user-b", "token-b", 2)); + let binding_a = new_external_editor_project_binding( + "local-project-a", + &principal_a, + "remote-project-a", + "remote-folder-a", + 1, + ) + .expect("account A binding"); + let binding_b = new_external_editor_project_binding( + "local-project-a", + &principal_b, + "remote-project-b", + "remote-folder-b", + 2, + ) + .expect("account B binding"); + write_external_editor_project_binding_at(directory.path(), &binding_a) + .expect("write account A binding"); + write_external_editor_project_binding_at(directory.path(), &binding_b) + .expect("write account B binding"); + + assert_eq!( + read_external_editor_project_binding_at( + directory.path(), + "local-project-a", + &principal_a, + ) + .expect("read A"), + Some(binding_a) + ); + assert_eq!( + read_external_editor_project_binding_at( + directory.path(), + "local-project-a", + &principal_b, + ) + .expect("read B"), + Some(binding_b) + ); + assert_ne!( + external_editor_project_binding_key_sha256("local-project-a", &principal_a) + .expect("first local project"), + external_editor_project_binding_key_sha256("local-project-b", &principal_a) + .expect("second local project") + ); + } + + #[test] + fn resource_key_changes_with_source_sha_kind_and_remote_project() { + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let principal_key = external_editor_project_binding_key_sha256("local-project", &principal) + .expect("principal key"); + let source = source_identity(); + let baseline = external_editor_resource_binding_key_sha256( + "local-project", + &principal_key, + "remote-project", + &source, + ) + .expect("baseline key"); + for changed in [ + ExternalEditorSourceIdentity { + source_sha256: "2".repeat(64), + ..source.clone() + }, + ExternalEditorSourceIdentity { + canonical_asset_kind: "scene".to_string(), + ..source.clone() + }, + ExternalEditorSourceIdentity { + local_asset_id: "asset:other".to_string(), + ..source.clone() + }, + ExternalEditorSourceIdentity { + media_type: "image/webp".to_string(), + ..source.clone() + }, + ] { + assert_ne!( + baseline, + external_editor_resource_binding_key_sha256( + "local-project", + &principal_key, + "remote-project", + &changed, + ) + .expect("changed source key") + ); + } + assert_ne!( + baseline, + external_editor_resource_binding_key_sha256( + "local-project", + &principal_key, + "remote-project-other", + &source, + ) + .expect("changed remote project key") + ); + } + + #[test] + fn resource_store_round_trips_and_invalidates_exact_binding() { + let directory = tempfile::tempdir().expect("temp project"); + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let source = source_identity(); + let binding = new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source, + Some("editor-resource-1"), + "generated/assets/source.png", + "asset-object-1", + Some(128), + Some(64), + 1, + ) + .expect("resource binding"); + write_external_editor_resource_binding_at(directory.path(), &binding) + .expect("write resource binding"); + assert_eq!( + read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .expect("read resource binding"), + Some(binding) + ); + assert!(invalidate_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .expect("invalidate binding")); + assert!(read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .expect("read invalidated binding") + .is_none()); + } + + #[test] + fn video_object_binding_does_not_require_project_resource_or_dimensions() { + let directory = tempfile::tempdir().expect("temp project"); + let principal = platform_principal(&platform_session("user-a", "token-a", 1)); + let source = new_external_editor_source_identity( + "asset:video", + &"2".repeat(64), + "video/mp4", + "video", + ) + .expect("video source identity"); + let binding = new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source, + None, + "generated/assets/clip.mp4", + "asset-object-video-1", + None, + None, + 1, + ) + .expect("video object binding"); + write_external_editor_resource_binding_at(directory.path(), &binding) + .expect("write video object binding"); + assert_eq!( + read_external_editor_resource_binding_at( + directory.path(), + "local-project", + &principal, + "remote-project", + &source, + ) + .expect("read video object binding"), + Some(binding) + ); + } + + #[test] + fn corrupt_and_identity_mismatched_sidecars_fail_closed() { + let directory = tempfile::tempdir().expect("temp project"); + let principal_a = platform_principal(&platform_session("user-a", "token-a", 1)); + let principal_b = platform_principal(&platform_session("user-b", "token-b", 2)); + let binding_a = new_external_editor_project_binding( + "local-project", + &principal_a, + "remote-project-a", + "remote-folder-a", + 1, + ) + .expect("account A binding"); + let b_key = external_editor_project_binding_key_sha256("local-project", &principal_b) + .expect("account B key"); + write_agent_runtime_json_sidecar_with_max_bytes( + directory.path(), + &external_editor_project_binding_relative_path(&b_key), + "mismatched test binding", + &binding_a, + EXTERNAL_EDITOR_BINDING_MAX_BYTES, + ) + .expect("write mismatched binding"); + assert!(read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal_b, + ) + .expect_err("identity mismatch must fail") + .contains("身份不匹配")); + + let a_key = external_editor_project_binding_key_sha256("local-project", &principal_a) + .expect("account A key"); + let corrupt_path = resolve_local_project_path( + directory.path(), + &external_editor_project_binding_relative_path(&a_key), + ) + .expect("corrupt path"); + fs::create_dir_all(corrupt_path.parent().expect("binding parent")) + .expect("create binding parent"); + fs::write(&corrupt_path, b"{not-json\n").expect("write corrupt binding"); + assert!(read_external_editor_project_binding_at( + directory.path(), + "local-project", + &principal_a, + ) + .is_err()); + } + + #[test] + fn serialized_bindings_never_contain_tokens_or_absolute_paths() { + let session = platform_session("user-a", "super-secret-token", 1); + let principal = platform_principal(&session); + let project = new_external_editor_project_binding( + "local-project", + &principal, + "remote-project", + "remote-folder", + 1, + ) + .expect("project binding"); + let resource = new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source_identity(), + Some("editor-resource-1"), + "generated/assets/source.png", + "asset-object-1", + Some(16), + Some(16), + 1, + ) + .expect("resource binding"); + let serialized = format!( + "{}\n{}", + serde_json::to_string(&project).expect("serialize project"), + serde_json::to_string(&resource).expect("serialize resource"), + ); + assert!(!serialized.contains(&session.access_token)); + assert!(!serialized.contains("/data/")); + assert!(!serialized.contains("Authorization")); + assert!(new_external_editor_resource_binding( + "local-project", + &principal, + "remote-project", + &source_identity(), + Some("editor-resource-1"), + "/data/private/source.png", + "asset-object-1", + Some(16), + Some(16), + 1, + ) + .is_err()); + } + + #[test] + fn frozen_access_owns_route_selection_instead_of_mutable_global_session() { + let session = platform_session("user-a", "token-a", 1); + let platform = + ExternalEditorBindingAccess::for_platform(&session).expect("platform binding access"); + let developer = ExternalEditorBindingAccess::for_developer( + "https://dev.genarrative.world", + "tnr_sk_secret_a", + ) + .expect("developer binding access"); + + assert_eq!( + platform.api_route("/api/external/v1/editor/projects"), + "/api/editor/projects" + ); + assert_eq!( + platform.api_route("/api/external/v1/assets/read-url"), + "/api/assets/read-url" + ); + assert_eq!( + platform.generation_status_route("operation/1"), + "/api/runtime/external-generation/jobs/operation%2F1" + ); + assert_eq!( + developer.api_route("/api/external/v1/editor/projects"), + "/api/external/v1/editor/projects" + ); + assert_eq!( + developer.generation_status_route("operation/1"), + "/api/external/v1/generations/operation%2F1" + ); + } + + #[test] + fn developer_principal_isolated_by_credential_hash_without_serializing_key() { + let access_a = ExternalEditorBindingAccess::for_developer( + "https://dev.genarrative.world", + "tnr_sk_secret_a", + ) + .expect("developer A access"); + let access_b = ExternalEditorBindingAccess::for_developer( + "https://dev.genarrative.world", + "tnr_sk_secret_b", + ) + .expect("developer B access"); + let principal_a = + external_editor_binding_principal(&access_a).expect("developer A principal"); + let principal_b = + external_editor_binding_principal(&access_b).expect("developer B principal"); + assert_ne!(principal_a, principal_b); + let serialized = serde_json::to_string(&principal_a).expect("serialize principal"); + assert!(!serialized.contains(access_a.bearer_token())); + assert!(serialized.contains("credentialSha256")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 91972015a..829f955b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -267,6 +267,11 @@ struct ResourceEditSourceSnapshot { source_version: Option, } +struct PreparedResourceEditSourceReference { + stable_reference: String, + canvas_context: ExternalCanvasGenerationContext, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] enum ResourceEditLedgerPhase { @@ -336,6 +341,8 @@ struct ResourceEditLedger { endpoint: Option, request_body_json: Option, remote_operation_id: Option, + #[serde(default)] + remote_project_id: Option, remote_resource_id: Option, remote_object_key: Option, remote_asset_object_id: Option, @@ -625,63 +632,6 @@ fn update_resource_edit_version_journal_phase( write_resource_edit_version_journal(root, journal) } -fn stable_resource_edit_object_key(value: &str) -> Option { - let value = value.trim(); - (!value.is_empty() - && !value.starts_with(['/', '\\']) - && !value.starts_with("http://") - && !value.starts_with("https://") - && !value.contains(['?', '#', '\\']) - && !value.chars().any(char::is_control) - && !value.split('/').any(|segment| segment == "..")) - .then(|| value.to_string()) -} - -fn committed_resource_edit_object_key_for_asset( - root: &Path, - asset_id: &str, -) -> Result, String> { - let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; - let entries = match fs::read_dir(&directory) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(format!("读取资源编辑账本目录失败:{error}")), - }; - let mut inspected = 0_usize; - for entry in entries { - let entry = entry.map_err(|error| format!("读取资源编辑账本失败:{error}"))?; - let file_type = entry - .file_type() - .map_err(|error| format!("读取资源编辑账本类型失败:{error}"))?; - if !file_type.is_file() - || entry.path().extension().and_then(|value| value.to_str()) != Some("json") - { - continue; - } - inspected += 1; - if inspected > 4_096 { - return Err("资源编辑账本数量超过安全扫描上限".to_string()); - } - let entry_path = entry.path(); - let Some(operation_id) = entry_path.file_stem().and_then(|value| value.to_str()) else { - continue; - }; - let Some(ledger) = read_resource_edit_ledger(root, operation_id)? else { - continue; - }; - if ledger.schema_version == RESOURCE_EDIT_SCHEMA_VERSION - && ledger.phase == ResourceEditLedgerPhase::Committed - && ledger.result_asset_id.as_deref() == Some(asset_id) - { - return Ok(ledger - .remote_object_key - .as_deref() - .and_then(stable_resource_edit_object_key)); - } - } - Ok(None) -} - fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> { let parsed = Uuid::parse_str(value).map_err(|_| format!("{label} 必须是 UUID v4"))?; if parsed.get_version_num() != 4 || parsed.hyphenated().to_string() != value { @@ -922,14 +872,7 @@ fn media_read_limit(edit_kind: &LocalProjectResourceEditKind) -> usize { } fn source_asset_canonical_resource_id(asset: &GameCreationAppAssetManifestEntry) -> String { - asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("local-asset:{}", asset.id)) + format!("local-asset:{}", asset.id) } fn resource_edit_audio_kind(asset_kind: &str, path: &str) -> LocalProjectResourceEditKind { @@ -1409,8 +1352,7 @@ fn read_optional_resource_edit_staging( async fn request_resource_edit_upload_ticket( client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, ) -> Result { @@ -1425,12 +1367,14 @@ async fn request_resource_edit_upload_ticket( .and_then(|value| value.to_str()) .map(sanitize_file_name) .unwrap_or_else(|| "source.bin".to_string()); + access.validate_frozen_session()?; let response = client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/assets/direct-upload-tickets") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/direct-upload-tickets") )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&resource_edit_upload_ticket_payload( input, source, @@ -1440,6 +1384,7 @@ async fn request_resource_edit_upload_ticket( .send() .await .map_err(|_| "创建源资源上传凭证失败".to_string())?; + access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); } @@ -1456,6 +1401,7 @@ async fn request_resource_edit_upload_ticket( .json::() .await .map_err(|_| "解析源资源上传凭证失败".to_string())?; + access.validate_frozen_session()?; let upload = external_editor_response_data(&payload) .get("upload") .or_else(|| payload.pointer("/data/upload")) @@ -1519,17 +1465,20 @@ fn resource_edit_upload_ticket_payload( async fn upload_resource_edit_source( ticket: &ResourceEditUploadTicket, source: &ResourceEditSourceSnapshot, - api_base_url: &str, + access: &ExternalEditorBindingAccess<'_>, ) -> Result<(), String> { let bytes = source .bytes .as_ref() .ok_or_else(|| "源媒体上传缺少文件内容".to_string())?; - let upload_url = validate_external_asset_download_url(&ticket.host, api_base_url, true) - .map_err(|_| "源资源上传地址不安全".to_string())?; - let client = build_external_asset_download_client(&upload_url, api_base_url, true) + let upload_url = + validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) + .map_err(|_| "源资源上传地址不安全".to_string())?; + access.validate_frozen_session()?; + let client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await .map_err(|_| "无法创建源资源上传客户端".to_string())?; + access.validate_frozen_session()?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { form = form.text(key.clone(), value.clone()); @@ -1545,12 +1494,14 @@ async fn upload_resource_edit_source( .file_name(file_name) .mime_str(&source.media_type) .map_err(|_| "源资源媒体类型不能用于上传".to_string())?; + access.validate_frozen_session()?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await .map_err(|_| "上传源资源失败".to_string())?; + access.validate_frozen_session()?; if response.status().as_u16() != ticket.success_action_status { return Err(format!( "上传源资源失败:HTTP {}", @@ -1562,8 +1513,7 @@ async fn upload_resource_edit_source( async fn confirm_resource_edit_source( client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ticket: &ResourceEditUploadTicket, source: &ResourceEditSourceSnapshot, ) -> Result { @@ -1571,12 +1521,14 @@ async fn confirm_resource_edit_source( .bytes .as_ref() .ok_or_else(|| "确认源媒体上传缺少文件内容".to_string())?; + access.validate_frozen_session()?; let response = client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm") + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/assets/objects/confirm") )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ "bucket": ticket.bucket, "objectKey": ticket.object_key, @@ -1589,6 +1541,7 @@ async fn confirm_resource_edit_source( .send() .await .map_err(|_| "确认源资源上传失败".to_string())?; + access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); } @@ -1605,6 +1558,7 @@ async fn confirm_resource_edit_source( .json::() .await .map_err(|_| "解析源资源确认响应失败".to_string())?; + access.validate_frozen_session()?; let asset_object = external_editor_response_data(&payload) .get("assetObject") .or_else(|| payload.pointer("/data/assetObject")) @@ -1619,52 +1573,41 @@ async fn confirm_resource_edit_source( async fn resolve_resource_edit_source_project( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, - source: &ResourceEditSourceSnapshot, -) -> Result { - if let Some(project_id) = source - .source_asset - .as_ref() - .and_then(|asset| asset.source.canvas_project_id.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return Ok(project_id.to_string()); - } - prepare_external_canvas_generation_context(root, client, api_base_url, api_key) - .await - .map(|context| context.project_id) + access: &ExternalEditorBindingAccess<'_>, +) -> Result { + access.validate_frozen_session()?; + let context = prepare_external_canvas_generation_context(root, client, access).await?; + access.validate_frozen_session()?; + Ok(context) } async fn register_resource_edit_source_image( - root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, + remote_project_id: &str, ticket: &ResourceEditUploadTicket, asset_object_id: &str, -) -> Result { +) -> Result<(String, u32, u32), String> { let bytes = source .bytes .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; let decoded = image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; - let project_id = - resolve_resource_edit_source_project(root, client, api_base_url, api_key, source).await?; let endpoint = format!( "/api/external/v1/editor/projects/{}/resources", - percent_encode_query_component(&project_id) + percent_encode_query_component(remote_project_id) ); + access.validate_frozen_session()?; let response = client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route(&endpoint) + "{}{}", + access.api_base_url(), + access.api_route(&endpoint) )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .json(&serde_json::json!({ "imageSrc": format!("/{}", ticket.object_key.trim_start_matches('/')), "objectKey": ticket.object_key, @@ -1681,6 +1624,7 @@ async fn register_resource_edit_source_image( .send() .await .map_err(|_| "登记源图片项目资源失败".to_string())?; + access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); } @@ -1697,83 +1641,122 @@ async fn register_resource_edit_source_image( .json::() .await .map_err(|_| "解析源图片项目资源响应失败".to_string())?; + access.validate_frozen_session()?; let data = external_editor_response_data(&payload); - json_string_field(data.get("resource").unwrap_or(data), "resourceId") - .ok_or_else(|| "源图片项目资源响应缺少 resourceId".to_string()) + let resource_id = json_string_field(data.get("resource").unwrap_or(data), "resourceId") + .ok_or_else(|| "源图片项目资源响应缺少 resourceId".to_string())?; + Ok((resource_id, decoded.width(), decoded.height())) } async fn ensure_resource_edit_source_reference( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, + principal: &ExternalEditorPrincipalIdentity, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, ledger: &mut ResourceEditLedger, -) -> Result { - if let Some(reference) = ledger.source_stable_reference.clone() { - if input.edit_kind != LocalProjectResourceEditKind::ImageReference - || is_registered_editor_reference_id(&reference) - { - return Ok(reference); +) -> Result { + access.validate_frozen_session()?; + let canvas_context = resolve_resource_edit_source_project(root, client, access).await?; + access.validate_frozen_session()?; + let local_asset_id = source + .source_asset + .as_ref() + .map(|asset| asset.id.as_str()) + .unwrap_or(source.canonical_resource_id.as_str()); + let source_identity = new_external_editor_source_identity( + local_asset_id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + )?; + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &input.expected_project_id, + principal, + &canvas_context.project_id, + &source_identity, + )? { + let stable_reference = match input.edit_kind { + LocalProjectResourceEditKind::ImageReference => binding + .remote_resource_id + .filter(|reference| is_registered_editor_reference_id(reference)), + LocalProjectResourceEditKind::Video => Some(binding.object_key), + _ => return Err("当前资源类型不需要远端源 binding".to_string()), + }; + if let Some(stable_reference) = stable_reference { + ledger.source_stable_reference = Some(stable_reference.clone()); + ledger.remote_project_id = Some(canvas_context.project_id.clone()); + write_resource_edit_ledger(root, ledger)?; + return Ok(PreparedResourceEditSourceReference { + stable_reference, + canvas_context, + }); } - // Legacy prepared ledgers could store an objectKey here. An image edit - // accepts only a registered resource or asset, so discard that transient - // preparation before any remote operation is accepted and register again. - ledger.source_stable_reference = None; + invalidate_external_editor_resource_binding_at( + root, + &input.expected_project_id, + principal, + &canvas_context.project_id, + &source_identity, + )?; + } + + // Prepared legacy ledgers and manifest fields are not authorization evidence. If the current + // principal has no exact source binding, upload the committed local bytes again. + if ledger.source_stable_reference.take().is_some() { write_resource_edit_ledger(root, ledger)?; } - if let Some(asset) = source.source_asset.as_ref() { + let ticket = request_resource_edit_upload_ticket(client, access, input, source).await?; + access.validate_frozen_session()?; + upload_resource_edit_source(&ticket, source, access).await?; + access.validate_frozen_session()?; + let asset_object_id = confirm_resource_edit_source(client, access, &ticket, source).await?; + access.validate_frozen_session()?; + let (stable_reference, remote_resource_id, width, height) = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - if let Some(reference) = asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - && !value.starts_with("task:") - }) - .map(str::to_string) - .filter(|_| asset.source.canvas_project_id.is_some()) - { - ledger.source_stable_reference = Some(reference.clone()); - write_resource_edit_ledger(root, ledger)?; - return Ok(reference); - } - } else if let Some(reference) = - committed_resource_edit_object_key_for_asset(root, &asset.id)? - { - ledger.source_stable_reference = Some(reference.clone()); - write_resource_edit_ledger(root, ledger)?; - return Ok(reference); - } - } - let ticket = - request_resource_edit_upload_ticket(client, api_base_url, api_key, input, source).await?; - upload_resource_edit_source(&ticket, source, api_base_url).await?; - let asset_object_id = - confirm_resource_edit_source(client, api_base_url, api_key, &ticket, source).await?; - let reference = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - register_resource_edit_source_image( - root, - client, - api_base_url, - api_key, - input, - source, - &ticket, - &asset_object_id, - ) - .await? - } else { - ticket.object_key.clone() - }; - ledger.source_stable_reference = Some(reference.clone()); + let (resource_id, width, height) = register_resource_edit_source_image( + client, + access, + input, + source, + &canvas_context.project_id, + &ticket, + &asset_object_id, + ) + .await?; + ( + resource_id.clone(), + Some(resource_id), + Some(width), + Some(height), + ) + } else { + (ticket.object_key.clone(), None, None, None) + }; + access.validate_frozen_session()?; + let binding = new_external_editor_resource_binding( + &input.expected_project_id, + principal, + &canvas_context.project_id, + &source_identity, + remote_resource_id.as_deref(), + &ticket.object_key, + &asset_object_id, + width, + height, + unix_timestamp(), + )?; + write_external_editor_resource_binding_at(root, &binding)?; + access.validate_frozen_session()?; + ledger.source_stable_reference = Some(stable_reference.clone()); + ledger.remote_project_id = Some(canvas_context.project_id.clone()); write_resource_edit_ledger(root, ledger)?; - Ok(reference) + Ok(PreparedResourceEditSourceReference { + stable_reference, + canvas_context, + }) } fn take_resource_edit_chars(value: &str, max_chars: usize) -> String { @@ -1911,8 +1894,7 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { async fn submit_resource_edit_remote( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ledger: &mut ResourceEditLedger, ) -> Result { let endpoint = ledger @@ -1942,18 +1924,21 @@ async fn submit_resource_edit_remote( ); } } + access.validate_frozen_session()?; let response = client .post(format!( - "{api_base_url}{}", - resolve_platform_editor_api_route(endpoint) + "{}{}", + access.api_base_url(), + access.api_route(endpoint) )) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .header("Idempotency-Key", &ledger.idempotency_key) .header(reqwest::header::CONTENT_TYPE, "application/json") .body(body_value.to_string()) .send() .await .map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?; + access.validate_frozen_session()?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); @@ -1992,6 +1977,7 @@ async fn submit_resource_edit_remote( return Err("result-unknown: 资源编辑响应无法解析".to_string()); } }; + access.validate_frozen_session()?; if resource_edit_operation_id(&payload).is_none() { update_resource_edit_phase( root, @@ -2006,8 +1992,7 @@ async fn submit_resource_edit_remote( async fn wait_for_resource_edit_remote( root: &Path, client: &reqwest::Client, - api_base_url: &str, - api_key: &str, + access: &ExternalEditorBindingAccess<'_>, ledger: &mut ResourceEditLedger, ) -> Result { let operation_id = ledger @@ -2015,8 +2000,9 @@ async fn wait_for_resource_edit_remote( .as_deref() .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string())?; let status_url = format!( - "{api_base_url}{}", - resolve_platform_generation_status_route(&operation_id) + "{}{}", + access.api_base_url(), + access.generation_status_route(&operation_id) ); let started_at = tokio::time::Instant::now(); let mut poll_after_ms = 1_000; @@ -2024,13 +2010,16 @@ async fn wait_for_resource_edit_remote( if started_at.elapsed() >= Duration::from_secs(35 * 60) { return Err("result-unknown: 资源编辑任务仍在执行,已停止本地等待".to_string()); } + access.validate_frozen_session()?; tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; + access.validate_frozen_session()?; let response = client .get(&status_url) - .bearer_auth(api_key) + .bearer_auth(access.bearer_token()) .send() .await .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; + access.validate_frozen_session()?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { return Err(editor_api_authentication_error()); } @@ -2051,6 +2040,7 @@ async fn wait_for_resource_edit_remote( .json::() .await .map_err(|_| "result-unknown: 资源编辑任务响应无法解析".to_string())?; + access.validate_frozen_session()?; let job = platform_generation_status_data(&payload); match json_string_field(job, "status").as_deref() { Some("completed") => { @@ -2317,19 +2307,17 @@ async fn prepare_remote_resource_edit( prompt: &str, asset_name: &str, ledger: &mut ResourceEditLedger, - frozen_platform_session: Option<&PlatformSessionSnapshot>, + access: &ExternalEditorBindingAccess<'_>, + principal: &ExternalEditorPrincipalIdentity, ) -> Result<(), String> { - let (api_base_url, api_key, platform_session) = - resolve_canvas_sync_api_credentials(None, None)?; - if platform_session.as_ref() != frozen_platform_session { - return Err( - "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" - .to_string(), - ); - } - bind_resource_edit_platform_owner(root, ledger, frozen_platform_session)?; - ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; - match prepare_resource_edit_service_identity(root, ledger, &api_base_url, &api_key)? { + bind_resource_edit_platform_owner(root, ledger, access.frozen_platform_session())?; + access.validate_frozen_session()?; + match prepare_resource_edit_service_identity( + root, + ledger, + access.api_base_url(), + access.bearer_token(), + )? { ResourceEditServiceIdentityDecision::Ready => {} ResourceEditServiceIdentityDecision::ConfirmationRequired(_) => { return Err("service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原资源编辑 operation".to_string()) @@ -2355,44 +2343,29 @@ async fn prepare_remote_resource_edit( .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? } else { let generated = if ledger.remote_operation_id.is_some() { - let result = - wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger) - .await?; - ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; + access.validate_frozen_session()?; result } else { - let source_reference = if matches!( + let prepared_source = if matches!( input.edit_kind, LocalProjectResourceEditKind::ImageReference | LocalProjectResourceEditKind::Video ) { Some( ensure_resource_edit_source_reference( - root, - &client, - &api_base_url, - &api_key, - input, - source, - ledger, + root, &client, access, principal, input, source, ledger, ) .await?, ) } else { None }; - if input.edit_kind == LocalProjectResourceEditKind::ImageReference - && ledger.remote_operation_id.is_none() - && ledger.request_body_json.as_deref().is_some_and(|body| { - serde_json::from_str::(body) - .ok() - .is_some_and(|value| { - value.get("sourceImageSrc").is_some() - || value.get("sourceReferenceId").is_none() - }) - }) + if prepared_source.is_some() + && (ledger.endpoint.is_some() || ledger.request_body_json.is_some()) { - // Only a not-yet-submitted legacy request may be rebuilt. Once an - // operation exists, preserve its immutable request and reconcile it. + // A not-yet-submitted snapshot is not authorization evidence. Rebuild it from + // the exact current-principal source binding so a legacy prepared operation can + // never carry another owner's resourceId, objectKey, or projectId. ledger.endpoint = None; ledger.request_body_json = None; write_resource_edit_ledger(root, ledger)?; @@ -2403,8 +2376,25 @@ async fn prepare_remote_resource_edit( source, prompt, asset_name, - source_reference.as_deref(), + prepared_source + .as_ref() + .map(|prepared| prepared.stable_reference.as_str()), )?; + let mut body = body; + if let Some(prepared) = prepared_source.as_ref() { + let body_object = body + .as_object_mut() + .ok_or_else(|| "资源编辑生成请求正文必须是 object".to_string())?; + body_object.insert( + "projectId".to_string(), + serde_json::Value::String(prepared.canvas_context.project_id.clone()), + ); + body_object.insert( + "assetFolderId".to_string(), + serde_json::Value::String(prepared.canvas_context.asset_folder_id.clone()), + ); + ledger.remote_project_id = Some(prepared.canvas_context.project_id.clone()); + } ledger.endpoint = Some(endpoint.to_string()); ledger.request_body_json = Some( serde_json::to_string(&body) @@ -2412,18 +2402,15 @@ async fn prepare_remote_resource_edit( ); write_resource_edit_ledger(root, ledger)?; } - let submission = - submit_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await?; - ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + let submission = submit_resource_edit_remote(root, &client, access, ledger).await?; + access.validate_frozen_session()?; let operation_id = resource_edit_operation_id(&submission).ok_or_else(|| { "result-unknown: 资源编辑已受理但响应缺少 operationId".to_string() })?; ledger.remote_operation_id = Some(operation_id.clone()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; - let result = - wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger) - .await?; - ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; + access.validate_frozen_session()?; result }; let (resource_id, object_key, asset_object_id, model) = @@ -2436,17 +2423,20 @@ async fn prepare_remote_resource_edit( object_key }; let download_source = serde_json::json!({ "objectKey": object_key }); - let download = resolve_canvas_resource_download_with_limit( + access.validate_frozen_session()?; + let read_url_route = access.api_route("/api/external/v1/assets/read-url"); + let download = resolve_canvas_resource_download_with_limit_and_route( &client, - &api_base_url, - &api_key, + access.api_base_url(), + access.bearer_token(), &download_source, media_read_limit(&input.edit_kind), + &read_url_route, ) .await .map_err(|_| "result-unknown: 远端资源编辑结果下载或换签失败".to_string())? .ok_or_else(|| "远端资源编辑结果缺少可下载媒体".to_string())?; - ensure_frozen_resource_edit_platform_session(platform_session.as_ref())?; + access.validate_frozen_session()?; let (media_type, extension) = validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes)?; write_resource_edit_staging(root, &input.operation_id, &download.bytes)?; @@ -3129,11 +3119,8 @@ fn commit_resource_edit_asset_internal( GameCreationAppAssetSourceKind::Generated }, canvas_project_id: None, - resource_id: ledger - .remote_resource_id - .clone() - .or_else(|| Some(format!("local-asset:{asset_id}"))), - asset_object_id: ledger.remote_asset_object_id.clone(), + resource_id: Some(format!("local-asset:{asset_id}")), + asset_object_id: None, task_id: input.producer_task_id.clone(), prompt: Some(prompt.to_string()), model: ledger @@ -3438,6 +3425,67 @@ fn commit_resource_edit_asset( commit_resource_edit_asset_internal(root, input, source, prompt, asset_name, ledger, None) } +fn write_resource_edit_result_binding( + root: &Path, + input: &DeriveLocalProjectResourceInput, + result: &DeriveLocalProjectResourceResult, + ledger: &ResourceEditLedger, + access: &ExternalEditorBindingAccess<'_>, + principal: &ExternalEditorPrincipalIdentity, +) -> Result<(), String> { + let Some(asset) = result.asset.as_ref() else { + return Ok(()); + }; + let Some(remote_project_id) = ledger.remote_project_id.as_deref() else { + return Ok(()); + }; + let Some(object_key) = ledger.remote_object_key.as_deref() else { + return Ok(()); + }; + let Some(asset_object_id) = ledger.remote_asset_object_id.as_deref() else { + return Ok(()); + }; + if input.edit_kind == LocalProjectResourceEditKind::ImageReference + && ledger.remote_resource_id.is_none() + { + return Ok(()); + } + let bytes = read_stable_resource_edit_file( + root, + &asset.local_path, + media_read_limit(&input.edit_kind), + "派生资源 binding 文件", + )?; + let (width, height) = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + let decoded = image::load_from_memory(&bytes) + .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; + (Some(decoded.width()), Some(decoded.height())) + } else { + (None, None) + }; + let source_identity = new_external_editor_source_identity( + &asset.id, + &sha256_hex(&bytes), + &asset.media_type, + &asset.kind, + )?; + let binding = new_external_editor_resource_binding( + &input.expected_project_id, + principal, + remote_project_id, + &source_identity, + ledger.remote_resource_id.as_deref(), + object_key, + asset_object_id, + width, + height, + unix_timestamp(), + )?; + access.validate_frozen_session()?; + write_external_editor_resource_binding_at(root, &binding)?; + access.validate_frozen_session() +} + fn commit_resource_edit_version( root: &Path, input: &DeriveLocalProjectResourceInput, @@ -4101,6 +4149,7 @@ pub(crate) async fn derive_local_project_resource_at( endpoint: None, request_body_json: None, remote_operation_id: None, + remote_project_id: None, remote_resource_id: None, remote_object_key: None, remote_asset_object_id: None, @@ -4138,11 +4187,43 @@ pub(crate) async fn derive_local_project_resource_at( let _project_guard = project_lock.lock().await; return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger); } - let frozen_platform_session = if input.edit_kind.is_remote_media() { - current_platform_session() + let remote_credentials = if input.edit_kind.is_remote_media() + && ledger.phase != ResourceEditLedgerPhase::MediaDownloaded + { + Some(resolve_canvas_sync_api_credentials(None, None)?) } else { None }; + let frozen_platform_session = remote_credentials + .as_ref() + .and_then(|(_, _, session)| session.clone()) + .or_else(|| { + input + .edit_kind + .is_remote_media() + .then(current_platform_session) + .flatten() + }); + if input.edit_kind.is_remote_media() + && ledger.platform_owner_user_id.is_some() + && frozen_platform_session.is_none() + { + return Err( + "authentication-required: 此资源编辑属于已登录账号,请恢复原账号后提交本地结果" + .to_string(), + ); + } + let binding_principal = remote_credentials + .as_ref() + .map(|(api_base_url, api_key, _)| { + let access = ExternalEditorBindingAccess::new( + api_base_url, + api_key, + frozen_platform_session.as_ref(), + )?; + external_editor_binding_principal(&access) + }) + .transpose()?; bind_resource_edit_platform_owner(root, &mut ledger, frozen_platform_session.as_ref())?; if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { let generation_result = if input.edit_kind.is_text() { @@ -4172,6 +4253,14 @@ pub(crate) async fn derive_local_project_resource_at( })(), } } else if input.edit_kind.is_remote_media() { + let (api_base_url, api_key, _) = remote_credentials + .as_ref() + .ok_or_else(|| "资源编辑缺少 External Editor 凭据".to_string())?; + let access = ExternalEditorBindingAccess::new( + api_base_url, + api_key, + frozen_platform_session.as_ref(), + )?; prepare_remote_resource_edit( root, &input, @@ -4179,7 +4268,10 @@ pub(crate) async fn derive_local_project_resource_at( &prompt, &asset_name, &mut ledger, - frozen_platform_session.as_ref(), + &access, + binding_principal + .as_ref() + .ok_or_else(|| "资源编辑缺少 External Editor principal".to_string())?, ) .await } else { @@ -4202,14 +4294,29 @@ pub(crate) async fn derive_local_project_resource_at( let project_lock = resource_edit_project_mutation_lock(root)?; let _project_guard = project_lock.lock().await; ensure_frozen_resource_edit_platform_session(frozen_platform_session.as_ref())?; - commit_resource_edit_asset(root, &input, &source, &prompt, &asset_name, &mut ledger) + let result = + commit_resource_edit_asset(root, &input, &source, &prompt, &asset_name, &mut ledger)?; + if let (Some((api_base_url, api_key, _)), Some(principal)) = + (remote_credentials.as_ref(), binding_principal.as_ref()) + { + let access = ExternalEditorBindingAccess::new( + api_base_url, + api_key, + frozen_platform_session.as_ref(), + )?; + // The local asset transaction is already committed. A binding-sidecar failure must not + // roll back or hide that durable local result; a future edit will safely re-upload bytes. + let _ = + write_resource_edit_result_binding(root, &input, &result, &ledger, &access, principal); + } + Ok(result) } #[cfg(test)] mod tests { use super::*; use std::future::Future; - use std::io::{Read, Write}; + use std::io::{Cursor, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; @@ -4316,6 +4423,14 @@ mod tests { .expect("write resource editor media bytes"); } + fn resource_editor_test_png() -> Vec { + let mut output = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(2, 2) + .write_to(&mut output, image::ImageFormat::Png) + .expect("encode resource editor PNG"); + output.into_inner() + } + fn input( root: &Path, operation_id: String, @@ -4384,6 +4499,7 @@ mod tests { endpoint: None, request_body_json: None, remote_operation_id: None, + remote_project_id: None, remote_resource_id: None, remote_object_key: None, remote_asset_object_id: None, @@ -4649,60 +4765,297 @@ mod tests { } #[tokio::test] - async fn derived_video_reuses_committed_object_key_instead_of_asset_object_id() { - let directory = tempfile::tempdir().expect("create committed video fixture"); - let root = directory.path(); - init_local_game_project_at(root, PROJECT_ID, "派生视频二次编辑测试") - .expect("initialize project"); - let source_bytes = b"\0\0\0\x18ftypisom\0\0\0\0isomiso2"; - let uploaded = upload_local_asset_at(root, "derived-video.mp4", "video/mp4", source_bytes) - .expect("register derived video"); - let manifest = read_existing_manifest_for_project(root).expect("read manifest"); - let source_asset = manifest - .assets - .iter() - .find(|asset| asset.id == uploaded.id) - .cloned() - .expect("source asset"); - let mut prior_input = input( - root, - Uuid::new_v4().to_string(), - LocalProjectResourceEditKind::Video, - format!("asset:{}", source_asset.id), - ); - prior_input.source_asset_id = Some(source_asset.id.clone()); - prior_input.source_path = Some(source_asset.local_path.clone()); - prior_input.source_media_type = Some(source_asset.media_type.clone()); - let source = - resolve_resource_edit_source(root, &manifest, &prior_input).expect("resolve source"); - let mut committed = ledger_for(&prior_input, &source, ResourceEditLedgerPhase::Committed); - committed.result_asset_id = Some(source_asset.id.clone()); - committed.remote_object_key = Some("generated/stable-video.mp4".to_string()); - committed.remote_asset_object_id = Some("asset-object-must-not-be-used".to_string()); - write_resource_edit_ledger(root, &committed).expect("write committed ledger"); + async fn account_principal_switch_rebinds_image_and_video_without_sending_owner_a_ids() { + for (edit_kind, file_name, media_type, source_bytes) in [ + ( + LocalProjectResourceEditKind::ImageReference, + "owner-a-image.png", + "image/png", + resource_editor_test_png(), + ), + ( + LocalProjectResourceEditKind::Video, + "owner-a-video.mp4", + "video/mp4", + b"\0\0\0\x18ftypisom\0\0\0\0isomiso2".to_vec(), + ), + ] { + let directory = tempfile::tempdir().expect("create account switch fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "跨账号资源编辑测试") + .expect("initialize project"); + let uploaded = upload_local_asset_at(root, file_name, media_type, &source_bytes) + .expect("register local source"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter_mut() + .find(|asset| asset.id == uploaded.id) + .expect("source asset"); + source_asset.source.kind = GameCreationAppAssetSourceKind::Canvas; + source_asset.source.canvas_project_id = Some("owner-a-project".to_string()); + source_asset.source.resource_id = Some("editor-resource-owner-a".to_string()); + source_asset.source.asset_object_id = Some("owner-a-asset-object".to_string()); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write owner A manifest metadata"); - let mut current_input = prior_input.clone(); - current_input.operation_id = Uuid::new_v4().to_string(); - current_input.idempotency_key = Uuid::new_v4().to_string(); - let mut current = ledger_for(¤t_input, &source, ResourceEditLedgerPhase::Prepared); - let reference = ensure_resource_edit_source_reference( - root, - &reqwest::Client::new(), - "http://127.0.0.1:9", - "unused-key", - ¤t_input, - &source, - &mut current, - ) - .await - .expect("reuse committed object key without upload"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind account switch server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("account switch address") + ); + let upload_url = format!("{base_url}/upload-owner-b-source"); + let is_image = edit_kind == LocalProjectResourceEditKind::ImageReference; + let owner_b_object_key = format!( + "generated/owner-b-source.{}", + if is_image { "png" } else { "mp4" } + ); + let request_count = if is_image { 6 } else { 7 }; + let server_upload_url = upload_url.clone(); + let server_object_key = owner_b_object_key.clone(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + for request_index in 0..request_count { + let mut stream = accept_resource_editor_fixture_connection( + &listener, + "account switch fixture", + request_index, + ); + let request = read_http_request(&mut stream); + sender.send(request.clone()).expect("capture B request"); + let request_line = request.lines().next().unwrap_or_default(); + if request_line.starts_with("GET /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + if is_image { + serde_json::json!({"data": {"projects": [{ + "projectId": "owner-b-project" + }]}}) + } else { + serde_json::json!({"data": {"projects": []}}) + }, + ); + } else if request_line.starts_with("POST /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"project": {"projectId": "owner-b-project"}}}), + ); + } else if request_line + .starts_with("GET /api/external/v1/editor/assets/library ") + { + write_json( + &mut stream, + "200 OK", + if is_image { + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "owner-b-folder" + }]}}}) + } else { + serde_json::json!({"data": {"library": {"folders": []}}}) + }, + ); + } else if request_line + .starts_with("POST /api/external/v1/editor/assets/folders ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"folder": {"folderId": "owner-b-folder"}}}), + ); + } else if request_line + .starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": server_upload_url, + "bucket": "private-test-bucket", + "objectKey": server_object_key, + "successActionStatus": 204, + "formFields": {"key": server_object_key} + }}}), + ); + } else if request_line.starts_with("POST /upload-owner-b-source ") { + write_bytes(&mut stream, "204 No Content", "text/plain", &[]); + } else if request_line + .starts_with("POST /api/external/v1/assets/objects/confirm ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": server_object_key, + "assetObjectId": "owner-b-asset-object" + }}}), + ); + } else if request_line.starts_with( + "POST /api/external/v1/editor/projects/owner-b-project/resources ", + ) { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-owner-b" + }}}), + ); + } else { + panic!("unexpected account switch request: {request_line}"); + } + } + }); - assert_eq!(reference, "generated/stable-video.mp4"); - assert_ne!(reference, "asset-object-must-not-be-used"); - assert_eq!( - current.source_stable_reference.as_deref(), - Some(reference.as_str()) - ); + let owner_a_access = ExternalEditorBindingAccess::new(&base_url, "owner-a-token", None) + .expect("owner A access"); + let owner_a_principal = + external_editor_binding_principal(&owner_a_access).expect("owner A principal"); + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + PROJECT_ID, + &owner_a_principal, + "owner-a-project", + "owner-a-folder", + 1, + ) + .expect("owner A project binding"), + ) + .expect("write owner A project binding"); + + let mut request = input( + root, + Uuid::new_v4().to_string(), + edit_kind.clone(), + format!("asset:{}", uploaded.id), + ); + request.source_asset_id = Some(uploaded.id.clone()); + request.source_path = Some(uploaded.local_path.clone()); + request.source_media_type = Some(media_type.to_string()); + let manifest = read_existing_manifest_for_project(root).expect("read source manifest"); + let source = + resolve_resource_edit_source(root, &manifest, &request).expect("resolve source"); + let source_identity = new_external_editor_source_identity( + &uploaded.id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + ) + .expect("owner A source identity"); + let (a_resource_id, a_width, a_height) = + if edit_kind == LocalProjectResourceEditKind::ImageReference { + (Some("editor-resource-owner-a"), Some(2), Some(2)) + } else { + (None, None, None) + }; + write_external_editor_resource_binding_at( + root, + &new_external_editor_resource_binding( + PROJECT_ID, + &owner_a_principal, + "owner-a-project", + &source_identity, + a_resource_id, + "generated/owner-a-object", + "owner-a-asset-object", + a_width, + a_height, + 1, + ) + .expect("owner A resource binding"), + ) + .expect("write owner A resource binding"); + + let owner_b_access = ExternalEditorBindingAccess::new(&base_url, "owner-b-token", None) + .expect("owner B access"); + let owner_b_principal = + external_editor_binding_principal(&owner_b_access).expect("owner B principal"); + if is_image { + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + PROJECT_ID, + &owner_b_principal, + "owner-b-project", + "owner-b-folder", + 2, + ) + .expect("owner B project binding"), + ) + .expect("write owner B project binding"); + write_external_editor_resource_binding_at( + root, + &new_external_editor_resource_binding( + PROJECT_ID, + &owner_b_principal, + "owner-b-project", + &source_identity, + None, + "generated/stale-owner-b-image", + "stale-owner-b-asset-object", + Some(2), + Some(2), + 2, + ) + .expect("stale owner B image binding"), + ) + .expect("write stale owner B image binding"); + } + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + let prepared = ensure_resource_edit_source_reference( + root, + &reqwest::Client::new(), + &owner_b_access, + &owner_b_principal, + &request, + &source, + &mut ledger, + ) + .await + .expect("owner B rebinds committed local bytes"); + server.join().expect("join account switch server"); + + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), request_count); + for request in &requests { + for owner_a_id in [ + "owner-a-project", + "owner-a-folder", + "editor-resource-owner-a", + "generated/owner-a-object", + "owner-a-asset-object", + "owner-a-token", + ] { + assert!( + !request.contains(owner_a_id), + "B request leaked {owner_a_id}" + ); + } + } + assert_eq!(prepared.canvas_context.project_id, "owner-b-project"); + assert_eq!(ledger.remote_project_id.as_deref(), Some("owner-b-project")); + let owner_b_binding = read_external_editor_resource_binding_at( + root, + PROJECT_ID, + &owner_b_principal, + "owner-b-project", + &source_identity, + ) + .expect("read owner B binding") + .expect("owner B binding exists"); + assert_eq!(owner_b_binding.object_key, owner_b_object_key); + if edit_kind == LocalProjectResourceEditKind::ImageReference { + assert_eq!(prepared.stable_reference, "editor-resource-owner-b"); + assert_eq!( + owner_b_binding.remote_resource_id.as_deref(), + Some("editor-resource-owner-b") + ); + } else { + assert_eq!(prepared.stable_reference, owner_b_object_key); + assert_eq!(owner_b_binding.remote_resource_id, None); + } + } } #[tokio::test] @@ -4744,8 +5097,9 @@ mod tests { write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - submit_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + submit_resource_edit_remote(root, &client, &access, &mut ledger).await }) .await .expect_err("authentication status must fail"); @@ -4777,8 +5131,9 @@ mod tests { write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + wait_for_resource_edit_remote(root, &client, &access, &mut ledger).await }) .await .expect_err("poll authentication status must fail"); @@ -4809,8 +5164,9 @@ mod tests { ); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + wait_for_resource_edit_remote(root, &client, &access, &mut ledger).await }) .await .expect_err("missing remote status must stay unknown"); @@ -4997,14 +5353,10 @@ mod tests { .expect("read confirmed ledger") .expect("confirmed ledger"); with_test_external_editor_credentials(&base_url, new_api_key, async { - wait_for_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - new_api_key, - &mut resumed, - ) - .await + let access = ExternalEditorBindingAccess::new(&base_url, new_api_key, None) + .expect("binding access"); + wait_for_resource_edit_remote(root, &reqwest::Client::new(), &access, &mut resumed) + .await }) .await .expect("resume accepted operation by GET"); @@ -5073,14 +5425,10 @@ mod tests { ); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - submit_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + submit_resource_edit_remote(root, &reqwest::Client::new(), &access, &mut ledger) + .await }) .await .expect_err("submission status must fail"); @@ -5197,14 +5545,10 @@ mod tests { ); }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - submit_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + submit_resource_edit_remote(root, &reqwest::Client::new(), &access, &mut ledger) + .await }) .await .expect_err("unproven accepted response must fail closed"); @@ -5345,14 +5689,9 @@ mod tests { }); let error = with_test_external_editor_credentials(&base_url, "rotated-key", async { - wait_for_resource_edit_remote( - root, - &reqwest::Client::new(), - &base_url, - "rotated-key", - &mut ledger, - ) - .await + let access = ExternalEditorBindingAccess::new(&base_url, "rotated-key", None) + .expect("binding access"); + wait_for_resource_edit_remote(root, &reqwest::Client::new(), &access, &mut ledger).await }) .await .expect_err("failed status must become terminal"); @@ -5579,7 +5918,7 @@ mod tests { let server_generated_video = generated_video.clone(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for request_index in 0..7 { + for request_index in 0..11 { let mut stream = accept_resource_editor_fixture_connection( &listener, "External video fixture", @@ -5591,7 +5930,37 @@ mod tests { .expect("capture External video request"); let request_line = request.lines().next().unwrap_or_default(); let request_lower = request.to_ascii_lowercase(); - if request_line.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") { + if request_line.starts_with("GET /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": []}}), + ); + } else if request_line.starts_with("POST /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"project": { + "projectId": "external-video-project" + }}}), + ); + } else if request_line.starts_with("GET /api/external/v1/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": []}}}), + ); + } else if request_line.starts_with("POST /api/external/v1/editor/assets/folders ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"folder": { + "folderId": "external-video-folder" + }}}), + ); + } else if request_line + .starts_with("POST /api/external/v1/assets/direct-upload-tickets ") + { assert!(request_lower .contains("authorization: bearer resource-editor-external-key")); write_json( @@ -5630,6 +5999,7 @@ mod tests { .contains("authorization: bearer resource-editor-external-key")); assert!(request_lower.contains("idempotency-key:")); assert!(request.contains(&server_source_key)); + assert!(request.contains("external-video-project")); write_json( &mut stream, "202 Accepted", @@ -5696,7 +6066,7 @@ mod tests { .expect("derive External video"); server.join().expect("join External video server"); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 7); + assert_eq!(requests.len(), 11); assert!(requests.iter().all(|request| { !request.starts_with("POST /api/editor/") && !request.starts_with("POST /api/assets/") diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 773fc4d37..a0a10aba0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14355,3 +14355,12 @@ - 素材生成确认、离开确认和旧服务身份确认沿用现有独立 modal,不在当前面板下方追加内容。modal 打开后焦点必须进入对话框,并同时隔离工具栏、画布视口、缩放/小地图、状态区和并存操作层,使背景从键盘焦点顺序与 accessibility tree 中退出。 - `Tab` / `Shift+Tab` 必须在当前 modal 内双向循环;非异步 pending 状态允许 `Escape` 安全关闭。关闭后优先恢复到原触发器,自动弹出的 modal 则回退到可操作的工具栏入口,不能把焦点遗留在已卸载节点或被隔离背景中。 + +## 2026-08-23 AGC 本地资源与 External Editor 账号绑定分离 + +- 权威边界:本地项目 ID、manifest asset、正式本地文件和内容摘要属于设备上的本地项目;`canvasProjectId / resourceId / assetObjectId / objectKey` 属于具体 External Editor 服务 principal。manifest 中现有远端字段继续保留生成来源,不再承担“当前账号可编辑句柄”,本轮不修改共享 manifest schema。 +- 私有投影:统一在 `.agent/runtime/external-editor-bindings/` 持久化 active binding。项目 binding 按服务 origin、平台 userId 或 Developer Key 摘要以及本地 `projectId` 分区;资源 binding 再绑定远端项目、本地 asset ID、源 SHA-256、媒体类型和 canonical asset kind。sidecar 不保存 Token、Key、Cookie、Authorization、签名上传表单、媒体正文或绝对路径。 +- 切号语义:账号 B 打开账号 A 曾生成资源的同一本地项目时,不访问、迁移或覆盖 A 的私有画布;B 从本地正式文件在自己的远端项目重新上传、confirm、登记并形成独立 binding。切回 A 时复用 A binding。项目标题只用于远端展示,不能作为同名项目的权威关联;项目改名不换 binding。 +- 在途边界:资源编辑、素材画布生成和 Agent 美术生成的 `prepared / accepted / running` 账本继续锁定发起 principal。账号切换后只能停止请求并保留原 operation 供切回或对账,不得把在途副作用迁移到新账号,也不得重建正文或重复 POST。只有尚未提交远端生成的新操作可以为当前账号建立新的项目和资源 binding。 +- 集成边界:`resource_editor`、素材画布参考准备、`canvas.asset_generate` 的 art-spec 派生和直连只读恢复统一消费当前 principal binding;PR 176 rebase 后必须删除或整合其局部 canonical cache,不能保留第二套账号身份系统。External v1 路由和公开 DTO 不变,无需修改 OpenAPI。 +- 验收:至少覆盖 A 生成到本地后切 B 重登记、B 请求零 A 远端 ID、重启后复用 B、切回 A 复用 A、两个同名本地项目隔离、项目改名不漂移、源 SHA 或 kind 变化重登记、A 在途任务切 B 零网络,以及 sidecar 零凭据和身份篡改失败关闭。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 29ce6c558..655f864f0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4839,3 +4839,11 @@ - 原因:当前完整模块 init 的 RSS 会超过基础 Compose 旧 `896m` cgroup 上限;内核 OOM kill SpacetimeDB,客户端只看到传输错误,容易被误判为网络竞态。 - 处理:先查 kernel journal 的 `Memory cgroup out of memory` 和目标容器 ID,再把本地/预发完整容器 SpacetimeDB 上限统一为 `2g`;保留 page pool 限制。不要只增加 publish 重试,也不要把 `/healthz` 或首页改成数据库就绪探针。 - 验证:用新空卷完成模块 publish、五服务启动和 Web/API smoke,并确认容器未 OOM、SpacetimeDB 与 API/Nginx 最终 healthy。 + +## 同一本地项目切换账号后不能继续信任 manifest 远端 ID(2026-08-23) + +- 现象:本地资源文件仍存在,但账号 A 生成后切到账号 B,快速编辑、GIF/视频派生、art-spec 下游生成或直连恢复提示画布不存在、无权限或资源不属于当前项目;原请求重试仍失败。 +- 原因:本地 manifest 只有一组 `source.canvasProjectId / resourceId / assetObjectId`,旧实现把它同时当作历史来源和当前账号可编辑引用。新的 operation 虽已绑定 B,却会把 A 的 project/resource/object 身份配合 B Token 发出;另一部分链路又把 A 的画布 ID 与 B 按标题选出的项目严格比较。 +- 处理:manifest 远端字段只保留历史 provenance。所有远端编辑和派生先解析 `.agent/runtime/external-editor-bindings/` 中当前 principal 的项目/资源 binding;不存在时从本地正式文件按 asset ID、SHA-256、媒体类型和 canonical kind 在当前账号重登记。禁止按标题、manifest ID 或其他账号 committed ledger 自动采纳远端对象。A 的在途 operation 继续留在 A,B 只能开始自己的新操作。 +- 验证:用真实平台会话 fixture 覆盖 A→B→重启→A,逐个断言 B 的 URL、请求体和稳定引用中没有 A 的 project/resource/object ID;另测同名 localProjectId 隔离、改名不漂移、源摘要变化、非 refine 参考、视频 committed objectKey、art-spec 派生以及在途 operation 切号零网络。Developer Key fixture 不能替代平台账号隔离证据。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs`、`project/resource_editor.rs`、`project/asset_canvas/generation.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_runtime.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8a4479b41..edddd254e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -395,7 +395,9 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpoint;Runtime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 -- 后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;普通已认证客户端通过固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*` 调用平台,独立 standalone game-chat/显式高级模式才通过 `/api/external/v1/*` 与自备 Developer Key 调用。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 用图片生成 `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,用图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions` 调用图标 spritesheet 生成,产出真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把调用模式、相对 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;普通模式还冻结 `official-platform-v1 + 官方 origin + ownerUserId`,高级模式冻结 External service origin,两者均不保存 Token 或 Key。`accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许使用原 endpoint、原始 JSON 字节与同一键恢复 POST,不得重建远端项目/目录、重组正文或分配新键。普通模式站内响应兼容 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;高级 External v1 固定 `202 + operationId`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。轮询超时、临时鉴权或网关错误只保留原 operation 并在恢复时继续;结果损坏、状态损坏、透明派生失败或远端完成后的本地提交失败进入对账。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局。旧正式图不合格时只返回 `needs-repair`;Supervisor 认领后由原 owner 使用 `replaceExisting=true` 原位替换。凭据不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- 后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;普通已认证客户端通过固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*` 调用平台,独立 standalone game-chat/显式高级模式才通过 `/api/external/v1/*` 与自备 Developer Key 调用。生成前按当前服务 principal 与本地 `projectId` 的私有 binding 创建或复用远端画布项目和素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 用图片生成 `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,用图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions` 调用图标 spritesheet 生成,产出真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把调用模式、相对 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;普通模式还冻结 `official-platform-v1 + 官方 origin + ownerUserId`,高级模式冻结 External service origin,两者均不保存 Token 或 Key。`accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许使用原 endpoint、原始 JSON 字节与同一键恢复 POST,不得重建远端项目/目录、重组正文或分配新键。普通模式站内响应兼容 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;高级 External v1 固定 `202 + operationId`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。轮询超时、临时鉴权或网关错误只保留原 operation 并在恢复时继续;结果损坏、状态损坏、透明派生失败或远端完成后的本地提交失败进入对账。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局。旧正式图不合格时只返回 `needs-repair`;Supervisor 认领后由原 owner 使用 `replaceExisting=true` 原位替换。凭据不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- External Editor 的远端项目和资源属于服务 principal,不属于本地项目的跨账号身份。`.agent/manifest.json` 中 `source.canvasProjectId / resourceId / assetObjectId` 只记录生成来源,不得作为当前登录账号的 active editable binding。客户端在 `.agent/runtime/external-editor-bindings/` 私有保存账号级投影:项目键至少包含服务 origin、principal 和本地 `projectId`,资源键再包含远端项目、本地 asset ID、源文件 SHA-256、媒体类型与 canonical asset kind;值保存该 principal 下的远端 project/folder/resource/object 身份。Token 轮换继续复用同一 principal binding,账号、服务、源字节或 canonical kind 变化必须重新建立;同名本地项目和项目改名不得参与权威匹配。 +- 已完成的本地正式资源以本地文件和 manifest asset 身份为权威。切换账号后,当前账号没有资源 binding 时,必须从本地字节上传、confirm 并登记到当前账号的远端项目后再编辑或派生;任何请求都不得把旧账号的 `canvasProjectId / resourceId / objectKey` 与新账号 Token 组合。新绑定不得覆盖历史来源,也不得迁移或删除旧账号远端对象。已经 `prepared / accepted / running` 的生成或编辑 operation 仍冻结原 principal、原正文和原 operation;切换账号只能停止恢复并保留对账证据,不能改绑、重放或重复扣费。 - 2026-08-03 durable 恢复补充,2026-08-04 扩展 `prepared`:`prepared / accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源;`prepared` 只原样恢复同一逻辑 POST,`accepted` 只查询既有 operation。不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住恢复。显式“继续/恢复”复用原 action、pending、provider batch 与生成账本身份,不创建 successor 请求或新幂等键。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 - 生成账本必须绑定调用模式与服务身份:普通模式为固定官方 origin + ownerUserId,高级 External v1 模式为显式 service origin;Access Token 或 Developer Key 只负责当前请求授权,轮换不得改变已有 operation 的归属。退出或换号提升单调账号 generation,中止并脱离旧请求;旧 owner 账本在新 owner 下禁止 POST、GET、下载、安装和归档,只有重新登录同一 owner 后才能恢复。绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。首次请求结果已经未知后,鉴权或其它非成功响应不能证明原请求未入队,必须保留原账本。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。