diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 43781b495..619fd4162 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -15,9 +15,10 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; +const DIRECT_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -123,6 +124,204 @@ fn direct_prompt_requests_fresh_art_generation(prompt: &str) -> bool { .any(|marker| prompt.contains(marker)) } +/// 三维/引擎意图的确定性识别。 +/// +/// 三维请求不再绑定 Phaser,也不要求先澄清引擎:识别结果只用来给本回合注入 +/// "自选三维技术栈"的执行合同。识别只读用户原文,既不改写用户消息,也不触发生成。 +const DIRECT_ENGINE_3D_MARKERS: [&str; 2] = ["3d", "三维"]; + +/// 明确要求平面化的说法:这些词被移除后才判断三维意图,避免把用户主动选择的 +/// "伪 3D / 等轴 / 2.5D" 表现误判成三维选型请求。 +const DIRECT_ENGINE_FLAT_MARKERS: [&str; 5] = ["伪3d", "伪 3d", "pseudo-3d", "2.5d", "等轴"]; + +/// 三维需求必须落在游戏创作语义里,避免把 "三维数组" 之类的代码话题当成建游戏。 +const DIRECT_ENGINE_3D_CONTEXT_MARKERS: [&str; 24] = [ + "游戏", "玩法", "关卡", "角色", "场景", "画面", "引擎", "视角", "建模", "模型", "世界", "地图", + "城市", "射击", "冒险", "模拟", "经营", "塔防", "game", "level", "scene", "world", "model", + "fps", +]; + +const DIRECT_ENGINE_NAME_MARKERS: [(&str, DirectNamedEngine); 12] = [ + ("cocos", DirectNamedEngine::Cocos), + ("unity", DirectNamedEngine::Unity), + ("unreal", DirectNamedEngine::Unreal), + ("ue4", DirectNamedEngine::Unreal), + ("ue5", DirectNamedEngine::Unreal), + ("godot", DirectNamedEngine::Godot), + ("three.js", DirectNamedEngine::ThreeJs), + ("threejs", DirectNamedEngine::ThreeJs), + ("three js", DirectNamedEngine::ThreeJs), + ("babylon", DirectNamedEngine::Babylon), + ("phaser", DirectNamedEngine::Phaser), + ("虚幻", DirectNamedEngine::Unreal), +]; + +/// 用户消息里表达的目标引擎:点名引擎时不再由客户端裁定用法,只用于区分"用户已经 +/// 选了栈"和"只说三维、由 Codex 自己选栈"。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectEngineIntent { + /// 只说了三维,没有点名引擎。 + ThreeDimensional, + /// 点名了具体引擎或引擎家族。 + Named(DirectNamedEngine), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectNamedEngine { + Phaser, + ThreeJs, + Babylon, + Cocos, + Unity, + Godot, + Unreal, +} + +/// 当前项目根的引擎归属。只读工程结构标记,不读取用户数据。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectProjectEngine { + CocosCreator, + Unity, + Godot, + Unreal, + WebGame, + Unknown, +} + +impl DirectProjectEngine { + fn label(self) -> &'static str { + match self { + Self::CocosCreator => "Cocos Creator", + Self::Unity => "Unity", + Self::Godot => "Godot", + Self::Unreal => "Unreal", + Self::WebGame => "Web(Phaser/Vite)工程", + Self::Unknown => "未识别引擎的工程", + } + } +} + +fn direct_normalize_prompt_text(prompt: &str) -> String { + prompt + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +/// 标记必须独立成词,避免把 "value4" 里的 `ue4` 之类片段当成引擎名。 +fn direct_prompt_contains_marker(prompt: &str, marker: &str) -> bool { + let boundary = |character: Option| { + character.is_none_or(|character| !character.is_ascii_alphanumeric()) + }; + prompt.match_indices(marker).any(|(index, _)| { + boundary(prompt[..index].chars().next_back()) + && boundary(prompt[index + marker.len()..].chars().next()) + }) +} + +/// 用户原文里的三维/引擎意图。点名的引擎优先,避免 "用 Unity 做" 这类没有 3D +/// 字样的请求漏判。 +pub(crate) fn direct_engine_intent_from_prompt(prompt: &str) -> Option { + let normalized = direct_normalize_prompt_text(prompt); + if let Some((_, engine)) = DIRECT_ENGINE_NAME_MARKERS + .iter() + .find(|(marker, _)| direct_prompt_contains_marker(&normalized, marker)) + { + return Some(DirectEngineIntent::Named(*engine)); + } + let mut remaining = normalized.clone(); + for marker in DIRECT_ENGINE_FLAT_MARKERS { + remaining = remaining.replace(marker, " "); + } + if !DIRECT_ENGINE_3D_MARKERS + .iter() + .any(|marker| direct_prompt_contains_marker(&remaining, marker)) + { + return None; + } + let has_game_context = DIRECT_ENGINE_3D_CONTEXT_MARKERS + .iter() + .any(|marker| remaining.contains(marker)); + if !has_game_context { + return None; + } + Some(DirectEngineIntent::ThreeDimensional) +} + +fn direct_project_has_unreal_project_file(root: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { + return false; + }; + entries.flatten().any(|entry| { + entry + .path() + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("uproject")) + }) +} + +/// 当前项目根的引擎归属。判定失败按 Unknown 处理,不因为探测错误阻断回合。 +pub(crate) fn direct_project_engine(root: &Path) -> DirectProjectEngine { + if crate::project::discover_local_godot_project_root(root) + .ok() + .flatten() + .is_some() + { + return DirectProjectEngine::Godot; + } + if root.join("ProjectSettings/ProjectVersion.txt").is_file() { + return DirectProjectEngine::Unity; + } + if direct_project_has_unreal_project_file(root) { + return DirectProjectEngine::Unreal; + } + if crate::project::discover_local_cocos_project_root(root) + .ok() + .flatten() + .is_some() + { + return DirectProjectEngine::CocosCreator; + } + if root.join("game/index.html").is_file() || root.join("index.html").is_file() { + return DirectProjectEngine::WebGame; + } + DirectProjectEngine::Unknown +} + +/// 三维请求的执行合同。 +/// +/// 用户要做三维游戏时,"新 Web 游戏固定 Phaser 4.2.1" 的约束让位:由 Codex 自己 +/// 选三维技术栈(Three.js / Babylon.js 等 npm 运行时,或当前工程自带的引擎)。客户 +/// 端不要求先澄清引擎、不阻断工具、不拒绝登记产出;唯一保留的红线是不能用等轴伪 3D +/// 冒充三维交付而不说明。识别只读用户原文,不改写用户消息。 +pub(crate) fn direct_engine_three_dimensional_contract( + root: &Path, + prompt: &str, +) -> Option { + match direct_engine_intent_from_prompt(prompt)? { + DirectEngineIntent::Named(_) => None, + DirectEngineIntent::ThreeDimensional => { + let project = direct_project_engine(root); + Some(format!( + "三维请求执行合同(本回合):用户要求做三维(3D)游戏,本回合不受“新 Web 游戏固定 Phaser 4.2.1”的约束。由你自行选择合适的三维技术栈——例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎(当前工程识别:{})——可以按需新增 npm 依赖,沿用现有 npm + Vite 与 game/ 目录约定,也可以按需调整工程结构;不必先向用户确认引擎,直接按你的判断推进并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;如果评估后只能用二维表现,就在回复里说明限制与原因。其它边界不变:只改当前工程、构建通过后再试玩、不伪造成功、不读取或输出凭据。", + project.label() + )) + } + } +} + +/// 首页回合的三维提示:允许按既有规则创建项目,但提醒默认模板不是三维引擎。 +fn direct_engine_three_dimensional_home_note(prompt: &str) -> Option { + match direct_engine_intent_from_prompt(prompt)? { + DirectEngineIntent::Named(_) => None, + DirectEngineIntent::ThreeDimensional => Some( + "三维请求说明(首页):用户要做三维游戏。可以按既有规则创建项目;创建后由你自行选择三维技术栈(例如 Three.js / Babylon.js),不要因为默认模板是二维 Phaser 就只做等轴伪 3D。".to_string(), + ), + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct DirectTaonierArtAssetIdentity { project_id: String, @@ -3053,14 +3252,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( )?; let _platform_session_lease = access .frozen_platform_session() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &format!("{:x}", Sha256::digest(session.access_token.as_bytes())), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; // The network phase deliberately runs without the project write lock. Capture rollback state // only after acquiring the lock and revalidating the source identity, otherwise a failure can @@ -4350,6 +4542,7 @@ fn build_direct_codex_system_prompt_with_search( "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), "AGC 工具授权边界:DirectProject 的 agc_tools 由当前客户端桥接到 AGC 后端,使用客户端已有登录会话和受控凭据完成授权。用户不需要、也不得向你提供、配置、粘贴或创建 API Key、Token、Cookie、URL 或 .env。工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止,不要索要凭据、猜测外部 API,也不要暴露内部 URL。".to_string(), DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), + DIRECT_ENGINE_FREEDOM_GUIDANCE.to_string(), DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_COCOS_CAPABILITY_GUIDE.to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。工具返回 isError、构建失败、验证失败或试玩异常时,把错误当作调试上下文,读取当前项目、修复真实文件并重跑失败步骤,不要直接结束或伪造成功;鉴权、权限、余额、身份、历史、传输断开和操作状态不确定等安全错误才停止。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), @@ -4447,7 +4640,14 @@ pub(crate) async fn run_direct_game_creator_home_turn( attachments: &[DirectCodexTurnAttachment], ) -> Result { let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?; - direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt) + // 首页也只有这一轮对话:三维请求直接放行创建,但要提醒默认模板不是三维引擎。 + let engine_note = direct_engine_three_dimensional_home_note(prompt); + let base_system_prompt = build_direct_codex_home_system_prompt(); + let system_prompt = match engine_note.as_deref() { + Some(note) => format!("{note}\n{base_system_prompt}"), + None => base_system_prompt, + }; + direct_game_creator_home_codex_chat(system_prompt, user_prompt) .await .map(parse_direct_codex_home_reply) .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) @@ -4725,10 +4925,20 @@ async fn run_direct_game_creator_turn_inner( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) - .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) - })?; + let base_system_prompt = + build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error), + )?; + // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, + // 避免被长度上限截断,也不阻断任何工具。 + let engine_contract = direct_engine_three_dimensional_contract(root, prompt); + let system_prompt = match engine_contract.as_deref() { + Some(contract) => format!("{contract}\n{base_system_prompt}") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect(), + None => base_system_prompt, + }; let reply = if let Some(emitter) = turn_emitter { let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); @@ -5037,10 +5247,18 @@ where { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) - .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) - })?; + let base_system_prompt = + build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error), + )?; + let engine_contract = direct_engine_three_dimensional_contract(root, prompt); + let system_prompt = match engine_contract.as_deref() { + Some(contract) => format!("{contract}\n{base_system_prompt}") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect(), + None => base_system_prompt, + }; let reply = run_turn(system_prompt, prompt.to_string()) .await .map_err(|error| { @@ -5758,6 +5976,7 @@ mod tests { assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时")); assert!(prompt.contains("先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明")); assert!(prompt.contains("用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎")); + assert!(prompt.contains("三维请求合同")); assert!(prompt.contains("Cocos 的编辑器能力来自客户端随包提供的内置插件")); assert!(prompt.contains("不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展")); assert!(prompt.contains("不得改为查项目扩展或要求用户打开 Cocos MCP 面板")); @@ -5775,6 +5994,108 @@ mod tests { ); } + #[test] + fn three_dimensional_game_request_frees_the_engine_choice() { + let root = direct_engine_test_root("web"); + let prompt = "帮我做个 3D 城市模拟游戏"; + assert_eq!( + direct_engine_intent_from_prompt(prompt), + Some(DirectEngineIntent::ThreeDimensional) + ); + let contract = + direct_engine_three_dimensional_contract(root.path(), prompt).expect("contract"); + // 三维请求不再固定 Phaser,也不要求先澄清引擎。 + assert!(contract.contains("不受")); + assert!(contract.contains("Phaser 4.2.1")); + assert!(contract.contains("不必先向用户确认引擎")); + assert!(contract.contains("Three.js")); + assert!(contract.contains("Babylon.js")); + assert!(contract.contains("Web(Phaser/Vite)工程")); + // 用户原文不被改写,也不产生任何副作用。 + assert!(!root.path().join("assets").exists()); + } + + #[test] + fn explicit_flat_presentation_requests_do_not_trigger_three_dimensional_selection() { + let root = direct_engine_test_root("web"); + for prompt in [ + "用等轴伪3D做城市表现就行", + "做成 2.5D 的,别用真 3D", + "把三维数组的这段代码重构一下", + "把 value4 这个字段改成 5", + "看看这个关卡为什么会卡", + ] { + assert!( + direct_engine_three_dimensional_contract(root.path(), prompt).is_none(), + "prompt should stay executable: {prompt}" + ); + } + } + + #[test] + fn named_engine_requests_keep_the_existing_engineering_rule() { + let root = direct_engine_test_root("web"); + // 点名引擎时不注入三维选型合同:工程不匹配的澄清规则由既有工程合同承担。 + assert!( + direct_engine_three_dimensional_contract(root.path(), "用 Unity 重做这个 3D 关卡") + .is_none() + ); + assert_eq!( + direct_engine_intent_from_prompt("用 Unity 重做这个 3D 关卡"), + Some(DirectEngineIntent::Named(DirectNamedEngine::Unity)) + ); + } + + #[test] + fn three_dimensional_contract_reports_the_current_project_engine() { + let cocos = direct_engine_test_root("cocos"); + assert_eq!( + direct_project_engine(cocos.path()), + DirectProjectEngine::CocosCreator + ); + let cocos_contract = + direct_engine_three_dimensional_contract(cocos.path(), "做个 3D 城市").expect("cocos"); + assert!(cocos_contract.contains("Cocos Creator")); + let web = direct_engine_test_root("web"); + let web_contract = + direct_engine_three_dimensional_contract(web.path(), "做个 3D 城市").expect("web"); + assert!(web_contract.contains("Web(Phaser/Vite)工程")); + } + + fn direct_engine_test_root(engine: &str) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("temp dir"); + match engine { + "cocos" => { + std::fs::create_dir_all(root.path().join("assets")).expect("cocos assets"); + std::fs::write( + root.path().join("package.json"), + r#"{"creator":{"version":"3.8.8"}}"#, + ) + .expect("cocos package"); + } + "godot" => { + std::fs::write(root.path().join("project.godot"), "[application]\n") + .expect("godot project"); + } + "unity" => { + std::fs::create_dir_all(root.path().join("ProjectSettings")) + .expect("unity settings"); + std::fs::write( + root.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.0f1\n", + ) + .expect("unity version"); + } + "web" => { + std::fs::create_dir_all(root.path().join("game")).expect("web game dir"); + std::fs::write(root.path().join("game/index.html"), "") + .expect("web entry"); + } + _ => {} + } + root + } + #[test] fn system_prompt_uses_only_the_reviewed_skill_index() { let root = tempfile::tempdir().expect("temp dir"); @@ -5840,6 +6161,19 @@ mod tests { assert!(prompt.chars().count() <= MAX_DIRECT_SYSTEM_PROMPT_CHARS); } + #[test] + fn home_three_dimensional_note_keeps_project_creation_available() { + let note = + direct_engine_three_dimensional_home_note("帮我做个 3D 城市游戏").expect("home note"); + assert!(note.contains("三维请求说明")); + assert!(note.contains("可以按既有规则创建项目")); + assert!(note.contains("Three.js")); + assert!(!note.contains(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER)); + // 点名引擎与普通二维请求不加提示。 + assert!(direct_engine_three_dimensional_home_note("用 Unity 做 3D").is_none()); + assert!(direct_engine_three_dimensional_home_note("做个霓虹风格扫雷").is_none()); + } + #[test] fn home_prompt_has_no_project_or_side_effect_path_and_declares_the_only_creation_marker() { let prompt = build_direct_codex_home_system_prompt(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 1005ed813..615fd3ead 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -49,7 +49,7 @@ struct ExternalMcpHttpState { root: PathBuf, token: String, session_user_id: String, - session_generation: u64, + session_identity_generation: u64, } pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { @@ -1241,7 +1241,8 @@ fn external_mcp_session_id(root: &Path) -> String { material.push('\0'); material.push_str(&session.user_id); material.push('\0'); - material.push_str(&session.generation.to_string()); + // 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。 + material.push_str(&session.identity_generation.to_string()); } format!("mcp-{:x}", Sha256::digest(material.as_bytes())) } @@ -1759,7 +1760,9 @@ async fn handle_external_mcp_http_request( let Some(session) = current_platform_session() else { return Err(StatusCode::UNAUTHORIZED); }; - if session.user_id != state.session_user_id || session.generation != state.session_generation { + if session.user_id != state.session_user_id + || session.identity_generation != state.session_identity_generation + { return Err(StatusCode::UNAUTHORIZED); } let response = EXTERNAL_MCP_BRIDGE_URL @@ -1794,7 +1797,7 @@ pub(crate) async fn start_external_mcp_loopback( root, token: token.clone(), session_user_id: session.user_id, - session_generation: session.generation, + session_identity_generation: session.identity_generation, }; let app = Router::new() .route(&route, post(handle_external_mcp_http_request)) 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 8275aa26c..f545cc279 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 @@ -11,6 +11,10 @@ use super::external_generation_state::{ retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState, }; use super::*; +use crate::platform_session::{ + acquire_platform_session_identity_lease, validate_platform_session_identity, + PlatformSessionIdentity, +}; use reqwest::multipart::{Form, Part}; const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); @@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice { #[derive(Clone)] struct PreparedPlatformSessionFence { - user_id: String, - api_base_url: String, - generation: u64, - access_token_sha256: String, + identity: PlatformSessionIdentity, } impl PreparedPlatformSessionFence { @@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence { access .frozen_platform_session() .map(|session| PreparedPlatformSessionFence { - user_id: session.user_id.clone(), - api_base_url: session.api_base_url.clone(), - generation: session.generation, - access_token_sha256: format!( - "{:x}", - Sha256::digest(session.access_token.as_bytes()) - ), + identity: session.identity(), }) } fn validate(&self) -> Result<(), String> { - let matches = current_platform_session().is_some_and(|session| { - session.user_id == self.user_id - && session.api_base_url == self.api_base_url - && session.generation == self.generation - && format!("{:x}", Sha256::digest(session.access_token.as_bytes())) - == self.access_token_sha256 - }); - if matches { - Ok(()) - } else { - Err( - "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" - .to_string(), - ) - } + // 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。 + validate_platform_session_identity(&self.identity) } fn acquire_lease(&self) -> Result { - acquire_validated_platform_session_fingerprint( - &self.user_id, - &self.api_base_url, - self.generation, - &self.access_token_sha256, - ) + acquire_platform_session_identity_lease(&self.identity) } } @@ -10636,7 +10613,7 @@ mod canvas_generation_tests { } drop(owner_a_access); drop(frozen_owner_a); - install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2) + install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2) .expect("switch to owner B"); let error = match request_platform_art_asset_with_runtime_options_at( @@ -10761,8 +10738,14 @@ mod canvas_generation_tests { .recv_timeout(Duration::from_secs(3)) .expect("wait for accepted response"); std::thread::sleep(Duration::from_millis(50)); - install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2) - .expect("switch platform account after accepted response"); + install_platform_session( + "post-202-user-b", + "post-202-token-b", + &switch_base_url, + 2, + 2, + ) + .expect("switch platform account after accepted response"); }); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 03b0dcac1..0a8974342 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -1431,12 +1431,13 @@ mod external_generation_state_tests { base_url, ); let frozen_a = current_platform_session().expect("freeze owner A"); - validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch"); + validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch"); replace_platform_session_for_gui_owner( "fingerprint-owner-b", "fingerprint-token-b", base_url, 2, + 2, ) .expect("switch global session to owner B"); let current_b = current_platform_session().expect("owner B is current after switch"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 54ce0c7be..f5724810b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1939,8 +1939,9 @@ pub(crate) async fn polish_local_project_prompt( } #[tauri::command] -pub(crate) fn read_platform_account_session_generation() -> u64 { - current_platform_session_generation() +pub(crate) fn read_platform_account_session_state( +) -> crate::platform_session::PlatformSessionWriteState { + crate::platform_session::current_platform_session_write_state() } #[tauri::command] @@ -1948,28 +1949,45 @@ pub(crate) async fn install_platform_account_session( user_id: String, access_token: String, api_base_url: String, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { tokio::task::spawn_blocking(move || { - validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + validate_platform_session_input( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; install_external_agent_runner_platform_session( &user_id, &access_token, &api_base_url, - generation, + identity_generation, + revision, )?; - install_platform_session(&user_id, &access_token, &api_base_url, generation) + install_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + ) }) .await .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } #[tauri::command] -pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> { +pub(crate) async fn clear_platform_account_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { tokio::task::spawn_blocking(move || { shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(generation)?; - clear_platform_session(generation); + clear_external_agent_runner_platform_session(identity_generation, revision)?; + clear_platform_session(identity_generation, revision); Ok(()) }) .await @@ -4247,14 +4265,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( access.validate_frozen_session()?; let _platform_session_lease = frozen_session .as_ref() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &format!("{:x}", Sha256::digest(session.access_token.as_bytes())), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; access.validate_frozen_session()?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ea8d647e8..fe77f4532 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2733,7 +2733,7 @@ fn main() { confirm_resume_game_creator_agent_runtime_tasks, schedule_game_creator_agent_ready_tasks, check_game_creator_llm_config, - read_platform_account_session_generation, + read_platform_account_session_state, install_platform_account_session, clear_platform_account_session, read_game_creator_app_config, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 0e24b3b53..41fe89783 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,5 +1,4 @@ use serde::Deserialize; -use sha2::{Digest, Sha256}; use std::fs::{self, OpenOptions}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -12,12 +11,41 @@ pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_ const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; +/// 平台会话快照 = 身份(登录主体 + 服务 origin)+ 凭据(当前 access token)。 +/// +/// `identity_generation` 只在登录主体、服务 origin 或登出状态变化时推进;同一身份的 +/// access token 轮换(长回合保活、401 续期、同账号重新登录)必须保持它不变。 +/// `revision` 只用于 native 写入顺序判定,防止迟到 install / clear 复活旧状态, +/// 不表达身份归属。 #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PlatformSessionSnapshot { pub(crate) user_id: String, pub(crate) access_token: String, pub(crate) api_base_url: String, - pub(crate) generation: u64, + pub(crate) identity_generation: u64, + pub(crate) revision: u64, +} + +/// 冻结会话的身份判据。 +/// +/// 只包含登录主体、服务 origin 和身份代次,不包含 token 字节:同一身份的凭据轮换 +/// 不得让在途生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin +/// 变化必须让它失配。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlatformSessionIdentity { + pub(crate) user_id: String, + pub(crate) api_base_url: String, + pub(crate) identity_generation: u64, +} + +impl PlatformSessionSnapshot { + pub(crate) fn identity(&self) -> PlatformSessionIdentity { + PlatformSessionIdentity { + user_id: self.user_id.clone(), + api_base_url: self.api_base_url.clone(), + identity_generation: self.identity_generation, + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -231,26 +259,31 @@ pub(crate) fn load_platform_session_fixture_from_env_for_build( let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; let bytes = read_fixture_file(&fixture_path)?; let fixture = parse_platform_session_fixture(&bytes)?; + // fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达 + // “从零安装一次确定的会话”,不表达同一身份的凭据续期。 let snapshot = validated_platform_session_snapshot( &fixture.user_id, &fixture.access_token, &fixture.api_base_url, fixture.generation, + fixture.generation, )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - // A fresh CLI/Runner normally starts at generation zero. Replacing the + // A fresh CLI/Runner normally starts at revision zero. Replacing the // state here also makes a Debug GUI fixture deterministic without relaxing - // the normal account-switch generation rules. - current.generation = snapshot.generation; + // the normal account-switch rules. + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); Ok(()) } #[derive(Default)] struct PlatformSessionState { - generation: u64, + revision: u64, + identity_generation: u64, snapshot: Option, } @@ -265,37 +298,60 @@ fn install_platform_session_in( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) { - if generation < current.generation { + if revision < current.revision { return; } - if generation == current.generation { + if revision == current.revision { if current.snapshot.as_ref().is_some_and(|snapshot| { snapshot.user_id == user_id && snapshot.access_token == access_token && snapshot.api_base_url == api_base_url + && snapshot.identity_generation == identity_generation }) { return; } - // Equal-generation retries may only repeat the exact committed snapshot. In - // particular, a late install cannot revive a generation that was cleared. + // 同一 revision 只允许逐字段重复已提交的会话。尤其地:迟到写入不能复活已清除的 + // 会话,也不能在同一个 revision 上偷偷换掉主体或 token。 return; } - current.generation = generation; + if identity_generation < current.identity_generation { + return; + } + if current.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.identity_generation == identity_generation + && (snapshot.user_id != user_id || snapshot.api_base_url != api_base_url) + }) { + // 同一个身份代次不允许更换登录主体或服务 origin:换号必须先推进身份代次, + // 否则旧账号的在途 operation 可能拿到新账号的凭据。 + return; + } + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = Some(PlatformSessionSnapshot { user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url: api_base_url.to_string(), - generation, + identity_generation, + revision, }); } -fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) { - if generation < current.generation { +fn clear_platform_session_in( + current: &mut PlatformSessionState, + identity_generation: u64, + revision: u64, +) { + if revision <= current.revision { return; } - current.generation = generation; + if identity_generation < current.identity_generation { + return; + } + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = None; } @@ -303,10 +359,16 @@ pub(crate) fn install_platform_session( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -315,7 +377,8 @@ pub(crate) fn install_platform_session( &snapshot.user_id, &snapshot.access_token, &snapshot.api_base_url, - snapshot.generation, + snapshot.identity_generation, + snapshot.revision, ); Ok(()) } @@ -324,7 +387,8 @@ fn validated_platform_session_snapshot( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result { if editor_api_mode() == EditorApiMode::ExternalDeveloper { return Err("独立外部开发发行版不接受陶泥儿网站登录态".to_string()); @@ -342,7 +406,8 @@ fn validated_platform_session_snapshot( user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url, - generation, + identity_generation, + revision, }) } @@ -350,23 +415,43 @@ pub(crate) fn validate_platform_session_input( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ()) + validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + ) + .map(|_| ()) } pub(crate) fn replace_platform_session_for_gui_owner( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - current.generation = snapshot.generation; + // 这条路径是 GUI authority epoch 的重定性入口:只有 durable claim 的 epoch + session + // revision 与登记完全一致时才会走到这里,新 epoch 可以替换旧进程留下的任意计数器。 + // 因此按调用方快照重定基准,让原生计数与渲染层认知严格一致;Runner 同 epoch 的幂等 + // 重挂仍走 install_platform_session_checked 的精确相等校验。写入的持续单调性由渲染层 + // reserve(max(本地 + 1, 原生下限 + 1))和 durable session revision 保证。 + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); Ok(()) } @@ -375,10 +460,16 @@ pub(crate) fn install_platform_session_checked( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -387,12 +478,13 @@ pub(crate) fn install_platform_session_checked( &snapshot.user_id, &snapshot.access_token, &snapshot.api_base_url, - snapshot.generation, + snapshot.identity_generation, + snapshot.revision, ); if current.snapshot.as_ref() == Some(&snapshot) { Ok(()) } else { - Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string()) + Err("authentication-required: 平台登录态写入已过期或主体冲突".to_string()) } } @@ -422,30 +514,38 @@ fn normalize_platform_api_base_url(value: &str) -> Result { Ok(value.to_string()) } -pub(crate) fn clear_platform_session(generation: u64) { +pub(crate) fn clear_platform_session(identity_generation: u64, revision: u64) { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - clear_platform_session_in(&mut current, generation); + clear_platform_session_in(&mut current, identity_generation, revision); } -pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) { +pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, revision: u64) { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - current.generation = generation; + // 与 replace 同一口径:epoch 交接按调用方快照重定基准,避免原生计数与渲染层认知漂移。 + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = None; } -pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> { +pub(crate) fn clear_platform_session_checked( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - clear_platform_session_in(&mut current, generation); - if current.generation == generation && current.snapshot.is_none() { + clear_platform_session_in(&mut current, identity_generation, revision); + if current.revision >= revision + && current.identity_generation >= identity_generation + && current.snapshot.is_none() + { Ok(()) } else { - Err("authentication-required: 平台登出 generation 已过期".to_string()) + Err("authentication-required: 平台登出写入已过期".to_string()) } } @@ -457,17 +557,42 @@ pub(crate) fn current_platform_session() -> Option { .clone() } -pub(crate) fn current_platform_session_generation() -> u64 { - platform_session() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .generation +/// native 写入顺序 revision。渲染层用它作为只增不减的下限,避免新 WebView 的本地计数 +/// 复位后写出比现存会话更旧的 install / clear。 +/// 原生写入下限,供渲染层reserve新的身份代次与 revision。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlatformSessionWriteState { + pub(crate) identity_generation: u64, + pub(crate) revision: u64, } -pub(crate) fn validate_platform_session_snapshot( +pub(crate) fn current_platform_session_write_state() -> PlatformSessionWriteState { + let current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + PlatformSessionWriteState { + identity_generation: current.identity_generation, + revision: current.revision, + } +} + +/// 冻结会话校验:只比较身份,不比较 token 字节。 +pub(crate) fn validate_frozen_platform_session( expected: &PlatformSessionSnapshot, ) -> Result<(), String> { - if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) { + validate_platform_session_identity(&expected.identity()) +} + +pub(crate) fn validate_platform_session_identity( + expected: &PlatformSessionIdentity, +) -> Result<(), String> { + let matches = current_platform_session() + .as_ref() + .map(PlatformSessionSnapshot::identity) + .as_ref() + == Some(expected); + if matches { Ok(()) } else { Err( @@ -477,19 +602,11 @@ pub(crate) fn validate_platform_session_snapshot( } } -pub(crate) fn with_validated_platform_session_fingerprint( - expected_user_id: &str, - expected_api_base_url: &str, - expected_generation: u64, - expected_access_token_sha256: &str, +pub(crate) fn with_validated_platform_session_identity( + expected: &PlatformSessionIdentity, action: impl FnOnce() -> Result, ) -> Result { - let lease = acquire_validated_platform_session_fingerprint( - expected_user_id, - expected_api_base_url, - expected_generation, - expected_access_token_sha256, - )?; + let lease = acquire_platform_session_identity_lease(expected)?; let result = action(); drop(lease); result @@ -499,22 +616,20 @@ pub(crate) struct ValidatedPlatformSessionLease { _guard: std::sync::MutexGuard<'static, PlatformSessionState>, } -pub(crate) fn acquire_validated_platform_session_fingerprint( - expected_user_id: &str, - expected_api_base_url: &str, - expected_generation: u64, - expected_access_token_sha256: &str, +/// 取得身份租约:持锁期间换号 / 退出无法落地,调用方可以安全地用当前凭据完成一次 +/// 本地提交。凭据续期不改变身份,因此不会被这个租约挡住。 +pub(crate) fn acquire_platform_session_identity_lease( + expected: &PlatformSessionIdentity, ) -> Result { let current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let matches = current.snapshot.as_ref().is_some_and(|snapshot| { - snapshot.user_id == expected_user_id - && snapshot.api_base_url == expected_api_base_url - && snapshot.generation == expected_generation - && format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes())) - == expected_access_token_sha256 - }); + let matches = current + .snapshot + .as_ref() + .map(PlatformSessionSnapshot::identity) + .as_ref() + == Some(expected); if !matches { return Err( "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" @@ -524,13 +639,6 @@ pub(crate) fn acquire_validated_platform_session_fingerprint( Ok(ValidatedPlatformSessionLease { _guard: current }) } -fn platform_session_snapshot_matches( - current: Option<&PlatformSessionSnapshot>, - expected: &PlatformSessionSnapshot, -) -> bool { - current == Some(expected) -} - pub(crate) fn platform_session_is_available() -> bool { current_platform_session().is_some() } @@ -581,12 +689,14 @@ pub(crate) fn install_test_platform_session( .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::mem::take(&mut *current); *current = PlatformSessionState { - generation: 1, + revision: 1, + identity_generation: 1, snapshot: Some(PlatformSessionSnapshot { user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url: api_base_url.to_string(), - generation: 1, + identity_generation: 1, + revision: 1, }), }; drop(current); @@ -621,73 +731,42 @@ pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard { mod tests { use super::*; + const TEST_ORIGIN: &str = "https://dev.genarrative.world"; + #[test] - fn cleared_generation_rejects_late_install_and_older_clear() { + fn cleared_revision_rejects_late_install_and_older_clear() { let mut state = PlatformSessionState::default(); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 1, 1); + clear_platform_session_in(&mut state, 2, 2); + install_platform_session_in(&mut state, "user-a", "late-token-a", TEST_ORIGIN, 1, 1); install_platform_session_in( &mut state, "user-a", - "token-a", - "https://dev.genarrative.world", - 1, - ); - clear_platform_session_in(&mut state, 2); - install_platform_session_in( - &mut state, - "user-a", - "late-token-a", - "https://dev.genarrative.world", - 1, - ); - install_platform_session_in( - &mut state, - "user-a", - "same-generation-token", - "https://dev.genarrative.world", + "same-revision-token", + TEST_ORIGIN, + 2, 2, ); assert!(state.snapshot.is_none()); - assert_eq!(state.generation, 2); + assert_eq!(state.revision, 2); - install_platform_session_in( - &mut state, - "user-b", - "token-b", - "https://dev.genarrative.world", - 3, - ); - clear_platform_session_in(&mut state, 2); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 3, 3); + clear_platform_session_in(&mut state, 2, 2); assert_eq!( state.snapshot.as_ref().map(|value| value.user_id.as_str()), Some("user-b") ); - assert_eq!(state.generation, 3); + assert_eq!(state.revision, 3); + assert_eq!(state.identity_generation, 3); } #[test] - fn equal_generation_only_accepts_the_exact_idempotent_snapshot() { + fn equal_revision_only_accepts_the_exact_idempotent_snapshot() { let mut state = PlatformSessionState::default(); - install_platform_session_in( - &mut state, - "user-a", - "token-a", - "https://dev.genarrative.world", - 4, - ); - install_platform_session_in( - &mut state, - "user-a", - "token-a", - "https://dev.genarrative.world", - 4, - ); - install_platform_session_in( - &mut state, - "user-b", - "token-b", - "https://dev.genarrative.world", - 4, - ); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-a", "token-b", TEST_ORIGIN, 4, 4); assert_eq!( state.snapshot.as_ref().map(|value| value.user_id.as_str()), Some("user-a") @@ -702,18 +781,115 @@ mod tests { } #[test] - fn current_generation_preserves_the_floor_after_session_clear() { + fn same_identity_credential_refresh_keeps_identity_and_frozen_session() { + let _session = install_test_platform_session("refresh-user", "token-a", TEST_ORIGIN); + let frozen = current_platform_session().expect("frozen platform session"); + let identity = frozen.identity(); + + install_platform_session("refresh-user", "token-b", TEST_ORIGIN, 1, 2) + .expect("refresh credential for the same identity"); + + assert_eq!( + current_platform_session().map(|session| session.access_token), + Some("token-b".to_string()) + ); + assert_eq!( + current_platform_session_write_state().identity_generation, + 1 + ); + validate_frozen_platform_session(&frozen) + .expect("same-identity token rotation must keep the frozen session valid"); + validate_platform_session_identity(&identity) + .expect("same-identity token rotation must keep the identity valid"); + } + + #[test] + fn identity_change_invalidates_frozen_session_and_needs_a_new_identity_generation() { + let _session = install_test_platform_session("identity-user-a", "token-a", TEST_ORIGIN); + let frozen = current_platform_session().expect("frozen platform session"); + install_platform_session("identity-user-a", "token-b", TEST_ORIGIN, 1, 2) + .expect("credential refresh for the same identity"); + + // 同身份代次不允许换主体:否则旧账号在途请求会拿到新账号凭据。 + install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 1, 3) + .expect("conflicting subject at the same identity generation is ignored"); + assert_eq!( + current_platform_session().map(|session| session.user_id), + Some("identity-user-a".to_string()) + ); + validate_frozen_platform_session(&frozen) + .expect("ignored conflicting write must not disturb the frozen session"); + + install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 2, 4) + .expect("account switch advances the identity generation"); + assert!(validate_frozen_platform_session(&frozen).is_err()); + assert!(current_platform_session().is_some()); + } + + #[test] + fn gui_owner_replacement_rebases_to_the_authority_and_only_subject_change_fences() { + let _session = clear_test_platform_session(); + replace_platform_session_for_gui_owner("gui-owner-a", "token-a", TEST_ORIGIN, 5, 5) + .expect("install gui owner A"); + let installed = current_platform_session().expect("gui owner A session"); + assert_eq!(installed.identity_generation, 5); + assert_eq!(installed.revision, 5); + + // 同一主体只换凭据(续期后 Runner 重挂走的就是这条 replace 路径):身份代次保持、 + // 写入 revision 前进,在途 operation 的冻结会话仍然有效。 + replace_platform_session_for_gui_owner("gui-owner-a", "token-a2", TEST_ORIGIN, 5, 6) + .expect("refresh gui owner A credential"); + let refreshed = current_platform_session().expect("gui owner A refreshed session"); + assert_eq!(refreshed.identity_generation, 5); + assert_eq!(refreshed.revision, 6); + validate_frozen_platform_session(&installed) + .expect("same-subject credential replacement keeps the frozen session valid"); + + // 换主体必须推进身份代次,旧身份的在途 operation 失败关闭。 + replace_platform_session_for_gui_owner("gui-owner-b", "token-b", TEST_ORIGIN, 6, 7) + .expect("switch gui owner"); + let switched = current_platform_session().expect("gui owner B session"); + assert_eq!(switched.user_id, "gui-owner-b"); + assert_eq!(switched.identity_generation, 6); + assert!(validate_frozen_platform_session(&installed).is_err()); + assert!(validate_frozen_platform_session(&refreshed).is_err()); + + // epoch 交接后的清除同样按调用方快照重定基准,让原生计数与渲染层认知一致。 + clear_platform_session_for_gui_owner(7, 8); + let cleared = current_platform_session_write_state(); + assert_eq!(cleared.revision, 8); + assert_eq!(cleared.identity_generation, 7); + assert!(current_platform_session().is_none()); + } + + #[test] + fn older_identity_generation_cannot_restore_a_replaced_subject() { + let mut state = PlatformSessionState::default(); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 5); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 6, 6); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 7); + assert_eq!( + state.snapshot.as_ref().map(|value| value.user_id.as_str()), + Some("user-b") + ); + } + + #[test] + fn current_revision_preserves_the_floor_after_session_clear() { let _session = clear_test_platform_session(); install_platform_session( - "generation-floor-user", - "generation-floor-token", - "https://dev.genarrative.world", + "revision-floor-user", + "revision-floor-token", + TEST_ORIGIN, + 41, 41, ) - .expect("install session generation floor"); - clear_platform_session(42); + .expect("install session revision floor"); + clear_platform_session(42, 42); - assert_eq!(current_platform_session_generation(), 42); + let state = current_platform_session_write_state(); + assert_eq!(state.revision, 42); + assert_eq!(state.identity_generation, 42); assert!(current_platform_session().is_none()); } @@ -752,64 +928,40 @@ mod tests { } #[test] - fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() { - let expected = PlatformSessionSnapshot { - user_id: "user-a".to_string(), - access_token: "token-a".to_string(), - api_base_url: "https://dev.genarrative.world".to_string(), - generation: 4, - }; - assert!(platform_session_snapshot_matches( - Some(&expected), - &expected - )); + fn frozen_platform_session_rejects_logout_and_account_switch_but_allows_token_rotation() { + let _session = install_test_platform_session("frozen-user-a", "token-a", TEST_ORIGIN); + let identity = current_platform_session() + .expect("frozen platform session") + .identity(); + validate_platform_session_identity(&identity).expect("matching identity is valid"); - for current in [ - None, - Some(PlatformSessionSnapshot { - user_id: "user-b".to_string(), - ..expected.clone() - }), - Some(PlatformSessionSnapshot { - access_token: "token-b".to_string(), - generation: 5, - ..expected.clone() - }), - ] { - assert!(!platform_session_snapshot_matches( - current.as_ref(), - &expected - )); - } + install_platform_session("frozen-user-a", "token-b", TEST_ORIGIN, 1, 2) + .expect("same-identity credential rotation"); + validate_platform_session_identity(&identity) + .expect("token rotation must not invalidate the frozen identity"); + + install_platform_session("frozen-user-b", "token-c", TEST_ORIGIN, 2, 3) + .expect("account switch"); + assert!(validate_platform_session_identity(&identity).is_err()); + + clear_platform_session(3, 4); + assert!(validate_platform_session_identity(&identity).is_err()); } #[test] fn validated_session_lease_linearizes_local_commit_with_account_switch() { - let _session = install_test_platform_session( - "lease-user-a", - "lease-token-a", - "https://dev.genarrative.world", - ); - let expected = current_platform_session().expect("current lease session"); - let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes())); - let lease = acquire_validated_platform_session_fingerprint( - &expected.user_id, - &expected.api_base_url, - expected.generation, - &token_sha256, - ) - .expect("acquire validated session lease"); + let _session = install_test_platform_session("lease-user-a", "lease-token-a", TEST_ORIGIN); + let expected = current_platform_session() + .expect("current lease session") + .identity(); + let lease = acquire_platform_session_identity_lease(&expected) + .expect("acquire validated session lease"); let (started_sender, started_receiver) = std::sync::mpsc::channel(); let (finished_sender, finished_receiver) = std::sync::mpsc::channel(); let switcher = std::thread::spawn(move || { started_sender.send(()).expect("signal account switch"); - install_platform_session( - "lease-user-b", - "lease-token-b", - "https://dev.genarrative.world", - 2, - ) - .expect("switch account after lease release"); + install_platform_session("lease-user-b", "lease-token-b", TEST_ORIGIN, 2, 2) + .expect("switch account after lease release"); finished_sender.send(()).expect("signal switched account"); }); started_receiver diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 430fd6e9a..36025909e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -110,11 +110,13 @@ impl<'a> ExternalEditorBindingAccess<'a> { } /// 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. + /// binding. 只比较身份:同一账号的 access token 轮换(长回合保活、401 续期)不得让 + /// 在途的生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin 变化仍然 + /// 失败关闭。Developer-key 模式没有进程级身份代次可比对。 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)?; + validate_frozen_platform_session(session)?; } Ok(()) } @@ -1152,7 +1154,8 @@ mod tests { user_id: user_id.to_string(), access_token: token.to_string(), api_base_url: "https://dev.genarrative.world".to_string(), - generation, + identity_generation: generation, + revision: generation, } } 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 b1f292976..77183a884 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 @@ -4389,14 +4389,7 @@ fn with_frozen_resource_edit_platform_session( let Some(platform_session) = platform_session else { return action(); }; - let access_token_sha256 = sha256_hex(platform_session.access_token.as_bytes()); - with_validated_platform_session_fingerprint( - &platform_session.user_id, - &platform_session.api_base_url, - platform_session.generation, - &access_token_sha256, - action, - ) + with_validated_platform_session_identity(&platform_session.identity(), action) } fn commit_resource_edit_asset_with_frozen_platform_session( @@ -4774,14 +4767,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at( let current_platform_session = current_platform_session(); let _platform_session_lease = current_platform_session .as_ref() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &sha256_hex(session.access_token.as_bytes()), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; let entries = match fs::read_dir(&directory) { @@ -5084,12 +5070,8 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at( Ok(()) }; if let Some(session) = platform_session { - let access_token_sha256 = sha256_hex(session.access_token.as_bytes()); - crate::platform_session::with_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &access_token_sha256, + crate::platform_session::with_validated_platform_session_identity( + &session.identity(), archive, )?; } else { @@ -5500,7 +5482,8 @@ mod tests { user_id: "gui-owner".to_string(), access_token: "gui-token".to_string(), api_base_url: "https://dev.genarrative.world".to_string(), - generation: 7, + identity_generation: 7, + revision: 7, }; let developer_credentials = ( "https://dev.genarrative.world".to_string(), @@ -5911,6 +5894,7 @@ mod tests { "source-binding-token-b", api_base_url, *generation, + *generation, ) .expect("switch account after source registration"); } @@ -6925,7 +6909,7 @@ mod tests { listener, upload_url, false, - Some((base_url.clone(), frozen_session.generation + 1)), + Some((base_url.clone(), frozen_session.identity_generation + 1)), done_receiver, ); let client = reqwest::Client::new(); @@ -6952,7 +6936,8 @@ mod tests { "source-binding-owner-a", "source-binding-token-a", &base_url, - frozen_session.generation + 2, + frozen_session.identity_generation + 2, + frozen_session.identity_generation + 2, ) .expect("switch back to source binding owner A"); let resumed_session = current_platform_session().expect("resumed source binding owner A"); @@ -7018,7 +7003,7 @@ mod tests { install_test_platform_session("submission-owner-a", "submission-token-a", &base_url); let frozen_session = current_platform_session().expect("frozen owner A session"); let switch_base_url = base_url.clone(); - let switch_generation = frozen_session.generation + 1; + let switch_generation = frozen_session.identity_generation + 1; let server = std::thread::spawn(move || { let mut stream = accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0); @@ -7032,6 +7017,7 @@ mod tests { "submission-token-b", &switch_base_url, switch_generation, + switch_generation, ) .expect("switch to owner B before returning accepted response"); write_json( @@ -7447,8 +7433,14 @@ mod tests { ledger.access_scheme = None; initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a)) .expect("write resource ledger for owner A"); - replace_platform_session_for_gui_owner("resource-owner-b", "resource-token-b", base_url, 2) - .expect("switch global resource session to owner B"); + replace_platform_session_for_gui_owner( + "resource-owner-b", + "resource-token-b", + base_url, + 2, + 2, + ) + .expect("switch global resource session to owner B"); let error = prepare_resource_edit_service_identity( root, @@ -7509,7 +7501,8 @@ mod tests { user_id: "resource-identity-owner-b".to_string(), access_token: "resource-identity-token-b".to_string(), api_base_url: owner_a.api_base_url.clone(), - generation: owner_a.generation + 1, + identity_generation: owner_a.identity_generation + 1, + revision: owner_a.revision + 1, }; let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string()); @@ -7535,7 +7528,8 @@ mod tests { &owner_b.user_id, &owner_b.access_token, &owner_b.api_base_url, - owner_b.generation, + owner_b.identity_generation, + owner_b.revision, ) .expect("switch to resource non-owner B"); @@ -7639,7 +7633,8 @@ mod tests { "resource-lease-owner-b", "resource-lease-token-b", api_base_url, - frozen_a.generation + 1, + frozen_a.identity_generation + 1, + frozen_a.revision + 1, ) .expect("switch resource lease owner"); switched_sender.send(()).expect("signal resource switch"); @@ -8259,7 +8254,8 @@ mod tests { "archive-owner-b", "archive-token-b", api_base_url, - owner_a.generation + 1, + owner_a.identity_generation + 1, + owner_a.revision + 1, ) .expect("switch to owner B"); let error = archive_failed_local_project_resource_edit_at( @@ -8388,7 +8384,8 @@ mod tests { "pending-owner-b", "pending-token-b", api_base_url, - owner_a.generation + 1, + owner_a.identity_generation + 1, + owner_a.revision + 1, ) .expect("switch to pending owner B"); let owner_b = current_platform_session().expect("pending owner B session"); @@ -9347,7 +9344,7 @@ mod tests { let (attempted_sender, attempted_receiver) = mpsc::channel(); let (completed_sender, completed_receiver) = mpsc::channel(); let switch_api_base_url = api_base_url.to_string(); - let switch_generation = frozen_session.generation + 1; + let switch_generation = frozen_session.identity_generation + 1; let switch_thread = std::thread::spawn(move || { begin_switch_receiver .recv() @@ -9360,6 +9357,7 @@ mod tests { "commit-token-b", &switch_api_base_url, switch_generation, + switch_generation, ) .expect("switch to commit owner B"); completed_sender diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 12561a7f8..e27a07792 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -1107,7 +1107,10 @@ pub(crate) fn attach_external_agent_runner_gui_owner( platform_api_base_url: platform_session .as_ref() .map(|session| session.api_base_url.clone()), - platform_auth_generation: platform_session.map(|session| session.generation), + platform_auth_generation: platform_session + .as_ref() + .map(|session| session.identity_generation), + platform_auth_revision: platform_session.map(|session| session.revision), ..ExternalAgentRunnerRequestParams::default() }, )?; @@ -1118,7 +1121,8 @@ pub(crate) fn install_external_agent_runner_platform_session( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?; @@ -1128,7 +1132,8 @@ pub(crate) fn install_external_agent_runner_platform_session( remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), Some((user_id, access_token, api_base_url)), - generation, + identity_generation, + revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { @@ -1137,7 +1142,8 @@ pub(crate) fn install_external_agent_runner_platform_session( &config_dir, &endpoint, Some((user_id, access_token, api_base_url)), - generation, + identity_generation, + revision, ) }) }, @@ -1145,7 +1151,10 @@ pub(crate) fn install_external_agent_runner_platform_session( ) } -pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> { +pub(crate) fn clear_external_agent_runner_platform_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { let Some(config_dir) = external_agent_runner_config_dir() else { return Ok(()); }; @@ -1155,7 +1164,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), None, - generation, + identity_generation, + revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { @@ -1164,7 +1174,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R &config_dir, &endpoint, None, - generation, + identity_generation, + revision, ) }) }, @@ -1177,7 +1188,8 @@ fn validate_external_agent_runner_platform_session_attachment( config_dir: &Path, endpoint: &ExternalAgentRunnerEndpoint, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { let state = lock_unpoisoned(state); let registration = state.registration.as_ref().ok_or_else(|| { @@ -1190,7 +1202,8 @@ fn validate_external_agent_runner_platform_session_attachment( || registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()) || registration.params.gui_owner_epoch.is_none() || registration.params.gui_owner_session_revision != Some(registration.generation) - || registration.params.platform_auth_generation != Some(generation) + || registration.params.platform_auth_generation != Some(identity_generation) + || registration.params.platform_auth_revision != Some(revision) || registration.params.platform_user_id.as_deref() != expected_user_id || registration.params.platform_access_token.as_deref() != expected_access_token || registration.params.platform_api_base_url.as_deref() != expected_api_base_url @@ -1221,12 +1234,14 @@ pub(super) fn synchronize_external_agent_runner_platform_session_with( pub(super) fn remember_external_agent_runner_platform_session( state: &Mutex, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { remember_external_agent_runner_platform_session_with( state, session, - generation, + identity_generation, + revision, publish_external_agent_runner_gui_owner_claim, ) } @@ -1234,21 +1249,26 @@ pub(super) fn remember_external_agent_runner_platform_session( pub(super) fn remember_external_agent_runner_platform_session_with( state: &Mutex, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, publish_claim: impl FnOnce(&Path, u64) -> Result, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); let Some(registration) = state.registration.as_ref() else { return Ok(()); }; - let current_generation = registration.params.platform_auth_generation.unwrap_or(0); - if generation < current_generation { + // 写入顺序只认 revision;身份代次只表达主体归属,同一账号续期会推进 revision + // 但保持 identity generation 不变。 + let current_revision = registration.params.platform_auth_revision.unwrap_or(0); + if revision < current_revision { return Ok(()); } - if generation == current_generation { + if revision == current_revision { match session { Some((user_id, access_token, api_base_url)) if registration.params.platform_user_id.as_deref() == Some(user_id) + && registration.params.platform_auth_generation + == Some(identity_generation) && registration.params.platform_access_token.as_deref() == Some(access_token) && registration.params.platform_api_base_url.as_deref() @@ -1290,7 +1310,8 @@ pub(super) fn remember_external_agent_runner_platform_session_with( session.map(|(_, access_token, _)| access_token.to_string()); registration.params.platform_api_base_url = session.map(|(_, _, api_base_url)| api_base_url.to_string()); - registration.params.platform_auth_generation = Some(generation); + registration.params.platform_auth_generation = Some(identity_generation); + registration.params.platform_auth_revision = Some(revision); if let Some(claim) = published_claim { registration.params.gui_owner_epoch = Some(claim.owner_epoch); registration.params.gui_owner_session_revision = Some(claim.session_revision); @@ -1640,6 +1661,7 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( platform_access_token: None, platform_api_base_url: None, platform_auth_generation: None, + platform_auth_revision: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index 344d478e8..3186fd863 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -140,37 +140,46 @@ fn apply_external_agent_runner_gui_owner_attachment( params.platform_access_token.as_deref(), params.platform_api_base_url.as_deref(), params.platform_auth_generation, + params.platform_auth_revision, ) { - (Some(user_id), Some(access_token), Some(api_base_url), Some(generation)) => { + ( + Some(user_id), + Some(access_token), + Some(api_base_url), + Some(identity_generation), + Some(revision), + ) => { if replace_claim { crate::replace_platform_session_for_gui_owner( user_id, access_token, api_base_url, - generation, + identity_generation, + revision, ) } else { crate::install_platform_session_checked( user_id, access_token, api_base_url, - generation, + identity_generation, + revision, ) } } - (None, None, None, Some(generation)) => { + (None, None, None, Some(identity_generation), Some(revision)) => { if replace_claim { - crate::clear_platform_session_for_gui_owner(generation); + crate::clear_platform_session_for_gui_owner(identity_generation, revision); Ok(()) } else { - crate::clear_platform_session_checked(generation) + crate::clear_platform_session_checked(identity_generation, revision) } } - (None, None, None, None) if epoch_changed => { - crate::clear_platform_session_for_gui_owner(0); + (None, None, None, None, None) if epoch_changed => { + crate::clear_platform_session_for_gui_owner(0, 0); Ok(()) } - (None, None, None, None) => Ok(()), + (None, None, None, None, None) => Ok(()), _ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()), }; result?; @@ -178,7 +187,7 @@ fn apply_external_agent_runner_gui_owner_attachment( Ok(claim) => claim, Err(error) => { *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); return Err(format!( "Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}" )); @@ -188,7 +197,7 @@ fn apply_external_agent_runner_gui_owner_attachment( || committed_claim.session_revision != requested_revision { *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string()); } if let Some(event_sink) = event_sink { @@ -214,7 +223,7 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( return Ok(()); } *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); match durable_claim { Ok(_) => Err( "authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离" diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 0728e9050..5f4ae187c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -280,6 +280,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) platform_api_base_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) platform_auth_generation: Option, + /// 原生写入 revision:只用于 install / clear 的顺序判定。同一身份的凭据轮换会推进 + /// revision,但不推进 `platform_auth_generation`(身份代次)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_auth_revision: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 8e9bcb08b..f1d2f7a39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -691,14 +691,16 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { &state, Some(("user-a", "token-a", "https://dev.genarrative.world")), 4, + 4, ) .expect("remember owner A session"); - remember_external_agent_runner_platform_session(&state, None, 5) + remember_external_agent_runner_platform_session(&state, None, 5, 5) .expect("remember logged-out session"); remember_external_agent_runner_platform_session( &state, Some(("user-a", "late-token-a", "https://dev.genarrative.world")), 4, + 4, ) .expect("ignore stale owner A session"); remember_external_agent_runner_platform_session( @@ -709,12 +711,14 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { "https://dev.genarrative.world", )), 5, + 5, ) .expect("ignore conflicting same-generation session"); remember_external_agent_runner_platform_session( &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 6, + 6, ) .expect("remember latest owner B session"); @@ -756,6 +760,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { platform_access_token: Some("token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -776,7 +781,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { ) .expect("attach owner A"); - remember_external_agent_runner_platform_session(&state, None, 2) + remember_external_agent_runner_platform_session(&state, None, 2, 2) .expect("remember logged-out session"); attach_registered_external_agent_runner_gui_owner_if_needed_with( &state, @@ -806,6 +811,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { platform_access_token: Some("token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -825,6 +831,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 2, + 2, ) .expect("remember owner B while owner A attach is in flight"); Ok(()) @@ -868,6 +875,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() { gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_auth_generation: Some(2), + platform_auth_revision: Some(2), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -898,6 +906,7 @@ fn gui_owner_partial_platform_session_payload_fails_without_mutation() { platform_user_id: Some("runner-owner-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(2), + platform_auth_revision: Some(2), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -930,6 +939,7 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(10), + platform_auth_revision: Some(10), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -946,12 +956,14 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("new GUI epoch replaces higher-generation old owner"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-b".to_string(), 1)) ); @@ -964,6 +976,7 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(11), + platform_auth_revision: Some(11), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -999,6 +1012,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(8), + platform_auth_revision: Some(8), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1022,6 +1036,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ platform_access_token: Some("runner-token-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1029,7 +1044,8 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ validate_external_agent_runner_gui_owner_claim_current(&state) .expect("reattached owner B claim is current"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-b".to_string(), 1)) ); } @@ -1073,6 +1089,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1089,6 +1106,7 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { "https://dev.genarrative.world", )), 2, + 2, |_, _| Err("injected durable claim write failure".to_string()), ) }, @@ -1423,12 +1441,14 @@ fn second_window_attach_with_same_claim_keeps_runner_platform_session() { platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(7), + platform_auth_revision: Some(7), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("first window installs its session"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-a".to_string(), 7)) ); @@ -1443,7 +1463,8 @@ fn second_window_attach_with_same_claim_keeps_runner_platform_session() { ) .expect("second window attaches with the same claim"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-a".to_string(), 7)), "同一 claim 的第二个窗口不得清空平台登录态" ); diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 2c9fe3834..fdef7af6f 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -404,7 +404,9 @@ export function AuthenticatedClient({ ); return; } - if (result.status === 'failed') { + // 仅服务端明确否认当前身份时才登出。网络错误、5xx 和网关错误属于刷新暂时 + // 不可用,必须保留既有会话与 access token。 + if (result.status === 'failed' && result.authoritative) { clearStoredAuthAccessToken(); setAuthUser(null); setAuthStatus('unauthenticated'); diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index 65d0650da..1869351e7 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -125,7 +125,13 @@ class ClientAuthRequestError extends Error { } } -function isClientAuthUnauthorizedError(error: unknown) { +/** + * 服务端明确否认当前身份(401/403)才算权威失效。 + * + * 网络错误、5xx、网关错误和响应契约异常都属于"刷新暂时不可用":调用方必须保留既有 + * 会话与 access token,不能把一次瞬时失败放大成登出。 + */ +export function isClientAuthAuthorityFailure(error: unknown) { return ( error instanceof ClientAuthRequestError && (error.status === 401 || error.status === 403) @@ -133,7 +139,7 @@ function isClientAuthUnauthorizedError(error: unknown) { } export function isClientAuthRecoverableCheckError(error: unknown) { - return !isClientAuthUnauthorizedError(error); + return !isClientAuthAuthorityFailure(error); } export function getClientAuthErrorMessage(error: unknown, fallback: string) { @@ -255,12 +261,24 @@ export async function refreshClientAuthAccessToken( apiBaseUrl, transitionClientOperation(operation, 'network'), ); - const refreshPromise = requestAuthJson( - '/api/auth/refresh', - { method: 'POST' }, - '刷新登录状态失败', - { skipAuth: true, apiBaseUrl }, - ) + const performRefresh = () => + requestAuthJson( + '/api/auth/refresh', + { method: 'POST' }, + '刷新登录状态失败', + { skipAuth: true, apiBaseUrl }, + ); + const refreshWithConvergenceRetry = async () => { + try { + return await performRefresh(); + } catch (error) { + if (!isClientAuthAuthorityFailure(error)) throw error; + // 并发轮换收敛:另一个窗口 / 实例可能刚刚轮换过 refresh cookie,用当前 cookie + // 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算登录态权威失效。 + return await performRefresh(); + } + }; + const refreshPromise = refreshWithConvergenceRetry() .then((response) => { clientAuthRefreshOperations.set( apiBaseUrl, diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index 8f40a9ef6..a28535b03 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -3,6 +3,7 @@ import { resolveTauriInvoke } from '../app/tauri'; import { getCurrentClientAuthUser, getStoredAuthAccessToken, + isClientAuthAuthorityFailure, refreshClientAuthAccessToken, } from './clientAuth'; import { getClientServerBaseUrl } from './clientHttp'; @@ -14,6 +15,14 @@ import { const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; +function readStoredAccessTokenOrThrow() { + const accessToken = getStoredAuthAccessToken(); + if (!accessToken) { + throw new Error('陶泥儿登录凭据缺失,请重新登录'); + } + return accessToken; +} + type CommittedPlatformSession = { user: AuthUser; accessToken: string; @@ -21,10 +30,25 @@ type CommittedPlatformSession = { generation: number; }; +/** 原生写入:身份代次表达主体归属,revision 只表达写入顺序。 */ +type PlatformNativeSessionWrite = { + identityGeneration: number; + revision: number; +}; + export type PlatformSessionRefreshResult = | { status: 'refreshed'; user: AuthUser; generation: number } | { status: 'stale' } - | { status: 'failed'; error: unknown }; + | { + status: 'failed'; + error: unknown; + /** + * 只有服务端明确否认当前身份(401/403,且收敛重试后仍失败)才为 true。 + * 网络错误、5xx、网关错误和响应契约异常必须保留既有会话与 access token, + * 调用方不得据此把用户登出。 + */ + authoritative: boolean; + }; type PlatformSessionRefreshListener = ( result: PlatformSessionRefreshResult, @@ -33,8 +57,14 @@ type PlatformSessionRefreshListener = ( type PlatformSessionGenerationListener = (generation: number) => void; let platformAuthGeneration = 0; -let platformNativeGeneration = 0; -let platformNativeGenerationFloorPromise: Promise | null = null; +/** 原生写入 revision:每次安装 / 清除都推进,用于拒绝迟到写入。 */ +let platformNativeRevision = 0; +/** 原生身份代次:只在登录、切号、登出或新 authority epoch 推进,续期保持不变。 */ +let platformNativeIdentityGeneration = 0; +let platformNativeSessionFloorPromise: Promise<{ + identityGeneration: number; + revision: number; +}> | null = null; let committedPlatformSession: CommittedPlatformSession | null = null; let desiredPlatformSession: CommittedPlatformSession | null = null; let platformSessionRefreshPromise: Promise | null = @@ -95,7 +125,7 @@ function notifyPlatformSessionGeneration() { async function installNativePlatformSession( session: CommittedPlatformSession, - generation: number, + write: PlatformNativeSessionWrite, ) { const invoke = resolveTauriInvoke(); if (!invoke) return; @@ -103,14 +133,18 @@ async function installNativePlatformSession( userId: session.user.id, accessToken: session.accessToken, apiBaseUrl: session.apiBaseUrl, - generation, + identityGeneration: write.identityGeneration, + revision: write.revision, }); } -async function clearNativePlatformSession(generation: number) { +async function clearNativePlatformSession(write: PlatformNativeSessionWrite) { const invoke = resolveTauriInvoke(); if (!invoke) return; - await invoke('clear_platform_account_session', { generation }); + await invoke('clear_platform_account_session', { + identityGeneration: write.identityGeneration, + revision: write.revision, + }); } function waitForNativeMutationAbandonment( @@ -147,37 +181,60 @@ function enqueuePlatformSessionNativeMutation( async function readNativePlatformSessionGenerationFloor() { const invoke = resolveTauriInvoke(); - if (!invoke) return 0; - const floor = await invoke( - 'read_platform_account_session_generation', - ); + if (!invoke) return { identityGeneration: 0, revision: 0 }; + const state = await invoke<{ + identityGeneration?: unknown; + revision?: unknown; + } | null>('read_platform_account_session_state'); // Browser/unit-test adapters commonly expose a no-op invoke that returns null // for native-only read commands. They have no surviving Rust generation floor. - if (floor === null) return 0; - if (!Number.isSafeInteger(floor) || floor < 0) { - throw new Error('本地运行时登录态 generation 无效,请重启客户端后重试'); + if (state === null || state === undefined) { + return { identityGeneration: 0, revision: 0 }; } - return floor; + const identityGeneration = Number(state.identityGeneration ?? 0); + const revision = Number(state.revision ?? 0); + if ( + !Number.isSafeInteger(identityGeneration) || + identityGeneration < 0 || + !Number.isSafeInteger(revision) || + revision < 0 + ) { + throw new Error('本地运行时登录态写入下限无效,请重启客户端后重试'); + } + return { identityGeneration, revision }; } -async function reserveNativePlatformSessionGeneration() { - platformNativeGenerationFloorPromise ??= +async function reserveNativePlatformSessionWrite(options: { + identityChange: boolean; +}): Promise { + platformNativeSessionFloorPromise ??= readNativePlatformSessionGenerationFloor(); - let nativeGenerationFloor: number; + let floor: { identityGeneration: number; revision: number }; try { - nativeGenerationFloor = await platformNativeGenerationFloorPromise; + floor = await platformNativeSessionFloorPromise; } catch (error) { // 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程 // 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。 - platformNativeGenerationFloorPromise = null; + platformNativeSessionFloorPromise = null; throw error; } - platformNativeGeneration = Math.max( - platformNativeGeneration + 1, + platformNativeRevision = Math.max( + platformNativeRevision + 1, platformAuthGeneration, - nativeGenerationFloor + 1, + floor.revision + 1, ); - return platformNativeGeneration; + // 同一账号的凭据续期必须复用当前身份代次;只有登录、切号、登出或新 authority epoch + // 才允许推进它,否则在途生成 operation 会被自己的续期判成"旧账号请求"。 + platformNativeIdentityGeneration = options.identityChange + ? Math.max( + platformNativeIdentityGeneration + 1, + floor.identityGeneration + 1, + ) + : Math.max(platformNativeIdentityGeneration, floor.identityGeneration); + return { + identityGeneration: platformNativeIdentityGeneration, + revision: platformNativeRevision, + }; } async function reconcileNativePlatformSessionToCurrentAuthority() { @@ -186,17 +243,20 @@ async function reconcileNativePlatformSessionToCurrentAuthority() { const authoritativeSession = desiredPlatformSession ? { ...desiredPlatformSession } : null; - const reconciliationGeneration = - await reserveNativePlatformSessionGeneration(); + // 只有权威会话与上一次已提交会话不是同一身份时才推进身份代次:同账号续期后的对账 + // 仍然算同一身份,不得让在途 operation 失效。 + const identityChange = + !committedPlatformSession || + !authoritativeSession || + committedPlatformSession.user.id !== authoritativeSession.user.id || + committedPlatformSession.apiBaseUrl !== authoritativeSession.apiBaseUrl; + const write = await reserveNativePlatformSessionWrite({ identityChange }); restoreCurrentRendererAccessToken(); try { if (authoritativeSession) { - await installNativePlatformSession( - authoritativeSession, - reconciliationGeneration, - ); + await installNativePlatformSession(authoritativeSession, write); } else { - await clearNativePlatformSession(reconciliationGeneration); + await clearNativePlatformSession(write); } } catch (error) { if (platformAuthGeneration === authoritativeGeneration) { @@ -229,6 +289,39 @@ function resolvePlatformApiBaseUrl() { return getClientServerBaseUrl(); } +async function commitNativePlatformSession( + candidate: CommittedPlatformSession, + authorityGeneration: number, + options: { identityChange: boolean }, +): Promise { + const write = await reserveNativePlatformSessionWrite({ + identityChange: options.identityChange, + }); + try { + await installNativePlatformSession(candidate, write); + } catch (error) { + if (platformAuthGeneration === authorityGeneration) { + desiredPlatformSession = committedPlatformSession + ? { ...committedPlatformSession } + : null; + } + await reconcileNativePlatformSessionToCurrentAuthority(); + if (platformAuthGeneration !== authorityGeneration) return null; + throw error; + } + if (platformAuthGeneration !== authorityGeneration) { + await reconcileNativePlatformSessionToCurrentAuthority(); + return null; + } + committedPlatformSession = candidate; + desiredPlatformSession = { ...candidate }; + restoreCommittedAccessToken(); + if (options.identityChange) { + notifyPlatformSessionGeneration(); + } + return candidate; +} + async function commitPlatformSession( user: AuthUser, accessToken: string, @@ -251,28 +344,50 @@ async function commitPlatformSession( desiredPlatformSession = { ...candidate }; notifyPlatformSessionGeneration(); restoreCommittedAccessToken(); - const nativeGeneration = await reserveNativePlatformSessionGeneration(); - try { - await installNativePlatformSession(candidate, nativeGeneration); - } catch (error) { - if (platformAuthGeneration === candidate.generation) { - desiredPlatformSession = committedPlatformSession - ? { ...committedPlatformSession } - : null; - } - await reconcileNativePlatformSessionToCurrentAuthority(); - if (platformAuthGeneration !== candidate.generation) return null; - throw error; - } - if (platformAuthGeneration !== candidate.generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); + return commitNativePlatformSession(candidate, candidate.generation, { + identityChange: true, + }); +} + +/** + * 同一身份的凭据续期:只替换 access token 与 native 写入 revision,保持身份代次不变, + * 因此在途生成、编辑、上传、确认和下载 operation 不会被自己的续期判成旧账号请求。 + */ +async function commitPlatformCredentialRefresh( + user: AuthUser, + accessToken: string, + apiBaseUrl: string, + expectedGeneration: number, +): Promise { + if (platformAuthGeneration !== expectedGeneration) { + restoreCurrentRendererAccessToken(); return null; } - committedPlatformSession = candidate; + const current = committedPlatformSession; + if (!current) { + restoreCurrentRendererAccessToken(); + return null; + } + if (current.user.id !== user.id || current.apiBaseUrl !== apiBaseUrl) { + // 身份已经变化:按换号路径重新提交,不能复用旧身份代次。 + return commitPlatformSession( + user, + accessToken, + apiBaseUrl, + expectedGeneration, + ); + } + const candidate: CommittedPlatformSession = { + user, + accessToken, + apiBaseUrl, + generation: current.generation, + }; desiredPlatformSession = { ...candidate }; - restoreCommittedAccessToken(); - notifyPlatformSessionGeneration(); - return candidate; + restoreCurrentRendererAccessToken(); + return commitNativePlatformSession(candidate, expectedGeneration, { + identityChange: false, + }); } export function currentPlatformSessionGeneration() { @@ -283,6 +398,11 @@ export function currentPlatformSessionApiBaseUrl() { return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); } +/** 仅供测试断言:同一账号续期不得推进这个身份代次。 */ +export function currentPlatformNativeIdentityGenerationForTests() { + return platformNativeIdentityGeneration; +} + export function beginPlatformSessionTransition() { platformAuthGeneration += 1; desiredPlatformSession = committedPlatformSession @@ -304,10 +424,7 @@ export async function commitAuthenticatedPlatformSession( expectedGeneration: number, apiBaseUrl = resolvePlatformApiBaseUrl(), ) { - const accessToken = getStoredAuthAccessToken(); - if (!accessToken) { - throw new Error('陶泥儿登录凭据缺失,请重新登录'); - } + const accessToken = readStoredAccessTokenOrThrow(); const operation = createClientOperation( 'auth-transition', { userId: user.id }, @@ -386,16 +503,21 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } - const committedGeneration = await commitAuthenticatedPlatformSession( - user, - expectedGeneration, - apiBaseUrl, + // 同一账号的续期只更新凭据:身份代次保持不变,因此在途生成 operation 不会被 + // 自己的续期判成旧账号请求。 + const committed = await enqueuePlatformSessionNativeMutation(() => + commitPlatformCredentialRefresh( + user, + readStoredAccessTokenOrThrow(), + apiBaseUrl, + expectedGeneration, + ), ); - if (committedGeneration === null) return { status: 'stale' }; + if (committed === null) return { status: 'stale' }; return { status: 'refreshed', user, - generation: committedGeneration, + generation: committed.generation, }; } catch (error) { if (platformAuthGeneration !== expectedGeneration) { @@ -411,9 +533,12 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } + // 只有服务端明确否认当前身份才算权威失效。网络错误、5xx、网关错误和响应契约 + // 异常必须保留既有会话与 access token,否则一次后台保活抖动就会把用户登出。 + const authoritative = isClientAuthAuthorityFailure(error); if ( - !currentOwnerUserId || - currentOwnerUserId === expectedSessionUserId + authoritative && + (!currentOwnerUserId || currentOwnerUserId === expectedSessionUserId) ) { const clearGeneration = beginPlatformSessionClearTransition(); try { @@ -421,8 +546,10 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { } catch (clearError) { failure = clearError; } + return { status: 'failed', error: failure, authoritative: true }; } - return { status: 'failed', error: failure }; + restoreCurrentRendererAccessToken(); + return { status: 'failed', error: failure, authoritative: false }; } })().then((result) => { notifyPlatformSessionRefresh(result); @@ -474,9 +601,11 @@ export async function clearCommittedPlatformSession(generation: number) { return; } desiredPlatformSession = null; - const nativeGeneration = await reserveNativePlatformSessionGeneration(); + const write = await reserveNativePlatformSessionWrite({ + identityChange: true, + }); try { - await clearNativePlatformSession(nativeGeneration); + await clearNativePlatformSession(write); } catch { if (platformAuthGeneration === generation) { desiredPlatformSession = null; @@ -516,8 +645,9 @@ export async function clearCommittedPlatformSession(generation: number) { export function resetPlatformSessionStateForTests() { platformAuthGeneration = 0; - platformNativeGeneration = 0; - platformNativeGenerationFloorPromise = null; + platformNativeRevision = 0; + platformNativeIdentityGeneration = 0; + platformNativeSessionFloorPromise = null; committedPlatformSession = null; desiredPlatformSession = null; platformSessionRefreshPromise = null; diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 586a4e0a7..68a9c30c4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -11,6 +11,7 @@ import { beginPlatformSessionTransition, clearCommittedPlatformSession, commitAuthenticatedPlatformSession, + currentPlatformNativeIdentityGenerationForTests, currentPlatformSessionGeneration, requestPlatformSessionRefresh, resetPlatformSessionStateForTests, @@ -214,78 +215,95 @@ export function registerAuthTests() { ); }); - it('reserves install and clear generations above the native floor after renderer state resets', async () => { - let nativeGenerationFloor = 57; + it('reserves install and clear writes above the native floor after renderer state resets', async () => { + let nativeFloor = { identityGeneration: 57, revision: 57 }; const mutations: Array<{ command: string; - generation: number; + identityGeneration: number; + revision: number; }> = []; const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'read_platform_account_session_generation') { - return nativeGenerationFloor; + if (command === 'read_platform_account_session_state') { + return nativeFloor; } if ( command === 'install_platform_account_session' || command === 'clear_platform_account_session' ) { - const generation = (payload as { generation?: number } | undefined) - ?.generation; - if (generation === undefined) { - throw new Error('missing native session generation'); + const write = payload as + | { identityGeneration?: number; revision?: number } + | undefined; + if ( + write?.identityGeneration === undefined || + write?.revision === undefined + ) { + throw new Error('missing native session write identity'); } - mutations.push({ command, generation }); - nativeGenerationFloor = generation; + mutations.push({ + command, + identityGeneration: write.identityGeneration, + revision: write.revision, + }); + nativeFloor = { + identityGeneration: write.identityGeneration, + revision: write.revision, + }; } return null; }); window.__TAURI__ = { core: { invoke } }; resetPlatformSessionStateForTests(); - const installFloor = nativeGenerationFloor; + const installFloor = nativeFloor.revision; + const installIdentityFloor = nativeFloor.identityGeneration; const loginGeneration = beginPlatformSessionTransition(); window.localStorage.setItem( 'genarrative.auth.access-token.v1', 'renderer-reload-token', ); await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration); - expect(mutations[0]).toEqual({ - command: 'install_platform_account_session', - generation: expect.any(Number), - }); - expect(mutations[0]?.generation).toBeGreaterThan(installFloor); + expect(mutations[0]?.command).toBe('install_platform_account_session'); + expect(mutations[0]?.identityGeneration).toBeGreaterThan( + installIdentityFloor, + ); + expect(mutations[0]?.revision).toBeGreaterThan(installFloor); resetPlatformSessionStateForTests(); - const clearFloor = nativeGenerationFloor; + const clearFloor = nativeFloor.revision; + const clearIdentityFloor = nativeFloor.identityGeneration; const logoutGeneration = beginPlatformSessionClearTransition(); await clearCommittedPlatformSession(logoutGeneration); - expect(mutations[1]).toEqual({ - command: 'clear_platform_account_session', - generation: expect.any(Number), - }); - expect(mutations[1]?.generation).toBeGreaterThan(clearFloor); + expect(mutations[1]?.command).toBe('clear_platform_account_session'); + expect(mutations[1]?.revision).toBeGreaterThan(clearFloor); + expect(mutations[1]?.identityGeneration).toBeGreaterThan( + clearIdentityFloor, + ); expect( invoke.mock.calls.filter( - ([command]) => command === 'read_platform_account_session_generation', + ([command]) => command === 'read_platform_account_session_state', ), ).toHaveLength(2); }); - it('retries the native session generation floor read after a transient failure', async () => { + it('retries the native session write floor read after a transient failure', async () => { let floorReads = 0; const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'read_platform_account_session_generation') { + if (command === 'read_platform_account_session_state') { floorReads += 1; if (floorReads === 1) { throw new Error('runner not ready'); } - return 12; + return { identityGeneration: 12, revision: 12 }; } if ( command === 'install_platform_account_session' || command === 'clear_platform_account_session' ) { expect(payload).toEqual( - expect.objectContaining({ generation: expect.any(Number) }), + expect.objectContaining({ + identityGeneration: expect.any(Number), + revision: expect.any(Number), + }), ); } return null; @@ -570,7 +588,8 @@ export function registerAuthTests() { expect(invoke).toHaveBeenLastCalledWith( 'clear_platform_account_session', expect.objectContaining({ - generation: expect.any(Number), + identityGeneration: expect.any(Number), + revision: expect.any(Number), }), ); expect( @@ -732,9 +751,17 @@ export function registerAuthTests() { expect.objectContaining({ userId: 'user-b', accessToken: 'account-b-token', - generation: currentPlatformSessionGeneration(), + identityGeneration: expect.any(Number), + revision: expect.any(Number), }), ); + // 换号必须推进身份代次:旧账号在途 operation 不能拿到新账号凭据。 + const lastInstall = invoke.mock.calls.at(-1)?.[1] as + | { identityGeneration?: number } + | undefined; + expect(lastInstall?.identityGeneration).toBe( + currentPlatformNativeIdentityGenerationForTests(), + ); }); it('treats a late old-account refresh failure as stale after switching accounts', async () => { @@ -822,6 +849,103 @@ export function registerAuthTests() { }); }); + it('keeps the identity generation stable when the same account renews its credential', async () => { + const installs: Array<{ identityGeneration?: number; revision?: number }> = + []; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + if (command === 'install_platform_account_session') { + installs.push( + payload as { identityGeneration?: number; revision?: number }, + ); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const generation = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'expired-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, generation); + const identityGenerationAfterLogin = + currentPlatformNativeIdentityGenerationForTests(); + const sessionGenerationAfterLogin = currentPlatformSessionGeneration(); + + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response(JSON.stringify({ token: 'renewed-token' }), { + status: 200, + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await requestPlatformSessionRefresh(testAuthUser.id); + expect(result).toEqual( + expect.objectContaining({ status: 'refreshed', user: testAuthUser }), + ); + // 续期只换凭据:身份代次与平台会话代次都不推进,在途生成 operation 不会被判成 + // 旧账号请求;native 写入仍然用更高的 revision 拒绝迟到写入。 + expect(currentPlatformNativeIdentityGenerationForTests()).toBe( + identityGenerationAfterLogin, + ); + expect(currentPlatformSessionGeneration()).toBe( + sessionGenerationAfterLogin, + ); + expect(installs).toHaveLength(2); + expect(installs[1]?.identityGeneration).toBe( + installs[0]?.identityGeneration, + ); + expect(installs[1]?.revision).toBeGreaterThan(installs[0]?.revision ?? 0); + }); + + it('keeps the session when a refresh fails for a transient reason', async () => { + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + const generation = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'still-valid-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, generation); + const sessionGeneration = currentPlatformSessionGeneration(); + + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 503 }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await requestPlatformSessionRefresh(testAuthUser.id); + // 刷新暂时不可用不等于登录态权威失效:保留会话与 access token,只让本次动作失败。 + expect(result).toMatchObject({ status: 'failed', authoritative: false }); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('still-valid-token'); + expect(currentPlatformSessionGeneration()).toBe(sessionGeneration); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'clear_platform_account_session', + ), + ).toHaveLength(0); + }); + it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => { setClientServerSelection({ preset: 'dev', customBaseUrl: '' }); const invoke = vi.fn(async () => null); @@ -1452,7 +1576,8 @@ export function registerAuthTests() { fetchSpy.mock.calls.filter( ([input]) => String(input) === '/api/auth/refresh', ), - ).toHaveLength(1); + // 401 刷新先用当前 cookie 收敛重试一次,重试仍被拒绝才算权威失效。 + ).toHaveLength(2); }); it('fails the renderer closed when native session clear is rejected during logout', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index 75a4be7a1..fa54a42e3 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -1338,6 +1338,7 @@ export function registerProjectCommandTests() { }); }); + // 连续 12 次聊天提交验证策略累计与互斥,包含 Lexical 提交和确认面板更新。 it('keeps project policy deny and confirm command lists mutually exclusive', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1612,7 +1613,7 @@ export function registerProjectCommandTests() { }), }); }); - }); + }, 15_000); it('does not write project policy for no-op policy changes', async () => { const manifest = createGameCreationAppManifest( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 21ec594a1..c701d873b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -10310,16 +10310,23 @@ export function registerProjectAgentStatusTests() { ); window.__TAURI__ = { core: { invoke } }; renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); - await waitFor(() => { - expect( - ( - screen.getByRole('button', { - name: '刷新 Agent', - }) as HTMLButtonElement - ).disabled, - ).toBe(false); - }); - + // 项目打开包含异步初始化;以按钮启用为就绪条件,再开始弹窗迟到读取场景。 + await screen.findByText('想做什么游戏?'); + await waitFor( + () => { + expect( + ( + screen.getByRole('button', { + name: '刷新 Agent', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + expect( + screen.getByRole('button', { name: /拆解创作方向/ }), + ).toBeTruthy(); + }, + { timeout: 5_000 }, + ); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); await waitFor(() => { expect(releaseConversation).not.toBeNull(); @@ -10334,7 +10341,7 @@ export function registerProjectAgentStatusTests() { expect(screen.queryByText('关闭后不该写入界面状态')).toBeNull(); expect(screen.queryByText('conversation.read')).toBeNull(); expect(screen.queryByText('memory.agent.read')).toBeNull(); - }); + }, 10_000); it('updates the open agent dialog when agent status is refreshed', async () => { const manifest = createGameCreationAppManifest( diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 91d2a90c6..34639803c 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -8,6 +8,7 @@ import { } from '../src/services/clientApi'; import { getClientAuthRefreshOperation, + getStoredAuthAccessToken, refreshClientAuthAccessToken, } from '../src/services/clientAuth'; import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp'; @@ -142,15 +143,53 @@ it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token expect(fetch).toHaveBeenCalledTimes(6); }); -it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => { +it.each([401])( + '续期被明确拒绝时保留原 HTTP %s,且不重发业务请求', + async (status) => { + let refreshCalls = 0; + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input) => { + if (String(input) === '/api/auth/refresh') { + refreshCalls += 1; + return json({}, 401); + } + return json({}, status); + }); + await expect( + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + ).rejects.toMatchObject({ status }); + // 401 刷新先用当前 cookie 收敛重试一次;重试仍被拒绝才算登录态权威失效, + // 而且不能把一次卡片级失败放大成全局登出。 + expect(refreshCalls).toBe(2); + expect(fetch).toHaveBeenCalledTimes(3); + expect(getStoredAuthAccessToken()).toBe(''); + }, +); + +it('续期 401 后用当前 cookie 收敛重试并继续业务请求', async () => { + let refreshCalls = 0; const fetch = vi .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(json({}, status)) - .mockResolvedValueOnce(json({}, 401)); + .mockImplementation(async (input, init) => { + if (String(input) === '/api/auth/refresh') { + refreshCalls += 1; + return refreshCalls === 1 + ? json({}, 401) + : json({ token: 'rotated-token' }); + } + if (String(input) === '/api/auth/me') return json({ user }); + const token = new Headers(init?.headers).get('Authorization'); + if (token === 'Bearer expired-token') return json({}, 401); + expect(token).toBe('Bearer rotated-token'); + return json(catalog); + }); + await expect( requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), - ).rejects.toMatchObject({ status }); - expect(fetch).toHaveBeenCalledTimes(2); + ).resolves.toEqual(catalog); + expect(refreshCalls).toBe(2); + expect(getStoredAuthAccessToken()).toBe('rotated-token'); }); it('跳过鉴权的请求不触发续期', async () => { diff --git a/docs/project-memory/plans/【实施计划】Direct三维请求自选技术栈-2026-09-16.md b/docs/project-memory/plans/【实施计划】Direct三维请求自选技术栈-2026-09-16.md new file mode 100644 index 000000000..1caba8de9 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Direct三维请求自选技术栈-2026-09-16.md @@ -0,0 +1,27 @@ +Version: 1.0 +Status: implemented; real-project runtime smoke pending +Date: 2026-09-16 +Parent Milestone: docs/project-memory/plans/【里程碑】Direct三维请求自选技术栈-2026-09-16.md + +## 修改顺序 + +1. 在 `agent/direct_runtime/mod.rs` 保留三维/引擎意图与工程归属识别(含伪 3D、2.5D、代码话题例外)。 +2. 用"三维请求自选技术栈"合同替换澄清合同、工具阻断、注册门禁与首页创建阻断代码路径。 +3. 在三处装配点前置注入合同:首页回合、项目回合与测试用的旧装配点。 +4. 把工程合同里的 Phaser 固定约束按二维/三维分层。 +5. 更新定向用例与文档,运行格式化、编码检查与 diff 检查。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell three_dimensional` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell agent::direct_runtime::tests::system_prompt` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell agent::direct_tools_mcp` +- `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` +- `npm run check:encoding` +- `git diff --check` + +## 风险与回滚 + +- 误判风险:关键词识别可能对"三维数组"这类代码话题误触发,已用游戏语义上下文词、平面化排除词与独立成词边界收敛,并用用例钉住例外。 +- 行为风险:解除 Phaser 固定约束后,三维交付可能落到 Three.js / Babylon.js 等新栈;回滚点是移除前置合同注入与常驻合同段,恢复原 Phaser 固定文案。 +- 已知环境限制:本机临时目录 owner ACL 与当前进程用户不一致,涉及 `init_local_game_project_at` 的既有用例(未改动模块同样如此)无法在本机执行;本轮证据为定向用例 + 格式 + 编码 + diff 检查,全量分片与真机 smoke 仍待补。 diff --git a/docs/project-memory/plans/【实施计划】平台会话身份与凭据分离-2026-09-16.md b/docs/project-memory/plans/【实施计划】平台会话身份与凭据分离-2026-09-16.md new file mode 100644 index 000000000..2fe7234dd --- /dev/null +++ b/docs/project-memory/plans/【实施计划】平台会话身份与凭据分离-2026-09-16.md @@ -0,0 +1,47 @@ +# 【实施计划】平台会话身份与凭据分离 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md` | +| Status | done(待验收复核) | +| Owner | Codex | + +## 修改边界 + +- 允许修改:`apps/ai-game-creator-shell/src-tauri/src/platform_session.rs`、`commands.rs`、`runner/`(attach 参数与安装路径)、`project/external_editor_bindings.rs`、`project/resource_editor.rs`、`agent/generation/canvas_generation.rs`、`agent/direct_tools_mcp.rs`、`agent/direct_runtime/`、`assets.rs` 中与身份判据相关的调用点。 +- 允许修改:`apps/ai-game-creator-shell/src/services/platformSession.ts`、`clientAuth.ts`、`clientApi.ts`、`src/App.tsx` 及对应测试。 +- 允许修改:`src/services/apiClient.ts` 刷新收敛重试与对应测试。 +- 允许修改:`server-rs/crates/api-server/src/refresh_session.rs`、`auth_session.rs` 与对应测试。 +- 明确不修改:`/api/external/v1` 路由与 OpenAPI、`refresh_session` schema / 迁移 / 绑定、生成账本与计费、Provider 与 MCP 工具边界。 + +## 实现顺序 + +1. 原生会话状态拆分:`PlatformSessionSnapshot` 增加身份代次,`generation` 更名并明确为写入 revision;安装 / 清除 / 冻结校验按新判据重写,补单元用例。 +2. 逐个修正原生调用点(binding、生成 operation、MCP 会话、diagnostics、Runner attach),由编译器定位所有旧判据读取点。 +3. renderer:身份代次与 native revision 分离,续期走凭据更新路径;刷新失败只在权威失效时清会话;补前端用例。 +4. 刷新收敛:AGC 与网站前端在刷新 401 时用当前 cookie 重试一次。 +5. `api-server`:轮换失败不再下发清 cookie 响应,补定向用例。 +6. 回写主规范与共享记忆,删除临时计划。 + +## 验证命令 + +1. `npm run ai-game-creator-shell:check:rust`(或定向 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml platform_session`) +2. `npm --prefix apps/ai-game-creator-shell test -- src` 对应前端定向用例 +3. `npm run test -- src/services/apiClient.test.ts` +4. `cargo test -p api-server refresh_session --manifest-path server-rs/Cargo.toml` +5. `npm run typecheck`、`npm run check:encoding`、`git diff --check` + +## 风险与回滚点 + +- 风险:身份代次判据改宽后,若换号路径仍复用旧代次,旧账号请求可能带着新账号 token 发出。缓解:换号、退出和 origin 变化必须先推进身份代次;Rust 侧对同代次不同主体的写入失败关闭,并用用例覆盖。 +- 风险:revision 与身份代次混用会让迟到写入复活旧会话。缓解:写入顺序只认 revision,身份只认身份代次,两者分别有用例。 +- 回滚点:先回滚 renderer 的续期路径与原生判据(第 1-3 步),再回滚服务端响应语义(第 5 步);`refresh_session` schema 不在本次改动内,无需数据回滚。 + +## 执行结果 + +1. `platform_session.rs`:`PlatformSessionSnapshot` 拆成 `identity_generation` + `revision`,新增 `PlatformSessionIdentity`、身份租约与 `validate_frozen_platform_session`;安装 / 清除按"revision 排序 + 身份归属"双判据实现。 +2. 原生调用点:`external_editor_bindings.rs`、`resource_editor.rs`、`canvas_generation.rs`、`direct_tools_mcp.rs`、`direct_runtime`、`assets.rs`、`commands.rs` 全部改按身份判定;`commands.rs` 的 IPC 改为 `install_platform_account_session(userId, accessToken, apiBaseUrl, identityGeneration, revision)`、`clear_platform_account_session(identityGeneration, revision)`、`read_platform_account_session_state()`。 +3. Runner:attach 参数新增 `platform_auth_revision`,`platform_auth_generation` 只表达身份代次;`remember_*` / `validate_*` 按 revision 排序、按身份归属判定。 +4. renderer:`platformSession.ts` 拆分 `platformNativeIdentityGeneration` 与 `platformNativeRevision`,续期走 `commitPlatformCredentialRefresh`(不推进身份代次);刷新失败结果新增 `authoritative`,`AuthenticatedClient` 只在权威失效时登出。 +5. 刷新收敛:AGC `clientAuth.refreshClientAuthAccessToken` 与网站 `apiClient.refreshAccessToken` 在 401 后用当前 cookie 再试一次;网站侧额外要求本代次仍是当前代次。 +6. `api-server`:`refresh_session` 失败不再下发清 cookie 响应(`map_refresh_error_with_clear_cookie` 删除),并更新两条 `app::tests` 断言。 diff --git a/docs/project-memory/plans/【里程碑】Direct三维请求自选技术栈-2026-09-16.md b/docs/project-memory/plans/【里程碑】Direct三维请求自选技术栈-2026-09-16.md new file mode 100644 index 000000000..d74bf02bc --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Direct三维请求自选技术栈-2026-09-16.md @@ -0,0 +1,29 @@ +Version: 1.0 +Status: implemented; real-project runtime smoke pending +Date: 2026-09-16 +Parent Spec: docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md + +## 目标 + +用户要求做三维(3D)游戏时,DirectProject 不再把 Phaser 4.2.1 当作固定约束:由 Codex 自行选择三维技术栈并直接推进,客户端不要求先澄清引擎、不阻断工具、不拒绝登记产出。 + +## 边界 + +- 只读用户原文做确定性识别,不改写用户消息,不启动 Supervisor、专业 Agent、harness 或额外生成工作流。 +- 只对"说了三维、没有点名引擎"的回合注入自选栈合同;用户点名引擎时沿用既有工程合同(Cocos / Unity / Godot 等与当前目录不匹配时先说明不匹配)。 +- 用户明确选择"等轴 / 伪 3D / 2.5D"表现,或话题是代码里的三维概念时,不注入该合同。 +- 唯一保留的红线是诚实边界:不得用等轴伪 3D 或二维图集冒充三维交付而不说明。 +- 首页只加一行三维提示,不阻止创建项目;不改 `/api/external/v1` 契约、OpenAPI、DTO、SpacetimeDB schema 与前端投影协议。 + +## 验收标准 + +1. 常驻系统提示包含"三维请求合同":三维请求不受 Phaser 固定约束,由 Codex 自选三维技术栈,可新增 npm 依赖,仍需说明选型。 +2. 命中三维请求时,系统提示前置一段自选栈合同(含当前工程识别结果),不被 16K 提示词上限截断。 +3. 点名引擎、主动选择伪 3D / 等轴 / 2.5D、代码话题(如"三维数组"、"value4")不触发该合同。 +4. 引擎固定约束文案已按二维/三维分层:二维游戏仍固定 Phaser 4.2.1,三维请求不受该约束。 +5. 定向 Rust 用例覆盖识别、合同文本、工程识别与首页提示;格式化、编码检查与 `git diff --check` 通过。 + +## 依赖 + +- 现有 DirectProject 回合装配(`agent/direct_runtime/mod.rs`)。 +- 现有 Cocos / Godot 工程识别(`project::discover_local_cocos_project_root`、`project::discover_local_godot_project_root`)。 diff --git a/docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md b/docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md new file mode 100644 index 000000000..0c80b4934 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md @@ -0,0 +1,54 @@ +# 【里程碑】平台会话身份与凭据分离 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented(待验收复核) | +| Date | 2026-09-16 | +| Parent Spec | `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(`2026-09-16 平台会话身份与凭据分离`) | + +## 目标 + +同一账号在长回合内发生 access token 续期时,在途的生成、编辑、上传、确认和下载 operation 必须继续可用;只有登录主体、服务 origin 或登出状态真正变化时才允许中止它们。刷新失败与并发刷新不得把仍然有效的会话判成未登录。 + +## 范围 + +- AGC renderer 的平台会话状态机:身份代次与 native 写入 revision 的分离,续期不再推进身份代次。 +- 原生 Rust `platform_session` 会话状态:身份判据、token 判据、写入顺序判据的分离。 +- 冻结平台会话的校验语义(外部编辑器 binding、生成 operation、MCP 工具会话)。 +- 独立 Runner 的平台会话安装 / 清除与 attach 参数。 +- AGC 与网站前端的刷新失败语义、并发刷新收敛重试。 +- `api-server` `/api/auth/refresh` 轮换失败时的响应语义。 + +## 不在范围内 + +- 不改 `refresh_session` 的持久化 schema,不引入服务端轮换宽限表。 +- 不改 `/api/external/v1` 路由、OpenAPI 或共享 DTO。 +- 不改生成、计费、账本幂等键与恢复语义本身。 +- 不改 Provider、MCP 工具白名单、审批或项目锁。 + +## 依赖与前置条件 + +- 现有 native revision 与 durable GUI owner claim 语义保持不变。 +- 现有 `authentication-required` 错误分类保持不变,只收窄其触发条件。 + +## 验收标准 + +- [x] 同一账号续期后,续期前建立的在途冻结会话校验通过;换号、退出和 origin 变化后校验失败关闭。 +- [x] 同一账号续期不推进 renderer 身份代次,也不使 `AuthenticatedClient` / Direct 配置判据把会话判成 stale。 +- [x] 迟到或更旧的凭据写入被 revision 拒绝,不能覆盖更新的 token,也不能复活已清除的会话。 +- [x] 瞬时刷新失败(网络错误、5xx、网关错误、响应契约异常)不清除本地会话与 access token。 +- [x] 刷新返回 401 时先用当前 cookie 收敛重试一次;重试成功继续使用新凭据,重试仍 401 才清会话。 +- [x] `/api/auth/refresh` 轮换失败不下发清空 refresh cookie 的响应。 + +## 证据要求 + +- 自动化:AGC Rust 定向用例(身份判据、revision CAS、冻结会话)、AGC 前端用例(代次与刷新失败语义)、`api-server` 定向用例(轮换失败响应)、网站 `apiClient` 用例(收敛重试)。 +- 运行时:AGC shell Rust 定向测试与前端测试;`api-server` 定向 `cargo test`。 +- 边界:换号 / 退出失败关闭、迟到写入拒绝、并发刷新竞争、token 不出现在错误文本与持久化中。 + +## 证据与未验证项 + +- 已获得:AGC `platform_session::tests` 12/12、`runner::tests` 平台会话相关 5/5、`assets::tests` 平台路由 2/2;AGC `appSurface` 前端套件 468 项全通过;网站 `src/services/apiClient.test.ts` 33/33;`cargo test -p api-server refresh_session` 3/3。 +- 未验证:依赖项目夹具的 AGC Rust 用例(`project::resource_editor::tests`、`project::external_editor_bindings::tests` 等)在本机全部以同一条环境错误失败——测试临时目录属主是 `BUILTIN\Administrators`,而进程用户是 `kdletters\kdletters`,严格 ACL 校验在夹具初始化阶段失败关闭,与本次改动无关;需要非提权或属主正确的运行环境才能补齐。 +- 未验证:真实 Provider 的并发生图 + 长回合保活端到端 smoke 未执行(需要有效平台登录态与付费生成)。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 360df2121..5d8e744dc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,7 +2,6 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 - ## 2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner - 背景:双击或再次启动 AGC 客户端时报「应用启动失败」,启动日志为 `startup.runner.owner-lock.failed details=AI 游戏创作界面已由同一 AppData 目录中的其他进程运行`。原设计(2026-07-27 / 2026-08-23)要求同一 AppData 只有一个 GUI owner,第二个界面进程在 setup 阶段就失败退出。 @@ -14,6 +13,15 @@ - 未验证 / 已知环境问题:真实安装包双开需要重新构建发布后才能验证;`durable_provider_handoff_prevents_shutdown_even_when_corrupt`、`durable_provider_retry_prevents_shutdown_and_reopens_writes`、`runtime_interrupt_for_true_steer_decision_only_interrupts_older_provider_cursor` 三条用例在本机改动前的基线上即失败(Windows 安全对象 owner 校验与 Provider 请求重复),与本决策无关。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`(2026-09-16 节)、`docs/project-memory/plans/【里程碑】AGC同AppData多窗口共享Runner-2026-09-16.md`。 +## 2026-09-16 DirectProject 三维请求解除 Phaser 固定约束,由 Codex 自选技术栈 + +- 背景:三维需求("做个 3D 城市游戏")在只有 Phaser 4 二维通道的工程里没有落点,而完成判定以"源码引用已登记平台图片 + 浏览器渲染到该图"为硬门禁,模型可推进的唯一动作退化成生成图集;真机侧表现为长时间生图与反复接线,等轴伪 3D 成了默认交付。产品决定不再用"先澄清引擎"卡住三维请求,改为放开选型。 +- 决策:用户消息表达三维(3D / 三维)且没有点名引擎时,本回合注入**自选技术栈合同**:不受"新 Web 游戏固定 Phaser 4.2.1"约束,由 Codex 自行选择三维技术栈(Three.js、Babylon.js 等 npm 运行时,或当前工程自带的引擎),可以按需新增 npm 依赖、调整工程结构,并在回复里说明选型;客户端不要求先澄清、不阻断任何工具、不拒绝登记产出。 +- 决策(常驻口径):`DIRECT_AGC_ENGINEERING_GUIDANCE` 的 Phaser 固定约束按二维/三维分层——二维游戏仍是 Phaser 4.2.1,三维请求不受该约束。首页回合只加一行三维提示,仍允许按既有规则创建项目。 +- 边界:用户点名引擎时沿用既有工程合同(Cocos / Unity / Godot 等与当前目录不匹配时先说明不匹配);用户主动选择"等轴 / 伪 3D / 2.5D"或话题是代码里的三维概念时不注入合同。唯一保留的红线是不得用等轴伪 3D 或二维图集冒充三维交付而不说明。识别只读用户原文,不改写消息、不触发额外工作流,也不做工具阻断或注册门禁。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs`。**未改** `/api/external/v1` 契约 / OpenAPI / DTO、SpacetimeDB schema、AGC 工具桥行为、前端投影与资源工作台。 +- 验证方式:`three_dimensional_game_request_frees_the_engine_choice`、`explicit_flat_presentation_requests_do_not_trigger_three_dimensional_selection`、`named_engine_requests_keep_the_existing_engineering_rule`、`three_dimensional_contract_reports_the_current_project_engine`、`home_three_dimensional_note_keeps_project_creation_available`、`system_prompt_is_bounded_and_declares_direct_runtime`,以及 `agent::direct_tools_mcp` 17 passed;`cargo fmt --check` 通过。本机临时目录 owner ACL 与进程用户不一致,涉及 `init_local_game_project_at` 的既有用例(含未改动模块)在本机无法执行,全量分片与真机 smoke 未在本轮取得。 +- 关联文档:[里程碑](../plans/【里程碑】Direct三维请求自选技术栈-2026-09-16.md)、[实施计划](../plans/【实施计划】Direct三维请求自选技术栈-2026-09-16.md)。 ## 2026-09-16 图标图集自动拆图上限提高到 256 - 背景:AGC 图标图集自动连通域识别在一次生成中识别出 86 个区域,原有 64 片上限在后处理阶段阻断了请求;该上限同时影响 api-server 自动 / 手动切片、SpacetimeDB 批量落库和统一生成结果 item 数量。 @@ -8777,6 +8785,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。 - 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。 - 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。 + +## 2026-09-16 平台会话身份与凭据分离 + +- 背景:长回合保活和 401 续期每次签发新 access token,并推进 native generation 重新安装原生会话;冻结平台会话同时比对身份与 token 字节,于是同账号的正常续期被等价成换号,并发在途的生成 / 编辑 / 上传 / 确认 / 下载 operation 全部被判为“旧账号请求”失败。更彻底的一层是原生会话、Runner attach 与 renderer 代次都把“写入顺序”和“身份归属”压在同一个计数器上。 +- 决策:平台会话拆成身份(`userId + api origin + identity generation`)与凭据(当前 access token)。identity generation 只在登录、切号、登出或新的 GUI authority epoch 推进;同账号续期只更新凭据并推进只用于拒绝迟到写入的 revision。冻结会话校验、MCP 会话身份与 Runner attach 统一按身份判定,换号 / 退出仍然失败关闭。刷新失败只在服务端明确 401/403 且一次收敛重试后仍失败时清会话;`/api/auth/refresh` 的轮换失败不再下发清空 refresh cookie 的响应。 +- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-16 平台会话身份与凭据分离”;开发期计划见 `docs/project-memory/plans/【里程碑】平台会话身份与凭据分离-2026-09-16.md` 与对应实施计划。 +- 验证:AGC `platform_session::tests`、`runner::tests`、`assets::tests` 定向通过;AGC `appSurface` 前端套件 468 项通过;网站 `src/services/apiClient.test.ts` 33 项通过;`cargo test -p api-server refresh_session` 通过。项目夹具类 Rust 用例受本机临时目录属主为 `BUILTIN\Administrators` 的环境限制,未计入本次证据。 ## 2026-09-15 Direct 回合跨页面继续运行与活动项目面板 - 决策:采用后台继续运行语义。Direct 回合由进程内项目身份锁持有,页面离开不取消;重进项目通过活动回合只读快照与 Thread Manager bootstrap/consume 恢复忙碌态和进度。左上角面板复用同一快照列出正在运行的 Direct 项目并支持进入。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 59f167beb..9630581dd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5637,3 +5637,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - 验证:`git config --show-origin --get user.name` 出现 `file:.git/config Git Hooks Test`、`git rev-parse --show-toplevel` 指向 `%TEMP%\genarrative-pre-push-*\repo` 都是被污染的确定性证据;被 `core.worktree` 劫持期间执行的 `git pull` 会把检出写进临时目录,真实工作树整体落后(本次 93 个文件),配置修好后用 `git checkout HEAD -- .` 回填。 - Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 - 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。 + +## 2026-09-16 并发生图遇到“登录态冲突”:身份代次与凭据轮换混用 + +- **现象**:DirectProject 长回合里并发派发的生图 / 素材生成请求中途报 `authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试`,或平台工具返回 `HTTP 401 invalid-token`;账号并没有切换,重新登录后短时间内可复现。 +- **原因**:客户端把两件事压成了一个判据。原生冻结会话(`PlatformSessionSnapshot`)同时承担“身份归属”和“access token 字节比对”,而长回合保活与 401 续期每次都会签发新 token 并推进 native generation 重新安装会话;于是同账号的正常续期被等价成换号,续期窗口内所有在途生成、编辑、上传、确认和下载 operation 全部失配。保活定时器(回合 busy 时每 5 分钟一次)会稳定落在生图窗口内,所以并发越多越必现。另一层在服务端:`/api/auth/refresh` 是严格一次性轮换,且失败时下发清空 refresh cookie 的响应;两个窗口 / 实例并发续期时,输的一方会把赢家刚写入的有效 cookie 删掉。 +- **处理(现行口径)**:平台会话统一拆成**身份**(`userId + api origin + identity generation`)与**凭据**(当前 access token)。identity generation 只在登录、切号、登出和新的 GUI authority epoch 推进;同账号续期只更新凭据并推进写入 revision(revision 只用于拒绝迟到写入)。冻结会话校验只比身份,比 token 字节的判据已被取代。刷新失败语义收紧为:只有服务端明确返回 401/403 且经一次收敛重试后仍失败,才清本地会话;网络错误、5xx、网关错误和响应契约异常必须保留会话与 access token。`/api/auth/refresh` 的轮换失败不再下发清 cookie 响应。 +- **验证**:AGC `platform_session::tests` 覆盖“同身份 token 轮换后冻结会话仍有效”“换号 / 退出后失效”“迟到 install 被 revision 拒绝”;`appSurface` 前端用例覆盖“续期不推进身份代次且 native 写入 revision 递增”“瞬时刷新失败不清会话”;网站 `src/services/apiClient.test.ts` 覆盖“刷新 401 后收敛重试成功”与“两次都被拒绝才判权威失效”;`api-server` `refresh_session_*` 覆盖“轮换失败不下发清 cookie”。定位同类问题先看冻结会话判据里有没有 token 字节,再看服务端失败响应有没有 `Max-Age=0`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 990ee7353..792e58a6c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -48,9 +48,22 @@ DirectProject 的 AGC 工具、构建、验证和浏览器试玩错误,若不属于鉴权、权限、余额、项目身份、历史损坏、传输断开、取消或付费操作状态不确定等安全终止边界,必须作为脱敏错误上下文回传同一 LLM 会话,由 LLM 读取当前项目、修改真实文件并重跑失败阶段。客户端最多连续反馈三次;每次保留 stage、工具 / 命令、错误正文和已有证据,不得静默吞错、伪造成功或用占位产物跳过阶段。达到三次仍失败后,才向用户投影终态错误和诊断引用。 +## 2026-09-16 平台会话身份与凭据分离(并发续期不再中断在途生成) + +平台会话在 AGC renderer、原生 Rust 层和独立 Runner 中统一拆成两个互不替代的概念:**身份**(`userId + api origin + identity generation`)与**凭据**(当前 access token)。身份代次只表达登录主体或服务 origin 的更换;凭据轮换必须保持身份代次不变。 + +- 同一身份内的 access token 轮换 —— 长回合保活、`401` 续期、同一账号重新登录 —— 只更新凭据,不推进身份代次,也不得让任何在途生成、编辑、上传、确认或下载 operation 的冻结会话失效。此前把 token 字节和 native generation 一起当作身份判据,使一次正常续期被等价成换号,在途生图被判为 `authentication-required: 陶泥儿登录态已变化`;本条款取代该判据。 +- 冻结会话的校验判据只包含身份,不含 token 字节。换号、退出或 origin 变化仍然失败关闭:旧身份的 operation 后续一切 POST、poll、下载和结果安装都必须停止,只保留对账证据,不得改绑或重放。 +- 原生 install / clear 继续使用单调 revision 作为写入顺序判据,防止迟到写入复活旧状态;身份代次不参与写入排序,只参与身份归属判定。同身份凭据更新必须携带不小于当前 revision 的新 revision 且保持身份代次不变,才能替换 token。 +- `authentication-required` 只表达"当前 operation 对应的身份已不是权威身份"。同一身份的凭据轮换不得产生该错误。 +- 刷新失败语义:只有服务端明确返回 `401` / `403`,且收敛重试后仍然失败,才允许清除本地会话与 access token。网络错误、`5xx`、网关错误和响应契约异常必须保留既有会话与 access token,只让发起刷新的那个动作失败。 +- 并发刷新收敛:同一 origin 的 refresh 单飞;一次刷新返回 `401` 后允许用当前 refresh cookie 收敛重试一次,重试成功则继续使用新凭据,重试仍为 `401` 才判定登录态权威失效。 +- 服务端 `/api/auth/refresh` 的轮换失败不再下发清空 refresh cookie 的响应。refresh cookie 的失效只由会话吊销、过期或身份变更语义决定,不由一次轮换竞争决定;客户端以"收敛重试后仍 401"作为登出判据。 +- 证据要求:Rust 定向用例覆盖"同身份 token 轮换后冻结会话与在途 operation 仍有效""换号 / 退出后冻结会话失效""迟到 install 被 revision 拒绝";AGC 前端用例覆盖"续期不推进身份代次""瞬时刷新失败不清会话""收敛重试成功不登出";`api-server` 用例覆盖"刷新轮换失败不下发清 cookie"。 + ## 2026-09-15 DirectProject 长回合平台会话保活 -DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。 +DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新复用单飞请求、身份判据和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。2026-09-16 起,同一账号的保活续期只更新凭据、不推进身份代次,因此不得中断在途生成 operation。 完成门禁同时允许已登记的普通平台图片作为运行时素材。此前只把 canonical art-spec、背景、图集和图集切片加入来源白名单;`agc_generate_image` 生成的 `assets/neon-*.png` 即使已经登记并被源码引用,也会被判成“未引用平台图片”,触发同一回合的重复修复。浏览器预览把本地图片 URL 改写成 UUID 路径时,验收按每个视口的已渲染本地图片数量与源码引用数量做有界匹配;仍要求两个视口都有对应观察,空视口继续进入修复。 @@ -66,6 +79,8 @@ DirectProject 自身的 `read_direct_project_conversation` 也必须在 blocking ## 图片生成恢复与测试边界 +App 界面测试中,关闭 Agent 弹窗后的迟到读取用例先等待「刷新 Agent」按钮启用,以项目已载入作为前置条件;该等待最多 5 秒,整条用例最多 10 秒,弹窗关闭后的状态断言保持不变。权限策略 deny/confirm 互斥用例保留连续命令写入与累计策略断言,因包含 12 次聊天提交,单独设置 15 秒预算。其余用例继续使用默认超时,不以固定 sleep 替代状态等待。 + 已有持久生成账本的 Provider 待执行动作恢复时,若动作省略了旧视觉 Agent 自动补齐的参数,只在 Agent、动作身份、生成种类和冻结提示词均匹配旧合同后补齐缺省参数;显式参数不得被覆盖。新请求继续按当前自由图片合同执行,不能重新引入固定视觉产物门禁。恢复复用原 operation 与幂等账本,不因默认值变化重复提交已受理请求。 单 HTML 测试须在项目初始化前准备 HTML;npm 项目的预览与导出测试须准备构建目录。图片测试按现行数量和布局合同验证资源、透明度、引用和持久恢复,不继续要求固定四切片。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index a3a761b3f..d4d081884 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -4933,17 +4933,20 @@ mod tests { .expect("stale refresh request should succeed"); assert_eq!(stale_refresh_response.status(), StatusCode::UNAUTHORIZED); + // 轮换竞争或重放都不能清空浏览器当前的 refresh cookie:清 cookie 会删掉并发 + // 请求刚刚写入的有效 cookie,把一次竞争放大成登出。cookie 的失效只由会话吊销、 + // 过期或身份变更决定,客户端在收敛重试后仍收到 401 时才清本地会话。 assert!( stale_refresh_response .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.contains("Max-Age=0")) + .is_none_or(|value| !value.contains("Max-Age=0")) ); } #[tokio::test] - async fn refresh_session_rejects_missing_cookie_and_clears_cookie() { + async fn refresh_session_rejects_missing_cookie_without_touching_cookies() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let response = app @@ -4963,7 +4966,7 @@ mod tests { .headers() .get("set-cookie") .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.contains("Max-Age=0")) + .is_none_or(|value| !value.contains("Max-Age=0")) ); } diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index c6c783f67..a1bff0077 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -12,8 +12,7 @@ use crate::{ api_response::json_success_body, auth::RefreshSessionToken, auth_session::{ - attach_set_cookie_header, build_clear_refresh_session_cookie_header, - build_refresh_session_cookie_header, map_refresh_session_error, + attach_set_cookie_header, build_refresh_session_cookie_header, map_refresh_session_error, record_daily_login_tracking_event_after_auth_success, sign_access_token_for_user, }, http_error::AppError, @@ -30,10 +29,9 @@ pub async fn refresh_session( .map(|token| token.0.token().to_string()) .unwrap_or_default(); if raw_refresh_token.trim().is_empty() { - return Err(map_refresh_error_with_clear_cookie( - &state, - RefreshSessionError::MissingToken, - )); + // 缺少 cookie 时同样不下发清 cookie 响应:这里没有可清的对象,失败关闭由 + // 客户端"收敛重试后仍 401 才登出"的判据负责。 + return Err(map_refresh_session_error(RefreshSessionError::MissingToken)); } let refresh_token_hash = hash_refresh_session_token(&raw_refresh_token); let next_refresh_token = platform_auth::create_refresh_session_token(); @@ -57,13 +55,11 @@ pub async fn refresh_session( OffsetDateTime::now_utc(), ) { Ok(rotated) => rotated, - Err(RefreshSessionError::SessionNotFound) => { - return Err(map_refresh_error_with_clear_cookie( - &state, - RefreshSessionError::SessionNotFound, - )); - } - Err(error) => return Err(map_refresh_error_with_clear_cookie(&state, error)), + // 轮换失败不下发清空 refresh cookie 的响应。并发或乱序刷新时,清 cookie 会删掉 + // 另一个请求刚刚写入的有效 refresh cookie,把一次轮换竞争放大成登出;refresh + // cookie 的失效只由会话吊销、过期或身份变更语义决定。客户端在收敛重试后仍然 + // 收到 401 / 403 时才清本地会话。 + Err(error) => return Err(map_refresh_session_error(error)), }; let access_token = sign_access_token_for_user( &state, @@ -103,12 +99,3 @@ pub async fn refresh_session( ), )) } - -fn map_refresh_error_with_clear_cookie(state: &AppState, error: RefreshSessionError) -> AppError { - let response_error = map_refresh_session_error(error); - if let Ok(set_cookie) = build_clear_refresh_session_cookie_header(state) { - return response_error.with_header("set-cookie", set_cookie); - } - - response_error -} diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index b04406338..fcd08330e 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -322,6 +322,7 @@ describe('apiClient', () => { it('emits auth change events when refresh fails on protected requests', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 401 })); @@ -330,7 +331,12 @@ describe('apiClient', () => { }); expect(response.status).toBe(401); - expect(fetchMock).toHaveBeenCalledTimes(2); + // 业务 401 + refresh 401 收敛重试 + refresh 仍 401:只有两次刷新都被明确拒绝, + // 才判定登录态权威失效并广播一次全局鉴权变化。 + expect( + fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'), + ).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(dispatchEventMock).toHaveBeenCalledTimes(1); expect(getStoredAccessToken()).toBe(''); }); @@ -405,7 +411,9 @@ describe('apiClient', () => { it('keeps local token when explicit refresh opts out of clearing on failure', async () => { setStoredAccessToken('usable-local-token', { emit: false }); - fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce(createResponseMock({ status: 401 })); await expect( refreshStoredAccessToken({ clearOnFailure: false }), @@ -456,7 +464,9 @@ describe('apiClient', () => { it('clears local token when refresh confirms the session is unauthorized', async () => { setStoredAccessToken('expired-local-token', { emit: false }); - fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce(createResponseMock({ status: 401 })); await expect(refreshStoredAccessToken()).rejects.toMatchObject({ status: 401, @@ -466,6 +476,32 @@ describe('apiClient', () => { expect(getStoredAccessToken()).toBe(''); }); + it('retries refresh once with the current cookie after a rotation race', async () => { + setStoredAccessToken('expired-local-token', { emit: false }); + fetchMock + // 并发轮换竞争:这一次 refresh 拿到的是被另一个客户端轮换过的旧 cookie。 + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + // 收敛重试使用浏览器当前 cookie,拿到轮换后的新 token。 + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'converged-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + + await expect(refreshStoredAccessToken()).resolves.toBe('converged-token'); + expect( + fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'), + ).toHaveLength(2); + expect(getStoredAccessToken()).toBe('converged-token'); + expect(dispatchEventMock).not.toHaveBeenCalled(); + }); + it('does not clear auth when protected request refresh fails transiently', async () => { setStoredAccessToken('expired-token-during-restart', { emit: false }); fetchMock diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 880e52921..f594ca662 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -775,7 +775,7 @@ async function refreshAccessToken() { return refreshAccessTokenAttempt.promise; } - const promise = (async () => { + const performRefresh = async () => { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', @@ -802,6 +802,23 @@ async function refreshAccessToken() { publishRefreshedAccessToken(nextToken, authStateSnapshot); return nextToken; + }; + const promise = (async () => { + try { + return await performRefresh(); + } catch (error) { + const authoritative = + error instanceof ApiClientError && + (error.status === 401 || error.status === 403); + // 登录态已经变化(换号 / 退出 / 另一个 refresh 已发布新 token)时不要重试: + // 这次 refresh 的归属已经过期,重试只会把旧账号的结论带到新代次上。 + if (!authoritative || !isCurrentAuthState(authStateSnapshot)) { + throw error; + } + // 并发轮换收敛:另一个标签页 / 客户端可能刚刚轮换过 refresh cookie,用当前 + // cookie 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算权威失效。 + return await performRefresh(); + } })(); const attempt: RefreshAccessTokenAttempt = { ...authStateSnapshot,