diff --git a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 index cba0be69..c2d501e4 100644 --- a/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 +++ b/Tools/windows/Tests/DaemonHandoff.Live.Tests.ps1 @@ -56,8 +56,7 @@ function Start-HandoffShell( $startInfo.Environment["GRAPHCODE_SHELL_REQUIRE_DAEMON"] = "0" $startInfo.Environment["GRAPHCODE_DAEMON_HANDOFF_TEST_STATE"] = $daemonStatePath $startInfo.Environment["GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK"] = "1" - $startInfo.Environment["USERNAME"] = $userName - $startInfo.Environment["USER"] = $userName + $startInfo.Environment["GRAPHCODE_DAEMON_HANDOFF_TEST_USER"] = $userName $process = [Diagnostics.Process]::new() $process.StartInfo = $startInfo if (-not $process.Start()) { diff --git a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 new file mode 100644 index 00000000..d50820b8 --- /dev/null +++ b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 @@ -0,0 +1,241 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $Shell, + [Parameter(Mandatory)][string] $Daemon, + [Parameter(Mandatory)][string] $Cli, + [Parameter(Mandatory)][string] $ScratchRoot +) + +$ErrorActionPreference = "Stop" +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class ScrubbedStartupNative { + public delegate bool EnumWindowsProc(IntPtr window, IntPtr parameter); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc callback, IntPtr parameter); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr window, System.Text.StringBuilder value, int capacity); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr window); + [DllImport("user32.dll")] static extern bool PostMessage(IntPtr window, uint message, UIntPtr wparam, IntPtr lparam); + public static IntPtr Find(uint processId, string className) { + IntPtr result = IntPtr.Zero; + EnumWindows(delegate(IntPtr window, IntPtr parameter) { + uint owner; + GetWindowThreadProcessId(window, out owner); + if (owner != processId || !IsWindowVisible(window)) return true; + var actual = new System.Text.StringBuilder(128); + GetClassName(window, actual, actual.Capacity); + if (!String.Equals(actual.ToString(), className, StringComparison.Ordinal)) return true; + result = window; + return false; + }, IntPtr.Zero); + return result; + } + public static bool Escape(IntPtr window) { + return PostMessage(window, 0x0100, (UIntPtr)0x1B, IntPtr.Zero) && + PostMessage(window, 0x0101, (UIntPtr)0x1B, IntPtr.Zero); + } + public static bool Command(IntPtr window, uint action) { + return PostMessage(window, 0x0111, (UIntPtr)action, IntPtr.Zero); + } +} +'@ + +$allowedKeys = @( + "SystemRoot", "windir", "USERPROFILE", "LOCALAPPDATA", "APPDATA", + "TEMP", "TMP", "ProgramData", "HOMEDRIVE", "HOMEPATH", "PATH" +) +$forbiddenPathFragments = @( + ".graphcode-tools", "Visual Studio", "Windows Kits", "Swift" +) + +function New-StartInfo([string] $file, [string] $root) { + $profile = Join-Path $root "profile" + $system32 = Join-Path $env:SystemRoot "System32" + $powerShell = Join-Path $system32 "WindowsPowerShell\v1.0" + $info = [Diagnostics.ProcessStartInfo]::new() + $info.FileName = $file + $info.WorkingDirectory = Split-Path -Parent $file + $info.UseShellExecute = $false + $info.Environment.Clear() + $values = [ordered]@{ + SystemRoot = $env:SystemRoot + windir = $env:WINDIR + USERPROFILE = $profile + LOCALAPPDATA = Join-Path $profile "AppData\Local" + APPDATA = Join-Path $profile "AppData\Roaming" + TEMP = Join-Path $root "temp" + TMP = Join-Path $root "temp" + ProgramData = $env:ProgramData + HOMEDRIVE = [IO.Path]::GetPathRoot($profile).TrimEnd("\") + HOMEPATH = $profile.Substring([IO.Path]::GetPathRoot($profile).Length - 1) + PATH = "$system32;$powerShell;$(Split-Path -Parent $file)" + } + foreach ($name in $values.Keys) { $info.Environment[$name] = [string]$values[$name] } + return $info +} + +function Wait-NativeWindow([int] $processId, [string] $className, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + $window = [ScrubbedStartupNative]::Find([uint32]$processId, $className) + if ($window -ne [IntPtr]::Zero) { return $window } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return [IntPtr]::Zero +} + +function Wait-NativeWindowClosed([int] $processId, [string] $className, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + if ([ScrubbedStartupNative]::Find([uint32]$processId, $className) -eq [IntPtr]::Zero) { + return $true + } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return $false +} + +function Wait-File([string] $path, [int] $seconds) { + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + if (Test-Path -LiteralPath $path -PathType Leaf) { return $true } + Start-Sleep -Milliseconds 50 + } while ([DateTime]::UtcNow -lt $deadline) + return $false +} + +function Invoke-Case( + [string] $name, + [ValidateSet("escape", "skip", "complete", "marker")] + [string] $action +) { + $root = Join-Path $ScratchRoot $name + $profile = Join-Path $root "profile" + $support = Join-Path $profile ".graphcode" + New-Item -ItemType Directory -Force -Path ` + $support,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),(Join-Path $root "temp") | + Out-Null + if ($action -eq "marker") { + Set-Content -LiteralPath (Join-Path $support "onboarding-seen") -Value "seen" -NoNewline + } + + $daemonProcess = [Diagnostics.Process]::new() + $daemonInfo = New-StartInfo $Daemon $root + $daemonInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support + $daemonProcess.StartInfo = $daemonInfo + $shellProcess = [Diagnostics.Process]::new() + $shellProcess.StartInfo = New-StartInfo $Shell $root + try { + if (-not $daemonProcess.Start() -or -not $shellProcess.Start()) { + throw "$name could not start production processes" + } + [void]$daemonProcess.Handle + [void]$shellProcess.Handle + $keys = @($shellProcess.StartInfo.Environment.Keys | Sort-Object) + if (($keys -join "|") -cne (($allowedKeys | Sort-Object) -join "|")) { + throw "$name environment keys differ: $($keys -join ',')" + } + foreach ($fragment in $forbiddenPathFragments) { + if ($shellProcess.StartInfo.Environment["PATH"].IndexOf( + $fragment, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + throw "$name PATH contains forbidden developer fragment '$fragment'" + } + } + + $onboarding = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsOnboarding" 10 + if ($action -ne "marker") { + if ($onboarding -eq [IntPtr]::Zero) { throw "$name onboarding was not shown" } + switch ($action) { + "escape" { + if (-not [ScrubbedStartupNative]::Escape($onboarding)) { + throw "$name onboarding rejected Escape" + } + } + "skip" { + if (-not [ScrubbedStartupNative]::Command($onboarding, 2)) { + throw "$name onboarding rejected Skip" + } + } + "complete" { + foreach ($page in 1..4) { + if (-not [ScrubbedStartupNative]::Command($onboarding, 4)) { + throw "$name onboarding rejected primary action on page $page" + } + Start-Sleep -Milliseconds 50 + } + } + } + if (-not (Wait-NativeWindowClosed $shellProcess.Id "GraphCodeWindowsOnboarding" 10)) { + throw "$name onboarding did not close" + } + } elseif ($onboarding -ne [IntPtr]::Zero) { + throw "$name onboarding was shown despite the marker" + } + + $main = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsShell" 10 + $shellProcess.Refresh() + $daemonProcess.Refresh() + if ($main -eq [IntPtr]::Zero -or $shellProcess.HasExited -or $daemonProcess.HasExited) { + throw "$name did not retain live shell/daemon after onboarding" + } + $markerPath = Join-Path $support "onboarding-seen" + if (-not (Wait-File $markerPath 10)) { + throw "$name did not persist the onboarding marker" + } + $logPath = Join-Path $support "graphcode-windows.log" + $log = if (Test-Path $logPath) { Get-Content $logPath -Raw } else { "" } + if ($log -match 'event=fatal') { throw "$name logged a fatal startup event: $log" } + + $cliInfo = New-StartInfo $Cli $root + $cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support + [void]$cliInfo.ArgumentList.Add("projects") + $cliInfo.RedirectStandardOutput = $true + $cliInfo.RedirectStandardError = $true + $cliProcess = [Diagnostics.Process]::new() + $cliProcess.StartInfo = $cliInfo + try { + if (-not $cliProcess.Start() -or -not $cliProcess.WaitForExit(10000)) { + throw "$name CLI endpoint check timed out" + } + $stdout = $cliProcess.StandardOutput.ReadToEnd() + $stderr = $cliProcess.StandardError.ReadToEnd() + if ($cliProcess.ExitCode -ne 0) { + throw "$name CLI endpoint rejected the request: $stderr" + } + } finally { + $cliProcess.Dispose() + } + return [ordered]@{ + name = $name + onboardingObserved = $onboarding -ne [IntPtr]::Zero + action = $action + mainWindow = $main.ToInt64() + shellProcessId = $shellProcess.Id + daemonProcessId = $daemonProcess.Id + cliOutput = $stdout.Trim() + environmentKeys = $keys + fatalLogAbsent = $true + } + } finally { + foreach ($process in @($shellProcess,$daemonProcess)) { + if ($process -and -not $process.HasExited) { + Stop-Process -Id $process.Id -Force + [void]$process.WaitForExit(10000) + } + if ($process) { $process.Dispose() } + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } +} + +New-Item -ItemType Directory -Force -Path $ScratchRoot | Out-Null +$results = @( + Invoke-Case "first-run-escape" "escape" + Invoke-Case "first-run-skip" "skip" + Invoke-Case "first-run-complete" "complete" + Invoke-Case "onboarding-marker" "marker" +) +if ($results.Count -ne 4) { throw "Scrubbed startup executed $($results.Count)/4 cases" } +Write-Output ("SCRUBBED_SHELL_STARTUP: PASS; executed=$($results.Count); " + + "developerToolsExcluded=true; results=" + ($results | ConvertTo-Json -Compress -Depth 5)) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 27fd0479..e224e3f3 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -214,6 +214,41 @@ $traySource = Get-Content (Join-Path $shellRoot "src\Tray.zig") -Raw $win32Source = Get-Content (Join-Path $shellRoot "src\Win32.zig") -Raw $inputSource = Get-Content (Join-Path $shellRoot "src\InputRouter.zig") -Raw $stubSource = Get-Content (Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1") -Raw +$validationRunnerSource = Get-Content (Join-Path $repoRoot "Tools\windows\validate.ps1") -Raw +$scrubbedStartupSource = Get-Content ` + (Join-Path $repoRoot "Tools\windows\Tests\ScrubbedShellStartup.Live.Tests.ps1") -Raw +$allowedKeysBlock = [regex]::Match( + $scrubbedStartupSource, + '(?s)\$allowedKeys\s*=\s*@\((.*?)\)' +) +Assert-Contract ($validationRunnerSource -match + '(?s)Pinned GraphCode Windows shell build and smoke.*?Scrubbed production shell startup.*?ScrubbedShellStartup\.Live\.Tests\.ps1.*?Native UI Automation live gate') ` + "Windows shell validation must run the scrubbed production startup gate before UIA" +Assert-Contract ($validationRunnerSource -match + '(?s)Scrubbed production shell startup.*?& pwsh -NoProfile -File.*?ScrubbedShellStartup\.Live\.Tests\.ps1') ` + "scrubbed production startup must run in an isolated PowerShell process before UIA" +Assert-Contract $allowedKeysBlock.Success ` + "scrubbed production startup gate must declare an explicit environment allowlist" +$actualAllowedKeys = @( + [regex]::Matches($allowedKeysBlock.Groups[1].Value, '"([^"]+)"') | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object +) +$expectedAllowedKeys = @( + "APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", "ProgramData", + "SystemRoot", "TEMP", "TMP", "USERPROFILE", "windir" +) | Sort-Object +Assert-Contract (($actualAllowedKeys -join "|") -ceq ($expectedAllowedKeys -join "|")) ` + "scrubbed production startup gate must preserve the proven explicit environment allowlist" +Assert-Contract ($actualAllowedKeys -notcontains "USERNAME" -and + $actualAllowedKeys -notcontains "USER" -and + $scrubbedStartupSource -match 'developerToolsExcluded=true' -and + $scrubbedStartupSource -match 'first-run-escape' -and + $scrubbedStartupSource -match 'first-run-skip' -and + $scrubbedStartupSource -match 'first-run-complete' -and + $scrubbedStartupSource -match 'onboarding-marker' -and + $scrubbedStartupSource -match 'event=fatal') ` + "scrubbed production startup gate must preserve the developer-free onboarding contract" $menuTimerBlock = [regex]::Match( $appSource, '(?s)else if \(wparam == MainWindow\.timer_id\) \{.*?const updated_connection_state' diff --git a/Tools/windows/validate.ps1 b/Tools/windows/validate.ps1 index 8e14bba0..3a250488 100644 --- a/Tools/windows/validate.ps1 +++ b/Tools/windows/validate.ps1 @@ -321,7 +321,28 @@ function Assert-WindowsShellProfileUnchanged( ) { $after = Get-WindowsShellProfileSnapshot $path if (-not [string]::Equals($before, $after, [StringComparison]::Ordinal)) { - throw "Windows shell default profile artifact changed: $([IO.Path]::GetFullPath($path))" + $beforeState = $before | ConvertFrom-Json + $afterState = $after | ConvertFrom-Json + $beforeEntries = @{} + $afterEntries = @{} + foreach ($entry in @($beforeState.entries)) { $beforeEntries[$entry.relativePath] = $entry } + foreach ($entry in @($afterState.entries)) { $afterEntries[$entry.relativePath] = $entry } + $changes = [Collections.Generic.List[string]]::new() + if ([bool]$beforeState.exists -ne [bool]$afterState.exists) { + $changes.Add("root:$($beforeState.exists)->$($afterState.exists)") + } + foreach ($relativePath in @($beforeEntries.Keys + $afterEntries.Keys | Sort-Object -Unique)) { + if (-not $beforeEntries.ContainsKey($relativePath)) { + $changes.Add("added:$relativePath") + } elseif (-not $afterEntries.ContainsKey($relativePath)) { + $changes.Add("removed:$relativePath") + } elseif (($beforeEntries[$relativePath] | ConvertTo-Json -Compress) -cne + ($afterEntries[$relativePath] | ConvertTo-Json -Compress)) { + $changes.Add("changed:$relativePath") + } + } + $summary = @($changes | Select-Object -First 20) -join "," + throw "Windows shell default profile artifact changed: $([IO.Path]::GetFullPath($path)); changes=$summary" } } @@ -1180,6 +1201,14 @@ function Invoke-Task([string] $name) { -SkipTrayLive:$SkipTrayLive ` -Stress } + Invoke-Native "Scrubbed production shell startup" { + & pwsh -NoProfile -File ` + (Join-Path $repoRoot "Tools\windows\Tests\ScrubbedShellStartup.Live.Tests.ps1") ` + -Shell (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") ` + -Daemon (Join-Path $daemonRuntime "graphcoded.exe") ` + -Cli (Join-Path $daemonRuntime "graphcode.exe") ` + -ScratchRoot (Join-Path $env:TEMP "scrubbed-shell-startup") + } & (Join-Path $repoRoot "Tools\windows\Tests\TrayDaemon.Tests.ps1") ` -Executable (Join-Path $repoRoot "graphcode-windows\zig-out\bin\graphcode-windows.exe") if ($LASTEXITCODE -ne 0) { diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index cb18e96b..7c5489fd 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -62,6 +62,7 @@ const workspace_restart_message = "Workspace identity changed or could not be ve const worktrees_deferred_message = "Worktrees are deferred for this preview"; const tray_test_hook_environment = "GRAPHCODE_TRAY_TEST_HOOK"; const daemon_supervisor_test_hook_environment = "GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK"; +const daemon_handoff_test_user_environment = "GRAPHCODE_DAEMON_HANDOFF_TEST_USER"; const daemon_supervisor_test_property = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode.Windows.DaemonSupervisorState"); @@ -134,11 +135,38 @@ const FolderOpenApi = struct { } }; +const WorkspaceUserApi = struct { + fn read(buffer: [*]u16, size: *c.DWORD) bool { + return c.GetUserNameW(buffer, size) != 0; + } +}; + +fn workspaceUserWith(allocator: std.mem.Allocator, comptime Api: type) ![]u8 { + var wide: [257]u16 = [_]u16{0} ** 257; + var size: c.DWORD = wide.len; + if (!Api.read(&wide, &size) or size == 0 or size > wide.len) + return error.WorkspaceUserUnavailable; + const length = if (wide[size - 1] == 0) size - 1 else size; + if (length == 0) return error.WorkspaceUserUnavailable; + return std.unicode.utf16LeToUtf8Alloc(allocator, wide[0..length]); +} + fn workspaceUser(allocator: std.mem.Allocator) ![]u8 { - return std.process.getEnvVarOwned(allocator, "USERNAME") catch |err| switch (err) { - error.EnvironmentVariableNotFound => std.process.getEnvVarOwned(allocator, "USER"), - else => err, - }; + if (envFlag(daemon_supervisor_test_hook_environment)) { + const test_user = std.process.getEnvVarOwned( + allocator, + daemon_handoff_test_user_environment, + ) catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => return err, + }; + if (test_user) |user| { + if (user.len != 0) return user; + allocator.free(user); + return error.WorkspaceUserUnavailable; + } + } + return workspaceUserWith(allocator, WorkspaceUserApi); } fn workspaceInstanceKey(allocator: std.mem.Allocator, path: []const u8) ![:0]u16 { @@ -8802,6 +8830,33 @@ test "workspace reservations exclude lexical aliases and old raw path mutexes" { defer reacquired.deinit(); } +test "workspace identity uses the Windows account instead of optional environment variables" { + const Api = struct { + fn read(buffer: [*]u16, size: *c.DWORD) bool { + const value = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeUser"); + if (size.* < value.len) return false; + @memcpy(buffer[0..value.len], value); + size.* = value.len; + return true; + } + }; + const user = try workspaceUserWith(std.testing.allocator, Api); + defer std.testing.allocator.free(user); + try std.testing.expectEqualStrings("GraphCodeUser", user); +} + +test "workspace identity reports a bounded Windows account failure" { + const Api = struct { + fn read(_: [*]u16, _: *c.DWORD) bool { + return false; + } + }; + try std.testing.expectError( + error.WorkspaceUserUnavailable, + workspaceUserWith(std.testing.allocator, Api), + ); +} + const WorkspaceMutationFixture = struct { const reserve = WorkspaceReservation.acquire; const rename = WorkspaceMutationApi.rename; diff --git a/graphcode-windows/src/Diagnostics.zig b/graphcode-windows/src/Diagnostics.zig index 5bd5d515..ac314b24 100644 --- a/graphcode-windows/src/Diagnostics.zig +++ b/graphcode-windows/src/Diagnostics.zig @@ -22,6 +22,7 @@ pub fn record(allocator: std.mem.Allocator, event: []const u8, detail: []const u } else { file.seekFromEnd(0) catch return; } + const line = std.fmt.allocPrint( allocator, "{d} event={s} detail={s}\r\n", @@ -31,6 +32,15 @@ pub fn record(allocator: std.mem.Allocator, event: []const u8, detail: []const u file.writeAll(line) catch {}; } +pub fn startupFatalDetail(err: anyerror) []const u8 { + return switch (err) { + error.UserProfileMissing => "operation=resolve_support_directory missing_environment=USERPROFILE", + error.WorkspaceUserUnavailable => "operation=reserve_workspace windows_identity=GetUserNameW", + error.EnvironmentVariableNotFound => "operation=startup_environment missing_environment=unexpected", + else => @errorName(err), + }; +} + fn supportDirectory(allocator: std.mem.Allocator) ![]u8 { if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| { return value; @@ -44,3 +54,18 @@ test "diagnostic log has a bounded filename and size" { try std.testing.expectEqualStrings("graphcode-windows.log", std.fs.path.basename("x\\graphcode-windows.log")); try std.testing.expect(max_bytes >= 1024 * 1024); } + +test "startup fatal diagnostics name the bounded operation without environment values" { + try std.testing.expectEqualStrings( + "operation=resolve_support_directory missing_environment=USERPROFILE", + startupFatalDetail(error.UserProfileMissing), + ); + try std.testing.expectEqualStrings( + "operation=reserve_workspace windows_identity=GetUserNameW", + startupFatalDetail(error.WorkspaceUserUnavailable), + ); + try std.testing.expectEqualStrings( + "operation=startup_environment missing_environment=unexpected", + startupFatalDetail(error.EnvironmentVariableNotFound), + ); +} diff --git a/graphcode-windows/src/main.zig b/graphcode-windows/src/main.zig index c51aacd1..4ee2232f 100644 --- a/graphcode-windows/src/main.zig +++ b/graphcode-windows/src/main.zig @@ -46,7 +46,7 @@ pub export fn WinMain( _: c.INT, ) callconv(.winapi) c.INT { main() catch |err| { - Diagnostics.record(std.heap.c_allocator, "fatal", @errorName(err)); + Diagnostics.record(std.heap.c_allocator, "fatal", Diagnostics.startupFatalDetail(err)); return 1; }; return 0; diff --git a/investigation/windows-preview-devbox-qualification-plan.md b/investigation/windows-preview-devbox-qualification-plan.md index 3e40041a..a4e53ff4 100644 --- a/investigation/windows-preview-devbox-qualification-plan.md +++ b/investigation/windows-preview-devbox-qualification-plan.md @@ -209,11 +209,24 @@ signer state, foreign install root, or unexplained profile mutation. Git and developer tools may remain installed on the Dev Box. The proof is that startup does not rely on them. -1. Build a process-only environment containing: - - - Windows system paths; - - PowerShell/runtime paths needed by the installed package; - - installed GraphCode runtime paths. +1. Build a process-only environment containing exactly these keys: + + - `SystemRoot`; + - `windir`; + - `USERPROFILE`; + - `LOCALAPPDATA`; + - `APPDATA`; + - `TEMP`; + - `TMP`; + - `ProgramData`; + - `HOMEDRIVE`; + - `HOMEPATH`; + - `PATH`. + + `PATH` may contain only Windows system paths, Windows PowerShell, and the + installed GraphCode runtime directory. Do not add `USERNAME` or `USER`; + GraphCode resolves the Windows account identity through the operating + system rather than process environment text. 2. Remove from the candidate process environment: @@ -235,7 +248,11 @@ startup does not rely on them. 5. Record process tree, executable paths/hashes, and environment-key names only. Do not retain secret values. -PASS requires connected Welcome and no hidden developer dependency. +PASS requires first-run onboarding to close, the main window and daemon to +remain alive, the CLI endpoint to respond, connected Welcome, no fatal startup +event, and no hidden developer dependency. A genuinely required variable must +fail with a bounded diagnostic naming the startup operation and variable; a raw +`EnvironmentVariableNotFound` diagnostic is a failure. After this step, restore the one declared Git executable for Git-backed project/worktree features and record its path, version, and hash.