From 82d2261a605dc956b0274cefb9b089653256f2fd Mon Sep 17 00:00:00 2001 From: Travis Hoover Date: Wed, 8 Apr 2026 14:28:04 -0700 Subject: [PATCH 1/2] Fix Windows parent directory ownership using PowerShell Set-Acl Replace icacls /setowner with PowerShell Set-Acl for setting Windows file and directory ownership. icacls was silently failing on directories because it doesn't explicitly enable SeRestorePrivilege in the process token. PowerShell's Set-Acl uses .NET's SetAccessControl which properly enables the privilege, making it reliable for both files and directories. All paths are batched into a single PowerShell call per user to avoid the performance penalty of spawning multiple processes. Also adds parent directory ownership verification to the E2E config test. --- ci/e2e-config-test.sh | 68 +++++++++++++++++++++++++++++++++++++++++++ src/hosts/index.ts | 45 +++++++++++++++++----------- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/ci/e2e-config-test.sh b/ci/e2e-config-test.sh index 82b6dd5..e8dd1ed 100755 --- a/ci/e2e-config-test.sh +++ b/ci/e2e-config-test.sh @@ -257,6 +257,74 @@ else exit 1 fi +echo "" +echo "=== Verify parent directory ownership ===" +DIR_OWNERSHIP_OK=true +DIR_CHECKED=0 + +# Collect unique parent directories (up to but not including HOME) +PARENT_DIRS_FILE="$(mktemp)" +for f in "${CREATED_FILES[@]}"; do + dir="$(dirname "$f")" + while [ "$dir" != "$HOME" ] && [ "$dir" != "/" ] && [ ${#dir} -gt ${#HOME} ]; do + echo "$dir" + dir="$(dirname "$dir")" + done +done | sort -u > "$PARENT_DIRS_FILE" + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + echo "Expected owner: $EXPECTED_OWNER" + while IFS= read -r d; do + WIN_D=$(cygpath -w "$d") + ACTUAL_OWNER=$(powershell.exe -NoProfile -NonInteractive -Command \ + "(Get-Acl -LiteralPath '${WIN_D}').Owner" | tr -d '\r') + DIR_CHECKED=$((DIR_CHECKED + 1)) + if [ "$ACTUAL_OWNER" = "$EXPECTED_OWNER" ]; then + echo " OK: $d" + else + echo " FAIL: $d (expected: $EXPECTED_OWNER, actual: $ACTUAL_OWNER)" + DIR_OWNERSHIP_OK=false + fi + done < "$PARENT_DIRS_FILE" + ;; + Darwin) + echo "Expected owner: $EXPECTED_OWNER" + while IFS= read -r d; do + ACTUAL_OWNER=$(stat -f '%Su' "$d") + DIR_CHECKED=$((DIR_CHECKED + 1)) + if [ "$ACTUAL_OWNER" = "$EXPECTED_OWNER" ]; then + echo " OK: $d" + else + echo " FAIL: $d (expected: $EXPECTED_OWNER, actual: $ACTUAL_OWNER)" + DIR_OWNERSHIP_OK=false + fi + done < "$PARENT_DIRS_FILE" + ;; + Linux) + echo "Expected owner: $EXPECTED_OWNER" + while IFS= read -r d; do + ACTUAL_OWNER=$(stat -c '%U' "$d") + DIR_CHECKED=$((DIR_CHECKED + 1)) + if [ "$ACTUAL_OWNER" = "$EXPECTED_OWNER" ]; then + echo " OK: $d" + else + echo " FAIL: $d (expected: $EXPECTED_OWNER, actual: $ACTUAL_OWNER)" + DIR_OWNERSHIP_OK=false + fi + done < "$PARENT_DIRS_FILE" + ;; +esac + +rm -f "$PARENT_DIRS_FILE" + +if [ "$DIR_OWNERSHIP_OK" = true ]; then + echo "PASS [dir-ownership]: All $DIR_CHECKED parent dir(s) owned by $EXPECTED_OWNER" +else + echo "FAIL [dir-ownership]: Some parent directories have incorrect ownership" + exit 1 +fi + echo "" echo "=== Compute Run 1 checksums ===" # Deduplicate paths (e.g. cursor and cursor-agent share the same file) diff --git a/src/hosts/index.ts b/src/hosts/index.ts index 9bdd8f9..a30f0c7 100644 --- a/src/hosts/index.ts +++ b/src/hosts/index.ts @@ -64,23 +64,23 @@ function resolveProfileOwner(homeDir: string): string | null { } } -function setOwnerWindows(filePath: string, owner: string): void { +function setOwnerWindowsBatch(paths: string[], owner: string): void { + if (paths.length === 0) return + const escapedOwner = owner.replace(/'/g, "''") + const pathsList = paths.map((p) => `'${p.replace(/'/g, "''")}'`).join(',') try { - execFileSync('icacls', [filePath, '/setowner', owner], { - stdio: 'pipe', - timeout: 30_000, - }) + execFileSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-Command', + `[Console]::OutputEncoding = [Text.Encoding]::UTF8; $o = [System.Security.Principal.NTAccount]'${escapedOwner}'; @(${pathsList}) | ForEach-Object { $p = $_; try { $a = Get-Acl -LiteralPath $p; $a.SetOwner($o); Set-Acl -LiteralPath $p -AclObject $a } catch { Write-Warning "Failed to set owner on ${p}: $_" } }`, + ], + { encoding: 'utf-8', stdio: 'pipe', timeout: 60_000 }, + ) } catch (err) { - log.warn(`Failed to set owner of ${filePath} to ${owner}: ${err}`) - } -} - -function setOwnerWindowsAncestors(filePath: string, stopAt: string, owner: string): void { - const stopDir = resolve(stopAt) - let dir = dirname(resolve(filePath)) - while (dir.length > stopDir.length && dir.startsWith(stopDir)) { - setOwnerWindows(dir, owner) - dir = dirname(dir) + log.warn(`Failed to batch-set ownership to ${owner}: ${err}`) } } @@ -112,6 +112,7 @@ export function configureHosts(options: ConfigureHostsOptions): ConfigureResult[ // creates with the correct principal. const windowsOwner = currentPlatform === 'win32' ? resolveProfileOwner(userHomeDir) ?? username : undefined + const windowsOwnerPaths: string[] = [] for (const client of clients) { const configPath = client.configPath[currentPlatform] @@ -160,9 +161,15 @@ export function configureHosts(options: ConfigureHostsOptions): ConfigureResult[ if (currentPlatform === 'win32' && windowsOwner) { if (!lstatSync(resolvedPath).isSymbolicLink()) { - setOwnerWindows(resolvedPath, windowsOwner) + windowsOwnerPaths.push(resolvedPath) + } + // Collect ancestor directories up to (but not including) the home dir + const stopDir = resolve(userHomeDir) + let dir = dirname(resolve(resolvedPath)) + while (dir.length > stopDir.length && dir.startsWith(stopDir)) { + windowsOwnerPaths.push(dir) + dir = dirname(dir) } - setOwnerWindowsAncestors(resolvedPath, userHomeDir, windowsOwner) } else if (uid !== undefined && gid !== undefined) { if (!lstatSync(resolvedPath).isSymbolicLink()) { chownSync(resolvedPath, uid, gid) @@ -178,5 +185,9 @@ export function configureHosts(options: ConfigureHostsOptions): ConfigureResult[ } } + if (windowsOwner && windowsOwnerPaths.length > 0) { + setOwnerWindowsBatch([...new Set(windowsOwnerPaths)], windowsOwner) + } + return results } From 75a30d0775d66a915b743312e677fa1e8f1eb1f0 Mon Sep 17 00:00:00 2001 From: Travis Hoover Date: Wed, 8 Apr 2026 14:32:20 -0700 Subject: [PATCH 2/2] Fix PowerShell variable interpolation in ownership batch command ${p} was being interpreted as a JavaScript template literal variable instead of a PowerShell variable reference, causing the entire batch ownership command to fail with "ReferenceError: p is not defined". --- src/hosts/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hosts/index.ts b/src/hosts/index.ts index a30f0c7..cdb8b6f 100644 --- a/src/hosts/index.ts +++ b/src/hosts/index.ts @@ -75,7 +75,7 @@ function setOwnerWindowsBatch(paths: string[], owner: string): void { '-NoProfile', '-NonInteractive', '-Command', - `[Console]::OutputEncoding = [Text.Encoding]::UTF8; $o = [System.Security.Principal.NTAccount]'${escapedOwner}'; @(${pathsList}) | ForEach-Object { $p = $_; try { $a = Get-Acl -LiteralPath $p; $a.SetOwner($o); Set-Acl -LiteralPath $p -AclObject $a } catch { Write-Warning "Failed to set owner on ${p}: $_" } }`, + `[Console]::OutputEncoding = [Text.Encoding]::UTF8; $o = [System.Security.Principal.NTAccount]'${escapedOwner}'; @(${pathsList}) | ForEach-Object { $p = $_; try { $a = Get-Acl -LiteralPath $p; $a.SetOwner($o); Set-Acl -LiteralPath $p -AclObject $a } catch { Write-Warning "Failed to set owner on $p : $_" } }`, ], { encoding: 'utf-8', stdio: 'pipe', timeout: 60_000 }, )