diff --git a/CHANGELOG.md b/CHANGELOG.md index db6a868..2c21734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - 统一菜单自定义图片的公开契约:新上传会落盘为正式用户主题,只有旧版 `custom-upload` 保留为本地兼容槽。 - 恢复 `llms-full.txt` 同步、确定性 `.skill` 制品与发布哈希一致性。 - 以 `package.json` 作为版本真相源,README 改为不绑定 patch 版本的最新 Release 链接。 +- 修复 Windows 缺少 `Set-Acl` 所需权限时,ACL 回退把裸 SID 传给 `icacls /setowner` 并触发 1332,最终误报 `LOCK_PERMISSIONS`、导致皮肤启动失败。 +- 修复 Windows Store 版把首次注入完全交给 detached 临时控制器时的启动竞态:前台在已验证 CDP 后同步完成首次注入,再启动临时控制器保活,避免连续 `160/160` 后误报“未确认皮肤已应用”。 +- 修复 Windows 前后台控制器每次抢锁都重复重写已合规状态目录 ACL,导致 `controller:start` 长时间持锁、后台 ACK 超时并补偿关闭:现对已有私有目录先做只读精确校验,仅在不合规时执行 ACL 迁移。 ### 测试 diff --git a/src/cli.mjs b/src/cli.mjs index db7fe4a..ee8e30e 100755 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1922,22 +1922,50 @@ export async function waitForAppliedSkin({ throw new Error("ephemeral controller 未确认皮肤已应用"); } -async function productionRegisterEphemeral({ deps, paths, port, preflight, themeId }) { - await ensureProductionState({ +export async function productionRegisterEphemeral({ + deps, + paths, + port, + preflight, + themeId, + loadedTheme, + themes, + preferStored, +}, { + ensureState = ensureProductionState, + spawnController = spawn, + controllerEntry = fileURLToPath(import.meta.url), + confirmApplied = waitForAppliedSkin, +} = {}) { + await ensureState({ paths, themeId, process: preflight.process, keepUntilProcessExit: true, }); - const child = spawn(process.execPath, [ - fileURLToPath(import.meta.url), + + // 首次注入必须由仍附着在交互会话中的前台进程同步完成。Store 版 Codex + // 在关闭/激活之间会重建进程树;若把首次注入完全交给 detached 子进程, + // 子进程可能尚未启动就随旧进程退出,前台只能无信息地轮询到超时。 + await deps.applySkin({ + loadedTheme, + themes, + activeId: themeId, + port, + currentVersion: await deps.readCurrentPackageVersion(), + preferStored, + control: null, + }); + + const child = spawnController(process.execPath, [ + controllerEntry, "controller", "--ephemeral", "--port", String(port), ], { detached: true, stdio: "ignore" }); child.unref(); - await waitForAppliedSkin({ deps, port, themeId }); + await confirmApplied({ deps, port, themeId }); return { mode: "active" }; } diff --git a/src/operation-lock.mjs b/src/operation-lock.mjs index 560b5e5..f1d2a30 100644 --- a/src/operation-lock.mjs +++ b/src/operation-lock.mjs @@ -2475,11 +2475,23 @@ async function prepareWindowsStateRoot(stateRoot, security) { throw lockError("LOCK_PATH_INVALID", `Windows state root must be a real directory: ${stateRoot}`); } try { + if (created) { + await windowsSecurityBatch(security, [ + { action: "protect-directory", path: stateRoot }, + { action: "verify-directory", path: stateRoot }, + ]); + return; + } + try { + // 正常运行时状态根已经是精确私有 ACL。先只读验证,避免每次抢锁都 + // Set-Acl/icacls 重写同一目录,造成前后台控制器互相阻塞。 + await security.verifyDirectory(stateRoot); + return; + } catch { + // 旧版本或手工改动留下的非精确 ACL 仍走带所有者/写权限前置检查的迁移。 + } await windowsSecurityBatch(security, [ - { - action: created ? "protect-directory" : "migrate-directory", - path: stateRoot, - }, + { action: "migrate-directory", path: stateRoot }, { action: "verify-directory", path: stateRoot }, ]); } catch (cause) { diff --git a/src/windows-secure-fs.mjs b/src/windows-secure-fs.mjs index 896db0f..6be00f2 100644 --- a/src/windows-secure-fs.mjs +++ b/src/windows-secure-fs.mjs @@ -111,7 +111,9 @@ foreach ($entry in $operations) { Microsoft.PowerShell.Security\Set-Acl -LiteralPath $TargetPath -AclObject $acl -ErrorAction Stop } catch { # 部分账户/会话没有 SeSecurityPrivilege;与 Windows 安装脚本一致,回退 icacls。 - # fallback 必须与 Set-Acl 路径保持同一精确契约:setowner + 重置 grant + 随后 exact verify。 + # migrate 已在上方证明 owner 是当前用户;protect 的目标则由当前进程刚创建。 + # 不要把裸 SID 交给 icacls /setowner:它会按账户名解析并以 1332 失败。 + # 与安装脚本保持同一契约:移除继承、重置当前 SID grant,再做 exact verify。 $detail = [string]$_.Exception.Message $icacls = Join-Path $env:SystemRoot 'System32\icacls.exe' if (-not (Test-Path -LiteralPath $icacls -PathType Leaf)) { @@ -119,7 +121,7 @@ foreach ($entry in $operations) { } $sidText = [string]$currentSid.Value $grant = if ($isDirectory) { '*{0}:(OI)(CI)F' -f $sidText } else { '*{0}:F' -f $sidText } - $output = & $icacls $TargetPath /inheritance:r /setowner $sidText /grant:r $grant 2>&1 + $output = & $icacls $TargetPath /inheritance:r /grant:r $grant 2>&1 if ($LASTEXITCODE -ne 0) { throw ("Set-Acl failed and icacls fallback failed: {0}; icacls: {1}" -f $detail, (($output | ForEach-Object { [string]$_ }) -join ' ')) } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 682c96a..4e0f205 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -21,6 +21,7 @@ import { probeWindowsNativeProcessFromSnapshot, productionLockOptions, productionPreflight, + productionRegisterEphemeral, runCli, runControllerProcess, spawnWindowsRestartIntoCdp, @@ -694,6 +695,114 @@ test("waitForAppliedSkin emits progress while confirmation is pending", async () assert.ok(messages.some((message) => /无需点击/.test(message))); }); +test("production ephemeral registration injects in the foreground before spawning its keeper", async () => { + const events = []; + const loadedTheme = { manifest: { id: "miku-488137" } }; + const themes = [loadedTheme]; + const preflight = { + process: { + pid: 4242, + executablePath: "C:\\Program Files\\Codex\\Codex.exe", + startedAt: "2026-07-31T01:00:00.0000000Z", + }, + }; + const child = { + unref() { events.push(["unref"]); }, + }; + + const result = await productionRegisterEphemeral({ + deps: { + readCurrentPackageVersion: async () => "5.4.11", + applySkin: async (input) => { + events.push(["apply", structuredClone(input)]); + return { applied: 1 }; + }, + skinStatus: async () => { + throw new Error("confirmation is injected for this unit test"); + }, + }, + paths: { stateRoot: "C:\\state" }, + port: 9341, + preflight, + themeId: "miku-488137", + loadedTheme, + themes, + preferStored: true, + }, { + ensureState: async (input) => { + events.push(["state", structuredClone(input)]); + }, + spawnController: (file, args, options) => { + events.push(["spawn", file, [...args], { ...options }]); + return child; + }, + controllerEntry: "C:\\repo\\src\\cli.mjs", + confirmApplied: async (input) => { + events.push(["confirm", input.port, input.themeId]); + }, + }); + + assert.deepEqual(result, { mode: "active" }); + assert.deepEqual(events.map(([event]) => event), [ + "state", + "apply", + "spawn", + "unref", + "confirm", + ]); + assert.deepEqual(events[0][1], { + paths: { stateRoot: "C:\\state" }, + themeId: "miku-488137", + process: preflight.process, + keepUntilProcessExit: true, + }); + assert.deepEqual(events[1][1], { + loadedTheme, + themes, + activeId: "miku-488137", + port: 9341, + currentVersion: "5.4.11", + preferStored: true, + control: null, + }); + assert.deepEqual(events[2].slice(2), [[ + "C:\\repo\\src\\cli.mjs", + "controller", + "--ephemeral", + "--port", + "9341", + ], { + detached: true, + stdio: "ignore", + }]); +}); + +test("production ephemeral registration does not spawn a keeper when foreground injection fails", async () => { + let spawned = false; + await assert.rejects(productionRegisterEphemeral({ + deps: { + readCurrentPackageVersion: async () => "5.4.11", + applySkin: async () => { + throw new Error("foreground injection failed"); + }, + }, + paths: { stateRoot: "C:\\state" }, + port: 9341, + preflight: { process: { pid: 4242 } }, + themeId: "miku-488137", + loadedTheme: { manifest: { id: "miku-488137" } }, + themes: [], + preferStored: false, + }, { + ensureState: async () => {}, + spawnController: () => { + spawned = true; + return { unref() {} }; + }, + }), /foreground injection failed/); + assert.equal(spawned, false); +}); + test("apply on a native Codex queues one detached CDP restart and applies only after restart", async () => { const fx = lifecycleDeps({ preflightLifecycle: async (input) => { diff --git a/test/operation-lock.test.mjs b/test/operation-lock.test.mjs index 293ec61..ed05c18 100644 --- a/test/operation-lock.test.mjs +++ b/test/operation-lock.test.mjs @@ -667,20 +667,55 @@ test("Windows operation lease safely migrates a current-user-owned legacy state const lockPath = join(stateRoot, "operation.lock"); await mkdir(stateRoot); const securityEvents = []; + const windowsSecurity = fakeWindowsSecurity(securityEvents); + const verifyDirectory = windowsSecurity.verifyDirectory; + let firstVerification = true; + windowsSecurity.verifyDirectory = async (path) => { + if (path === stateRoot && firstVerification) { + firstVerification = false; + securityEvents.push(["verify-directory", path]); + throw new Error("legacy ACL is not exact"); + } + return verifyDirectory(path); + }; const lease = await acquireOperationLock(acquisitionOptions(lockPath, { platform: "win32", stateRoot, - windowsSecurity: fakeWindowsSecurity(securityEvents), + windowsSecurity, })); t.after(() => lease.release()); - assert.deepEqual(securityEvents.slice(0, 2), [ + assert.deepEqual(securityEvents.slice(0, 3), [ + ["verify-directory", stateRoot], ["migrate-directory", stateRoot], ["verify-directory", stateRoot], ]); assert.equal(await lease.assertOwned(), true); }); +test("Windows operation lease does not rewrite an already private state root ACL", async (t) => { + const { root } = await fixture(t); + const stateRoot = join(root, "private-windows-state"); + const lockPath = join(stateRoot, "operation.lock"); + await mkdir(stateRoot); + const securityEvents = []; + const lease = await acquireOperationLock(acquisitionOptions(lockPath, { + platform: "win32", + stateRoot, + windowsSecurity: fakeWindowsSecurity(securityEvents), + })); + t.after(() => lease.release()); + + assert.deepEqual(securityEvents.slice(0, 1), [ + ["verify-directory", stateRoot], + ]); + assert.equal( + securityEvents.some(([action, path]) => action === "migrate-directory" && path === stateRoot), + false, + ); + assert.equal(await lease.assertOwned(), true); +}); + test("Windows operation lease has one concurrent winner and recovers a proven-dead owner", async (t) => { const { root } = await fixture(t); const stateRoot = join(root, "windows-state"); diff --git a/test/windows-runtime.test.mjs b/test/windows-runtime.test.mjs index 761b7d9..c8be986 100644 --- a/test/windows-runtime.test.mjs +++ b/test/windows-runtime.test.mjs @@ -279,6 +279,12 @@ test("Windows ACL adapter isolates Windows PowerShell modules from the parent ru ); assert.match(windowsAclPowerShellScript, /Microsoft\.PowerShell\.Security\\Get-Acl/); assert.match(windowsAclPowerShellScript, /Microsoft\.PowerShell\.Security\\Set-Acl/); + assert.match(windowsAclPowerShellScript, /'\*\{0\}:\(OI\)\(CI\)F'/); + assert.match( + windowsAclPowerShellScript, + /\$output = & \$icacls \$TargetPath \/inheritance:r \/grant:r \$grant/, + ); + assert.doesNotMatch(windowsAclPowerShellScript, /\/setowner \$sidText/); }); test("Windows ACL adapter preserves the canonical request path instead of rewriting 8.3 aliases", async () => {