Skip to content

Windows: setupUserSeparation() symlinkSync fails with EPERM; the copy fallback promised in INSTALL.md never runs (directory junctions work unelevated) #1730

Description

@umair-a11y

Summary

setupUserSeparation() in Tools/InstallEngine.ts creates the system/user separation link with plain symlinkSync(dataUserDir, liveUserDir) (lines 604 and 614, no type argument). On Windows, creating a real symlink requires elevation or Developer Mode, so on a stock machine symlinkSync throws EPERM; the engine reports symlink creation failed: EPERM ..., the separation link is never established, and checkSymlinkContract then fails with ... is not a symlink (system/user separation broken).

INSTALL.md line 163 promises otherwise:

| **Claude Code — Windows** | ✅ (copy fallback where symlinks need admin) | ✅ full |

No copy fallback exists in the v7.28.3 code path. Code and docs currently disagree about what happens on Windows.

Environment

LifeOS v7.28.3 (release tarball)
OS Windows 11 Home 10.0.26200
Harness Claude Code v2.1.220
bun 1.3.14
git 2.51.0.windows.1

Repro

  1. Windows 11, Developer Mode off, non-elevated PowerShell.
  2. Run the install through the user-separation step (with HOME set manually to get past the home-resolution bug, Windows: installer tools resolve home as process.env.HOME || "", so ScaffoldUser silently writes its USER tree into the release directory #1729).
  3. symlinkSync fails with EPERM: operation not permitted, symlink .... No fallback runs, and the symlink contract check fails.

Suggested fix: directory junctions

symlinkSync(dataUserDir, liveUserDir, "junction");

Why this works:

  • Directory junctions need no elevation and no Developer Mode.
  • Node and Bun ignore the type argument on non-Windows platforms, so no platform branch is needed.
  • dataUserDir is already absolute, which junctions require.
  • The existing contract survives unchanged: lstatSync().isSymbolicLink() returns true for junctions and readlinkSync() returns the target, which is all checkSymlinkContract() (Tools/InstallEngine.ts:625-639) relies on.

Verified on this install: after creating the junction manually,

New-Item -ItemType Junction -Path "$env:USERPROFILE\.claude\LIFEOS\USER" -Target "$env:USERPROFILE\.config\LIFEOS\USER"

checkSymlinkContract passed as written, and the installed system has been running against the junction since.

If the copy fallback described in INSTALL.md is still wanted as a last resort, junction-first is the better order: a copy forks USER state between the two locations, while a junction keeps the live-link semantics the separation design assumes. Either way, INSTALL.md and the code should agree.

Related Windows detection nits (can split into a separate issue if preferred)

  • detectTool() (Tools/InstallEngine.ts:108) and detectHarness() (Tools/InstallEngine.ts:132) probe with POSIX command -v, so on Windows the environment report claims bun, git, and sh are missing from PATH even when they are present and working.
  • The hook-interpreter probes check the POSIX execute bit (LIFEOS/TOOLS/Doctor.ts:582, LIFEOS/TOOLS/IntegrityCheck.ts:550), reporting registered hooks as not executable (chmod +x) on NTFS even though the hooks demonstrably execute via Git Bash.

Both are cosmetic, but they present as failures mid-install and make real errors harder to spot.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions