修复仓库回退配置模板被读取通道私有化 ACL 锁定
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Backend tests (pull_request) Successful in 6m27s
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

- 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 记录该排障经验与读取通道副作用白名单教训
This commit is contained in:
2026-09-10 08:57:31 +00:00
parent a1b9b24891
commit 9b5d1fe107
4 changed files with 105 additions and 2 deletions
@@ -3640,7 +3640,38 @@ pub(crate) fn merge_game_creator_config_file(
Ok(())
}
fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, 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<String, String> {
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<Option<String>, 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<Option<String>, 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))
}
@@ -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();
@@ -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()
@@ -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 的项目重新打开后,也可能看不到失败状态卡。