Compare commits

..

14 Commits

Author SHA1 Message Date
kdletters b48293fb1f 收敛平台会话 epoch 重装语义,避免原生计数与渲染层漂移
- GUI owner 重装与清除改为按 authority 快照重定基准,保持原生计数与渲染层认知一致
- 注释写明 epoch 交接的授权前提,以及写入单调性由渲染层 reserve 与 durable session revision 保证
- 同主体换凭据的重装保持身份代次,在途 operation 的冻结会话继续有效
- 补充 epoch 重装与换主体失效的回归用例,替换原先错误假设原生计数器单调的用例
2026-09-16 17:08:47 +08:00
kdletters e08171fd2e 补记平台会话身份与凭据分离的规范与记忆
- 主规范新增平台会话身份与凭据分离条款,并修正长回合保活的口径
- 共享记忆补充并发生图登录态冲突的排障口径与验证方式
- 决策记录补充身份代次、凭据轮换、刷新失败语义的长期决策
- 新增本次里程碑与实施计划,记录证据与未验证项
2026-09-16 17:08:47 +08:00
kdletters 479120d368 修复刷新轮换竞争被放大成登出
- refresh 接口的轮换失败不再下发清空 refresh cookie 的响应,避免删掉并发请求刚写入的有效 cookie
- 缺少 refresh cookie 的失败保持无副作用,cookie 失效只由吊销、过期或身份变更决定
- 网站前端在刷新返回 401 时先用当前 cookie 收敛重试一次,重试仍被拒绝才判权威失效
- 网站前端只在当前鉴权代次未变化时重试刷新,避免旧账号结论污染新代次
- 更新 api-server 与前端断言,覆盖轮换竞争与收敛重试两条路径
2026-09-16 17:08:47 +08:00
kdletters 4182979a19 平台会话身份与凭据分离,续期不再中断在途生成
- 原生会话快照拆成身份代次与写入 revision,安装与清除按 revision 排序、按身份归属判定
- 冻结平台会话校验改为只比身份,同账号 access token 轮换不再让在途生成、编辑、上传、确认和下载失败
- 同一身份代次禁止更换登录主体或服务 origin,换号、退出和 origin 变化继续失败关闭
- Runner attach 新增 platform_auth_revision,平台会话安装与校验按 revision 排序、按身份归属判定
- GUI owner 替换与清除改为计数器只增不减,同一主体重装保持身份代次,避免旧 epoch 迟到写入复活
- renderer 拆分平台原生身份代次与写入 revision,同账号续期只更新凭据,不推进身份代次
- 刷新失败结果新增权威失效判定,只有服务端明确 401 与 403 才清除本地会话,网络错误和 5xx 保留会话
- 收敛刷新判据,重复的刷新工具函数合并为一个对外判定,去掉一次性包装
- 补齐平台会话身份判据、续期与迟到写入的 Rust 与前端回归用例
2026-09-16 17:08:46 +08:00
lhk229 262deaf9b7 接入做成游戏后的策划文档指令 (#383)
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m46s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m16s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m17s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m14s
Project CI / AI game creator shell Rust crates (push) Successful in 4m1s
Project CI / Frontend tests (push) Successful in 4m32s
Project CI / Backend tests (push) Successful in 7m54s
Project CI / Repository checks (push) Successful in 4m19s
Project CI / AI game creator shell web tests (push) Failing after 4m9s
Project CI / Native shell tests (push) Successful in 9m12s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m35s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m39s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m3s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m9s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m40s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m16s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m32s
Project CI / Frontend tests (pull_request) Successful in 6m10s
Project CI / Native shell tests (pull_request) Successful in 8m50s
## 变更说明

点击策划阶段的“做成游戏”后,切换到 GameAgent 并自动发送读取 `design_artifacts/` 文档的指令。

## 验证

- npm run typecheck
- git diff --check
- 提交钩子 ESLint / Prettier

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/383
2026-09-16 16:51:37 +08:00
kdletters c36a5170f8 AGC 新增本地自定义 LLM 配置与展示模型勾选
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m28s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m29s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m6s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m46s
Project CI / AI game creator shell Rust crates (push) Successful in 2m51s
Project CI / Frontend tests (push) Successful in 4m44s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Repository checks (push) Successful in 4m17s
Project CI / Backend tests (push) Successful in 7m48s
Project CI / AI game creator shell web tests (push) Successful in 3m43s
- 本地配置新增 llm.customEnabled 与 llm.visibleModels,显式开启后保留自定义连接和勾选列表,官方路由下仍清空凭据
- 配置迁移始终写出 customEnabled、visibleModels、apiKey、baseUrl、model、apiKind、reasoningEffort,便于手写自定义连接
- 新增 discover_game_creator_llm_models 命令,直连自定义端点 GET /models,限制超时与响应大小并脱敏错误
- codex_app_server 自定义模式改用配置里的地址与 Key 走本地凭据代理,不再经过平台 /api/llm,也不回退官方路由
- 模型目录按官方与自定义来源隔离缓存,自定义模式只展示勾选模型,选项失效时回退第一项
- 设置页在自定义模式展示 API 地址、API Key、协议与推理档,并提供模型读取、搜索、勾选与已勾选预览
- 补齐固定项排版与读取按钮样式,同步 AGC 模型选择规范、实施计划与共享概览
2026-09-16 16:39:15 +08:00
kdletters 9663bbf911 修复 AGC 客户端登录失败原因提示
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m7s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 6m13s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m32s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m38s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m38s
Project CI / AI game creator shell Rust crates (push) Successful in 2m29s
Project CI / Frontend tests (push) Successful in 4m42s
Project CI / Repository checks (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 7m45s
Project CI / Backend tests (push) Successful in 8m39s
Project CI / AI game creator shell web tests (push) Successful in 3m0s
- clientAuth 错误响应解析兼容未带 envelope 的旧形态错误体,密码错误时显示接口返回原因而不是固定“登录失败”
- 非 JSON 与结构无法识别的响应继续使用本地化兜底,不回显内部英文和原始 JSON
- appSurface 认证用例补充 envelope 与旧形态两条回归测试
2026-09-16 16:05:59 +08:00
kdletters b3e9d0a906 发布 AGC 标准版 0.1.45
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m11s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m54s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m35s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m26s
Project CI / AI game creator shell Rust crates (push) Successful in 3m17s
Project CI / Frontend tests (push) Successful in 4m41s
Project CI / Repository checks (push) Successful in 3m53s
Project CI / Native shell tests (push) Successful in 6m43s
Project CI / Backend tests (push) Successful in 7m26s
Project CI / AI game creator shell web tests (push) Successful in 3m23s
- apps/ai-game-creator-shell/package.json 版本提升到 0.1.45
- apps/ai-game-creator-shell/src-tauri/tauri.conf.json 版本提升到 0.1.45
- apps/ai-game-creator-shell/src-tauri/Cargo.toml 版本提升到 0.1.45
- apps/ai-game-creator-shell/src-tauri/Cargo.lock 同步 genarrative-ai-game-creator-shell 版本
- package-lock.json 同步 apps/ai-game-creator-shell 工作区版本
2026-09-16 11:52:03 +08:00
kdletters a60328623d 调整开发调度管线备份策略
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m12s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m36s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m2s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m52s
Project CI / AI game creator shell Rust crates (push) Successful in 2m7s
Project CI / Frontend tests (push) Successful in 4m45s
Project CI / Repository checks (push) Successful in 4m42s
Project CI / Backend tests (push) Successful in 6m58s
Project CI / Native shell tests (push) Successful in 8m22s
Project CI / AI game creator shell web tests (push) Successful in 4m5s
恢复 Full Job 备份参数原样透传
开发调度统一传入 skip,默认不上传 OSS
保持 Full 与 AGC 使用同一固定 revision 参数
2026-09-16 11:43:22 +08:00
kdletters 9e63b76991 修正开发环境备份默认映射
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m7s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m56s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m37s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m45s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m18s
Project CI / AI game creator shell Rust crates (push) Successful in 2m48s
Project CI / Repository checks (push) Failing after 1m54s
Project CI / Frontend tests (push) Successful in 5m15s
Project CI / Backend tests (push) Failing after 7m46s
Project CI / AI game creator shell web tests (push) Failing after 4m21s
Project CI / Native shell tests (push) Successful in 8m53s
保留备份参数原有枚举顺序以通过运维门禁
开发目标未显式选择时将 async 映射为 skip
开发环境默认不上传 OSS,release 仍保留显式策略
2026-09-16 11:40:22 +08:00
kdletters a98ebcf68f 调整开发环境默认备份策略
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m30s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m35s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m55s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 6m0s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m4s
Project CI / AI game creator shell Rust crates (push) Successful in 2m33s
Project CI / Repository checks (push) Failing after 1m16s
Project CI / Frontend tests (push) Successful in 4m59s
Project CI / AI game creator shell web tests (push) Successful in 3m28s
Project CI / Native shell tests (push) Successful in 7m12s
Project CI / Backend tests (push) Successful in 8m33s
开发环境默认跳过 OSS 备份上传
需要冷备时显式选择 async
保持 release 发布的备份策略可配置
2026-09-16 11:37:56 +08:00
kdletters 576ff07a5e 新增定时版本调度管线并收回两条下游管线的定时与去重
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m9s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 5m11s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m34s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m20s
Project CI / Frontend tests (push) Successful in 4m23s
Project CI / Repository checks (push) Successful in 4m13s
Project CI / Native shell tests (push) Successful in 6m44s
Project CI / Backend tests (push) Successful in 8m22s
Project CI / AI game creator shell web tests (push) Successful in 3m28s
- 新增 Genarrative-Scheduled-Revision-Trigger:每小时用 git ls-remote 解析分支版本,与上一次触发过的 revision 不同才继续
- 触发时把同一个固定 COMMIT_HASH 同时传给 Full Build 与 AGC Windows Build,保证两条管线构建同一个版本
- Full Build 与 AGC Windows Build 去掉自带定时触发器与管线内版本去重
- 新增 scheduled-revision-trigger Job 配置 XML,使用本机 Git 入口与既有 SSH 凭据,定时器留在 Jenkinsfile
- 生产运维门禁改为校验调度管线约束,并拦住两条下游管线重新加 cron 或自行去重
- 同步开发运维文档、共享开发工作流与踩坑记录
2026-09-16 10:55:16 +08:00
kdletters 5aa616134c 修复 Hook 链继承仓库定位变量导致真实仓库被夹具污染
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m30s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m47s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m53s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 6m8s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m48s
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / Frontend tests (push) Failing after 4m6s
Project CI / Repository checks (push) Successful in 3m57s
Project CI / Native shell tests (push) Successful in 7m40s
Project CI / Backend tests (push) Successful in 8m27s
Project CI / AI game creator shell web tests (push) Successful in 12m53s
- .husky/pre-commit 与 .husky/pre-push 入口清除 GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE 等仓库定位变量

- scripts/check-repository-ci.sh 增加同样的清理,保证本地门禁与 CI 走同一套隔离

- scripts/git-hooks.test.mjs 夹具 Git 调用前自检仓库归属并禁用 Hook,命令落到外部仓库时直接失败

- 守卫用例的子进程必须真的继承 GIT_DIR,避免隔离断言空转

- 更新开发运维文档与 shared-memory/pitfalls.md,记录链接工作树注入形态与配置修复步骤
2026-09-16 10:35:07 +08:00
kdletters 187b66c3b1 Jenkins 定时构建增加版本未变化去重
- Full Build 改为每小时触发,并按已解析 commit 与全部构建参数判断是否重复
- AGC Windows Build 增加每小时触发与同一套去重逻辑,跳过时不再安装依赖、构建和上传
- 跳过记录为 NOT_BUILT,越过跳过记录后最近一次实际构建成功才允许跳过,失败或中断可重试
- 生产运维门禁补充两个 Job 的去重约束与各阶段跳过检查
- 同步开发运维文档与共享开发工作流说明
2026-09-16 10:32:03 +08:00
93 changed files with 3521 additions and 2993 deletions
+3
View File
@@ -1 +1,4 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run format:staged npm run format:staged
+3
View File
@@ -1 +1,4 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run check:pre-push-master -- "$@" npm run check:pre-push-master -- "$@"
-16
View File
@@ -172,20 +172,6 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。 Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships ## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发** - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -220,5 +206,3 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。 - “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。 - “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。 - “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
@@ -2,6 +2,8 @@
"schemaVersion": "game-creator-config.v2", "schemaVersion": "game-creator-config.v2",
"agentMode": "codex_app_server", "agentMode": "codex_app_server",
"llm": { "llm": {
"customEnabled": false,
"visibleModels": [],
"apiKey": "", "apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1", "baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra", "model": "gpt-6-astra",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@genarrative/ai-game-creator-shell", "name": "@genarrative/ai-game-creator-shell",
"private": true, "private": true,
"version": "0.1.29", "version": "0.1.45",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "node scripts/start-tauri-dev.mjs", "dev": "node scripts/start-tauri-dev.mjs",
+1 -1
View File
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]] [[package]]
name = "genarrative-ai-game-creator-shell" name = "genarrative-ai-game-creator-shell"
version = "0.1.29" version = "0.1.45"
dependencies = [ dependencies = [
"agent-runtime-core", "agent-runtime-core",
"axum", "axum",
@@ -1,6 +1,6 @@
[package] [package]
name = "genarrative-ai-game-creator-shell" name = "genarrative-ai-game-creator-shell"
version = "0.1.29" version = "0.1.45"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -21,7 +21,6 @@ mod direct_project_history;
mod direct_project_turn_history; mod direct_project_turn_history;
mod direct_runtime; mod direct_runtime;
mod direct_thread_manager; mod direct_thread_manager;
mod direct_thread_wire;
mod direct_tool_bridge; mod direct_tool_bridge;
mod direct_tool_calls; mod direct_tool_calls;
mod direct_tools_mcp; mod direct_tools_mcp;
@@ -56,7 +55,6 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*; pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*; pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*; pub(crate) use direct_thread_manager::*;
pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*; pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*; pub(crate) use direct_tools_mcp::*;
@@ -131,7 +131,6 @@ impl CodexAppServerCredential {
) -> Option<(&'a str, &'a str)> { ) -> Option<(&'a str, &'a str)> {
match self { match self {
Self::PlatformSession { .. } => None, Self::PlatformSession { .. } => None,
#[cfg(test)]
Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty()) Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty())
.then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())), .then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())),
#[cfg(test)] #[cfg(test)]
@@ -139,7 +138,7 @@ impl CodexAppServerCredential {
.as_deref() .as_deref()
.map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)), .map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)),
#[cfg(not(test))] #[cfg(not(test))]
Self::AppDataKey { .. } | Self::AuthBridge { .. } => None, Self::AuthBridge { .. } => None,
} }
} }
} }
@@ -564,17 +563,6 @@ enum CodexTurnEvent {
item_id: String, item_id: String,
delta: String, delta: String,
}, },
/// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。
///
/// `item/reasoning/summaryTextDelta`core `ReasoningContentDelta`)与
/// `item/reasoning/textDelta`core `ReasoningRawContentDelta`)都进这条通道:前者是
/// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed`
/// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为
/// 活动状态,不下发正文。
ReasoningDelta {
item_id: String,
delta: String,
},
IntermediateText(String), IntermediateText(String),
Activity(&'static str), Activity(&'static str),
Item { Item {
@@ -582,7 +570,7 @@ enum CodexTurnEvent {
params: serde_json::Value, params: serde_json::Value,
}, },
Request { Request {
kind: DirectThreadRequestKind, event_type: &'static str,
params: serde_json::Value, params: serde_json::Value,
}, },
RawItem(serde_json::Value), RawItem(serde_json::Value),
@@ -752,14 +740,23 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
direct_codex_safe_activity_for_item(item_type) direct_codex_safe_activity_for_item(item_type)
} }
/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。 /// Project an app-server item into the small public payload carried by the
/// /// DirectProject event queue. Full item contents are persisted in JSONL and
/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 /// must not be forwarded through the runtime event stream.
fn direct_thread_event_item( fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value {
root: &std::path::Path, serde_json::json!({
item: &serde_json::Value, "itemType": item
) -> Option<DirectThreadItem> { .get("type")
direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) .and_then(serde_json::Value::as_str)
.unwrap_or("unknown"),
})
}
fn direct_thread_item_id(item: &serde_json::Value) -> Option<String> {
item.get("id")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
} }
fn direct_codex_command_is_game_verification(command: &str) -> bool { fn direct_codex_command_is_game_verification(command: &str) -> bool {
@@ -976,21 +973,19 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
} }
} }
fn direct_codex_request_event_type(method: &str) -> Option<DirectThreadRequestKind> { fn direct_codex_request_event_type(method: &str) -> Option<&'static str> {
match method { match method {
"item/fileChange/requestApproval" "item/fileChange/requestApproval"
| "item/commandExecution/requestApproval" | "item/commandExecution/requestApproval"
| "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested), | "item/permissions/requestApproval" => Some("approval.requested"),
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => { "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"),
Some(DirectThreadRequestKind::AskRequested)
}
_ => None, _ => None,
} }
} }
fn direct_codex_resolution_event_type(method: &str) -> Option<DirectThreadRequestKind> { fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> {
match method { match method {
"serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved), "serverRequest/resolved" => Some("request.resolved"),
_ => None, _ => None,
} }
} }
@@ -1063,33 +1058,6 @@ fn direct_codex_notification_event(
intermediate_text: Option<String>, intermediate_text: Option<String>,
safe_activity: Option<&'static str>, safe_activity: Option<&'static str>,
) -> Option<CodexTurnEvent> { ) -> Option<CodexTurnEvent> {
// 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为
// "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。
//
// 两条通知都下发正文,不下发活动文本:
// - `item/reasoning/summaryTextDelta`core `ReasoningContentDelta`)→ reasoning item 的 `summary`
// - `item/reasoning/textDelta`core `ReasoningRawContentDelta`)→ reasoning item 的 `content`
// 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。
// 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围;
// 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。
if matches!(
method,
"item/reasoning/summaryTextDelta" | "item/reasoning/textDelta"
) {
return params
.get("delta")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(|delta| CodexTurnEvent::ReasoningDelta {
item_id: params
.get("itemId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| "direct-missing-item".to_string()),
delta: delta.to_string(),
});
}
let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { let (activity, intermediate_text) = match (&intermediate_text, safe_activity) {
(Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None),
_ => (safe_activity, intermediate_text), _ => (safe_activity, intermediate_text),
@@ -2049,7 +2017,13 @@ impl CodexAppServerConnection {
let codex_cli_version = game_creator_codex_cli_version_identity() let codex_cli_version = game_creator_codex_cli_version_identity()
.map_err(platform_llm::LlmError::InvalidConfig)?; .map_err(platform_llm::LlmError::InvalidConfig)?;
let mut effective_llm = llm.clone(); let mut effective_llm = llm.clone();
let credential = if game_creator_official_llm_route_locked() { let credential = if llm.custom_enabled {
crate::config::validate_custom_llm_connection(llm)
.map_err(platform_llm::LlmError::InvalidConfig)?;
CodexAppServerCredential::AppDataKey {
fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())),
}
} else if game_creator_official_llm_route_locked() {
let session = current_platform_session().ok_or_else(|| { let session = current_platform_session().ok_or_else(|| {
platform_llm::LlmError::InvalidConfig( platform_llm::LlmError::InvalidConfig(
"authentication-required: 请先登录陶泥儿账号".to_string(), "authentication-required: 请先登录陶泥儿账号".to_string(),
@@ -2217,7 +2191,8 @@ impl CodexAppServerConnection {
true, true,
), ),
_ => ( _ => (
(workspace_mode == CodexAppServerWorkspaceMode::DirectProject) (llm.custom_enabled
|| workspace_mode == CodexAppServerWorkspaceMode::DirectProject)
.then(|| credential.direct_provider_route(llm)) .then(|| credential.direct_provider_route(llm))
.flatten() .flatten()
.map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())),
@@ -2957,7 +2932,18 @@ impl CodexAppServerConnection {
turn_start_guard.armed = false; turn_start_guard.armed = false;
let direct_thread_id = history_root.to_string_lossy().into_owned(); let direct_thread_id = history_root.to_string_lossy().into_owned();
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started()); append_direct_thread_event(
&direct_thread_id,
DirectThreadRawEventDraft {
event_type: "turn.started".to_string(),
turn_id: turn_id.clone(),
item_id: None,
payload: serde_json::json!({
"threadId": thread_id,
"turnId": turn_id,
}),
},
);
} }
let mut receiver = self.register_turn(&turn_id).await; let mut receiver = self.register_turn(&turn_id).await;
let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut direct_project_history = DirectProjectHistoryAccumulator::default();
@@ -3012,13 +2998,12 @@ impl CodexAppServerConnection {
direct_project_history.observe_delta(&item_id, &delta); direct_project_history.observe_delta(&item_id, &delta);
append_direct_thread_event( append_direct_thread_event(
&direct_thread_id, &direct_thread_id,
// 事件自足:增量自带 item 身份与正文类别(正文 / 思考), DirectThreadRawEventDraft {
// 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 event_type: "item.delta".to_string(),
DirectThreadEvent::item_delta( turn_id: turn_id.clone(),
item_id.clone(), item_id: Some(item_id.clone()),
DirectThreadDeltaKind::Message, payload: serde_json::json!({ "delta": delta.clone() }),
delta.clone(), },
),
); );
} }
streamed_text.push_str(&delta); streamed_text.push_str(&delta);
@@ -3049,18 +3034,6 @@ impl CodexAppServerConnection {
}); });
} }
} }
Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_delta(
item_id,
DirectThreadDeltaKind::Reasoning,
delta,
),
);
}
}
Some(CodexTurnEvent::IntermediateText(text)) => { Some(CodexTurnEvent::IntermediateText(text)) => {
if let Some(observer) = direct_observer.as_deref_mut() { if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::IntermediateText(text)); observer(DirectCodexTurnObservation::IntermediateText(text));
@@ -3073,7 +3046,6 @@ impl CodexAppServerConnection {
"rawResponseItem/completed 缺少 item".to_string(), "rawResponseItem/completed 缺少 item".to_string(),
)); ));
} }
let entry_item = direct_thread_event_item(history_root, &item);
let history_root = history_root.to_path_buf(); let history_root = history_root.to_path_buf();
let history_item = item.clone(); let history_item = item.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
@@ -3087,15 +3059,19 @@ impl CodexAppServerConnection {
})? })?
.map_err(platform_llm::LlmError::InvalidRequest)?; .map_err(platform_llm::LlmError::InvalidRequest)?;
direct_project_history.complete_item(&item); direct_project_history.complete_item(&item);
if let Some(entry_item) = entry_item { let item_id = direct_thread_item_id(&item);
append_direct_thread_event( append_direct_thread_event(
&direct_thread_id, &direct_thread_id,
DirectThreadEvent::item_completed(entry_item), DirectThreadRawEventDraft {
); event_type: "item.completed".to_string(),
} turn_id: turn_id.clone(),
item_id,
payload: serde_json::json!({}),
},
);
} }
} }
Some(CodexTurnEvent::Request { kind, params }) => { Some(CodexTurnEvent::Request { event_type, params }) => {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let request_id = params let request_id = params
.get("requestId") .get("requestId")
@@ -3105,7 +3081,14 @@ impl CodexAppServerConnection {
.map(str::to_string); .map(str::to_string);
append_direct_thread_event( append_direct_thread_event(
&direct_thread_id, &direct_thread_id,
DirectThreadEvent::request(kind, request_id), DirectThreadRawEventDraft {
event_type: event_type.to_string(),
turn_id: turn_id.clone(),
item_id: None,
payload: request_id
.map(|id| serde_json::json!({ "requestId": id }))
.unwrap_or_else(|| serde_json::json!({})),
},
); );
} }
} }
@@ -3216,14 +3199,16 @@ impl CodexAppServerConnection {
&& self.inner.workspace_mode && self.inner.workspace_mode
== CodexAppServerWorkspaceMode::DirectProject == CodexAppServerWorkspaceMode::DirectProject
{ {
if let Some(entry_item) = let item_id = direct_thread_item_id(item);
direct_thread_event_item(history_root, item) append_direct_thread_event(
{ &direct_thread_id,
append_direct_thread_event( DirectThreadRawEventDraft {
&direct_thread_id, event_type: "item.started".to_string(),
DirectThreadEvent::item_started(entry_item), turn_id: turn_id.clone(),
); item_id,
} payload: direct_thread_item_started_payload(item),
},
);
} }
} }
} }
@@ -3265,7 +3250,12 @@ impl CodexAppServerConnection {
{ {
append_direct_thread_event( append_direct_thread_event(
&direct_thread_id, &direct_thread_id,
DirectThreadEvent::turn_completed(status.to_string()), DirectThreadRawEventDraft {
event_type: "turn.completed".to_string(),
turn_id: turn_id.clone(),
item_id: None,
payload: serde_json::json!({ "status": status }),
},
); );
} }
match status { match status {
@@ -3960,8 +3950,8 @@ async fn read_game_creator_codex_app_server_stdout(
continue; continue;
} }
} }
let event = if let Some(kind) = direct_codex_resolution_event_type(method) { let event = if let Some(event_type) = direct_codex_resolution_event_type(method) {
CodexTurnEvent::Request { kind, params } CodexTurnEvent::Request { event_type, params }
} else if let Some(activity) = safe_activity { } else if let Some(activity) = safe_activity {
// Preparing notifications may carry private plan/reasoning text; // Preparing notifications may carry private plan/reasoning text;
// expose only the safe activity category. Other categories may // expose only the safe activity category. Other categories may
@@ -4013,8 +4003,8 @@ async fn read_game_creator_codex_app_server_stdout(
), ),
method if direct_codex_request_event_type(method).is_some() => { method if direct_codex_request_event_type(method).is_some() => {
CodexTurnEvent::Request { CodexTurnEvent::Request {
kind: direct_codex_request_event_type(method) event_type: direct_codex_request_event_type(method)
.expect("request kind checked above"), .expect("request event type checked above"),
params, params,
} }
} }
@@ -4525,29 +4515,11 @@ mod tests {
"arguments": { "path": "game/index.html", "token": "secret" }, "arguments": { "path": "game/index.html", "token": "secret" },
"result": { "content": "large output" } "result": { "content": "large output" }
}); });
// 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item");
assert_eq!(projected.item_id(), "item-1");
let payload = serde_json::to_value(&projected).expect("payload");
assert_eq!( assert_eq!(
payload.get("itemType").and_then(serde_json::Value::as_str), direct_thread_item_started_payload(&item),
Some("mcpToolCall") serde_json::json!({ "itemType": "mcpToolCall" })
); );
assert_eq!(
payload.get("itemId").and_then(serde_json::Value::as_str),
Some("item-1")
);
// 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。
assert!(payload.get("toolCall").is_none(), "{payload}");
assert!(payload.get("title").is_none(), "{payload}");
assert!(payload.get("summary").is_none(), "{payload}");
assert!(payload.get("kind").is_none(), "{payload}");
// 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。
let arguments = payload
.get("arguments")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
assert!(!arguments.contains("\"secret\""), "{payload}");
} }
#[test] #[test]
@@ -4777,6 +4749,8 @@ mod tests {
fn test_llm() -> GameCreatorLlmConfig { fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig { GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "fixture-secret".to_string(), api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(), base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(), model: "fixture-model".to_string(),
@@ -5562,6 +5536,59 @@ mod tests {
assert_ne!(command_token, provider_key); assert_ne!(command_token, provider_key);
} }
#[tokio::test]
async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() {
let mut llm = test_llm();
llm.custom_enabled = true;
llm.api_key = "custom-upstream-fixture-secret".into();
llm.base_url = "http://127.0.0.1:9/v1".into();
llm.model = "vendor/model.v1:latest".into();
llm.visible_models = vec![llm.model.clone()];
let credential = CodexAppServerCredential::AppDataKey {
fingerprint: "custom-fixture".into(),
};
let (base, key) = credential
.direct_provider_route(&llm)
.expect("custom route");
assert_eq!(base, llm.base_url);
assert_eq!(key, llm.api_key);
let proxy = start_codex_provider_proxy(base, key, false).await.unwrap();
for mode in [
CodexAppServerWorkspaceMode::DirectProject,
CodexAppServerWorkspaceMode::ToolHost,
] {
let mut command = tokio::process::Command::new("fixture");
configure_game_creator_codex_app_server_command_for_mode(
&mut command,
&llm,
mode,
Some(&proxy),
None,
true,
)
.unwrap();
let arguments = command
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join("\n");
let params = codex_app_server_thread_start_params(
&llm.model,
std::path::Path::new("fixture-workspace"),
mode,
String::new(),
true,
);
assert_eq!(params["model"], "vendor/model.v1:latest");
assert!(!arguments.contains(&llm.api_key));
assert!(!arguments.contains("/api/llm"));
for (_, value) in command.as_std().get_envs() {
assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key)));
}
}
}
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn direct_project_spawn_restores_broker_token_after_environment_isolation() { async fn direct_project_spawn_restores_broker_token_after_environment_isolation() {
@@ -3053,14 +3053,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
)?; )?;
let _platform_session_lease = access let _platform_session_lease = access
.frozen_platform_session() .frozen_platform_session()
.map(|session| { .map(|session| acquire_platform_session_identity_lease(&session.identity()))
acquire_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
)
})
.transpose()?; .transpose()?;
// The network phase deliberately runs without the project write lock. Capture rollback state // 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 // only after acquiring the lock and revalidating the source identity, otherwise a failure can
@@ -5329,6 +5322,8 @@ mod tests {
fn direct_test_llm() -> GameCreatorLlmConfig { fn direct_test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig { GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "fixture-secret".to_string(), api_key: "fixture-secret".to_string(),
base_url: "https://example.invalid/v1".to_string(), base_url: "https://example.invalid/v1".to_string(),
model: "fixture-model".to_string(), model: "fixture-model".to_string(),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,9 +8,11 @@
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
use super::direct_thread_wire::sanitize_detail_text; use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -84,6 +86,134 @@ fn tool_calls_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/tool-calls.jsonl") root.join(".agent/conversations/tool-calls.jsonl")
} }
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
let mut index = start;
let mut relative = String::new();
while index < value.len() {
let character = value[index..].chars().next().unwrap_or_default();
if matches!(character, '/' | '\\') {
if !relative.is_empty() {
relative.push('/');
}
index += character.len_utf8();
continue;
}
if character.is_whitespace()
|| matches!(
character,
'\'' | '"'
| '`'
| ','
| ';'
| '|'
| '&'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '<'
| '>'
| ':'
)
{
break;
}
relative.push(character);
index += character.len_utf8();
}
while relative.ends_with('/') {
relative.pop();
}
(index, relative)
}
/// 把项目根目录前缀换成**项目相对路径**`<root>/game/src/x.ts` → `game/src/x.ts`)。
///
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
let root_text = root.to_string_lossy();
let root_text = root_text.trim_end_matches(['/', '\\']);
if root_text.is_empty() {
return value.to_string();
}
let mut needles = [
root_text.to_string(),
root_text.replace('\\', "/"),
root_text.replace('/', "\\"),
]
.into_iter()
.map(|needle| needle.to_ascii_lowercase())
.filter(|needle| !needle.is_empty())
.collect::<Vec<_>>();
needles.sort();
needles.dedup();
let lower = value.to_ascii_lowercase();
let mut output = String::with_capacity(value.len());
let mut cursor = 0usize;
while cursor < value.len() {
let mut hit: Option<(usize, usize)> = None;
for needle in &needles {
let mut search = cursor;
while let Some(relative) = lower[search..].find(needle.as_str()) {
let start = search + relative;
let end = start + needle.len();
let left_is_boundary = start == 0
|| lower[..start].chars().next_back().is_some_and(|character| {
!character.is_alphanumeric() && character != '_' && character != '-'
});
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
if hit.is_none_or(|(best_start, _)| start < best_start) {
hit = Some((start, end));
}
break;
}
search = end;
}
}
let Some((start, end)) = hit else {
break;
};
output.push_str(&value[cursor..start]);
let (consumed, relative) = project_relative_path_segment(value, end);
if relative.is_empty() {
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
output.push_str("<absolute-path>");
} else {
output.push_str(&relative);
}
cursor = consumed;
}
output.push_str(&value[cursor..]);
output
}
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
/// 错误上下文脱敏。
///
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
///
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
///
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
let without_project_root = relativize_project_root_paths(root, value);
let without_absolute = redact_absolute_path_tokens(&without_project_root);
let without_secret = redact_secret_tokens(&without_absolute);
sanitize_error_context(&without_secret)
}
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
fn bounded_chars(value: &str, max_chars: usize) -> String { fn bounded_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars { if value.chars().count() <= max_chars {
@@ -49,7 +49,7 @@ struct ExternalMcpHttpState {
root: PathBuf, root: PathBuf,
token: String, token: String,
session_user_id: String, session_user_id: String,
session_generation: u64, session_identity_generation: u64,
} }
pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { 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('\0');
material.push_str(&session.user_id); material.push_str(&session.user_id);
material.push('\0'); 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())) 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 { let Some(session) = current_platform_session() else {
return Err(StatusCode::UNAUTHORIZED); 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); return Err(StatusCode::UNAUTHORIZED);
} }
let response = EXTERNAL_MCP_BRIDGE_URL let response = EXTERNAL_MCP_BRIDGE_URL
@@ -1794,7 +1797,7 @@ pub(crate) async fn start_external_mcp_loopback(
root, root,
token: token.clone(), token: token.clone(),
session_user_id: session.user_id, session_user_id: session.user_id,
session_generation: session.generation, session_identity_generation: session.identity_generation,
}; };
let app = Router::new() let app = Router::new()
.route(&route, post(handle_external_mcp_http_request)) .route(&route, post(handle_external_mcp_http_request))
@@ -11,6 +11,10 @@ use super::external_generation_state::{
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState, retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
}; };
use super::*; use super::*;
use crate::platform_session::{
acquire_platform_session_identity_lease, validate_platform_session_identity,
PlatformSessionIdentity,
};
use reqwest::multipart::{Form, Part}; use reqwest::multipart::{Form, Part};
const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60);
@@ -1510,10 +1514,7 @@ struct PreparedPlatformArtAssetSlice {
#[derive(Clone)] #[derive(Clone)]
struct PreparedPlatformSessionFence { struct PreparedPlatformSessionFence {
user_id: String, identity: PlatformSessionIdentity,
api_base_url: String,
generation: u64,
access_token_sha256: String,
} }
impl PreparedPlatformSessionFence { impl PreparedPlatformSessionFence {
@@ -1521,41 +1522,17 @@ impl PreparedPlatformSessionFence {
access access
.frozen_platform_session() .frozen_platform_session()
.map(|session| PreparedPlatformSessionFence { .map(|session| PreparedPlatformSessionFence {
user_id: session.user_id.clone(), identity: session.identity(),
api_base_url: session.api_base_url.clone(),
generation: session.generation,
access_token_sha256: format!(
"{:x}",
Sha256::digest(session.access_token.as_bytes())
),
}) })
} }
fn validate(&self) -> Result<(), String> { fn validate(&self) -> Result<(), String> {
let matches = current_platform_session().is_some_and(|session| { // 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。
session.user_id == self.user_id validate_platform_session_identity(&self.identity)
&& session.api_base_url == self.api_base_url
&& session.generation == self.generation
&& format!("{:x}", Sha256::digest(session.access_token.as_bytes()))
== self.access_token_sha256
});
if matches {
Ok(())
} else {
Err(
"authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试"
.to_string(),
)
}
} }
fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> { fn acquire_lease(&self) -> Result<ValidatedPlatformSessionLease, String> {
acquire_validated_platform_session_fingerprint( acquire_platform_session_identity_lease(&self.identity)
&self.user_id,
&self.api_base_url,
self.generation,
&self.access_token_sha256,
)
} }
} }
@@ -10636,7 +10613,7 @@ mod canvas_generation_tests {
} }
drop(owner_a_access); drop(owner_a_access);
drop(frozen_owner_a); 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"); .expect("switch to owner B");
let error = match request_platform_art_asset_with_runtime_options_at( 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)) .recv_timeout(Duration::from_secs(3))
.expect("wait for accepted response"); .expect("wait for accepted response");
std::thread::sleep(Duration::from_millis(50)); std::thread::sleep(Duration::from_millis(50));
install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2) install_platform_session(
.expect("switch platform account after accepted response"); "post-202-user-b",
"post-202-token-b",
&switch_base_url,
2,
2,
)
.expect("switch platform account after accepted response");
}); });
let runtime_context = PlatformArtGenerationRuntimeContext { let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(), agent_id: "art-director".to_string(),
@@ -1431,12 +1431,13 @@ mod external_generation_state_tests {
base_url, base_url,
); );
let frozen_a = current_platform_session().expect("freeze owner A"); 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( replace_platform_session_for_gui_owner(
"fingerprint-owner-b", "fingerprint-owner-b",
"fingerprint-token-b", "fingerprint-token-b",
base_url, base_url,
2, 2,
2,
) )
.expect("switch global session to owner B"); .expect("switch global session to owner B");
let current_b = current_platform_session().expect("owner B is current after switch"); let current_b = current_platform_session().expect("owner B is current after switch");
@@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() {
response_stream_fixture("finalization-tool-plan-repair-chain-run"); response_stream_fixture("finalization-tool-plan-repair-chain-run");
let root = project.path(); let root = project.path();
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "finalization-tool-plan-key".to_string(), api_key: "finalization-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "finalization-tool-plan-model".to_string(), model: "finalization-tool-plan-model".to_string(),
@@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
let root = project.path(); let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]);
let old_llm = GameCreatorLlmConfig { let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-provider-key".to_string(), api_key: "old-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-provider-model".to_string(), model: "old-provider-model".to_string(),
@@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
LlmMessage::user("修复格式"), LlmMessage::user("修复格式"),
]); ]);
let old_llm = GameCreatorLlmConfig { let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-tool-plan-provider-key".to_string(), api_key: "old-tool-plan-provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-tool-plan-model".to_string(), model: "old-tool-plan-model".to_string(),
@@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov
response_stream_fixture("generic-retry-drift-tool-plan-chain-run"); response_stream_fixture("generic-retry-drift-tool-plan-chain-run");
let root = project.path(); let root = project.path();
let old_llm = GameCreatorLlmConfig { let old_llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "old-generic-retry-key".to_string(), api_key: "old-generic-retry-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "old-generic-retry-model".to_string(), model: "old-generic-retry-model".to_string(),
@@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
response_stream_fixture("tool-plan-capacity-preflight-run"); response_stream_fixture("tool-plan-capacity-preflight-run");
let root = project.path(); let root = project.path();
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-capacity-key".to_string(), api_key: "tool-plan-capacity-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-capacity-model".to_string(), model: "tool-plan-capacity-model".to_string(),
@@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
LlmMessage::user("修复格式"), LlmMessage::user("修复格式"),
]); ]);
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "durable-control-tool-plan-key".to_string(), api_key: "durable-control-tool-plan-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "durable-control-tool-plan-model".to_string(), model: "durable-control-tool-plan-model".to_string(),
@@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
snapshot.request_slot = "loop-0-repair-0".to_string(); snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]); let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]);
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "tool-plan-cleanup-key".to_string(), api_key: "tool-plan-cleanup-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "tool-plan-cleanup-model".to_string(), model: "tool-plan-cleanup-model".to_string(),
@@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
snapshot.request_slot = "loop-0-repair-0".to_string(); snapshot.request_slot = "loop-0-repair-0".to_string();
let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]); let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]);
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "terminal-handoff-key".to_string(), api_key: "terminal-handoff-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "terminal-handoff-model".to_string(), model: "terminal-handoff-model".to_string(),
@@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
let root = project.path(); let root = project.path();
let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]);
let llm = GameCreatorLlmConfig { let llm = GameCreatorLlmConfig {
custom_enabled: false,
visible_models: Vec::new(),
api_key: "provider-key".to_string(), api_key: "provider-key".to_string(),
base_url: "http://127.0.0.1:1/v1".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(),
model: "provider-model".to_string(), model: "provider-model".to_string(),
@@ -1939,8 +1939,9 @@ pub(crate) async fn polish_local_project_prompt(
} }
#[tauri::command] #[tauri::command]
pub(crate) fn read_platform_account_session_generation() -> u64 { pub(crate) fn read_platform_account_session_state(
current_platform_session_generation() ) -> crate::platform_session::PlatformSessionWriteState {
crate::platform_session::current_platform_session_write_state()
} }
#[tauri::command] #[tauri::command]
@@ -1948,28 +1949,45 @@ pub(crate) async fn install_platform_account_session(
user_id: String, user_id: String,
access_token: String, access_token: String,
api_base_url: String, api_base_url: String,
generation: u64, identity_generation: u64,
revision: u64,
) -> Result<(), String> { ) -> Result<(), String> {
tokio::task::spawn_blocking(move || { 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( install_external_agent_runner_platform_session(
&user_id, &user_id,
&access_token, &access_token,
&api_base_url, &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 .await
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
} }
#[tauri::command] #[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 || { tokio::task::spawn_blocking(move || {
shutdown_game_creator_codex_app_servers()?; shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(generation)?; clear_external_agent_runner_platform_session(identity_generation, revision)?;
clear_platform_session(generation); clear_platform_session(identity_generation, revision);
Ok(()) Ok(())
}) })
.await .await
@@ -1991,6 +2009,8 @@ pub(crate) fn write_game_creator_app_config(
.lock() .lock()
.map_err(|_| "配置写入锁不可用")?; .map_err(|_| "配置写入锁不可用")?;
let (current, overlays) = load_game_creator_app_config_for_write()?; let (current, overlays) = load_game_creator_app_config_for_write()?;
// 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。
config.llm.custom_enabled = current.llm.custom_enabled;
config.selected_model_id = current.selected_model_id; config.selected_model_id = current.selected_model_id;
config.selected_model_is_default = current.selected_model_is_default; config.selected_model_is_default = current.selected_model_is_default;
persist_game_creator_app_config(config, overlays, false) persist_game_creator_app_config(config, overlays, false)
@@ -2027,7 +2047,12 @@ pub(crate) fn select_game_creator_model(
let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK
.lock() .lock()
.map_err(|_| "配置写入锁不可用")?; .map_err(|_| "配置写入锁不可用")?;
if model_id.is_empty() let (mut config, overlays) = load_game_creator_app_config_for_write()?;
if config.llm.custom_enabled {
if !config.llm.visible_models.contains(&model_id) {
return Err("所选模型未勾选或已移除,请刷新模型列表".into());
}
} else if model_id.is_empty()
|| model_id.len() > 64 || model_id.len() > 64
|| !model_id || !model_id
.bytes() .bytes()
@@ -2035,12 +2060,21 @@ pub(crate) fn select_game_creator_model(
{ {
return Err("模型标识无效".into()); return Err("模型标识无效".into());
} }
let (mut config, overlays) = load_game_creator_app_config_for_write()?;
config.selected_model_id = model_id; config.selected_model_id = model_id;
config.selected_model_is_default = is_default; config.selected_model_is_default = is_default;
persist_game_creator_app_config(config, overlays, true) persist_game_creator_app_config(config, overlays, true)
} }
#[tauri::command]
pub(crate) async fn discover_game_creator_llm_models(
llm: GameCreatorLlmConfig,
) -> Result<Vec<String>, String> {
if !load_game_creator_app_config()?.llm.custom_enabled {
return Err("请先在本地配置中开启 llm.customEnabled".to_string());
}
fetch_custom_llm_models(&llm).await
}
fn persist_game_creator_app_config( fn persist_game_creator_app_config(
config: GameCreatorAppConfig, config: GameCreatorAppConfig,
overlays: Vec<(PathBuf, serde_json::Value)>, overlays: Vec<(PathBuf, serde_json::Value)>,
@@ -2056,8 +2090,8 @@ fn persist_game_creator_app_config(
let previous = overlay.clone(); let previous = overlay.clone();
if let Some(fields) = overlay.as_object_mut() { if let Some(fields) = overlay.as_object_mut() {
for (key, value) in fields.iter_mut() { for (key, value) in fields.iter_mut() {
if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") if !model_only
== model_only || matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault")
{ {
if let Some(saved_value) = saved.get(key) { if let Some(saved_value) = saved.get(key) {
// 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。
@@ -4231,14 +4265,7 @@ pub(crate) async fn import_account_editor_assets_for_agent(
access.validate_frozen_session()?; access.validate_frozen_session()?;
let _platform_session_lease = frozen_session let _platform_session_lease = frozen_session
.as_ref() .as_ref()
.map(|session| { .map(|session| acquire_platform_session_identity_lease(&session.identity()))
acquire_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&format!("{:x}", Sha256::digest(session.access_token.as_bytes())),
)
})
.transpose()?; .transpose()?;
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
access.validate_frozen_session()?; access.validate_frozen_session()?;
@@ -5383,21 +5410,10 @@ pub(crate) async fn read_direct_project_history_slice(
before_item_id.as_deref(), before_item_id.as_deref(),
limit.unwrap_or(20), limit.unwrap_or(20),
)?; )?;
let first_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let items = direct_thread_items_from_history(root, &items, |item| {
item.get("id")
.and_then(serde_json::Value::as_str)
.and_then(|id| item_timestamps.get(id).copied())
.unwrap_or_default()
});
Ok(DirectThreadHistorySlice { Ok(DirectThreadHistorySlice {
items, items,
has_more, has_more,
first_item_id, item_timestamps,
}) })
}) })
.await .await
File diff suppressed because it is too large Load Diff
@@ -1084,6 +1084,10 @@ struct GameCreatorAppConfigFile {
#[derive(Clone, Debug, Default, Deserialize, Serialize)] #[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigFile { struct GameCreatorLlmConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
custom_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
visible_models: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>, api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -1139,6 +1143,10 @@ struct GameCreatorAppConfig {
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfig { struct GameCreatorLlmConfig {
#[serde(default)]
custom_enabled: bool,
#[serde(default)]
visible_models: Vec<String>,
api_key: String, api_key: String,
base_url: String, base_url: String,
model: String, model: String,
@@ -1655,6 +1663,8 @@ impl Default for GameCreatorAppConfig {
impl Default for GameCreatorLlmConfig { impl Default for GameCreatorLlmConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
custom_enabled: false,
visible_models: Vec::new(),
api_key: String::new(), api_key: String::new(),
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(), base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
@@ -2715,12 +2725,13 @@ fn main() {
confirm_resume_game_creator_agent_runtime_tasks, confirm_resume_game_creator_agent_runtime_tasks,
schedule_game_creator_agent_ready_tasks, schedule_game_creator_agent_ready_tasks,
check_game_creator_llm_config, check_game_creator_llm_config,
read_platform_account_session_generation, read_platform_account_session_state,
install_platform_account_session, install_platform_account_session,
clear_platform_account_session, clear_platform_account_session,
read_game_creator_app_config, read_game_creator_app_config,
write_game_creator_app_config, write_game_creator_app_config,
select_game_creator_model, select_game_creator_model,
discover_game_creator_llm_models,
upload_local_asset, upload_local_asset,
register_local_asset, register_local_asset,
create_ui_design_resource, create_ui_design_resource,
File diff suppressed because it is too large Load Diff
@@ -110,11 +110,13 @@ impl<'a> ExternalEditorBindingAccess<'a> {
} }
/// Call before and after every awaited remote action and immediately before installing 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> { pub(crate) fn validate_frozen_session(&self) -> Result<(), String> {
validate_external_editor_binding_access_shape(self)?; validate_external_editor_binding_access_shape(self)?;
if let Some(session) = self.frozen_platform_session { if let Some(session) = self.frozen_platform_session {
validate_platform_session_snapshot(session)?; validate_frozen_platform_session(session)?;
} }
Ok(()) Ok(())
} }
@@ -1152,7 +1154,8 @@ mod tests {
user_id: user_id.to_string(), user_id: user_id.to_string(),
access_token: token.to_string(), access_token: token.to_string(),
api_base_url: "https://dev.genarrative.world".to_string(), api_base_url: "https://dev.genarrative.world".to_string(),
generation, identity_generation: generation,
revision: generation,
} }
} }
@@ -4389,14 +4389,7 @@ fn with_frozen_resource_edit_platform_session<T>(
let Some(platform_session) = platform_session else { let Some(platform_session) = platform_session else {
return action(); return action();
}; };
let access_token_sha256 = sha256_hex(platform_session.access_token.as_bytes()); with_validated_platform_session_identity(&platform_session.identity(), action)
with_validated_platform_session_fingerprint(
&platform_session.user_id,
&platform_session.api_base_url,
platform_session.generation,
&access_token_sha256,
action,
)
} }
fn commit_resource_edit_asset_with_frozen_platform_session( 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 current_platform_session = current_platform_session();
let _platform_session_lease = current_platform_session let _platform_session_lease = current_platform_session
.as_ref() .as_ref()
.map(|session| { .map(|session| acquire_platform_session_identity_lease(&session.identity()))
acquire_validated_platform_session_fingerprint(
&session.user_id,
&session.api_base_url,
session.generation,
&sha256_hex(session.access_token.as_bytes()),
)
})
.transpose()?; .transpose()?;
let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?;
let entries = match fs::read_dir(&directory) { let entries = match fs::read_dir(&directory) {
@@ -5084,12 +5070,8 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at(
Ok(()) Ok(())
}; };
if let Some(session) = platform_session { if let Some(session) = platform_session {
let access_token_sha256 = sha256_hex(session.access_token.as_bytes()); crate::platform_session::with_validated_platform_session_identity(
crate::platform_session::with_validated_platform_session_fingerprint( &session.identity(),
&session.user_id,
&session.api_base_url,
session.generation,
&access_token_sha256,
archive, archive,
)?; )?;
} else { } else {
@@ -5500,7 +5482,8 @@ mod tests {
user_id: "gui-owner".to_string(), user_id: "gui-owner".to_string(),
access_token: "gui-token".to_string(), access_token: "gui-token".to_string(),
api_base_url: "https://dev.genarrative.world".to_string(), api_base_url: "https://dev.genarrative.world".to_string(),
generation: 7, identity_generation: 7,
revision: 7,
}; };
let developer_credentials = ( let developer_credentials = (
"https://dev.genarrative.world".to_string(), "https://dev.genarrative.world".to_string(),
@@ -5911,6 +5894,7 @@ mod tests {
"source-binding-token-b", "source-binding-token-b",
api_base_url, api_base_url,
*generation, *generation,
*generation,
) )
.expect("switch account after source registration"); .expect("switch account after source registration");
} }
@@ -6925,7 +6909,7 @@ mod tests {
listener, listener,
upload_url, upload_url,
false, false,
Some((base_url.clone(), frozen_session.generation + 1)), Some((base_url.clone(), frozen_session.identity_generation + 1)),
done_receiver, done_receiver,
); );
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@@ -6952,7 +6936,8 @@ mod tests {
"source-binding-owner-a", "source-binding-owner-a",
"source-binding-token-a", "source-binding-token-a",
&base_url, &base_url,
frozen_session.generation + 2, frozen_session.identity_generation + 2,
frozen_session.identity_generation + 2,
) )
.expect("switch back to source binding owner A"); .expect("switch back to source binding owner A");
let resumed_session = current_platform_session().expect("resumed 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); install_test_platform_session("submission-owner-a", "submission-token-a", &base_url);
let frozen_session = current_platform_session().expect("frozen owner A session"); let frozen_session = current_platform_session().expect("frozen owner A session");
let switch_base_url = base_url.clone(); 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 server = std::thread::spawn(move || {
let mut stream = let mut stream =
accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0); accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0);
@@ -7032,6 +7017,7 @@ mod tests {
"submission-token-b", "submission-token-b",
&switch_base_url, &switch_base_url,
switch_generation, switch_generation,
switch_generation,
) )
.expect("switch to owner B before returning accepted response"); .expect("switch to owner B before returning accepted response");
write_json( write_json(
@@ -7447,8 +7433,14 @@ mod tests {
ledger.access_scheme = None; ledger.access_scheme = None;
initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a)) initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a))
.expect("write resource ledger for owner A"); .expect("write resource ledger for owner A");
replace_platform_session_for_gui_owner("resource-owner-b", "resource-token-b", base_url, 2) replace_platform_session_for_gui_owner(
.expect("switch global resource session to owner B"); "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( let error = prepare_resource_edit_service_identity(
root, root,
@@ -7509,7 +7501,8 @@ mod tests {
user_id: "resource-identity-owner-b".to_string(), user_id: "resource-identity-owner-b".to_string(),
access_token: "resource-identity-token-b".to_string(), access_token: "resource-identity-token-b".to_string(),
api_base_url: owner_a.api_base_url.clone(), 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); let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared);
ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string()); ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string());
@@ -7535,7 +7528,8 @@ mod tests {
&owner_b.user_id, &owner_b.user_id,
&owner_b.access_token, &owner_b.access_token,
&owner_b.api_base_url, &owner_b.api_base_url,
owner_b.generation, owner_b.identity_generation,
owner_b.revision,
) )
.expect("switch to resource non-owner B"); .expect("switch to resource non-owner B");
@@ -7639,7 +7633,8 @@ mod tests {
"resource-lease-owner-b", "resource-lease-owner-b",
"resource-lease-token-b", "resource-lease-token-b",
api_base_url, api_base_url,
frozen_a.generation + 1, frozen_a.identity_generation + 1,
frozen_a.revision + 1,
) )
.expect("switch resource lease owner"); .expect("switch resource lease owner");
switched_sender.send(()).expect("signal resource switch"); switched_sender.send(()).expect("signal resource switch");
@@ -8259,7 +8254,8 @@ mod tests {
"archive-owner-b", "archive-owner-b",
"archive-token-b", "archive-token-b",
api_base_url, api_base_url,
owner_a.generation + 1, owner_a.identity_generation + 1,
owner_a.revision + 1,
) )
.expect("switch to owner B"); .expect("switch to owner B");
let error = archive_failed_local_project_resource_edit_at( let error = archive_failed_local_project_resource_edit_at(
@@ -8388,7 +8384,8 @@ mod tests {
"pending-owner-b", "pending-owner-b",
"pending-token-b", "pending-token-b",
api_base_url, api_base_url,
owner_a.generation + 1, owner_a.identity_generation + 1,
owner_a.revision + 1,
) )
.expect("switch to pending owner B"); .expect("switch to pending owner B");
let owner_b = current_platform_session().expect("pending owner B session"); 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 (attempted_sender, attempted_receiver) = mpsc::channel();
let (completed_sender, completed_receiver) = mpsc::channel(); let (completed_sender, completed_receiver) = mpsc::channel();
let switch_api_base_url = api_base_url.to_string(); 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 || { let switch_thread = std::thread::spawn(move || {
begin_switch_receiver begin_switch_receiver
.recv() .recv()
@@ -9360,6 +9357,7 @@ mod tests {
"commit-token-b", "commit-token-b",
&switch_api_base_url, &switch_api_base_url,
switch_generation, switch_generation,
switch_generation,
) )
.expect("switch to commit owner B"); .expect("switch to commit owner B");
completed_sender completed_sender
@@ -987,7 +987,10 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
platform_api_base_url: platform_session platform_api_base_url: platform_session
.as_ref() .as_ref()
.map(|session| session.api_base_url.clone()), .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() ..ExternalAgentRunnerRequestParams::default()
}, },
)?; )?;
@@ -998,7 +1001,8 @@ pub(crate) fn install_external_agent_runner_platform_session(
user_id: &str, user_id: &str,
access_token: &str, access_token: &str,
api_base_url: &str, api_base_url: &str,
generation: u64, identity_generation: u64,
revision: u64,
) -> Result<(), String> { ) -> Result<(), String> {
let config_dir = external_agent_runner_config_dir() let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?; .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?;
@@ -1008,7 +1012,8 @@ pub(crate) fn install_external_agent_runner_platform_session(
remember_external_agent_runner_platform_session( remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(), external_agent_runner_gui_owner_attachment_state(),
Some((user_id, access_token, api_base_url)), Some((user_id, access_token, api_base_url)),
generation, identity_generation,
revision,
) )
.and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|_| ensure_external_agent_runner(&config_dir))
.and_then(|endpoint| { .and_then(|endpoint| {
@@ -1017,7 +1022,8 @@ pub(crate) fn install_external_agent_runner_platform_session(
&config_dir, &config_dir,
&endpoint, &endpoint,
Some((user_id, access_token, api_base_url)), Some((user_id, access_token, api_base_url)),
generation, identity_generation,
revision,
) )
}) })
}, },
@@ -1025,7 +1031,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 { let Some(config_dir) = external_agent_runner_config_dir() else {
return Ok(()); return Ok(());
}; };
@@ -1035,7 +1044,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R
remember_external_agent_runner_platform_session( remember_external_agent_runner_platform_session(
external_agent_runner_gui_owner_attachment_state(), external_agent_runner_gui_owner_attachment_state(),
None, None,
generation, identity_generation,
revision,
) )
.and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|_| ensure_external_agent_runner(&config_dir))
.and_then(|endpoint| { .and_then(|endpoint| {
@@ -1044,7 +1054,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R
&config_dir, &config_dir,
&endpoint, &endpoint,
None, None,
generation, identity_generation,
revision,
) )
}) })
}, },
@@ -1057,7 +1068,8 @@ fn validate_external_agent_runner_platform_session_attachment(
config_dir: &Path, config_dir: &Path,
endpoint: &ExternalAgentRunnerEndpoint, endpoint: &ExternalAgentRunnerEndpoint,
session: Option<(&str, &str, &str)>, session: Option<(&str, &str, &str)>,
generation: u64, identity_generation: u64,
revision: u64,
) -> Result<(), String> { ) -> Result<(), String> {
let state = lock_unpoisoned(state); let state = lock_unpoisoned(state);
let registration = state.registration.as_ref().ok_or_else(|| { let registration = state.registration.as_ref().ok_or_else(|| {
@@ -1070,7 +1082,8 @@ fn validate_external_agent_runner_platform_session_attachment(
|| registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()) || registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())
|| registration.params.gui_owner_epoch.is_none() || registration.params.gui_owner_epoch.is_none()
|| registration.params.gui_owner_session_revision != Some(registration.generation) || 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_user_id.as_deref() != expected_user_id
|| registration.params.platform_access_token.as_deref() != expected_access_token || registration.params.platform_access_token.as_deref() != expected_access_token
|| registration.params.platform_api_base_url.as_deref() != expected_api_base_url || registration.params.platform_api_base_url.as_deref() != expected_api_base_url
@@ -1101,12 +1114,14 @@ pub(super) fn synchronize_external_agent_runner_platform_session_with(
pub(super) fn remember_external_agent_runner_platform_session( pub(super) fn remember_external_agent_runner_platform_session(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>, state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
session: Option<(&str, &str, &str)>, session: Option<(&str, &str, &str)>,
generation: u64, identity_generation: u64,
revision: u64,
) -> Result<(), String> { ) -> Result<(), String> {
remember_external_agent_runner_platform_session_with( remember_external_agent_runner_platform_session_with(
state, state,
session, session,
generation, identity_generation,
revision,
write_external_agent_runner_gui_owner_claim_atomic, write_external_agent_runner_gui_owner_claim_atomic,
) )
} }
@@ -1114,21 +1129,26 @@ pub(super) fn remember_external_agent_runner_platform_session(
pub(super) fn remember_external_agent_runner_platform_session_with( pub(super) fn remember_external_agent_runner_platform_session_with(
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>, state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
session: Option<(&str, &str, &str)>, session: Option<(&str, &str, &str)>,
generation: u64, identity_generation: u64,
revision: u64,
write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>, write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut state = lock_unpoisoned(state); let mut state = lock_unpoisoned(state);
let Some(registration) = state.registration.as_ref() else { let Some(registration) = state.registration.as_ref() else {
return Ok(()); return Ok(());
}; };
let current_generation = registration.params.platform_auth_generation.unwrap_or(0); // 写入顺序只认 revision;身份代次只表达主体归属,同一账号续期会推进 revision
if generation < current_generation { // 但保持 identity generation 不变。
let current_revision = registration.params.platform_auth_revision.unwrap_or(0);
if revision < current_revision {
return Ok(()); return Ok(());
} }
if generation == current_generation { if revision == current_revision {
match session { match session {
Some((user_id, access_token, api_base_url)) Some((user_id, access_token, api_base_url))
if registration.params.platform_user_id.as_deref() == Some(user_id) 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() && registration.params.platform_access_token.as_deref()
== Some(access_token) == Some(access_token)
&& registration.params.platform_api_base_url.as_deref() && registration.params.platform_api_base_url.as_deref()
@@ -1168,7 +1188,8 @@ pub(super) fn remember_external_agent_runner_platform_session_with(
session.map(|(_, access_token, _)| access_token.to_string()); session.map(|(_, access_token, _)| access_token.to_string());
registration.params.platform_api_base_url = registration.params.platform_api_base_url =
session.map(|(_, _, api_base_url)| api_base_url.to_string()); 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);
registration.params.gui_owner_session_revision = Some(registration_generation); registration.params.gui_owner_session_revision = Some(registration_generation);
Ok(()) Ok(())
} }
@@ -1472,6 +1493,7 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity(
platform_access_token: None, platform_access_token: None,
platform_api_base_url: None, platform_api_base_url: None,
platform_auth_generation: None, platform_auth_generation: None,
platform_auth_revision: None,
}; };
match stable_identity { match stable_identity {
Some(stable_identity) => { Some(stable_identity) => {
@@ -133,37 +133,46 @@ fn apply_external_agent_runner_gui_owner_attachment(
params.platform_access_token.as_deref(), params.platform_access_token.as_deref(),
params.platform_api_base_url.as_deref(), params.platform_api_base_url.as_deref(),
params.platform_auth_generation, 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 { if replace_claim {
crate::replace_platform_session_for_gui_owner( crate::replace_platform_session_for_gui_owner(
user_id, user_id,
access_token, access_token,
api_base_url, api_base_url,
generation, identity_generation,
revision,
) )
} else { } else {
crate::install_platform_session_checked( crate::install_platform_session_checked(
user_id, user_id,
access_token, access_token,
api_base_url, api_base_url,
generation, identity_generation,
revision,
) )
} }
} }
(None, None, None, Some(generation)) => { (None, None, None, Some(identity_generation), Some(revision)) => {
if replace_claim { if replace_claim {
crate::clear_platform_session_for_gui_owner(generation); crate::clear_platform_session_for_gui_owner(identity_generation, revision);
Ok(()) Ok(())
} else { } else {
crate::clear_platform_session_checked(generation) crate::clear_platform_session_checked(identity_generation, revision)
} }
} }
(None, None, None, None) if replace_claim => { (None, None, None, None, None) if replace_claim => {
crate::clear_platform_session_for_gui_owner(0); crate::clear_platform_session_for_gui_owner(0, 0);
Ok(()) Ok(())
} }
(None, None, None, None) => Ok(()), (None, None, None, None, None) => Ok(()),
_ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()), _ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()),
}; };
result?; result?;
@@ -171,7 +180,7 @@ fn apply_external_agent_runner_gui_owner_attachment(
Ok(claim) => claim, Ok(claim) => claim,
Err(error) => { Err(error) => {
*active_claim = None; *active_claim = None;
crate::clear_platform_session_for_gui_owner(0); crate::clear_platform_session_for_gui_owner(0, 0);
return Err(format!( return Err(format!(
"Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}" "Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}"
)); ));
@@ -181,7 +190,7 @@ fn apply_external_agent_runner_gui_owner_attachment(
|| committed_claim.session_revision != requested_revision || committed_claim.session_revision != requested_revision
{ {
*active_claim = None; *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()); return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string());
} }
if let Some(event_sink) = event_sink { if let Some(event_sink) = event_sink {
@@ -207,7 +216,7 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current(
return Ok(()); return Ok(());
} }
*active_claim = None; *active_claim = None;
crate::clear_platform_session_for_gui_owner(0); crate::clear_platform_session_for_gui_owner(0, 0);
match durable_claim { match durable_claim {
Ok(_) => Err( Ok(_) => Err(
"authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离" "authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离"

Some files were not shown because too many files have changed in this diff Show More