From 32da0135455d0c397af229109af7a8142d46a1af Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Sun, 23 Aug 2026 01:20:28 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAGC=20Skill=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=8C=87=E7=BA=B9=E6=BC=82=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一以UTF-8 LF规范化内容计算Skill与清单指纹 安装隔离Skill时写入规范化文本避免Windows混合换行 增加LF与CRLF等价及安装结果回归测试 同步Skill Pack版本与实施文档、技术方案和踩坑记录 --- .../resources/agc-skills/manifest.json | 2 +- .../src-tauri/src/agent/skill_pack.rs | 63 +++++++++++++++++-- ...计划】AGC直连Codex Runtime迁移-2026-08-15.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 4 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 5 files changed, 62 insertions(+), 11 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 6a0ef530b..1bc0f29ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-22.2", + "version": "2026-08-22.3", "skills": [ { "name": "agc-project-structure", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 405281980..a7a9f5b80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use sha2::{Digest, Sha256}; +use std::borrow::Cow; use std::collections::BTreeSet; use std::path::{Component, Path}; @@ -122,6 +123,29 @@ fn bundled_skill_file(path: &str) -> Option<&'static [u8]> { .find_map(|(candidate, bytes)| (*candidate == path).then_some(*bytes)) } +fn canonical_skill_text_bytes<'a>(path: &str, bytes: &'a [u8]) -> Result, String> { + let text = std::str::from_utf8(bytes) + .map_err(|_| format!("内置 AGC Skill 审核文件 {path} 不是 UTF-8 文本"))?; + if !text.contains("\r\n") { + return Ok(Cow::Borrowed(bytes)); + } + Ok(Cow::Owned(text.replace("\r\n", "\n").into_bytes())) +} + +fn update_skill_content_digest( + digest: &mut Sha256, + relative: &str, + bundled_path: &str, + bytes: &[u8], +) -> Result<(), String> { + let canonical_bytes = canonical_skill_text_bytes(bundled_path, bytes)?; + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(canonical_bytes.as_ref()); + digest.update([0]); + Ok(()) +} + fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result { let mut files = entry.files.clone(); files.sort(); @@ -133,10 +157,7 @@ fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result Result { pub(crate) fn agc_skill_pack_fingerprint() -> Result { validated_skill_pack_manifest()?; - Ok(format!("{:x}", Sha256::digest(AGC_SKILL_PACK_MANIFEST))) + let canonical_manifest = canonical_skill_text_bytes("manifest.json", AGC_SKILL_PACK_MANIFEST)?; + Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref()))) } pub(crate) fn render_agc_skill_pack_index() -> Result { @@ -260,12 +282,13 @@ pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result String { + let mut digest = Sha256::new(); + update_skill_content_digest( + &mut digest, + "agents/openai.yaml", + "agc-project-structure/agents/openai.yaml", + bytes, + ) + .expect("hash reviewed skill text"); + format!("{:x}", digest.finalize()) + } + + assert_eq!( + digest(b"interface:\n display_name: AGC\n short_description: test\n"), + digest(b"interface:\r\n display_name: AGC\n short_description: test\r\n") + ); + } + #[test] fn skill_pack_installs_under_isolated_home_without_full_body_in_index() { let home = tempfile::tempdir().expect("temporary home"); @@ -308,6 +351,14 @@ mod tests { .path() .join(".agents/skills/taonier-art-assets/SKILL.md") .is_file()); + let installed_agent_metadata = std::fs::read( + home.path() + .join(".agents/skills/agc-project-structure/agents/openai.yaml"), + ) + .expect("read installed agent metadata"); + assert!(!installed_agent_metadata + .windows(2) + .any(|pair| pair == b"\r\n")); let index = render_agc_skill_pack_index().expect("render index"); assert!(index.contains("taonier-art-assets")); assert!(index.contains("agc_tools.taonier_prepare_game_art")); diff --git a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md index 603a7926e..3f34e1b25 100644 --- a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md +++ b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md @@ -310,7 +310,7 @@ ### 14.2 审核索引与五类 Skill -客户端内置 `agc-skill-pack.v1` 清单。每项只公开名称、用途、触发条件、所需工具、版本和内容 SHA-256;审核文件变化时必须在同次变更重算对应指纹。启动时逐文件复核清单和编译进客户端的内容,任何缺失、额外文件、路径越界或指纹不匹配都失败关闭;路径边界显式拒绝反斜杠、Windows 盘符、UNC、绝对路径和 `..`,不能因测试运行在 Linux 就把 Windows 绝对路径当作普通相对文件名。审核包只包含: +客户端内置 `agc-skill-pack.v1` 清单。每项只公开名称、用途、触发条件、所需工具、版本和内容 SHA-256;审核文本统一按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免编辑器产生的混合换行让同一 Git 内容在 Windows 与 Linux 上得到不同结果。审核文件的语义内容变化时必须在同次变更重算对应指纹并提升版本。启动时逐文件复核清单和编译进客户端的内容,任何缺失、额外文件、路径越界、非 UTF-8 内容或规范化后的指纹不匹配都失败关闭;路径边界显式拒绝反斜杠、Windows 盘符、UNC、绝对路径和 `..`,不能因测试运行在 Linux 就把 Windows 绝对路径当作普通相对文件名。审核包只包含: 1. `agc-project-structure`:项目根、`game/`、`assets/`、`.agent/` 的职责和禁止创建平行项目的约束。 2. `taonier-art-assets`:陶泥儿标准美术包、平台来源、警告语义和真实素材使用;`grid-2x2` 与四切片只是推荐路径,不是所有游戏的完成门。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 113a0281c..3af3fbd11 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4235,8 +4235,8 @@ - 现象:内置 Skill 文件集合没有缺失,原生测试却统一报内容指纹不匹配;另一个测试在 Linux 上把 `C:\\temp\\SKILL.md` 判为安全相对路径,受控资源工具可能继续处理 Windows 盘符或反斜杠遍历形式。 - 原因:审核文件定稿后未按最终字节重新生成 manifest SHA-256;同时 `std::path::Path` 只按当前宿主语义解析路径,Linux 不会把 Windows 盘符和反斜杠视为绝对路径或分隔符。 - 处理:Skill 文件变化与 manifest 指纹更新必须同次提交,并提升审核包版本;资源引用只接受使用 `/` 的普通相对段,显式拒绝反斜杠、冒号盘符、UNC、绝对路径和父目录段,再查询审核清单。不要先把反斜杠替换成 `/` 后再做安全检查。 -- 回归补充:即使 Skill 文件本轮没有变化,也不能从旧提交或旧构建结果复制清单指纹;必须对当前工作树最终字节现场重算,并在提交前运行原生 Skill Pack 校验。运行时只报告排序后的首个不匹配项,不能据此假定其余 Skill 已通过。 -- 验证:逐项按排序后的 `relativePath + NUL + file bytes + NUL` 重算并核对 manifest;Rust 单测同时覆盖 POSIX 绝对路径、`..`、`C:\\...`、`C:/...`、UNC 和反斜杠相对路径,受控 MCP 工具也必须把 Windows 绝对路径投影为 `isError=true`。 +- 回归补充:即使 Skill 文件本轮没有变化,也不能从旧提交或旧构建结果复制清单指纹;必须对当前工作树按 UTF-8 读取、将 CRLF 规范为 LF 后现场重算,并在提交前运行原生 Skill Pack 校验。Git 的 `eol=lf` 不能阻止编辑器在干净工作树里留下少量混合 CRLF,而 Cargo `include_bytes!` 会读取这些原始字节;因此运行时计算与安装也必须使用同一规范化函数。运行时只报告排序后的首个不匹配项,不能据此假定其余 Skill 已通过。 +- 验证:逐项按排序后的 `relativePath + NUL + canonical UTF-8 LF bytes + NUL` 重算并核对 manifest;Rust 单测同时覆盖 LF / CRLF 指纹等价、安装结果只含 LF、POSIX 绝对路径、`..`、`C:\\...`、`C:/...`、UNC 和反斜杠相对路径,受控 MCP 工具也必须把 Windows 绝对路径投影为 `isError=true`。 ## Gitea CI 预构建镜像不能只靠 tag 判断内容 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 1f0912b42..2141a0b9d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1162,7 +1162,7 @@ game-project/ - 普通项目对话只由一个 project-bound Codex app-server thread 执行。客户端系统提示词只放最小工程合同、当前游戏源码有界快照、项目 prompts 和审核 Skill 索引;不再批量读取项目 `.codex/.agents/.hermes` Skill 正文,也不恢复 Supervisor、专业 Agent 或 harness。 - 首页恢复“做游戏 / 做素材 / 做方案”三个创作类型,默认“做游戏”。该选择与设置页的 Agent Runtime 模式无关;每次首页提交仍只自动创建一个新项目并进入项目工作台。用户正文原样进入项目对话,`game|art|doc` 仅作为受限结构化首轮上下文传给同一 Codex thread,不拼接“初始意图”文案、不产生首页对话、不切换 Provider 或恢复旧 Runtime 编排。 -- `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;任何审核文件变化都必须同步重算对应清单指纹。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。 +- `agc-skill-pack.v1` 只包含项目结构、陶泥儿美术、Web 游戏实现、真实浏览器试玩、客户端资源投影五项 Skill。清单记录用途、触发条件、所需工具、版本和内容 SHA-256;审核文本按 UTF-8 读取并将 CRLF 规范为 LF 后计算指纹和安装,避免混合换行造成 Windows / Linux 构建结果漂移,语义内容变化时必须同步重算对应清单指纹并提升版本。客户端把审核文件安装到隔离目录后通过 app-server `skills/extraRoots/set + skills/list` 注册并复核,完整正文由 Codex 原生 Skill 机制按意图加载,一层引用只能经 `agc_read_skill_resource` 读取清单内 Markdown。引用路径按平台无关规则拒绝反斜杠、盘符、UNC、绝对路径和 `..`,不能依赖当前宿主的 `std::path` 语义判断其它平台路径。 - DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP,工具固定为审核引用读取、标准陶泥儿美术准备和 desktop/mobile 浏览器试玩。MCP 进程只做协议;真实浏览器和付费 External v1 调用通过随机 loopback 地址回到客户端主进程,因此不复制 GUI 登录态、开发者 Key 或项目路径到模型上下文。三项工具固定自动批准,通用 shell、任意网络、多 Agent、插件和外部 MCP 继续关闭。 - 陶泥儿生成继续复用既有私有 Key、持久幂等账本、operation 恢复、来源/下载/PNG 解码和 manifest 登记。完整可信图集缺切片可以继续,固定四切片只是推荐路径;凭据失效、来源不明或结果未知时失败关闭,不能自动换 Key 或重新扣费。 - 自定义 LLM API Key 路由只在 DirectHome/DirectProject 经 loopback `/responses` 流式代理转发。代理不注入 Key,只要求请求自带 Bearer,并剥离开发网关错误携带的 `X-Codex-*` ChatGPT 账户额度头,防止隔离 app-server 把 API Provider 误判为余额 0;旧 ToolHost 保持原 Provider 行为。 From 01b10aafb202abc8f2441ebf4009e864a66ee94e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 22 Aug 2026 21:54:36 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=94=9F=E6=88=90=E6=81=A2=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E6=80=81=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 素材生成凭据失败保留原幂等身份并支持登录后恢复 素材生成在远端提交、下载暂存和正式提交前复验会话与草稿状态 稳定 staging token 支持半提交修复并对冲突和文件系统异常失败关闭 素材画布三类模态框增加焦点圈闭、背景隔离和焦点恢复 Tauri 命令可达性改为 TypeScript AST 扫描并收紧 allowlist 补充生成恢复、取消态、staging 与模态交互回归及项目决策记录 --- .../scripts/check-config.mjs | 241 +++++++- .../src-tauri/src/project/asset_canvas.rs | 158 +++-- .../src/project/asset_canvas/generation.rs | 539 +++++++++++++++++- .../src/project/asset_canvas_tests.rs | 145 +++++ .../asset-canvas/AssetCanvasSurface.tsx | 125 +++- .../tests/assetCanvasSurface.test.tsx | 84 ++- .../shared-memory/decision-log.md | 17 + 7 files changed, 1197 insertions(+), 112 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 4ad40ee2a..bb8a9748f 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -4,6 +4,7 @@ import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import ts from 'typescript'; import { appIdentifier, @@ -80,7 +81,10 @@ const appSource = [ readSourceTree(new URL('../src/', import.meta.url), '.ts'), readSourceTree(new URL('../src/', import.meta.url), '.tsx'), ].join('\n'); -const appInvokeSource = appSource; +const appInvokeSources = readSourceFiles( + new URL('../src/', import.meta.url), + new Set(['.ts', '.tsx']), +); const appEntrypointSource = fs.readFileSync( new URL('../src/main.tsx', import.meta.url), 'utf8', @@ -120,23 +124,10 @@ const rustSharedContractSource = fs.readFileSync( 'utf8', ); const allowedUncalledTauriCommands = [ - 'archive_failed_local_project_resource_edit', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', - 'commit_local_project_asset', - 'confirm_local_project_asset_canvas_generation_service_identity', - 'create_local_project_asset_canvas_draft', - 'discard_local_project_asset_canvas_draft', - 'generate_local_project_asset_canvas_image', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', - 'read_local_project_asset_canvas_draft', - 'read_local_project_asset_canvas_media', - 'recover_local_project_asset_canvas_transactions', - 'recover_local_project_asset_canvas_generations', - 'stage_local_project_asset_canvas_image', - 'store_local_project_asset_canvas_media', - 'update_local_project_asset_canvas_draft', ]; const sourceExtensions = new Set([ '.json', @@ -186,6 +177,23 @@ function readSourceTree(path, extension) { return fs.readFileSync(path, 'utf8'); } +function readSourceFiles(path, extensions) { + const stat = fs.statSync(path); + if (stat.isDirectory()) { + return fs + .readdirSync(path, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => + readSourceFiles( + new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path), + extensions, + ), + ); + } + if (!extensions.has(pathnameExtension(path.pathname))) return []; + return [{ fileName: path.pathname, source: fs.readFileSync(path, 'utf8') }]; +} + function pathnameExtension(pathname) { const index = pathname.lastIndexOf('.'); return index === -1 ? '' : pathname.slice(index); @@ -317,13 +325,94 @@ function assertContractRecordsMatch(label, leftRecords, rightRecords) { } } -function parseAppInvokeCommandNames(source) { - return Array.from( - source.matchAll( - /(?:invoke|directInvoke)(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g, - ), - ([, command]) => command, +const APP_INVOKE_FILE_MAX_COUNT = 4 * 1024; +const APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH = 16 * 1024 * 1024; +const APP_INVOKE_SOURCE_MAX_LENGTH = 2 * 1024 * 1024; +const APP_INVOKE_COMMAND_MAX_LENGTH = 128; +const APP_INVOKE_CALL_MAX_COUNT = 4 * 1024; +const APP_INVOKE_BARE_CALL_NAMES = new Set([ + 'invoke', + 'directInvoke', + 'invokeInput', + 'invokeAuthenticatedInput', +]); + +function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') { + const sourceByteLength = Buffer.byteLength(source, 'utf8'); + if (sourceByteLength > APP_INVOKE_SOURCE_MAX_LENGTH) { + throw new Error( + `AI game creator shell App invoke source exceeds ${APP_INVOKE_SOURCE_MAX_LENGTH} bytes: ${fileName}`, + ); + } + const sourceFile = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ); + const parseDiagnostic = sourceFile.parseDiagnostics[0]; + if (parseDiagnostic !== undefined) { + throw new Error( + `AI game creator shell App invoke source cannot be parsed: ${fileName} (TS${parseDiagnostic.code})`, + ); + } + const commands = []; + const visit = (node) => { + if (ts.isCallExpression(node)) { + const expression = node.expression; + const isBareCall = + ts.isIdentifier(expression) && + APP_INVOKE_BARE_CALL_NAMES.has(expression.text); + const isObjectInvoke = + ts.isPropertyAccessExpression(expression) && + expression.name.text === 'invoke'; + const commandArgument = node.arguments[0]; + if ( + (isBareCall || isObjectInvoke) && + commandArgument !== undefined && + ts.isStringLiteral(commandArgument) && + commandArgument.text.length <= APP_INVOKE_COMMAND_MAX_LENGTH && + /^[a-z0-9_]+$/u.test(commandArgument.text) + ) { + commands.push(commandArgument.text); + if (commands.length > APP_INVOKE_CALL_MAX_COUNT) { + throw new Error( + `AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}: ${fileName}`, + ); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return commands; +} + +function parseAppInvokeSourceFiles(files) { + if (files.length > APP_INVOKE_FILE_MAX_COUNT) { + throw new Error( + `AI game creator shell App invoke files exceed ${APP_INVOKE_FILE_MAX_COUNT}`, + ); + } + const totalLength = files.reduce( + (length, file) => length + Buffer.byteLength(file.source, 'utf8'), + 0, + ); + if (totalLength > APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH) { + throw new Error( + `AI game creator shell App invoke sources exceed ${APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH} bytes`, + ); + } + const commands = files.flatMap(({ fileName, source }) => + parseAppInvokeCommandNames(source, fileName), + ); + if (commands.length > APP_INVOKE_CALL_MAX_COUNT) { + throw new Error( + `AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}`, + ); + } + return commands; } function parseTauriHandlerCommandNames(source) { @@ -354,6 +443,101 @@ function assertCommandNamesSubset(label, leftNames, rightNames) { } } +function assertCommandNamesDisjoint(label, leftNames, rightNames) { + const right = new Set(rightNames); + const overlapping = Array.from(new Set(leftNames)) + .filter((name) => right.has(name)) + .sort((left, rightName) => left.localeCompare(rightName)); + if (overlapping.length > 0) { + throw new Error(`${label} overlapping commands: ${overlapping.join(', ')}`); + } +} + +function runAppInvokeParserRegressionChecks() { + assert.deepEqual( + parseAppInvokeCommandNames(` + invoke('direct_command', {}); + directInvoke('generic_direct_command', {}); + invokeInput < Result > ('input_wrapper_command', {}); + invokeAuthenticatedInput( + 'authenticated_input_wrapper_command', + {}, + ); + input.invoke('object_field_command', {}); + `), + [ + 'direct_command', + 'generic_direct_command', + 'input_wrapper_command', + 'authenticated_input_wrapper_command', + 'object_field_command', + ], + ); + + assert.deepEqual( + parseAppInvokeCommandNames(` + // invoke('line_comment_decoy') + /* invokeInput('block_comment_decoy') */ + const quoted = "directInvoke('string_decoy')"; + const template = \`input.invoke('template_decoy')\`; + const expression = /invokeAuthenticatedInput\\('regex_decoy'\\)/u; + invokeCommand('unrelated_name'); + myinvoke('unrelated_suffix'); + invoke(dynamicCommand, {}); + invoke('UPPERCASE_COMMAND', {}); + `), + [], + ); + + assert.throws( + () => parseAppInvokeCommandNames("invoke('malformed_generic', {"), + /source cannot be parsed/u, + ); + + assert.deepEqual( + parseAppInvokeCommandNames( + `invoke('${'a'.repeat(APP_INVOKE_COMMAND_MAX_LENGTH + 1)}', {})`, + ), + [], + ); + assert.throws( + () => + parseAppInvokeCommandNames( + ' '.repeat(APP_INVOKE_SOURCE_MAX_LENGTH + 1), + ), + /source exceeds/u, + ); + + const wrapperInvocations = parseAppInvokeCommandNames( + "invokeInput('wrapper_reachability_command', {})", + ); + assert.doesNotThrow(() => + assertCommandNamesSubset( + 'App invoke parser reachability fixture', + ['wrapper_reachability_command'], + wrapperInvocations, + ), + ); + assert.throws( + () => + assertCommandNamesDisjoint( + 'App invoke parser false allowlist fixture', + wrapperInvocations, + ['wrapper_reachability_command'], + ), + /overlapping commands: wrapper_reachability_command/u, + ); + assert.throws( + () => + assertCommandNamesSubset( + 'App invoke parser removed wrapper fixture', + ['wrapper_reachability_command'], + parseAppInvokeCommandNames('const wrapperWasRemoved = true;'), + ), + /missing commands: wrapper_reachability_command/u, + ); +} + function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) { return { code, signal, stdout, stderr }; } @@ -939,6 +1123,10 @@ assertNoEnvironmentConfigFallbacks([ assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]); assertNoBlockingNativeFilePicker(tauriRustSource); +runAppInvokeParserRegressionChecks(); + +const appInvokeCommandNames = parseAppInvokeSourceFiles(appInvokeSources); + assertContractRecordsMatch( 'AI game creator shell command contract', parseTsCommands(sharedContractSource), @@ -953,7 +1141,7 @@ assertContractRecordsMatch( assertCommandNamesSubset( 'AI game creator shell Tauri handler', - parseAppInvokeCommandNames(appInvokeSource), + appInvokeCommandNames, parseTauriHandlerCommandNames(tauriHandlerSource), ); @@ -966,10 +1154,7 @@ assertCommandNamesSubset( assertCommandNamesSubset( 'AI game creator shell App invoke or explicit native-only allowlist', parseTauriHandlerCommandNames(tauriHandlerSource), - [ - ...parseAppInvokeCommandNames(appInvokeSource), - ...allowedUncalledTauriCommands, - ], + [...appInvokeCommandNames, ...allowedUncalledTauriCommands], ); assertCommandNamesSubset( @@ -978,6 +1163,12 @@ assertCommandNamesSubset( parseTauriHandlerCommandNames(tauriHandlerSource), ); +assertCommandNamesDisjoint( + 'AI game creator shell App invoke and explicit native-only allowlist', + appInvokeCommandNames, + allowedUncalledTauriCommands, +); + const tauriHandlerCommandNames = parseTauriHandlerCommandNames(tauriHandlerSource); if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 28b5f56bb..94a07ba68 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -1552,6 +1552,12 @@ fn stage_asset_canvas_image_with_token_at( let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if matches!( + draft.status, + AssetCanvasDraftStatus::Cancelled | AssetCanvasDraftStatus::Committed + ) { + return Err("素材画布草稿已取消或提交,不能继续暂存图片".to_string()); + } if draft.revision != input.expected_draft_revision { return Ok(StageAssetCanvasImageResult { status: "conflict".to_string(), @@ -1574,48 +1580,15 @@ fn stage_asset_canvas_image_with_token_at( } None => new_asset_canvas_token()?, }; - if stable_token.is_some() { - match read_staged_image_locked(root, &token) { - Ok((metadata, existing_bytes)) => { - if metadata.project_id != manifest.project_id - || metadata.draft_id != input.draft_id - || metadata.draft_revision != draft.revision - || metadata.media_type != media_type - || existing_bytes != input.bytes - { - return Err("稳定 staging token 已绑定到不同图片".to_string()); - } - return Ok(StageAssetCanvasImageResult { - status: "staged".to_string(), - staged_image_token: Some(token), - draft_id: input.draft_id.clone(), - draft_revision: draft.revision, - media_type: Some(metadata.media_type), - sha256: Some(metadata.sha256), - byte_length: Some(metadata.byte_length), - pixel_width: Some(metadata.pixel_width), - pixel_height: Some(metadata.pixel_height), - expires_at: Some(metadata.expires_at), - draft: None, - }); - } - Err(error) if !error.contains("不存在") => return Err(error), - Err(_) => {} - } - } let extension = media_extension(&media_type)?; let image_relative = format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}"); - install_new_asset_canvas_file( - &resolve_local_project_path(root, &image_relative)?, - &input.bytes, - "素材画布 staging 图片", - )?; + let image_path = resolve_local_project_path(root, &image_relative)?; let expires_at = asset_canvas_now() .saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS) .min(ASSET_CANVAS_MAX_SAFE_INTEGER); - let metadata = AssetCanvasStagedImage { + let expected_metadata = AssetCanvasStagedImage { schema_version: "game-creator-asset-canvas-staging.v1".to_string(), - project_id: manifest.project_id, + project_id: manifest.project_id.clone(), draft_id: input.draft_id.clone(), draft_revision: draft.revision, staged_image_token: token.clone(), @@ -1626,6 +1599,84 @@ fn stage_asset_canvas_image_with_token_at( pixel_height: height, expires_at, }; + if stable_token.is_some() { + let existing_metadata = read_staged_image_metadata_locked(root, &token)?; + if existing_metadata.as_ref().is_some_and(|metadata| { + metadata.project_id != expected_metadata.project_id + || metadata.draft_id != expected_metadata.draft_id + || metadata.draft_revision != expected_metadata.draft_revision + || metadata.media_type != expected_metadata.media_type + || metadata.sha256 != expected_metadata.sha256 + || metadata.byte_length != expected_metadata.byte_length + || metadata.pixel_width != expected_metadata.pixel_width + || metadata.pixel_height != expected_metadata.pixel_height + }) { + return Err("稳定 staging token 已绑定到不同图片".to_string()); + } + for candidate_extension in ["png", "jpg", "webp"] { + if candidate_extension == extension { + continue; + } + let candidate = resolve_local_project_path( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{candidate_extension}"), + )?; + match fs::symlink_metadata(candidate) { + Ok(_) => return Err("稳定 staging token 已绑定到不同图片".to_string()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("读取素材画布 staging 图片失败".to_string()), + } + } + let existing_image = match fs::symlink_metadata(&image_path) { + Ok(_) => Some(open_and_validate_image_file( + &image_path, + &media_type, + Some(&expected_metadata.sha256), + )?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err("读取素材画布 staging 图片失败".to_string()), + }; + if existing_image + .as_ref() + .is_some_and(|(bytes, existing_width, existing_height)| { + bytes != &input.bytes || *existing_width != width || *existing_height != height + }) + { + return Err("稳定 staging token 已绑定到不同图片".to_string()); + } + if existing_image.is_none() { + install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?; + } + let metadata = existing_metadata.unwrap_or(expected_metadata); + if read_staged_image_metadata_locked(root, &token)?.is_none() { + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), + "素材画布 staging 元数据", + &metadata, + 16 * 1024, + )?; + } + let (metadata, existing_bytes) = read_staged_image_locked(root, &token)?; + if existing_bytes != input.bytes { + return Err("稳定 staging token 已绑定到不同图片".to_string()); + } + return Ok(StageAssetCanvasImageResult { + status: "staged".to_string(), + staged_image_token: Some(token), + draft_id: input.draft_id.clone(), + draft_revision: draft.revision, + media_type: Some(metadata.media_type), + sha256: Some(metadata.sha256), + byte_length: Some(metadata.byte_length), + pixel_width: Some(metadata.pixel_width), + pixel_height: Some(metadata.pixel_height), + expires_at: Some(metadata.expires_at), + draft: None, + }); + } + install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?; + let metadata = expected_metadata; write_agent_runtime_json_sidecar_with_max_bytes( root, &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), @@ -2214,18 +2265,8 @@ fn read_staged_image_locked( token: &str, ) -> Result<(AssetCanvasStagedImage, Vec), String> { validate_plain_component(token, "stagedImageToken", 128)?; - let metadata = read_agent_runtime_json_sidecar_with_max_bytes::( - root, - &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), - "素材画布 staging 元数据", - 16 * 1024, - )? - .ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?; - if metadata.staged_image_token != token - || metadata.schema_version != "game-creator-asset-canvas-staging.v1" - { - return Err("素材画布 staging 身份无效".to_string()); - } + let metadata = read_staged_image_metadata_locked(root, token)? + .ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?; let extension = media_extension(&metadata.media_type)?; let path = resolve_local_project_path( root, @@ -2242,6 +2283,27 @@ fn read_staged_image_locked( Ok((metadata, bytes)) } +fn read_staged_image_metadata_locked( + root: &Path, + token: &str, +) -> Result, String> { + validate_plain_component(token, "stagedImageToken", 128)?; + let metadata = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), + "素材画布 staging 元数据", + 16 * 1024, + )?; + if let Some(metadata) = metadata.as_ref() { + if metadata.staged_image_token != token + || metadata.schema_version != "game-creator-asset-canvas-staging.v1" + { + return Err("素材画布 staging 身份无效".to_string()); + } + } + Ok(metadata) +} + fn committed_result_from_ledger( root: &Path, ledger: &AssetCanvasCommitLedger, 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 eec7d1e65..b66561170 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 @@ -765,6 +765,72 @@ fn set_private_phase( write_generation_ledger(root, ledger) } +fn retryable_credential_failure_phase( + ledger: &AssetCanvasGenerationLedger, +) -> Option { + if ledger.phase != GenerationLedgerPhase::Failed + || !matches!( + ledger.error_code.as_deref(), + Some("configuration-missing" | "authentication-required") + ) + { + return None; + } + Some(if ledger.operation_id.is_some() { + GenerationLedgerPhase::ReconciliationRequired + } else if ledger.request_body_json.is_some() { + GenerationLedgerPhase::Prepared + } else { + GenerationLedgerPhase::ContextPreparing + }) +} + +fn migrate_retryable_credential_failure( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result { + let Some(phase) = retryable_credential_failure_phase(ledger) else { + return Ok(false); + }; + ensure_generation_draft_active(root, ledger)?; + ledger.phase = phase; + write_generation_ledger(root, ledger)?; + Ok(true) +} + +fn validate_generation_draft_active( + ledger: &AssetCanvasGenerationLedger, + draft: &AssetCanvasDraft, +) -> Result<(), String> { + if draft.project_id != ledger.project_id + || draft.draft_id != ledger.draft_id + || draft.intent != ledger.intent + || draft.source_asset_id != ledger.source_asset_id + { + return Err("素材画布生成账本与草稿身份不一致".to_string()); + } + if draft.status == AssetCanvasDraftStatus::Cancelled { + return Err( + "generation-cancelled: 素材画布草稿已取消;私有生成账本将保留,但不得继续公开投影或提交资产" + .to_string(), + ); + } + if draft.status == AssetCanvasDraftStatus::Committed { + return Err("素材画布草稿已经提交,不能继续推进其它生成账本".to_string()); + } + Ok(()) +} + +fn ensure_generation_draft_active( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result<(), String> { + let _lock = acquire_asset_canvas_draft_lock(root)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + validate_generation_draft_active(ledger, &draft) +} + fn upsert_public_generation_record( root: &Path, ledger: &AssetCanvasGenerationLedger, @@ -774,12 +840,7 @@ fn upsert_public_generation_record( let _lock = acquire_asset_canvas_draft_lock(root)?; let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if draft.project_id != ledger.project_id - || draft.intent != ledger.intent - || draft.source_asset_id != ledger.source_asset_id - { - return Err("素材画布生成账本与草稿身份不一致".to_string()); - } + validate_generation_draft_active(ledger, &draft)?; let now = asset_canvas_now(); let output_asset_id = ledger .commit_result @@ -1785,6 +1846,10 @@ fn mark_generation_error( error_code: &str, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), ) -> Result<(), String> { + // Cancellation is authoritative and must not be overwritten by a late + // network/commit error. The private ledger keeps its last durable phase as + // evidence while all public projection and candidate persistence stop. + ensure_generation_draft_active(root, ledger)?; set_private_phase( root, ledger, @@ -1961,11 +2026,13 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { return committed_execution_from_private_result(root, &ledger); } + migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.phase == GenerationLedgerPhase::Failed { return Err(sanitized_generation_error( ledger.error_code.as_deref().unwrap_or("generation-failed"), )); } + ensure_generation_draft_active(root, &ledger)?; bind_generation_platform_owner(root, &mut ledger, platform_session)?; ensure_frozen_generation_platform_session(platform_session)?; let configuration_fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); @@ -2030,7 +2097,12 @@ async fn reconcile_generation( } else { "platform-service-configuration" }; - mark_generation_error(root, &mut ledger, false, code, emit)?; + if code == "authentication-required" { + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; + } else { + mark_generation_error(root, &mut ledger, false, code, emit)?; + } return Err(sanitized_generation_error(code)); } }; @@ -2041,7 +2113,12 @@ async fn reconcile_generation( ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await { let code = error.code(); - mark_generation_error(root, &mut ledger, false, code, emit)?; + if code == "authentication-required" { + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; + } else { + mark_generation_error(root, &mut ledger, false, code, emit)?; + } return Err(sanitized_generation_error(code)); } let (endpoint, body_json) = build_generation_request_snapshot(&ledger)?; @@ -2056,6 +2133,11 @@ async fn reconcile_generation( GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired ) && ledger.operation_id.is_none() { + ensure_generation_draft_active(root, &ledger)?; + // Context/reference preparation can contain several awaits. Re-check the + // exact account snapshot after those side effects and immediately before + // the first chargeable/idempotent remote submission. + ensure_frozen_generation_platform_session(platform_session)?; let endpoint = ledger .endpoint .as_deref() @@ -2086,7 +2168,16 @@ async fn reconcile_generation( let (code, reconciliation) = classify_canvas_submit_error(response).await; let reconciliation = preserve_submit_reconciliation(&ledger.phase, code, reconciliation); - mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + if code == "authentication-required" && !reconciliation { + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::Prepared, + Some(code), + )?; + } else { + mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + } return Err(sanitized_classified_generation_error(code, reconciliation)); } let submission = match response.json::().await { @@ -2204,6 +2295,7 @@ async fn reconcile_generation( let download = match download_result { Ok(Some(download)) => { ensure_frozen_generation_platform_session(platform_session)?; + ensure_generation_draft_active(root, &ledger)?; download } _ => { @@ -2260,6 +2352,7 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::MediaDownloaded { ensure_frozen_generation_platform_session(platform_session)?; + ensure_generation_draft_active(root, &ledger)?; if !synchronize_staged_image_revision(root, &mut ledger)? { mark_generation_error( root, @@ -2440,7 +2533,11 @@ pub(crate) async fn generate_asset_canvas_image_at( Ok(value) => value, Err(_) => { let code = "configuration-missing"; - mark_generation_error(root, &mut ledger, false, code, &mut emit)?; + // Credential availability is not a durable business outcome. Keep + // the original private identity retryable so login/configuration + // repair can resume the same idempotency keys. + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; return Err(sanitized_generation_error(code)); } }; @@ -2504,29 +2601,56 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } + let draft_status = { + let _lock = acquire_asset_canvas_draft_lock(root)?; + read_asset_canvas_draft_locked(root, &input.expected_project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())? + .status + }; + if draft_status == AssetCanvasDraftStatus::Cancelled { + return Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: Vec::new(), + service_identity_confirmations: Vec::new(), + }, + events: Vec::new(), + }); + } + let mut recoverable_generation_ids = Vec::new(); + for generation_id in generation_ids { + let Some(ledger) = read_generation_ledger(root, &generation_id)? else { + continue; + }; + if ledger.project_id == input.expected_project_id + && ledger.draft_id == input.draft_id + && ledger.phase != GenerationLedgerPhase::AssetDurableCommitted + && (ledger.phase != GenerationLedgerPhase::Failed + || retryable_credential_failure_phase(&ledger).is_some()) + { + recoverable_generation_ids.push(generation_id); + } + } + if recoverable_generation_ids.is_empty() { + return Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: Vec::new(), + service_identity_confirmations: Vec::new(), + }, + events: Vec::new(), + }); + } let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) .map_err(|_| sanitized_generation_error("configuration-missing"))?; let api_mode = CanvasGenerationApiMode { api_key }; let mut resumed = Vec::new(); let mut service_identity_confirmations = Vec::new(); let mut events = Vec::new(); - for generation_id in generation_ids { - let Some(initial_ledger) = read_generation_ledger(root, &generation_id)? else { - continue; - }; - if initial_ledger.project_id != input.expected_project_id - || initial_ledger.draft_id != input.draft_id - || matches!( - initial_ledger.phase, - GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::Failed - ) - { - continue; - } - let _guard = generation_singleflight_lock(&initial_ledger.project_id, &generation_id).await; + for generation_id in recoverable_generation_ids { + let _guard = generation_singleflight_lock(&input.expected_project_id, &generation_id).await; let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; + migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id || matches!( @@ -2536,6 +2660,9 @@ pub(crate) async fn recover_asset_canvas_generations_at( { continue; } + if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { + continue; + } match prepare_generation_service_identity(root, &mut ledger, &api_base_url, &api_mode) { Ok(CanvasServiceIdentityDecision::ConfirmationRequired(confirmation)) => { service_identity_confirmations.push(confirmation); @@ -2545,9 +2672,6 @@ pub(crate) async fn recover_asset_canvas_generations_at( Err(_) => {} } resumed.push(generation_id.clone()); - if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { - continue; - } match reconcile_generation( root, ledger, @@ -4321,5 +4445,366 @@ mod tests { let public = serde_json::to_string(&public_draft).expect("serialize failed draft"); assert!(!public.contains(prompt)); assert!(!public.contains(&input.idempotency_key)); + let ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read retryable generation ledger") + .expect("missing-login ledger exists"); + assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); + assert_eq!(ledger.error_code.as_deref(), Some("configuration-missing")); + assert_eq!(ledger.idempotency_key, input.idempotency_key); + assert_eq!(ledger.commit_id, input.commit_id); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind login recovery server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("login recovery address") + ); + let signed_url = format!("{base_url}/login-recovered.png"); + let server_signed_url = signed_url.clone(); + let recovered_png = test_png(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set login recovery fixture nonblocking"); + for request_index in 0..6 { + let mut stream = accept_generation_fixture_connection( + &listener, + "login recovery fixture", + request_index, + ); + let request = read_http_request(&mut stream); + if request.starts_with("GET /api/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "login-recovery-project", + "title": "阶段五缺失配置测试", + }]}}), + ); + } else if request.starts_with("GET /api/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "login-recovery-folder", + "label": "阶段五缺失配置测试", + }]}}}), + ); + } else if request.starts_with("POST /api/editor/images/generations ") { + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "login-recovery-operation", + "status": "queued", + "pollAfterMs": 0, + }}), + ); + } else if request.starts_with( + "GET /api/runtime/external-generation/jobs/login-recovery-operation ", + ) { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "login-recovery-operation", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "login-recovery-resource", + "objectKey": "generated/login-recovered.png", + "assetObjectId": "login-recovery-object", + }} + }}), + ); + } else if request.starts_with("GET /api/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": server_signed_url}}), + ); + } else if request.starts_with("GET /login-recovered.png ") { + write_png(&mut stream, &recovered_png); + } else { + panic!("unexpected login recovery request: {request}"); + } + } + }); + let _session = crate::platform_session::install_test_platform_session( + "original-login-owner", + "fresh-login-token", + &base_url, + ); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: ledger.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("fresh login resumes original private identity"); + server.join().expect("join login recovery server"); + assert_eq!( + recovered.result.resumed_generation_ids, + vec![input.generation_id.clone()] + ); + let resumed = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read resumed login ledger") + .expect("resumed login ledger exists"); + assert_eq!(resumed.idempotency_key, input.idempotency_key); + assert_eq!(resumed.commit_id, input.commit_id); + assert_eq!( + resumed.platform_owner_user_id.as_deref(), + Some("original-login-owner") + ); + assert_eq!(resumed.phase, GenerationLedgerPhase::AssetDurableCommitted); + assert_eq!(resumed.error_code, None); + assert!(resumed.commit_result.is_some()); + let manifest = + current_asset_canvas_manifest(directory.path()).expect("read login-recovered manifest"); + assert_eq!(manifest.assets.len(), 1); + } + + #[tokio::test] + async fn recovery_filters_mismatched_and_terminal_ledgers_before_resolving_credentials() { + let project_id = "generation-recovery-prefilter"; + let (directory, draft) = create_generation_fixture(project_id, "生成恢复预过滤测试"); + let mut terminal = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + terminal.phase = GenerationLedgerPhase::Failed; + terminal.error_code = Some("generation-failed".to_string()); + write_generation_ledger(directory.path(), &mut terminal).expect("write terminal ledger"); + let mut mismatched = accepted_ledger( + "other-project", + &draft, + "https://editor.example.test", + "private-key", + ); + write_generation_ledger(directory.path(), &mut mismatched) + .expect("write mismatched ledger"); + + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + }, + |_| {}, + ) + .await + .expect("terminal and mismatched ledgers do not require credentials"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + assert!(recovered.result.service_identity_confirmations.is_empty()); + assert!(recovered.events.is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn platform_session_is_revalidated_after_context_and_before_remote_submit() { + let project_id = "generation-session-submit-gate"; + let project_name = "生成提交登录态复验测试"; + let (directory, draft) = create_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind session gate server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("session gate address") + ); + let (ready_sender, ready_receiver) = mpsc::channel(); + let (resume_sender, resume_receiver) = mpsc::channel(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set session gate fixture nonblocking"); + for request_index in 0..2 { + let mut stream = accept_generation_fixture_connection( + &listener, + "session gate fixture", + request_index, + ); + let request = read_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture session gate request"); + if request.starts_with("GET /api/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "remote-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("GET /api/editor/assets/library ") { + ready_sender.send(()).expect("signal context prepared"); + resume_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("resume context response after session switch"); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "remote-folder", + "label": project_name, + }]}}}), + ); + } else { + panic!("unexpected session gate request: {request}"); + } + } + }); + let _session = crate::platform_session::install_test_platform_session( + "session-owner", + "session-token", + &base_url, + ); + let input = generation_input( + directory.path(), + project_id, + &draft, + "切换登录态后不得提交远端生成", + ); + let root = directory.path().to_path_buf(); + let generation_id = input.generation_id.clone(); + let generation = + tokio::spawn( + async move { generate_asset_canvas_image_at(&root, &input, |_| {}).await }, + ); + ready_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("context preparation reached final await"); + crate::platform_session::clear_platform_session(2); + resume_sender.send(()).expect("release context response"); + let error = generation + .await + .expect("join session gate generation") + .err() + .expect("changed session must stop before submit"); + server.join().expect("join session gate server"); + assert!(error.contains("登录态已变化")); + 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!(ledger.operation_id.is_none()); + } + + #[test] + fn cancelled_draft_rejects_public_generation_projection_and_keeps_private_evidence() { + let project_id = "cancelled-generation-projection"; + let (directory, draft) = create_generation_fixture(project_id, "取消生成投影测试"); + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + write_generation_ledger(directory.path(), &mut ledger).expect("write accepted ledger"); + let cancelled = discard_asset_canvas_draft_at( + directory.path(), + &DiscardAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + }, + ) + .expect("cancel generation draft"); + assert_eq!(cancelled.status, "cancelled"); + + let error = mark_generation_error( + directory.path(), + &mut ledger, + true, + "poll-result-unknown", + &mut |_| {}, + ) + .expect_err("cancelled draft must reject public projection"); + assert!(error.contains("generation-cancelled")); + let persisted = read_generation_ledger(directory.path(), &ledger.generation_id) + .expect("read private cancelled ledger") + .expect("private cancelled ledger exists"); + assert_eq!(persisted.phase, GenerationLedgerPhase::Accepted); + assert_eq!(persisted.error_code, None); + assert_eq!(persisted.operation_id, ledger.operation_id); + let public = read_asset_canvas_draft_at( + directory.path(), + &ReadAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + }, + ) + .expect("read cancelled draft") + .draft + .expect("cancelled draft exists"); + assert_eq!(public.status, AssetCanvasDraftStatus::Cancelled); + assert!(public.generations.is_empty()); + } + + #[test] + fn legacy_failed_credential_ledgers_migrate_by_remote_side_effect_boundary_only() { + let project_id = "legacy-credential-ledger-migration"; + let (directory, draft) = create_generation_fixture(project_id, "旧凭据账本恢复测试"); + for (has_request, has_operation, expected_phase) in [ + (false, false, GenerationLedgerPhase::ContextPreparing), + (true, false, GenerationLedgerPhase::Prepared), + (true, true, GenerationLedgerPhase::ReconciliationRequired), + ] { + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + ledger.phase = GenerationLedgerPhase::Failed; + ledger.error_code = Some("authentication-required".to_string()); + if !has_request { + ledger.request_body_json = None; + ledger.request_body_sha256 = None; + ledger.endpoint = None; + } + if !has_operation { + ledger.operation_id = None; + ledger.poll_after_ms = None; + } + write_generation_ledger(directory.path(), &mut ledger) + .expect("write legacy failed credential ledger"); + assert!( + migrate_retryable_credential_failure(directory.path(), &mut ledger) + .expect("migrate legacy failed credential ledger") + ); + assert_eq!(ledger.phase, expected_phase); + assert_eq!( + ledger.error_code.as_deref(), + Some("authentication-required") + ); + } + + let mut business_failure = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + business_failure.phase = GenerationLedgerPhase::Failed; + business_failure.error_code = Some("generation-rejected".to_string()); + write_generation_ledger(directory.path(), &mut business_failure) + .expect("write definitive business failure"); + assert!( + !migrate_retryable_credential_failure(directory.path(), &mut business_failure) + .expect("leave definitive business failure terminal") + ); + assert_eq!(business_failure.phase, GenerationLedgerPhase::Failed); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index ea302ecea..ab7ec8fc9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -130,6 +130,151 @@ fn stage_image(fixture: &Fixture, draft: &AssetCanvasDraft) -> StageAssetCanvasI .expect("stage image") } +#[test] +fn stable_staging_token_repairs_image_first_and_metadata_first_partial_installs() { + for image_first in [true, false] { + let fixture = initialize_fixture(); + let token = Uuid::new_v4().to_string(); + let staging_directory = fixture + .root() + .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")); + fs::create_dir_all(&staging_directory).expect("create partial staging directory"); + let expires_at = asset_canvas_now() + .saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS) + .min(ASSET_CANVAS_MAX_SAFE_INTEGER); + if image_first { + fs::write(staging_directory.join("image.png"), &fixture.png) + .expect("simulate image-first crash"); + } else { + let metadata = AssetCanvasStagedImage { + schema_version: "game-creator-asset-canvas-staging.v1".to_string(), + project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + draft_revision: fixture.draft.revision, + staged_image_token: token.clone(), + media_type: "image/png".to_string(), + sha256: asset_canvas_sha256(&fixture.png), + byte_length: fixture.png.len() as u64, + pixel_width: 4, + pixel_height: 3, + expires_at, + }; + write_agent_runtime_json_sidecar_with_max_bytes( + fixture.root(), + &format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"), + "素材画布 staging 元数据", + &metadata, + 16 * 1024, + ) + .expect("simulate metadata-first crash"); + } + + let repaired = stage_asset_canvas_image_with_token_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + Some(&token), + ) + .expect("repair partial stable staging install"); + assert_eq!(repaired.status, "staged"); + assert_eq!(repaired.staged_image_token.as_deref(), Some(token.as_str())); + let (metadata, bytes) = + read_staged_image_locked(fixture.root(), &token).expect("read repaired staging"); + assert_eq!(metadata.project_id, PROJECT_ID); + assert_eq!(metadata.draft_id, fixture.draft.draft_id); + assert_eq!(bytes, fixture.png); + + let replay = stage_asset_canvas_image_with_token_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + Some(&token), + ) + .expect("replay repaired stable staging install"); + assert_eq!(replay.staged_image_token, repaired.staged_image_token); + } +} + +#[test] +fn stable_staging_token_rejects_conflicting_partial_install() { + let fixture = initialize_fixture(); + let token = Uuid::new_v4().to_string(); + let staging_directory = fixture + .root() + .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")); + fs::create_dir_all(&staging_directory).expect("create conflicting staging directory"); + fs::write(staging_directory.join("image.png"), &fixture.png) + .expect("write conflicting image-first residue"); + let different_png = png_bytes([220, 31, 54, 255]); + let error = stage_asset_canvas_image_with_token_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: different_png, + }, + Some(&token), + ) + .expect_err("conflicting half-installed stable token must fail closed"); + assert!(error.contains("摘要不匹配") || error.contains("绑定到不同图片")); + assert!(read_staged_image_metadata_locked(fixture.root(), &token) + .expect("read missing conflicting metadata") + .is_none()); + assert_eq!( + fs::read(staging_directory.join("image.png")).expect("read preserved residue"), + fixture.png + ); +} + +#[test] +fn cancelled_draft_rejects_staging_before_writing_token() { + let fixture = initialize_fixture(); + discard_asset_canvas_draft_at( + fixture.root(), + &DiscardAssetCanvasDraftInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + }, + ) + .expect("cancel staging fixture draft"); + let token = Uuid::new_v4().to_string(); + let error = stage_asset_canvas_image_with_token_at( + fixture.root(), + &StageAssetCanvasImageInput { + project_path: project_path(fixture.root()), + expected_project_id: PROJECT_ID.to_string(), + draft_id: fixture.draft.draft_id.clone(), + expected_draft_revision: fixture.draft.revision, + media_type: "image/png".to_string(), + bytes: fixture.png.clone(), + }, + Some(&token), + ) + .expect_err("cancelled draft must reject stable staging"); + assert!(error.contains("草稿已取消或提交")); + assert!(!fixture + .root() + .join(format!("{ASSET_CANVAS_ROOT}/staging/{token}")) + .exists()); +} + fn commit_input( fixture: &Fixture, draft: &AssetCanvasDraft, diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index 3fc6544b7..765c7bf85 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -58,6 +58,7 @@ import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -139,6 +140,25 @@ type PendingGenerationIdentity = { commitIdempotencyKey: string; }; +const MODAL_FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +function modalFocusableElements(dialog: HTMLElement) { + return Array.from( + dialog.querySelectorAll(MODAL_FOCUSABLE_SELECTOR), + ).filter( + (element) => + element.getAttribute('aria-hidden') !== 'true' && + !element.closest('[inert]'), + ); +} + type DragState = | { kind: 'pan'; @@ -533,6 +553,10 @@ export function AssetCanvasSurface({ const generationFocusEpochRef = useRef(0); const generationStopButtonRef = useRef(null); const modalInitialFocusRef = useRef(null); + const modalDialogRef = useRef(null); + const modalReturnFocusRef = useRef(null); + const modalFallbackFocusRef = useRef(null); + const modalWasOpenRef = useRef(false); const generationDialogRef = useRef(generationDialog); const exitDialogOpenRef = useRef(exitDialogOpen); const serviceIdentityDialogOpenRef = useRef(serviceIdentityDialogOpen); @@ -1903,12 +1927,87 @@ export function AssetCanvasSurface({ } }, [generationInteractionLocked]); - useEffect(() => { - if (modalOpen) { - modalInitialFocusRef.current?.focus(); + const dismissActiveModal = useCallback(() => { + if (generationDialogRef.current !== null) { + closeGenerationDialog(); + return; } + if (serviceIdentityDialogOpenRef.current) { + if (!serviceIdentityPending) { + setServiceIdentityDialogOpen(false); + } + return; + } + if (exitDialogOpenRef.current && !exitActionPending) { + setExitDialogOpen(false); + } + }, [closeGenerationDialog, exitActionPending, serviceIdentityPending]); + + useLayoutEffect(() => { + if (!modalOpen) { + if (!modalWasOpenRef.current) return; + modalWasOpenRef.current = false; + const returnFocus = modalReturnFocusRef.current; + modalReturnFocusRef.current = null; + const focusTarget = + returnFocus?.isConnected === true + ? returnFocus + : modalFallbackFocusRef.current; + focusTarget?.focus(); + return; + } + + if (!modalWasOpenRef.current) { + const activeElement = document.activeElement; + modalReturnFocusRef.current = + activeElement instanceof HTMLElement && + activeElement !== document.body && + activeElement !== document.documentElement + ? activeElement + : null; + modalWasOpenRef.current = true; + } + (modalInitialFocusRef.current ?? modalDialogRef.current)?.focus(); }, [generationDialog, modalOpen, serviceIdentityDialogOpen]); + useEffect(() => { + if (!modalOpen) return undefined; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + dismissActiveModal(); + return; + } + if (event.key !== 'Tab') return; + const dialog = modalDialogRef.current; + if (!dialog) return; + const focusable = modalFocusableElements(dialog); + if (!focusable.length) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const activeElement = document.activeElement; + if ( + event.shiftKey && + (activeElement === first || !dialog.contains(activeElement)) + ) { + event.preventDefault(); + last?.focus(); + } else if ( + !event.shiftKey && + (activeElement === last || !dialog.contains(activeElement)) + ) { + event.preventDefault(); + first?.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [dismissActiveModal, modalOpen]); + const failurePresentation = lifecycle.kind === 'canvas.failed' ? assetCanvasFailurePresentation(lifecycle) @@ -1944,6 +2043,7 @@ export function AssetCanvasSurface({