补齐打开/选择目录入口的提权拒绝记忆清除
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
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
- aclElevation:新增唯一入口 clearAclElevationDenials()(Tauri 环境判断 + 命令失败只吞掉,旁路动作不影响本次操作) - useRecentProjects:rememberRecentWorkspace / refreshRecentWorkspace 改用该共享入口,删掉本地同名实现 - useHomeProjectCreation:openProject 入口先清除拒绝记忆再 inspect,覆盖行内打开、运行中项目入口与文件选择器选择目录;此前只挂在「打开/新建成功之后」,用户点了打开会撞上 120s 冷却直接失败且不弹 UAC - appSurface/home.suite:新增断言「用户动作先 clear 再 inspect」,并做逆向确认(去掉该调用即红) - docs:decision-log 写清前端唯一入口与必须挂的四个入口,pitfalls 记录 leader 失效接管这条残余边界
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
|
||||
/**
|
||||
* 用户主动操作(打开/新建项目、选择目录、重命名刷新)时调用:解除 Rust 侧的 ACL 提权拒绝记忆。
|
||||
*
|
||||
* Rust 侧闸门对「用户取消 UAC」有 120s 冷却,冷却期内同一目标的提权请求直接复用拒绝结果、
|
||||
* 不再弹窗。所以只要入口是明确的用户动作,就必须先清掉这份记忆,否则用户会看到
|
||||
* 「点了打开却立刻失败、也不问我要不要授权」。
|
||||
*
|
||||
* 零成本失败关闭:不在 Tauri 环境直接返回;命令失败也只吞掉(下一次用户操作会再试),
|
||||
* 不能让「重置拒绝记忆」这种旁路动作影响本次操作本身。
|
||||
*/
|
||||
export function clearAclElevationDenials() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
void invoke('clear_game_creator_acl_elevation_denials').catch(() => {});
|
||||
} catch {
|
||||
// 命令缺失等同步异常同样不影响本次操作:这只是一次旁路清零。
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
projectPathHasControlCharacter,
|
||||
} from '../project-summary/projectSummary';
|
||||
import { importDesignFiles } from '../project-workspace/importDesignFiles';
|
||||
import { clearAclElevationDenials } from './aclElevation';
|
||||
import {
|
||||
ensureHomeWebCreationEnvironment,
|
||||
HOME_WEB_PREFLIGHT_FAILURE,
|
||||
@@ -614,6 +615,9 @@ export function useHomeProjectCreation({
|
||||
mode: 'open' | 'create',
|
||||
analytics?: ProjectOpenAnalytics,
|
||||
) {
|
||||
// 打开/新建是明确的用户动作:先解除 Rust 侧的提权拒绝记忆,否则 120s 冷却内
|
||||
// 首条 inspect_local_project_directory 会直接复用「用户取消」的结果,既不弹 UAC 也打不开。
|
||||
clearAclElevationDenials();
|
||||
if (mode === 'create') {
|
||||
await createProjectFromProjectPage(nextProjectPath);
|
||||
return;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isAbsoluteProjectPath,
|
||||
projectPathHasControlCharacter,
|
||||
} from '../project-summary/projectSummary';
|
||||
import { clearAclElevationDenials } from './aclElevation';
|
||||
import {
|
||||
buildRecentProjectRows,
|
||||
readRecentWorkspaces,
|
||||
@@ -224,19 +225,8 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
};
|
||||
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
|
||||
|
||||
/** 用户主动操作后解除 Rust 侧的提权拒绝记忆(否则冷却期内不会再请求提权)。 */
|
||||
function resetAclElevationDenials() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
void invoke('clear_game_creator_acl_elevation_denials').catch(() => {
|
||||
// 重置失败不影响本次列表刷新:下一次用户操作会再试。
|
||||
});
|
||||
}
|
||||
|
||||
function rememberRecentWorkspace(projectPath: string) {
|
||||
resetAclElevationDenials();
|
||||
clearAclElevationDenials();
|
||||
nonRetryablePathsRef.current.clear();
|
||||
setRecentWorkspaces(writeRecentWorkspace(projectPath));
|
||||
setRecentWorkspaceRefreshKey((current) => current + 1);
|
||||
@@ -247,7 +237,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
if (!invoke) {
|
||||
return;
|
||||
}
|
||||
resetAclElevationDenials();
|
||||
clearAclElevationDenials();
|
||||
nonRetryablePathsRef.current.delete(projectPath);
|
||||
const inspection = await inspectRecentWorkspaceWithRetry(
|
||||
invoke,
|
||||
|
||||
@@ -2883,6 +2883,9 @@ export function registerRecentProjectsTests() {
|
||||
'厨房突围',
|
||||
);
|
||||
}
|
||||
if (command === 'clear_game_creator_acl_elevation_denials') {
|
||||
return undefined;
|
||||
}
|
||||
if (command === 'open_game_creator_workspace_window') {
|
||||
return undefined;
|
||||
}
|
||||
@@ -2986,10 +2989,25 @@ export function registerRecentProjectsTests() {
|
||||
{ projectPath: '/tmp/broken-status' },
|
||||
);
|
||||
|
||||
// 打开是明确的用户动作:必须先解除 Rust 侧的提权拒绝记忆,再 inspect。
|
||||
// 否则 120s 拒绝冷却内首条 inspect 直接复用「用户取消」的结果:既不弹 UAC,也打不开项目。
|
||||
const callsBeforeOpen = invoke.mock.calls.length;
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开项目 厨房突围' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('陶泥儿项目对话')).not.toBeNull();
|
||||
});
|
||||
const openedCalls = invoke.mock.calls.slice(callsBeforeOpen);
|
||||
const clearedAt = openedCalls.findIndex(
|
||||
([command]) => command === 'clear_game_creator_acl_elevation_denials',
|
||||
);
|
||||
const inspectedAt = openedCalls.findIndex(
|
||||
([command, args]) =>
|
||||
command === 'inspect_local_project_directory' &&
|
||||
(args as { projectPath?: string } | undefined)?.projectPath ===
|
||||
'/tmp/ok-game',
|
||||
);
|
||||
expect(clearedAt).toBeGreaterThanOrEqual(0);
|
||||
expect(inspectedAt).toBeGreaterThan(clearedAt);
|
||||
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'open_game_creator_workspace_window',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- 决策补充(key 归一化):key 的路径半边经 `windows_acl_repair_gate_key` 归一化——去掉 `\\?\` / `\\?\UNC\` 前缀并统一小写。最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法(客户端 localStorage 实测),不归一化就是两个 key,同一个目录仍会弹两次 UAC。这里刻意只做前缀与大小写归一而不 `canonicalize`:待修复目标恰恰是「读不动的目录」,解析不可靠。
|
||||
- 决策补充(冷却基准):冷却从**结果落库**时刻算起,不是 leader 起跑时刻。UAC 弹窗会被挂着几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,前端 15s/45s/120s 的整表重查紧跟着再弹一次。
|
||||
- 错误类型化:用户取消 UAC 的错误统一带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此判定「不可自动重试」,不再依赖中文文案匹配。
|
||||
- 用户主动操作(打开/新建项目、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。
|
||||
- 用户主动操作(打开/新建项目、文件选择器选择目录、重命名刷新)会调用 `clear_game_creator_acl_elevation_denials` 清除拒绝记忆,保证显式重试仍能再次请求提权。前端唯一入口是 `features/app-shell/aclElevation.ts` 的 `clearAclElevationDenials()`:最近项目 hook(`rememberRecentWorkspace` / `refreshRecentWorkspace`)与打开/新建链路(`useHomeProjectCreation.openProject`,覆盖行内打开与 picker)共用它;漏挂入口会让用户「点了打开立即失败、也不问授权」。
|
||||
- 未做:给提权子进程加有界等待(`Start-Process -Wait` 目前无超时)。理由:中断挂起的 UAC 流程比等待更糟,single-flight 已把并发弹窗收成一个,follower 的等待由 60s 窗口兜底。
|
||||
|
||||
## 2026-09-23 运行视窗:右下角全屏预览 + 没有内容就自动收起的信息栏
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- **处理**:进程级 single-flight(key = `(规范化 repair target, scope)`)+ 结果冷却(成功 30s / 失败 15s / 用户取消 120s)+ 等待窗口 60s 超时按失败关闭;leader 异常退出由 RAII 兜底唤醒等待者。用户取消带稳定标记 `AGC_ACL_ELEVATION_DENIED`,前端据此不自动重试;用户主动操作会清除拒绝记忆。
|
||||
- **不要踩的坑**:① 闸门 key 必须归一化 `\\?\` / `\\?\UNC\` 前缀——最近项目列表里同一项目实测同时存在 `\\?\C:\...` 与 `C:\...` 两种写法,按原始字符串做 key 会让同一个目录弹两次 UAC(`windows_acl_repair_gate_key`);② 冷却必须从**结果落库**时刻算起,用 leader 起跑时刻会让 120s 拒绝冷却在 UAC 被挂着两分钟时提前过期,紧接着的自动重查立刻再弹一次;③ 复现「多个项目共用同一 target」时,DENY 要写在祖先的**父目录**上靠继承落入祖先——`icacls` 直接加在容器自身实测只影响子项(容器自身 `GetFileAttributes` 仍成功),target 会退化成每个项目自己,repro 不出并发弹窗;④ 夹具路径必须落在 `game_creator_private_path_allows_auto_elevation` 放行范围内(runtime config dir / `.config/genarrative` / 打包 AppData / 带 `.agent/manifest.json` 的项目根),因为提权子进程会按 **repair target** 再校验一次 `scope.allows_path`,否则失败关闭。
|
||||
- **验证**:`src-tauri/src/tests/acl_repair_gate.rs`(并发只执行一次、冷却复用、拒绝冷却、清除后可重试、follower 超时、leader panic 唤醒等待者、冷却基准、路径写法归一)。真机复现(无需提权交互即可计数):在 Managed 放行范围内建 8 个带 `.agent/manifest.json` 的假项目 → 对共同祖先的**父目录** `icacls <父目录> /deny *<sid>:(OI)(CI)(RX)` → 挂载启动页,同时数 `powershell.exe` 里命令行带 `RunAs` 的进程数(`Start-Process -Wait` 会让它一直存活到用户应答)与 `consent.exe` 峰值:修复前 8 个并发请求,修复后 1 个;把同一目录的 `\\?\C:\...` 与 `C:\...` 两种写法一起塞进最近项目,还能验证 key 归一化是否生效(修复前 2 个、修复后 1 个)。
|
||||
- **已知残余边界**:闸门只有 follower 的有界等待(60s),没有 leader 失效接管——若提权子进程真的挂死(`Start-Process -Wait` 无超时),该 key 会一直 `running`,之后所有同目标调用都按 60s 超时失败,`clear_game_creator_acl_elevation_denials` 也不清理 running,只能重启客户端恢复。需要更激进策略时再单独讨论(记 `started_at` + 硬上限接管)。
|
||||
- **关联**:`src-tauri/src/acl_repair_gate.rs`、`src-tauri/src/config.rs`、issue #498。
|
||||
|
||||
## 2026-09-24 对话过程卡的读秒退回 1 秒一跳:刷新粒度必须与显示精度同格
|
||||
|
||||
Reference in New Issue
Block a user