AGC ACL 提权修复按目标做 single-flight,避免并发重复弹 UAC(#498) #502

Merged
suzmii merged 10 commits from fix/acl-elevation-single-flight into master 2026-09-24 19:15:35 +08:00
Member

关联 issue:#498

问题

AGC 启动页一次挂载会出现多个叠在一起的 UAC 提权弹窗;用户点「否」之后还会被再问一次。

根因

  1. windows_acl_repair_targetapps/ai-game-creator-shell/src-tauri/src/config.rs)对 Managed 作用域不返回叶路径,而是返回第一个读取被拒的祖先,同一祖先下的多个项目因此解析到同一个 repair target。
  2. 唯一的去重是单次调用内的局部 attempted_targets,函数返回即消失——跨调用、跨线程没有记忆
  3. 启动页一次并发检查 ≤8 个最近项目,于是并发启动同样多次 powershell -Verb RunAsStart-Process -Wait 没有超时,被忽略的弹窗会长期占住线程。
  4. 用户拒绝(powershell exit 1223)与「ACL 修复失败」在错误文案上无法区分,调用方只能去匹配中文。

方案

机制 说明
per-target single-flight 新增 acl_repair_gate:key = (规范化 repair target, scope)。并发调用只允许一次真实提权,其余等待并复用同一结果(成功/失败都复用,不是简单跳过)。
key 归一化 windows_acl_repair_gate_key 统一 \\?\ / \\?\UNC\ 扩展长度前缀:最近项目列表里同一项目实测会同时出现两种写法,不归一化会让同一个目录各弹一次 UAC。
结果冷却 成功 30s / 失败 15s / 用户取消 120s 内直接复用;冷却从结果落库时刻算起,而不是 leader 起跑时刻(否则 leader 被 UAC 挂着两分钟会让拒绝冷却提前过期,紧接着的重查立刻再弹一次)。
有界等待 follower 等待窗口 60s,超时按失败关闭,避免 UAC 无人应答时把等待者永久挂住。
失效接管 leader 线程 panic 或失效时由接管路径结束等待(panic 由 RAII 兜底记失败;卡死目标由接管逻辑接管并丢弃迟到结果),不会让该目标永久失败关闭。
错误类型化 用户取消 UAC 统一带稳定标记 AGC_ACL_ELEVATION_DENIED;前端据此判定「不可自动重试」,不再依赖中文文案。
用户主动操作可再试 拒绝记忆的清除抽到 src/features/app-shell/aclElevation.ts,在打开/新建项目、打开或选择目录、重命名刷新等入口统一调用 clear_game_creator_acl_elevation_denials

改动前后对照

场景 改前 改后
8 个同祖先项目并发检查 并发启动 ≤8 次 powershell -Verb RunAs,弹窗叠在一起 只启动 1 次,其余等待并复用结果
同一目录两种路径写法 各触发一次提权 归一化后同一个 key,只触发一次
用户点「否」后自动重试 每次都重新弹 UAC 冷却内直接复用拒绝结果,不再弹
用户点「否」后主动操作 无区分,仍可能被反复打断 用户操作即清除拒绝记忆,可再请求提权
UAC 无人应答 该调用线程被 -Wait 无限期占用,其它调用各自再弹一次 只有 1 个 leader 在等,follower 60s 后按失败关闭
leader 异常/卡死 该目标永久失败关闭或等待者挂死 接管路径收尾,迟到结果丢弃
调用方判定「不可重试」 匹配中文文案 匹配稳定标记 AGC_ACL_ELEVATION_DENIED(中文文案保留兼容)

不改的东西:ACL 判据与失败关闭语义、scope 校验、授权 nonce 机制、提权脚本本身、公开 API/DTO/持久化格式。

变更文件(相对 master 13 个)

apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs        (新增:闸门)
apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs  (新增:闸门单测)
apps/ai-game-creator-shell/src-tauri/src/config.rs                 (接线 + key 归一化 + 稳定标记)
apps/ai-game-creator-shell/src-tauri/src/commands.rs               (清除命令)
apps/ai-game-creator-shell/src-tauri/src/main.rs                   (模块与命令注册)
apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs
apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts  (新增:清除入口复用)
apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts
apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts
apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx
docs/project-memory/shared-memory/decision-log.md
docs/project-memory/shared-memory/pitfalls.md

验证

  • cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- tests::acl_repair_gate --test-threads=110 passed
    • 同 target 并发 8 线程只执行 1 次,其余拿到 Reused;不同 target 不合并;
    • 拒绝 / 成功 / 失败各自的冷却窗口,且冷却基准取自结果落库时刻
    • clear_denials 后显式重试可再执行;
    • follower 在 leader 不结束时得到 WaitTimedOut,放行后 leader 正常收尾;
    • leader panic 时 follower 得到 Reused(Failed)
    • stale leader 被接管且迟到结果被丢弃
    • 同一目录的 \\?\C:\...C:\... 写法归一到同一个 key(UNC 同理),scope 不同仍不合并。
  • tests::configuration 45 passed(未回归)。
  • 前端:recentProjectsHook 5(含「AGC_ACL_ELEVATION_DENIED 这类失败只检查一次」的跨模块契约用例)+ recentProjectsModel 1 + appSurface 220(9 skipped)+ themedModal / WindowChrome + admin-web 20 全通过。
  • cargo fmt --checknpm run check:encodinggit diff --checknpm run agc:typecheckeslintprettier 全绿。
  • CI:已多次合入最新 master(base-commit gate 需要 head 含最新 master);本 head 为合并后的 29c5409ac

未覆盖 / 未做

  • 真机验收(唯一没覆盖的环节):启动页一次挂载 8 个同祖先项目应只出现 1 个 UAC 弹窗;点「否」后不该被立刻再问;再点「打开项目」可重新触发提权。Windows 提权路径是 #[cfg(windows)],单测入口有意不触发 UAC,Linux CI 只能覆盖闸门逻辑。issue #498 里附了无需人应答的复现计数方法(对共同祖先的父目录 icacls /deny,数 powershell … RunAs 进程与 consent.exe 峰值)。
  • 提权子进程的有界等待Start-Process -Wait 目前仍无超时。中断一个正在等用户点击的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 由 60s 窗口兜底;需要更激进策略时再单独讨论。
  • 冷却记忆是进程内的,重启后遗忘(刻意:重启后应能重新授权)。
