From 9b5d1fe107ee916fc96bff980598be2e4d157ca7 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 10 Sep 2026 08:57:31 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E9=85=8D=E7=BD=AE=E6=A8=A1=E6=9D=BF=E8=A2=AB?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E9=80=9A=E9=81=93=E7=A7=81=E6=9C=89=E5=8C=96?= =?UTF-8?q?=20ACL=20=E9=94=81=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.rs 新增 game_creator_config_path_is_runtime_managed 判定,read_game_creator_config_file 按路径归属分流:AppData 托管目录内的真实凭据维持私有加固读取,仓库旁边的回退模板与 local 覆盖改用 open_project_snapshot_regular_file 非变异快照通道 - 根因:开发 CLI 无 AppHandle 时回退读取 worktree 内 git 跟踪模板,私有读通道在 Windows 上无条件收紧 DACL 为仅当前进程用户,导致其他账号与 cargo include_str! 全部 Access Denied - tests/mod.rs 新增 clear_test_runtime_config_dir 辅助 - tests/configuration.rs 新增两个回归测试锁定快照 / 私有两条读取通道的分流 - pitfalls.md 记录该排障经验与读取通道副作用白名单教训 --- .../src-tauri/src/config.rs | 39 +++++++++++++++- .../src-tauri/src/tests/configuration.rs | 46 +++++++++++++++++++ .../src-tauri/src/tests/mod.rs | 14 ++++++ docs/project-memory/shared-memory/pitfalls.md | 8 ++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 8a311a31f..8c02b4e6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -3640,7 +3640,38 @@ pub(crate) fn merge_game_creator_config_file( Ok(()) } -fn read_game_creator_config_file(path: &Path) -> Result, String> { +/// Only files inside the managed runtime config directory (real credentials) +/// may use the private-read channel: it hardens the owner/DACL on every read. +/// Repository-adjacent fallback templates and overlay files are shared inputs +/// that can be git-tracked; privatizing one on read silently locks the +/// worktree template to whichever account happened to run the dev CLI, so +/// they must go through the non-mutating snapshot channel instead. +pub(crate) fn game_creator_config_path_is_runtime_managed(path: &Path) -> bool { + game_creator_runtime_config_dir().is_some_and(|directory| path.starts_with(directory)) +} + +fn read_game_creator_snapshot_file_to_string( + path: &Path, + label: &str, + max_bytes: u64, +) -> Result { + let (mut file, metadata) = open_project_snapshot_regular_file(path, label)?; + if metadata.len() > max_bytes { + return Err(format!("{label}过大,已拒绝读取:{}", path.display())); + } + let mut content = String::with_capacity(metadata.len() as usize); + file.read_to_string(&mut content) + .map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?; + let final_metadata = file + .metadata() + .map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?; + if final_metadata.len() != metadata.len() { + return Err(format!("{label}读取期间文件发生漂移:{}", path.display())); + } + Ok(content) +} + +pub(crate) fn read_game_creator_config_file(path: &Path) -> Result, String> { let backup_path = game_creator_config_backup_path(path); let path_exists = validate_game_creator_config_file_entry(path)?; let read_path = if path_exists { @@ -3650,7 +3681,11 @@ fn read_game_creator_config_file(path: &Path) -> Result, String> } else { return Ok(None); }; - let content = read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?; + let content = if game_creator_config_path_is_runtime_managed(read_path) { + read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)? + } else { + read_game_creator_snapshot_file_to_string(read_path, "客户端配置", 256 * 1024)? + }; Ok(Some(content)) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index d1f561bfb..9636f7025 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -1184,6 +1184,52 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() { fs::remove_dir_all(root).ok(); } +#[test] +fn fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir() { + let root = unique_project_path(); + let template_dir = root.join("apps").join("ai-game-creator-shell"); + fs::create_dir_all(&template_dir).expect("fallback template dir"); + let template = template_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); + fs::write( + &template, + "{\n \"llm\": { \"model\": \"fallback-template-model\" }\n}\n", + ) + .expect("write fallback template"); + let _guard = clear_test_runtime_config_dir(); + + // 仓库旁边的回退模板是共享输入,读取绝不能走会收紧 owner/DACL 的私有通道。 + assert!(!game_creator_config_path_is_runtime_managed(&template)); + let content = read_game_creator_config_file(&template).expect("read fallback template"); + assert!( + content + .expect("fallback template content") + .contains("fallback-template-model") + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_config_read_stays_on_private_channel_inside_runtime_dir() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + fs::write( + &config_path, + "{\n \"llm\": { \"model\": \"managed-config-model\" }\n}\n", + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(root.clone()); + + assert!(game_creator_config_path_is_runtime_managed(&config_path)); + let content = read_game_creator_config_file(&config_path).expect("read runtime config"); + assert!( + content + .expect("runtime config content") + .contains("managed-config-model") + ); + fs::remove_dir_all(root).ok(); +} + #[test] fn llm_config_check_reports_agent_specific_config_paths() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index e2c15a956..fc5a2a45b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1327,6 +1327,20 @@ fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard { } } +fn clear_test_runtime_config_dir() -> TestRuntimeConfigDirGuard { + let lock = TEST_CONFIG_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = game_creator_runtime_config_dir(); + *game_creator_runtime_config_dir_lock() + .lock() + .expect("runtime config dir lock") = None; + TestRuntimeConfigDirGuard { + _lock: lock, + previous, + } +} + fn assert_task_status(manifest: &Value, task_id: &str, status: &str) { let task = manifest["tasks"] .as_array() diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e90226dc7..f6a3b783b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4185,6 +4185,14 @@ - 关联:`apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs`、`apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs`、`apps/ai-game-creator-shell/scripts/check-config.mjs`、`apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts`。 - 真实验收状态:外部 Provider 与画布 API 均可调用不等于全链路验收通过。2026-07-27 新起的独立轮次使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**:同一轮完成固定 `16` 个 manifest task exactly-once、七份基础产物、两张真实画布 PNG、当前 revision 静态检查、desktop / mobile `lane-defense-v1` playtest、唯一终态回复和安全清理;`turn.report` 的 busy / pending / running / confirmation / user-input / reconciliation 均为 `0`。此前失败轮、部分产物、单项接口成功和确定性结果仍不得与本轮拼接。 +## 仓库回退配置模板不能被读取通道私有化锁定 + +- 现象:Windows 上 `apps/ai-game-creator-shell/game-creator.config.json` 莫名其妙被"加锁"(DACL 被剥成只剩一个陌生 SID,连 `Get-Acl` 都 unauthorized),开发 agent 和其他用户无法修改,cargo 也因 `include_str!` 读不到文件而不能编译;手动解锁后过一段时间又被锁。 +- 原因:无 AppHandle 的开发 CLI(`llm-status`、`agent-run`、`agc:test:chat` 等)经 `game_creator_config_paths()` 从 CWD / `current_exe` 向上回溯 8 级探测到 worktree 里的 git 跟踪模板后,读取走了为 AppData 私密凭据设计的私有通道 `open_project_private_regular_file` → `prepare_game_creator_private_path_for_read`;该函数名为 "for read",在 Windows 上却无条件收紧目标 DACL 为"仅当前进程用户、禁止继承"。沙箱 agent 是其 checkout 文件的 owner,校验通过后被静默私有化,其他账号全部 Access Denied。 +- 处理:配置读取按路径归属分流——`read_game_creator_config_file` 只对位于 `game_creator_runtime_config_dir()`(AppData 托管目录)内的真实凭据走私有加固读取;仓库旁边的回退模板 / local 覆盖一律走 `open_project_snapshot_regular_file` 非变异快照通道,读取绝不修改 owner / DACL。这与 `open_project_private_regular_file` 注释中"非用户明确选择的文件用 snapshot 读"的既有原则一致。 +- 教训:任何名为"读前准备"的函数若附带权限收紧副作用,都必须按路径是否属于本进程托管范围设白名单;共享仓库文件、git 跟踪文件永远不在加固范围内。排查"文件莫名被锁"时优先查 DACL owner 是哪位 SID,再倒推哪个进程以该身份运行过。 +- 验证:`tests::configuration::fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir` 与 `runtime_config_read_stays_on_private_channel_inside_runtime_dir` 锁定两条通道的分流;`node scripts/check-config.mjs` 通过。 + ## 项目总控空态和持久 Runtime 不能依赖同一份 Session 索引 - 现象:新项目尚未发消息时右侧总控区域只剩整块空白;已有 `needs-reconciliation` Runtime 的项目重新打开后,也可能看不到失败状态卡。 From 3ebfcc0c2fb8901eb1f02e057ae9fdaa67079d75 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 10 Sep 2026 09:03:19 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E8=AF=BB=E5=8F=96=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=9A=84=20Rust=20=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/configuration.rs 按 cargo fmt 调整两个 assert! 宏换行 --- .../src-tauri/src/tests/configuration.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 9636f7025..8b517b021 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -1200,11 +1200,9 @@ fn fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir() { // 仓库旁边的回退模板是共享输入,读取绝不能走会收紧 owner/DACL 的私有通道。 assert!(!game_creator_config_path_is_runtime_managed(&template)); let content = read_game_creator_config_file(&template).expect("read fallback template"); - assert!( - content - .expect("fallback template content") - .contains("fallback-template-model") - ); + assert!(content + .expect("fallback template content") + .contains("fallback-template-model")); fs::remove_dir_all(root).ok(); } @@ -1222,11 +1220,9 @@ fn runtime_config_read_stays_on_private_channel_inside_runtime_dir() { assert!(game_creator_config_path_is_runtime_managed(&config_path)); let content = read_game_creator_config_file(&config_path).expect("read runtime config"); - assert!( - content - .expect("runtime config content") - .contains("managed-config-model") - ); + assert!(content + .expect("runtime config content") + .contains("managed-config-model")); fs::remove_dir_all(root).ok(); } From 89447ed432bfc6a895875c7412bb0da9d93158ac Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 10 Sep 2026 09:46:48 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E6=8C=89=E8=AF=84=E5=AE=A1=E6=84=8F?= =?UTF-8?q?=E8=A7=81=E5=BC=BA=E5=8C=96=E5=9B=9E=E9=80=80=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E8=AF=BB=E5=8F=96=E9=80=9A=E9=81=93=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/configuration.rs 回退模板测试改用与模板无关的 runtime dir,分类断言覆盖按路径归属而非 runtime dir 为 None 的恒真分支,并补充 runtime dir 内路径的 managed 正向断言 - tests/mod.rs 移除不再使用的 clear_test_runtime_config_dir 辅助 --- .../src-tauri/src/tests/configuration.rs | 10 +++++++++- .../src-tauri/src/tests/mod.rs | 14 -------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 8b517b021..adb540bc2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -1195,15 +1195,23 @@ fn fallback_template_read_stays_on_snapshot_channel_outside_runtime_dir() { "{\n \"llm\": { \"model\": \"fallback-template-model\" }\n}\n", ) .expect("write fallback template"); - let _guard = clear_test_runtime_config_dir(); + // 设置一个与模板无关的 runtime dir,让分类断言真正覆盖"按路径归属"而非 + // "runtime dir 为 None 时恒 false"的全局开关。 + let runtime_root = unique_project_path(); + fs::create_dir_all(&runtime_root).expect("unrelated runtime config dir"); + let _guard = use_test_runtime_config_dir(runtime_root.clone()); // 仓库旁边的回退模板是共享输入,读取绝不能走会收紧 owner/DACL 的私有通道。 assert!(!game_creator_config_path_is_runtime_managed(&template)); + assert!(game_creator_config_path_is_runtime_managed( + &runtime_root.join(GAME_CREATOR_CONFIG_FILE_NAME) + )); let content = read_game_creator_config_file(&template).expect("read fallback template"); assert!(content .expect("fallback template content") .contains("fallback-template-model")); fs::remove_dir_all(root).ok(); + fs::remove_dir_all(runtime_root).ok(); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index fc5a2a45b..e2c15a956 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1327,20 +1327,6 @@ fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard { } } -fn clear_test_runtime_config_dir() -> TestRuntimeConfigDirGuard { - let lock = TEST_CONFIG_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous = game_creator_runtime_config_dir(); - *game_creator_runtime_config_dir_lock() - .lock() - .expect("runtime config dir lock") = None; - TestRuntimeConfigDirGuard { - _lock: lock, - previous, - } -} - fn assert_task_status(manifest: &Value, task_id: &str, status: &str) { let task = manifest["tasks"] .as_array()