关联 issue:#498 ## 问题 AGC 启动页一次挂载会出现**多个叠在一起的 UAC 提权弹窗**;用户点「否」之后还会被再问一次。 ## 根因 1. `windows_acl_repair_target`(`apps/ai-game-creator-shell/src-tauri/src/config.rs`)对 Managed 作用域不返回叶路径,而是返回**第一个读取被拒的祖先**,同一祖先下的多个项目因此解析到**同一个** repair target。 2. 唯一的去重是单次调用内的局部 `attempted_targets`,函数返回即消失——**跨调用、跨线程没有记忆**。 3. 启动页一次并发检查 ≤8 个最近项目,于是并发启动同样多次 `powershell -Verb RunAs`;`Start-Process -Wait` 没有超时,被忽略的弹窗会长期占住线程。 4. 用户拒绝(`powershell` exit 1223)与「ACL 修复失败」在错误文案上无法区分,调用方只能去匹配中文。 ## 方案 | 机制 | 说明 | | --- | --- | | **per-target single-flight** | 新增 `acl_repair_gate`:key = `(规范化 repair target, scope)`。并发调用只允许一次真实提权,其余等待并复用**同一结果**(成功/失败都复用,不是简单跳过)。 | | **key 归一化** | `windows_acl_repair_gate_key` 统一 `\\?\` / `\\?\UNC\` 扩展长度前缀:最近项目列表里同一项目实测会同时出现两种写法,不归一化会让同一个目录各弹一次 UAC。 | | **结果冷却** | 成功 30s / 失败 15s / 用户取消 120s 内直接复用;冷却从**结果落库时刻**算起,而不是 leader 起跑时刻(否则 leader 被 UAC 挂着两分钟会让拒绝冷却提前过期,紧接着的重查立刻再弹一次)。 | | **有界等待** | follower 等待窗口 60s,超时按失败关闭,避免 UAC 无人应答时把等待者永久挂住。 | | **失效接管** | leader 线程 panic 或失效时由接管路径结束等待(panic 由 RAII 兜底记失败;卡死目标由接管逻辑接管并丢弃迟到结果),不会让该目标永久失败关闭。 | | **错误类型化** | 用户取消 UAC 统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`;前端据此判定「不可自动重试」,不再依赖中文文案。 | | **用户主动操作可再试** | 拒绝记忆的清除抽到 `src/features/app-shell/aclElevation.ts`,在打开/新建项目、打开或选择目录、重命名刷新等入口统一调用 `clear_game_creator_acl_elevation_denials`。 | ## 改动前后对照 | 场景 | 改前 | 改后 | | --- | --- | --- | | 8 个同祖先项目并发检查 | 并发启动 ≤8 次 `powershell -Verb RunAs`,弹窗叠在一起 | 只启动 **1 次**,其余等待并复用结果 | | 同一目录两种路径写法 | 各触发一次提权 | 归一化后同一个 key,只触发一次 | | 用户点「否」后自动重试 | 每次都重新弹 UAC | 冷却内直接复用拒绝结果,不再弹 | | 用户点「否」后主动操作 | 无区分,仍可能被反复打断 | 用户操作即清除拒绝记忆,可再请求提权 | | UAC 无人应答 | 该调用线程被 `-Wait` 无限期占用,其它调用各自再弹一次 | 只有 1 个 leader 在等,follower 60s 后按失败关闭 | | leader 异常/卡死 | 该目标永久失败关闭或等待者挂死 | 接管路径收尾,迟到结果丢弃 | | 调用方判定「不可重试」 | 匹配中文文案 | 匹配稳定标记 `AGC_ACL_ELEVATION_DENIED`(中文文案保留兼容) | 不改的东西:ACL 判据与失败关闭语义、scope 校验、授权 nonce 机制、提权脚本本身、公开 API/DTO/持久化格式。 ## 变更文件(相对 master 13 个) ``` apps/ai-game-creator-shell/src-tauri/src/acl_repair_gate.rs (新增:闸门) apps/ai-game-creator-shell/src-tauri/src/tests/acl_repair_gate.rs (新增:闸门单测) apps/ai-game-creator-shell/src-tauri/src/config.rs (接线 + key 归一化 + 稳定标记) apps/ai-game-creator-shell/src-tauri/src/commands.rs (清除命令) apps/ai-game-creator-shell/src-tauri/src/main.rs (模块与命令注册) apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs apps/ai-game-creator-shell/src/features/app-shell/aclElevation.ts (新增:清除入口复用) apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts apps/ai-game-creator-shell/tests/appSurface/home.suite.ts apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx docs/project-memory/shared-memory/decision-log.md docs/project-memory/shared-memory/pitfalls.md ``` ## 验证 - `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell -- tests::acl_repair_gate --test-threads=1` → **10 passed**: - 同 target 并发 8 线程只执行 **1 次**,其余拿到 `Reused`;不同 target 不合并; - 拒绝 / 成功 / 失败各自的冷却窗口,且**冷却基准取自结果落库时刻**; - `clear_denials` 后显式重试可再执行; - follower 在 leader 不结束时得到 `WaitTimedOut`,放行后 leader 正常收尾; - leader panic 时 follower 得到 `Reused(Failed)`; - **stale leader 被接管且迟到结果被丢弃**; - **同一目录的 `\\?\C:\...` 与 `C:\...` 写法归一到同一个 key**(UNC 同理),scope 不同仍不合并。 - `tests::configuration` 45 passed(未回归)。 - 前端:`recentProjectsHook` 5(含「`AGC_ACL_ELEVATION_DENIED` 这类失败只检查一次」的跨模块契约用例)+ `recentProjectsModel` 1 + `appSurface` 220(9 skipped)+ `themedModal` / `WindowChrome` + `admin-web` 20 全通过。 - `cargo fmt --check`、`npm run check:encoding`、`git diff --check`、`npm run agc:typecheck`、`eslint`、`prettier` 全绿。 - CI:已多次合入最新 master(base-commit gate 需要 head 含最新 master);本 head 为合并后的 `29c5409ac`。 ## 未覆盖 / 未做 - **真机验收**(唯一没覆盖的环节):启动页一次挂载 8 个同祖先项目应只出现 **1 个 UAC 弹窗**;点「否」后不该被立刻再问;再点「打开项目」可重新触发提权。Windows 提权路径是 `#[cfg(windows)]`,单测入口有意不触发 UAC,Linux CI 只能覆盖闸门逻辑。issue #498 里附了无需人应答的复现计数方法(对共同祖先的父目录 `icacls /deny`,数 `powershell … RunAs` 进程与 `consent.exe` 峰值)。 - **提权子进程的有界等待**:`Start-Process -Wait` 目前仍无超时。中断一个正在等用户点击的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 由 60s 窗口兜底;需要更激进策略时再单独讨论。 - 冷却记忆是**进程内**的,重启后遗忘(刻意:重启后应能重新授权)。
suzmii added the Kind/Bug
Priority
High
2
labels 2026-09-23 20:02:17 +08:00
suzmii added 2 commits 2026-09-23 20:02:17 +08:00
- 新增 acl_repair_gate:以 (规范化 repair target, scope) 为 key 的进程级 single-flight 与结果冷却(成功 30s / 失败 15s / 用户取消 120s),等待窗口 60s 超时按失败关闭
- acl_repair_gate:leader 异常退出由 RAII 兜底记为失败并唤醒全部等待者,避免等待者被永久挂住
- config:提权修复改经闸门执行;用户取消 UAC 统一返回稳定标记 AGC_ACL_ELEVATION_DENIED,不再依赖中文文案判定
- commands/main:新增 clear_game_creator_acl_elevation_denials,供用户主动操作时解除拒绝记忆
- useRecentProjects:识别新的稳定标记;打开/新建项目与重命名刷新时清除提权拒绝记忆
- tests/acl_repair_gate:并发只执行一次、冷却复用、清除后可重试、follower 超时、leader panic 唤醒等待者
- docs:decision-log 与 pitfalls 记录 single-flight 取舍与未做项
Merge remote-tracking branch 'origin/master' into fix/acl-elevation-single-flight
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
c079003719
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
suzmii added 1 commit 2026-09-23 20:12:04 +08:00
补一条用例:Rust 侧取消 UAC 的稳定标记同样不触发重试
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
d94ee96836
- tests/recentProjectsHook:AGC_ACL_ELEVATION_DENIED 这类失败只检查一次,钉住 Rust 错误标记与前端「不可重试」判定之间的契约
suzmii added 1 commit 2026-09-23 20:14:50 +08:00
Merge remote-tracking branch 'origin/master' into fix/acl-elevation-single-flight
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m9s
Project CI / Backend tests (pull_request) Successful in 4m43s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m14s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m4s
Project CI / Frontend tests (pull_request) Successful in 2m27s
Project CI / Native shell tests (pull_request) Successful in 6m16s
Project CI / Repository checks (pull_request) Successful in 2m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m45s
f056c8a325
suzmii added 3 commits 2026-09-24 12:49:28 +08:00
- 合入 origin/master(87e52860a):后台游戏审核弹窗、客户端维护态错误弹窗、发行入口同源路径等上游变更
- decision-log.md 冲突按「两边都保留」解决:单飞决策条目置顶,其后保留上游同日条目
- 本次合并未改动 ACL 单飞实现本身(acl_repair_gate.rs / config.rs / useRecentProjects.ts 无冲突)
- acl_repair_gate:冷却基准从 leader 起跑时刻改为结果落库时刻;UAC 被挂着几十秒到两分钟时,120s 拒绝冷却不再提前过期,避免紧跟的自动整表重查立刻再弹一次
- config:新增 windows_acl_repair_gate_key,闸门 key 的路径半边先去掉 \\?\ / \\?\UNC\ 前缀再统一小写;最近项目列表里同一项目实测同时存在 \\?\C:\... 与 C:\... 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC
- tests/acl_repair_gate:补「冷却从结果落库时刻算起」与「路径写法归一成一个 key」两条用例;两条都做过逆向确认(改回修复前语义即失败)
- docs:decision-log 与 pitfalls 补记 key 归一化、冷却基准,以及真机复现的三个坑(DENY 要加在祖先的父目录、夹具路径必须落在 Managed 放行范围内、提权子进程会按 repair target 再校验 scope)
合并 master 到 fix/acl-elevation-single-flight
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m8s
Project CI / Backend tests (pull_request) Successful in 4m8s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Native shell tests (pull_request) Successful in 6m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m48s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m39s
Project CI / Repository checks (pull_request) Successful in 2m14s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
9034b8a2d3
- 合入 origin/master(dcb4f2a1c):DirectProject 过程卡读秒粒度与样式修复(#508)
- pitfalls.md 冲突按「两边都保留」解决:ACL 单飞条目置顶,其后保留上游 2026-09-24 读秒条目
- 本次合并未改动 ACL 单飞实现(含刚收口的 key 归一化与冷却基准改动)
suzmii added 1 commit 2026-09-24 12:59:42 +08:00
补齐打开/选择目录入口的提权拒绝记忆清除
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m45s
Project CI / Backend tests (pull_request) Successful in 3m54s
Project CI / Frontend tests (pull_request) Successful in 2m5s
Project CI / Native shell tests (pull_request) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m24s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m38s
Project CI / Repository checks (pull_request) Successful in 2m10s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m1s
1931852e9e
- aclElevation:新增唯一入口 clearAclElevationDenials()(Tauri 环境判断 + 命令失败只吞掉,旁路动作不影响本次操作)
- useRecentProjects:rememberRecentWorkspace / refreshRecentWorkspace 改用该共享入口,删掉本地同名实现
- useHomeProjectCreation:openProject 入口先清除拒绝记忆再 inspect,覆盖行内打开、运行中项目入口与文件选择器选择目录;此前只挂在「打开/新建成功之后」,用户点了打开会撞上 120s 冷却直接失败且不弹 UAC
- appSurface/home.suite:新增断言「用户动作先 clear 再 inspect」,并做逆向确认(去掉该调用即红)
- docs:decision-log 写清前端唯一入口与必须挂的四个入口,pitfalls 记录 leader 失效接管这条残余边界
suzmii added 1 commit 2026-09-24 17:29:09 +08:00
闸门补 leader 失效接管,避免卡死目标永久失败关闭
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m25s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m6s
Project CI / Frontend tests (pull_request) Successful in 2m10s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m28s
Project CI / Native shell tests (pull_request) Successful in 5m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m38s
2e45608f68
- acl_repair_gate:策略新增 leader_deadline(默认 5 分钟),超过后新调用接管仍是 running 的 key;Entry 记 started_at + leader_id,被接管后旧 leader 迟到的结果按令牌丢弃,不覆盖接管者写下的结果
- acl_repair_gate:complete()/Drop 改为按令牌就地更新(不再无条件 insert),panic 兜底与接管语义保持一致
- acl_repair_gate:running 状态的 entry 不再写 recorded_at(那时还没有结果),冷却基准只在真正落库时记录
- tests/acl_repair_gate:新增 stale_leader_is_taken_over_and_its_late_result_is_discarded;临时关掉接管分支即红(逆向确认:Reused(Failed(...)) 而非 Executed(Repaired))
- docs:decision-log 记 leader 失效接管决策,pitfalls 把「已知残余边界」改成已兜底说明,验证清单补该用例
Author
Member

评审结论与真机验收(issue #498)

机制

per-target single-flight(acl_repair_gate,key = (规范化 repair target, scope))+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ follower 60s 有界等待 + leader 异常 RAII 兜底。闸门在锁外执行、完成时持锁写入后 notify_all,没有丢唤醒;executor 可注入,单测不需要真实 UAC。

评审发现并已修(3480a2f331931852e92e45608f6

  1. 闸门 key 未归一化路径写法:key 原先取 repair_path.to_string_lossy().to_lowercase()。最近项目列表里同一项目实测同时存在 \\?\C:\...C:\... 两种形态,同一个物理目录会算出两个 key → single-flight 退化成「每种写法弹一次」。改为经 windows_acl_repair_gate_keynormalize_windows_policy_path(去 \\?\ / \\?\UNC\)+ 小写;不 canonicalize(待修复目标恰恰是「读不动的目录」)。逆向确认:改回旧语义后新用例打印出两个不同 key(c:\users\...\projects vs \\?\c:\users\...\projects)。
  2. 冷却基准是 leader 起跑时刻:UAC 会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。改为结果落库时刻记录。
  3. 「用户主动操作可再试」漏挂入口:原先只挂在「打开/新建成功之后」,openProject(行内打开、文件选择器选择目录、运行中项目入口都走它)没有清零,用户点了打开会撞上 120s 冷却:直接失败且不弹 UAC。统一走 features/app-shell/aclElevation.ts::clearAclElevationDenials() 并在 openProject 入口先清零,补了「先 clear 再 inspect」的断言(逆向确认:去掉调用即红)。
  4. leader 卡死会永久占住 key:原先只有 follower 的 60s 有界等待,没有 leader 失效接管。提权子进程真挂死(Start-Process -Wait 无超时)时该 key 一直 running,之后同目标调用一律 60s 超时失败,而 clear_denials 不清理 running —— 只能重启客户端。现在加 leader_deadline(默认 5 分钟,系统对无人应答的 UAC 约 2 分钟超时,只兜真挂死),超时后新调用接管;每个 leader 带令牌,被接管后旧 leader 迟到的结果直接丢弃,不会覆盖接管者写下的结果。

真机验收(Windows 11,dev 客户端,8 个同祖先项目,共同 repair target = ...\acl-e2e\ancestor

观测方式:每 200ms 采样,统计命令行同时含 -Verb RunAs--repair-private-aclpowershell.exeStart-Process -Wait 会让它一直存活到应答)distinct PID 数与同刻并发峰值,并记录 consent.exe 峰值。

轮次 场景 提权请求数 弹窗峰值 结果
1 真实启动页挂载(注入 8 个项目 + reload,200s 观测) 1 1 点「否」→ 剩余观测窗内无第二次请求
2 受控 8 并发 inspect_local_project_directory(同祖先) 1 1 8 条返回逐字相同(同一次修复结果被 8 个调用复用)
3 夹具改为可修形状后再 8 并发 1(点是) 1 8 条全部成功;祖先 ACL 被提权子进程改写成 dongy:(OI)(CI)(F),整轮 3.37s
4 成功冷却内再跑 8 并发(25s 观测) 0 0 8 条全部成功,整轮 125ms(复用成功结果,不再弹窗)

对照:修复前每个调用各自 Start-Process -Verb RunAs,8 个项目 = 8 个并发 UAC(issue #498 截图)。

UI 前后对照:修复前同一列表渲染成「暂无最近项目」(8 行全部检查失败、canOpen=false 被隐藏);单次提权修复后启动页正常显示最近项目卡片且「进度 本地项目」。截图留在本机 %TEMP%\agc-acl-e2e\ui-restored.png:Gitea 的 issue asset 上传接口两次都返回 500(像是服务端附件存储没开),需要内联的话说一声我换方式补。

单测:tests::acl_repair_gate 10 passed(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃);上表四条新用例都做过逆向确认(改回修复前语义即红)。

残余边界

  • leader 卡死由 leader_deadline(5 分钟)兜底:超过后新调用接管;被接管期间旧 leader 仍可能在 Start-Process -Wait 上挂着,等它自行退出(系统对无人应答的 UAC 约 2 分钟超时)即释放。
  • 真机上没有单独复测「拒绝后 120s 内再调不再弹」的受控轮次(拒绝分支由单测 denied_elevation_is_reused_for_the_denial_cooldown 覆盖;真机只验证了挂载后单次弹窗 + 之后无重复请求)。

顺带查明的产品级事实(与本 PR 无关)

显式拒绝当前用户 SID」形态的 ACL 损坏,提权修复也修不了:UAC 提权不改变用户 SID,DENY ACE 对提权令牌同样生效,子进程连目标都 stat 不到而失败关闭(实测子进程 stderr:AGC ACL 提权修复失败:读取待修复私有对象失败:...: 拒绝访问。 (os error 5))。这类损坏只能人工/管理员处理;issue #498 现场更可能是「授权缺失 / 继承被改坏」这一类可修复形状——夹具换成该形状后,一次提权即修好。另外 icacls /grant:r 不删除 DENY ACE(需 /remove:d),复现时容易踩。

## 评审结论与真机验收(issue #498) ### 机制 per-target single-flight(`acl_repair_gate`,key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ follower 60s 有界等待 + leader 异常 RAII 兜底。闸门在锁外执行、完成时持锁写入后 `notify_all`,没有丢唤醒;executor 可注入,单测不需要真实 UAC。 ### 评审发现并已修(`3480a2f33`、`1931852e9`、`2e45608f6`) 1. **闸门 key 未归一化路径写法**:key 原先取 `repair_path.to_string_lossy().to_lowercase()`。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种形态,同一个物理目录会算出两个 key → single-flight 退化成「每种写法弹一次」。改为经 `windows_acl_repair_gate_key` 先 `normalize_windows_policy_path`(去 `\\?\` / `\\?\UNC\`)+ 小写;不 `canonicalize`(待修复目标恰恰是「读不动的目录」)。逆向确认:改回旧语义后新用例打印出两个不同 key(`c:\users\...\projects` vs `\\?\c:\users\...\projects`)。 2. **冷却基准是 leader 起跑时刻**:UAC 会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。改为结果落库时刻记录。 3. **「用户主动操作可再试」漏挂入口**:原先只挂在「打开/新建**成功之后**」,`openProject`(行内打开、文件选择器选择目录、运行中项目入口都走它)没有清零,用户点了打开会撞上 120s 冷却:直接失败且不弹 UAC。统一走 `features/app-shell/aclElevation.ts::clearAclElevationDenials()` 并在 `openProject` 入口先清零,补了「先 clear 再 inspect」的断言(逆向确认:去掉调用即红)。 4. **leader 卡死会永久占住 key**:原先只有 follower 的 60s 有界等待,没有 leader 失效接管。提权子进程真挂死(`Start-Process -Wait` 无超时)时该 key 一直 `running`,之后同目标调用一律 60s 超时失败,而 `clear_denials` 不清理 running —— 只能重启客户端。现在加 `leader_deadline`(默认 5 分钟,系统对无人应答的 UAC 约 2 分钟超时,只兜真挂死),超时后新调用接管;每个 leader 带令牌,被接管后旧 leader 迟到的结果直接丢弃,不会覆盖接管者写下的结果。 ### 真机验收(Windows 11,dev 客户端,8 个同祖先项目,共同 repair target = `...\acl-e2e\ancestor`) 观测方式:每 200ms 采样,统计命令行同时含 `-Verb RunAs` 与 `--repair-private-acl` 的 `powershell.exe`(`Start-Process -Wait` 会让它一直存活到应答)distinct PID 数与同刻并发峰值,并记录 `consent.exe` 峰值。 | 轮次 | 场景 | 提权请求数 | 弹窗峰值 | 结果 | |---|---|---|---|---| | 1 | 真实启动页挂载(注入 8 个项目 + reload,200s 观测) | **1** | **1** | 点「否」→ 剩余观测窗内无第二次请求 | | 2 | 受控 8 并发 `inspect_local_project_directory`(同祖先) | **1** | 1 | 8 条返回**逐字相同**(同一次修复结果被 8 个调用复用) | | 3 | 夹具改为可修形状后再 8 并发 | 1(点是) | 1 | **8 条全部成功**;祖先 ACL 被提权子进程改写成 `dongy:(OI)(CI)(F)`,整轮 3.37s | | 4 | 成功冷却内再跑 8 并发(25s 观测) | **0** | 0 | 8 条全部成功,整轮 125ms(复用成功结果,不再弹窗) | 对照:修复前每个调用各自 `Start-Process -Verb RunAs`,8 个项目 = 8 个并发 UAC(issue #498 截图)。 UI 前后对照:修复前同一列表渲染成「暂无最近项目」(8 行全部检查失败、`canOpen=false` 被隐藏);单次提权修复后启动页正常显示最近项目卡片且「进度 本地项目」。截图留在本机 `%TEMP%\agc-acl-e2e\ui-restored.png`:Gitea 的 issue asset 上传接口两次都返回 500(像是服务端附件存储没开),需要内联的话说一声我换方式补。 单测:`tests::acl_repair_gate` 10 passed(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一、leader 卡死接管与迟到结果丢弃);上表四条新用例都做过逆向确认(改回修复前语义即红)。 ### 残余边界 - leader 卡死由 `leader_deadline`(5 分钟)兜底:超过后新调用接管;被接管期间旧 leader 仍可能在 `Start-Process -Wait` 上挂着,等它自行退出(系统对无人应答的 UAC 约 2 分钟超时)即释放。 - 真机上没有单独复测「拒绝后 120s 内再调不再弹」的受控轮次(拒绝分支由单测 `denied_elevation_is_reused_for_the_denial_cooldown` 覆盖;真机只验证了挂载后单次弹窗 + 之后无重复请求)。 ### 顺带查明的产品级事实(与本 PR 无关) 「**显式拒绝当前用户 SID**」形态的 ACL 损坏,提权修复也修不了:UAC 提权不改变用户 SID,DENY ACE 对提权令牌同样生效,子进程连目标都 stat 不到而失败关闭(实测子进程 stderr:`AGC ACL 提权修复失败:读取待修复私有对象失败:...: 拒绝访问。 (os error 5)`)。这类损坏只能人工/管理员处理;issue #498 现场更可能是「授权缺失 / 继承被改坏」这一类可修复形状——夹具换成该形状后,一次提权即修好。另外 `icacls /grant:r` 不删除 DENY ACE(需 `/remove:d`),复现时容易踩。
suzmii added 1 commit 2026-09-24 18:16:58 +08:00
Merge remote-tracking branch 'origin/master' into fix/acl-elevation-single-flight
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m4s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Frontend tests (pull_request) Successful in 1m57s
Project CI / Native shell tests (pull_request) Successful in 5m48s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m59s
Project CI / Repository checks (pull_request) Successful in 1m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m21s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m36s
29c5409ac4
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
suzmii merged commit 96b0fada3a into master 2026-09-24 19:15:35 +08:00
suzmii deleted branch fix/acl-elevation-single-flight 2026-09-24 19:15:35 +08:00
Sign in to join this conversation.