diff --git a/Launch Codexify.cmd b/Launch Codexify.cmd new file mode 100644 index 00000000..8c80aaca --- /dev/null +++ b/Launch Codexify.cmd @@ -0,0 +1,2 @@ +@echo off +start "" /min "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -STA -ExecutionPolicy Bypass -WindowStyle Hidden -File "%~dp0scripts\windows-launcher.ps1" diff --git a/README.md b/README.md index f6dbd743..aa4ac007 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ standard installation. A running background service is restarted after the verified replacement; a stopped service stays stopped. The command reports when the installation is already up to date and never downgrades a newer version. +For a native Windows launcher that reuses your saved setup, double-click +`Launch Codexify.cmd` in this checkout. It starts chat, the OpenAI tunnel and the +browser companion, with live status and an optional Windows sign-in shortcut. +See the [Windows launcher guide](docs/WINDOWS_LAUNCHER.md) to install desktop and +Start menu shortcuts. + ### 2. Connect to ChatGPT ```sh diff --git a/START CODEXIFY.cmd b/START CODEXIFY.cmd new file mode 100644 index 00000000..550c525a --- /dev/null +++ b/START CODEXIFY.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\windows-start-local.ps1" +if errorlevel 1 pause diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 4ca5cb23..48921ed8 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -743,6 +743,12 @@ The supervisor also creates its ordinary server child with `CREATE_NO_WINDOW`. T prevents the otherwise empty console window without changing the Interactive logon principal required for tunnel and network access. +For a read-only Windows autowork snapshot, run +`./scripts/windows-autowork-status.ps1`. It reports server/browser readiness, +supervisor count, free disk space, active goals, last tool activity, and retry +errors. Use `-ChatPrefix` to follow particular conversation IDs. State reads +allow atomic replacement, so monitoring does not block goal updates. + The Windows supervisor begins liveness monitoring after the server first answers `/health` successfully. Six consecutive failed probes (with a five-second pause between them and a three-second request timeout) restart the contained server process tree. A slow diff --git a/docs/WINDOWS_LAUNCHER.md b/docs/WINDOWS_LAUNCHER.md new file mode 100644 index 00000000..f2a44c13 --- /dev/null +++ b/docs/WINDOWS_LAUNCHER.md @@ -0,0 +1,55 @@ +# Windows launcher + +Double-click **Launch Codexify.cmd** in the repository to open the native launcher. +It starts the installed Codexify service, its saved OpenAI tunnel(s), and the +ChatGPT browser companion when browser automation is enabled. Your private local +chat opens in the default browser once startup succeeds. + +To install a desktop and Start menu shortcut: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install-windows-launcher.ps1 -Launch +``` + +The installer copies the launcher into `%USERPROFILE%\.codexify\launcher`, so the +shortcuts keep working if this checkout moves. Run the installer again to update +those copies. It requires no administrator rights and uses Windows PowerShell 5.1 +and WPF, which are included with Windows. + +The launcher always uses the existing `%USERPROFILE%\.codexify\bin\codexify.exe` +and `%USERPROFILE%\.codexify\codexify.config.json`, rather than the example config +in this checkout. Existing tunnel key references, projects, chat history and the +browser profile are reused. A first-time installation still needs +`codexify quickstart`. The launcher does not provision a new tunnel or connector. + +- **Start all** starts the installed service and restores the browser session. + Existing running services and ready/busy browser sessions are reused. It checks + the local chat and tunnel health before reporting a successful connection. +- **Open chat** opens the authenticated local chat using the persistent owner + token in `owner-chat.json`. That token is never displayed in the launcher. +- **Stop services** stops the service (including its supervised tunnels), then + the browser companion. Use this when you intend to interrupt running work. +- **Browser** shows the existing ChatGPT browser window. **Sign in** opens its + sign-in surface when the saved session has expired. +- **Launch at Windows sign-in** adds/removes only this launcher's per-user Startup + shortcut. The existing Codexify service's own login setting is independent. +- **Open chat after startup** is remembered in `launcher\preferences.json`. + +Closing the launcher leaves the services running. Opening another copy brings the +existing launcher to the front. Periodic status checks and service commands run in +a background runspace so the window stays responsive. Startup failures remain +visible in the activity area; the service logs are under `.codexify\logs`. +Command output draining is bounded after the CLI exits, so a persistent browser +that inherits output handles cannot leave startup waiting indefinitely. Live +health checks still decide whether the chat and tunnel are connected. + +For a status-only launch, pass `-NoAutoStart` to `windows-launcher.ps1`. For a +non-operational visual preview, pass `-PreviewPath C:\path\preview.png`. Preview +mode starts no services and writes no preferences or shortcuts. `-CapturePath` +saves the actual window after its first completed action and leaves it open. + +Run the isolated launcher checks with: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test-windows-launcher.ps1 +``` diff --git a/scripts/install-windows-launcher.ps1 b/scripts/install-windows-launcher.ps1 new file mode 100644 index 00000000..a238c275 --- /dev/null +++ b/scripts/install-windows-launcher.ps1 @@ -0,0 +1,30 @@ +[CmdletBinding()] +param([switch]$Launch, [switch]$StartWithWindows) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'windows-launcher-core.ps1') +$context = New-LauncherContext +$null = Get-LauncherSettings $context +$null = New-Item -ItemType Directory -Path $context.LauncherDirectory -Force +foreach ($name in @('windows-launcher.ps1', 'windows-launcher-core.ps1', 'windows-launcher.xaml')) { + $source = Join-Path $PSScriptRoot $name + $destination = Join-Path $context.LauncherDirectory $name + if ([IO.Path]::GetFullPath($source) -ine [IO.Path]::GetFullPath($destination)) { + Copy-Item -LiteralPath $source -Destination $destination -Force + } +} +$installedScript = Join-Path $context.LauncherDirectory 'windows-launcher.ps1' +$desktopLink = Join-Path ([Environment]::GetFolderPath('DesktopDirectory')) 'Codexify Launcher.lnk' +$menuLink = Join-Path ([Environment]::GetFolderPath('Programs')) 'Codexify Launcher.lnk' +Set-LauncherShortcut $desktopLink $installedScript $context.Executable +Set-LauncherShortcut $menuLink $installedScript $context.Executable +if ($StartWithWindows) { + $startupLink = Join-Path ([Environment]::GetFolderPath('Startup')) 'Codexify Launcher.lnk' + Set-LauncherShortcut $startupLink $installedScript $context.Executable +} +[pscustomobject]@{ Launcher = $installedScript; DesktopShortcut = $desktopLink; StartMenuShortcut = $menuLink } +if ($Launch) { + $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + Start-Process -FilePath $powershell -ArgumentList (Get-LauncherShortcutArguments $installedScript) -WindowStyle Hidden +} diff --git a/scripts/test-windows-agent-update-guards.ps1 b/scripts/test-windows-agent-update-guards.ps1 new file mode 100644 index 00000000..59a6df48 --- /dev/null +++ b/scripts/test-windows-agent-update-guards.ps1 @@ -0,0 +1,112 @@ +#Requires -Version 7.0 +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'windows-agent-update-guards.ps1') +$app='C:\fixture\.codexify' +$cua='C:\fixture\runtimes\cua_node' +function Row($Id,$Parent,$Name,$Exe,$Command){[pscustomobject]@{ProcessId=$Id;ParentProcessId=$Parent;Name=$Name;ExecutablePath=$Exe;CommandLine=$Command}} +function Check($Rows,$Expected,$Label){ + $actual=@(Get-AgentUpdateWorkProcesses -Processes $Rows -ServerId 10 -BrowserId 20 -AppRoot $app -CuaRuntimeRoot $cua -SystemRoot 'C:\Windows'|ForEach-Object ProcessId|Sort-Object) + if(($actual -join ',') -ne (@($Expected|Sort-Object) -join ',')){throw "$Label failed: expected $Expected, got $actual"} +} +$base=@( + (Row 11 10 'node.exe' "$cua\v1\bin\node.exe" "node.exe C:/runtime/@oai/cua-repl/bin/cua-repl.mjs"), + (Row 12 10 'node_repl.exe' "$cua\v1\bin\node_repl.exe" "`"$cua\v1\bin\node_repl.exe`""), + (Row 13 11 'node_repl.exe' "$cua\v1\bin\node_repl.exe" "`"$cua\v1\bin\node_repl.exe`""), + (Row 14 10 'tunnel-client-runtime.exe' "$app\openai-tunnel\v1\tunnel-client-runtime.exe" 'tunnel-client-runtime.exe run --control-plane.tunnel-id fixture'), + (Row 15 10 'conhost.exe' 'C:\Windows\System32\conhost.exe' 'conhost.exe'), + (Row 20 10 'codexify-browser-companion.exe' "$app\browser\runtime\v1\app\codexify-browser-companion.exe" 'browser'), + (Row 21 20 'chrome.exe' "$app\browser\runtime\v1\chrome.exe" 'chrome'), + (Row 22 21 'powershell.exe' 'C:\Windows\powershell.exe' 'private renderer priority helper') +) +Check $base @() 'Known idle infrastructure' +Check ($base+@((Row 30 10 'powershell.exe' 'C:\Windows\powershell.exe' 'cargo build'),(Row 31 30 'cargo.exe' 'C:\cargo.exe' 'cargo build'),(Row 32 31 'rustc.exe' 'C:\rustc.exe' 'rustc'))) @(30,31,32) 'Yielded build remains busy' +Check ($base+@((Row 40 12 'node.exe' "$cua\v1\bin\node.exe" 'node project-server.mjs'))) @(40) 'User command through idle Node host' +Check ($base+@((Row 50 999 'cargo.exe' 'C:\cargo.exe' 'unrelated build'))) @() 'Unrelated project process' +Check @((Row 20 10 'codexify-browser-companion.exe' 'C:\project\other.exe' 'other'),(Row 21 20 'cargo.exe' 'C:\cargo.exe' 'build')) @(20,21) 'Unverified browser subtree is busy' +Check ($base+@((Row 60 10 'custom-build.exe' 'C:\project\custom-build.exe' 'custom'))) @(60) 'Unknown native child is busy' +$self=Get-CimInstance Win32_Process -Filter ('ProcessId='+$PID) +if(-not(Test-AgentUpdateProcessRunning $self)){throw 'Current test process must be detected as running'} +$fixture=Start-Process -FilePath (Get-Command pwsh).Source -ArgumentList @('-NoProfile','-Command','exit 0') -WindowStyle Hidden -PassThru +try { + $row=[pscustomobject]@{ProcessId=$fixture.Id;CreationDate=$fixture.StartTime} + if(-not $fixture.WaitForExit(10000)){throw 'Fixture process did not finish'} + if(Test-AgentUpdateProcessRunning $row){throw 'An exited process with a retained handle is not active work'} +} finally {$fixture.Dispose()} +if(-not(Test-AgentUpdateBrowserStopped ([pscustomobject]@{state='stopped';running=$false}) @())){throw 'Verified stopped status must be accepted'} +if(Test-AgentUpdateBrowserStopped ([pscustomobject]@{state='stopped';running=$false}) @([pscustomobject]@{ProcessId=999})){throw 'Stopped status must not hide a live private process'} +$idle=@{kind='codexify-browser-companion';status='idle';authenticated=$true;accountFingerprint='fixture';activeActionId=$null} +if(-not(Test-AgentUpdateBrowserIdle ([pscustomobject]$idle))){throw 'Verified idle legacy window must be accepted'} +foreach($change in @(@{status='busy'},@{authenticated=$false},@{activeActionId='accepted-send'},@{kind='other-service'})){ + $probe=$idle.Clone();foreach($key in $change.Keys){$probe[$key]=$change[$key]} + if(Test-AgentUpdateBrowserIdle ([pscustomobject]$probe)){throw 'A busy, warming or unrelated window was accepted for update'} +} +$pool=$idle.Clone();$pool.activeActionIds=@('another-page-send');$pool.actionCapacity=4;$pool.availableActionSlots=3 +if(Test-AgentUpdateBrowserIdle ([pscustomobject]$pool)){throw 'A free pool slot must not hide a Send owned by another page'} +$pool.activeActionIds=@() +if(Test-AgentUpdateBrowserIdle ([pscustomobject]$pool)){throw 'Unavailable pool capacity must defer updates even without a visible action ID'} +$pool.availableActionSlots=4 +if(-not(Test-AgentUpdateBrowserIdle ([pscustomobject]$pool))){throw 'A completely idle verified pool must be accepted'} +$stopped=[pscustomobject]@{generationRunning=$false;observedActionPages=1} +if(-not(Test-AgentUpdateBrowserQuiescent ([pscustomobject]$idle) $stopped)){throw 'A verified idle single page with no generation must pass'} +foreach($diagnostics in @($null,[pscustomobject]@{},[pscustomobject]@{generationRunning=$true;observedActionPages=1})){ + if(Test-AgentUpdateBrowserQuiescent ([pscustomobject]$idle) $diagnostics){throw 'Running or unknown generation must defer the update'} +} +if(Test-AgentUpdateBrowserQuiescent ([pscustomobject]$pool) $stopped){throw 'Inspecting only one of four pages is insufficient'} +$allStopped=[pscustomobject]@{generationRunning=$false;observedActionPages=4} +if(-not(Test-AgentUpdateBrowserQuiescent ([pscustomobject]$pool) $allStopped)){throw 'All idle verified pages must pass'} + +$tempRoot=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\' +$fixtureRoot=Join-Path $tempRoot ('codexify-navigation-stop-test-'+[Guid]::NewGuid().ToString('N')) +$originalProfile=$env:USERPROFILE +try { + $env:USERPROFILE=$fixtureRoot + $fixtureScripts=Join-Path $fixtureRoot 'scripts' + $fixtureArtifacts=Join-Path $fixtureRoot 'browser-companion/artifacts' + $fixtureApp=Join-Path $fixtureRoot '.codexify' + $browserRoot=Join-Path $fixtureApp 'browser' + $resources=Join-Path $browserRoot 'runtime/2.3.15/app/resources' + $null=New-Item -ItemType Directory -Path $fixtureScripts,$fixtureArtifacts,$resources,(Join-Path $fixtureApp 'backups') -Force + foreach($name in @('windows-apply-navigation-stall-patch.ps1','windows-agent-update-guards.ps1')){ + Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $fixtureScripts + } + @' +$script:fixtureDiagnosticCalls=0 +function Invoke-StartLocalCli { throw 'Unexpected native lifecycle command in stopped browser fixture' } +function Invoke-RestMethod { + param($Uri,$Headers,$TimeoutSec) + if($Uri -like '*/v1/health'){ + return [pscustomobject]@{kind='codexify-browser-companion';status='idle';authenticated=$true;accountFingerprint='fixture'} + } + if($Uri -notlike '*/v1/diagnostics/presentation'){throw 'Unexpected fixture request'} + $script:fixtureDiagnosticCalls++ + if($script:fixtureDiagnosticCalls -eq 2){[IO.File]::WriteAllText($lifecyclePath,'{"automaticStart":false}')} + [pscustomobject]@{generationRunning=$false;observedActionPages=1} +} +'@ | Set-Content -LiteralPath (Join-Path $fixtureScripts 'windows-start-local-core.ps1') + $archive=Join-Path $resources 'app.asar' + $candidate=Join-Path $fixtureArtifacts 'candidate.asar' + [IO.File]::WriteAllText($archive,'fixture installed archive') + [IO.File]::WriteAllText($candidate,'fixture candidate archive') + $previousHash=(Get-FileHash -LiteralPath $archive).Hash + @{version='2.3.15';live=$archive;candidate=$candidate;previousSha256=$previousHash;sha256=(Get-FileHash -LiteralPath $candidate).Hash} | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $fixtureArtifacts 'navigation-stall-ready.json') + $lifecycle=Join-Path $browserRoot 'lifecycle.json' + $fixtureUpdater=Join-Path $fixtureScripts 'windows-apply-navigation-stall-patch.ps1' + [IO.File]::WriteAllText($lifecycle,'{"automaticStart":false}') + $result=& $fixtureUpdater | ConvertFrom-Json + if($result.reason -ne 'browser_explicitly_stopped'){throw 'Explicitly stopped browser with no descriptor must defer'} + [IO.File]::WriteAllText($lifecycle,'{"automaticStart":true}') + [IO.File]::WriteAllText((Join-Path $browserRoot 'descriptor.json'),'{"endpoint":"http://127.0.0.1:1","token":"fixture"}') + [IO.File]::WriteAllText((Join-Path $fixtureApp 'codexify.config.json'),'{}') + $result=& $fixtureUpdater | ConvertFrom-Json + if($result.reason -ne 'browser_explicitly_stopped'){throw 'An explicit stop during updater preflight must defer'} + if((Get-FileHash -LiteralPath $archive).Hash -ne $previousHash){throw 'Deferral changed the installed archive'} +} finally { + $env:USERPROFILE=$originalProfile + if(Test-Path -LiteralPath $fixtureRoot){ + $resolved=(Resolve-Path -LiteralPath $fixtureRoot).Path + if(-not $resolved.StartsWith($tempRoot,[StringComparison]::OrdinalIgnoreCase) -or (Split-Path -Leaf $resolved) -notlike 'codexify-navigation-stop-test-*'){throw 'Unsafe fixture cleanup path'} + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} +'Passed 26 updater process, idle-window, generation and explicit-stop checks' diff --git a/scripts/test-windows-autowork-state-files.ps1 b/scripts/test-windows-autowork-state-files.ps1 new file mode 100644 index 00000000..3616a6c2 --- /dev/null +++ b/scripts/test-windows-autowork-state-files.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'windows-autowork-state-files.ps1') +$base = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' +$root = Join-Path $base ('codexify-monitor-test-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $root +function Transcript-Id([string]$Path) { + $sha = [Security.Cryptography.SHA256]::Create() + try { -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Path)) | ForEach-Object { $_.ToString('x2') }) } + finally { $sha.Dispose() } +} +try { + $key = 'a' * 64 + $real = Join-Path $root ('projects\registered\chats\' + $key) + $fake = Join-Path $root ('projects\.tmp-old-fixture\chats\' + ('b' * 64)) + $nested = Join-Path $root ('projects\registered\fixtures\nested\chats\' + ('c' * 64)) + foreach ($dir in @($real,$fake,$nested)) { + $null = New-Item -ItemType Directory -Path $dir -Force + [IO.File]::WriteAllText((Join-Path $dir 'CHAT.md'), '# fixture') + [IO.File]::WriteAllText((Join-Path $dir 'automation.json'), '{"goal":{"status":"active"}}') + } + $inventory = [pscustomobject]@{chats=@([pscustomobject]@{id=(Transcript-Id (Join-Path $real 'CHAT.md'))})} + $files = @(Select-AutoworkStateFiles $root $inventory) + if ($files.Count -ne 1 -or $files[0].FullName -ne (Join-Path $real 'automation.json')) { throw 'Unregistered or nested test state entered the monitor' } + $nativePath = $root + '/projects\registered\chats\' + $key + '\CHAT.md' + $inventory.chats[0].id = Transcript-Id $nativePath + if (@(Select-AutoworkStateFiles $root $inventory).Count -ne 1) { throw 'Native mixed-separator owner ID was lost' } + $inventory.chats = @() + if (@(Select-AutoworkStateFiles $root $inventory).Count -ne 0) { throw 'Empty live inventory fell back to historical goals' } + Write-Output 'PASS: registered chats, fake active fixtures, nested fixtures, native path spelling, empty inventory' +} finally { + $resolved = [IO.Path]::GetFullPath($root) + if (-not $resolved.StartsWith($base,[StringComparison]::OrdinalIgnoreCase) -or (Split-Path -Leaf $resolved) -notlike 'codexify-monitor-test-*') { throw 'Unsafe test cleanup path' } + Remove-Item -LiteralPath $resolved -Recurse -Force +} diff --git a/scripts/test-windows-autowork-unavailable.ps1 b/scripts/test-windows-autowork-unavailable.ps1 new file mode 100644 index 00000000..e9ae5806 --- /dev/null +++ b/scripts/test-windows-autowork-unavailable.ps1 @@ -0,0 +1,214 @@ +param([string]$Engine=(Get-Process -Id $PID).Path) +$ErrorActionPreference='Stop' +$PSNativeCommandUseErrorActionPreference=$false +# A private loopback fixture keeps successful inventory cases independent of +# the actual owner API outage. It serves exactly one authenticated request. +if(-not ('CodexifyMonitorInventoryFixture' -as [type])){ + Add-Type -TypeDefinition @' +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +public sealed class CodexifyMonitorInventoryFixture : IDisposable { + private readonly TcpListener listener; + public int Port { get; private set; } + public Task Completion { get; private set; } + public CodexifyMonitorInventoryFixture(string body) : this(body, "/api/chats", "fixture-monitor-token") {} + public CodexifyMonitorInventoryFixture(string body, string path, string token) { + listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + Port = ((IPEndPoint)listener.LocalEndpoint).Port; + Completion = Task.Run(async () => { + try { + using (var client = await listener.AcceptTcpClientAsync()) { + var stream = client.GetStream(); + var buffer = new byte[8192]; + var length = 0; + while (length < buffer.Length) { + var count = await stream.ReadAsync(buffer, length, buffer.Length - length); + if (count == 0) break; + length += count; + if (Encoding.ASCII.GetString(buffer, 0, length).Contains("\r\n\r\n")) break; + } + var request = Encoding.ASCII.GetString(buffer, 0, length); + if (!request.StartsWith("GET " + path + " HTTP/1.1\r\n") || + request.IndexOf("\r\nAuthorization: Bearer " + token + "\r\n", StringComparison.OrdinalIgnoreCase) < 0) + throw new Exception("Unexpected request or missing fixture authorization"); + var payload = Encoding.UTF8.GetBytes(body); + var header = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: " + payload.Length + "\r\n\r\n"); + await stream.WriteAsync(header, 0, header.Length); + await stream.WriteAsync(payload, 0, payload.Length); + } + } finally { listener.Stop(); } + }); + } + public void Dispose() { listener.Stop(); } +} +'@ +} +$base=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\' +$root=Join-Path $base ('codexify-monitor-unavailable-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $root +$listener=[Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0) +try { + $listener.Start() + $unusedPort=([Net.IPEndPoint]$listener.LocalEndpoint).Port + $listener.Stop() + $registered=Join-Path $root ('projects\registered\chats\'+('a'*64)) + $fake=Join-Path $root ('projects\.tmp-history\chats\'+('b'*64)) + foreach($directory in @($registered,$fake)){ + $null=New-Item -ItemType Directory -Path $directory -Force + [IO.File]::WriteAllText((Join-Path $directory 'automation.json'),'{"goal":{"status":"active"}}') + [IO.File]::WriteAllText((Join-Path $directory 'CHAT.md'),'# monitor fixture') + } + $sha=[Security.Cryptography.SHA256]::Create() + $fixtureNow=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + $expired=$fixtureNow-3600000 + $future=$fixtureNow+3600000 + $rows=@( + @{key='c';state=@{pairing=@{creatingChat=$true;pending=@{expiresAtMs=$expired}};action=@{status='unverified';sendAccepted=$true;updatedAtMs=$expired}}} + @{key='d';state=@{goal=@{status='active'};action=@{status='cancelled';automaticRetryPaused=$true;updatedAtMs=$expired}}} + @{key='e';state=@{goal=@{status='active'};action=@{updatedAtMs=$expired}};cursor=@{agentStopRequested=$true}} + @{key='f';state=@{pairing=@{creatingChat=$true;pending=@{expiresAtMs=$future}};action=@{status='unverified';updatedAtMs=$expired}}} + @{key='1';state=@{pairing=@{creatingChat=$true;pending=@{expiresAtMs=$expired}};action=@{status='failed';updatedAtMs=$fixtureNow}}} + @{key='2';state=@{goal=@{status='active'};action=@{status='cancelled';automaticRetryPaused=$false;updatedAtMs=$expired}}} + @{key='3';state=@{pairing=@{creatingChat=$true};action=@{status='queued';updatedAtMs=$expired}}} + ) + try { + $registeredId=-join($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes((Join-Path $registered 'CHAT.md')))|ForEach-Object {$_.ToString('x2')}) + $currentChats=@(@{id=$registeredId}) + foreach($row in $rows){ + $directory=Join-Path (Split-Path -Parent $registered) ($row.key*64) + $null=New-Item -ItemType Directory -Path $directory + $transcript=Join-Path $directory 'CHAT.md' + [IO.File]::WriteAllText($transcript,'# monitor fixture') + [IO.File]::WriteAllText((Join-Path $directory 'automation.json'),($row.state|ConvertTo-Json -Depth 8)) + if($row.cursor){[IO.File]::WriteAllText((Join-Path $directory 'cursor.json'),($row.cursor|ConvertTo-Json))} + $id=-join($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($transcript))|ForEach-Object {$_.ToString('x2')}) + $currentChats+=@{id=$id} + } + } + finally {$sha.Dispose()} + $script=Join-Path $PSScriptRoot 'windows-autowork-status.ps1' + $wrapper=Join-Path $root 'invoke-monitor.ps1' + [IO.File]::WriteAllText($wrapper,@' +param([string]$Script,[string]$Root,[int]$Port,[string]$SnapshotPath,[switch]$Compact,[string]$FollowAgentsOfChatPrefix) +$PSNativeCommandUseErrorActionPreference=$true +$monitorArgs=@{Root=$Root;Port=$Port;SnapshotPath=$SnapshotPath;Compact=$Compact} +if($FollowAgentsOfChatPrefix){$monitorArgs.FollowAgentsOfChatPrefix=$FollowAgentsOfChatPrefix} +& $Script @monitorArgs +exit $LASTEXITCODE +'@) + $browserDirectory=Join-Path $root 'browser' + $null=New-Item -ItemType Directory -Path $browserDirectory + $descriptor=Join-Path $browserDirectory 'descriptor.json' + $browserFixtures=@{ + empty=@{status='busy';authenticated=$false;expected='pending'} + single=@{status='idle';authenticated=$true;expected='verified'} + current=@{status='authentication_required';authenticated=$false;expected='required'} + } + foreach($case in @('unavailable','empty','single','current','targeted-history','unavailable-team','unavailable-team-partial')){ + foreach($compact in @($false,$true)){ + $server=$null + $browserServer=$null + try { + $port=$unusedPort + if($case -notlike 'unavailable*'){ + $chats=@() + if($case -eq 'single'){$chats+=@{id=$registeredId}} + if($case -in @('current','targeted-history')){$chats=$currentChats} + $server=[CodexifyMonitorInventoryFixture]::new((@{chats=$chats}|ConvertTo-Json -Compress)) + $port=$server.Port + } + [IO.File]::WriteAllText((Join-Path $root 'owner-chat.json'), + (@{port=$port;token='fixture-monitor-token'}|ConvertTo-Json -Compress)) + $browserFixture=$browserFixtures[$case] + $expectedAuthentication='unknown' + if($browserFixture){ + $health=@{status=$browserFixture.status;authenticated=$browserFixture.authenticated}|ConvertTo-Json -Compress + $browserServer=[CodexifyMonitorInventoryFixture]::new($health,'/v1/health','fixture-browser-token') + [IO.File]::WriteAllText($descriptor,(@{endpoint=('http://127.0.0.1:'+$browserServer.Port);token='fixture-browser-token'}|ConvertTo-Json -Compress)) + $expectedAuthentication=$browserFixture.expected + } + if($case -like 'unavailable-team*'){ + $null=New-Item -ItemType Directory -Path (Join-Path $root 'agents') -Force + $partial=if($case -eq 'unavailable-team-partial'){'true'}else{'false'} + $helper=@' +export async function agent(request) { + if(request.action==='list')return {teams:[{teamId:'good'},{teamId:'bad'}],hasMore:false}; + if(request.action!=='status')throw new Error('Monitor attempted a mutation'); + if(PARTIAL && request.teamId==='bad')throw new Error('Fixture unavailable status'); + return {team:{capacity:{active:1,limit:null},workers:[{taskKey:request.teamId,role:'fixture',goal:{status:'blocked',revision:2},stopped:true,toolCallCount:7,queuePosition:null}]}}; +} +'@ + [IO.File]::WriteAllText((Join-Path $root 'agents\client.mjs'),$helper.Replace('PARTIAL',$partial)) + } + $snapshot=Join-Path $root ('snapshot-'+$case+'-'+$compact+'.json') + $arguments=@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass', + '-File',$wrapper,'-Script',$script,'-Root',$root,'-Port',$unusedPort,'-SnapshotPath',$snapshot) + if($case -like 'unavailable*'){$arguments+=@('-FollowAgentsOfChatPrefix','a')} + if($case -eq 'targeted-history'){$arguments+=@('-FollowAgentsOfChatPrefix','c')} + if($compact){$arguments+=@('-Compact')} + $output=& $Engine @arguments + $code=$LASTEXITCODE + if($server){ + if(-not $server.Completion.Wait(5000)){throw 'Inventory fixture did not finish its request'} + $null=$server.Completion.GetAwaiter().GetResult() + } + if($browserServer){ + if(-not $browserServer.Completion.Wait(5000)){throw 'Browser fixture did not finish its request'} + $null=$browserServer.Completion.GetAwaiter().GetResult() + } + $expectedCode=0;if($case -like 'unavailable*'){$expectedCode=1} + if($code -ne $expectedCode){throw "$case inventory must return exit $expectedCode, got $code"} + $shown=$output|ConvertFrom-Json + $saved=Get-Content -LiteralPath $snapshot -Raw|ConvertFrom-Json + foreach($result in @($shown,$saved)){ + if(-not $result.PSObject.Properties['server'] -or -not $result.PSObject.Properties['browser']){throw 'Independent health diagnostics were lost'} + $authentication=if($result.browser -is [string]){$result.browserAuthentication}else{$result.browser.authenticationState} + if($authentication -ne $expectedAuthentication){throw "Unexpected browser authentication observation for $case"} + if($case -like 'unavailable*'){ + if($result.inventoryAvailable -ne $false -or -not $result.inventoryError){throw 'Inventory failure was not explicit'} + if($null -ne $result.agents -or $null -ne $result.uninitializedAgents){throw 'Unknown inventory became an empty or guessed team'} + if($case -like 'unavailable-team*'){ + if(-not $result.agentTeams.available -or -not $result.agentTeams.discoveryComplete -or $result.agentTeams.teams.Count -ne 2){throw 'Independent team inventory was lost'} + $good=$result.agentTeams.teams[0] + if(-not $good.statusAvailable -or -not $good.workers[0].stopped -or $good.workers[0].goalRevision -ne 2){throw 'Published stop/revision observation changed'} + if($case -eq 'unavailable-team-partial'){ + $bad=$result.agentTeams.teams[1] + if($result.agentTeams.allStatusesAvailable -or $bad.statusAvailable -or $null -ne $bad.workers -or -not $bad.error){throw 'Partial team status became empty success or was discarded'} + }elseif(-not $result.agentTeams.allStatusesAvailable){throw 'Complete published team statuses were lost'} + }elseif($result.agentTeams.available){throw 'Missing agent helper became available'} + }else{ + if($result.inventoryAvailable -ne $true -or $result.inventoryError){throw 'Successful inventory was reported unknown'} + $expectedKeys=switch($case){ + 'single' {'a'} + 'current' {'1';'2';'3';'a';'f'} + 'targeted-history' {'c'} + } + $actualKeys=@($result.agents|ForEach-Object {$_.chat.Substring(0,1)}|Sort-Object) + if($result.agents -isnot [array] -or ($actualKeys -join ',') -ne ($expectedKeys -join ',')){ + throw "$case inventory selected unexpected chats: $($actualKeys -join ',')" + } + if($case -eq 'targeted-history' -and ($result.agents[0].action -ne 'unverified' -or -not $result.agents[0].sendAccepted)){ + throw 'Targeted history lost the accepted pairing receipt' + } + if($result.uninitializedAgents -isnot [array] -or $result.uninitializedAgents.Count -ne 0){throw 'Known empty traversal lost its array shape'} + } + } + } finally { + if($server){$server.Dispose()} + if($browserServer){$browserServer.Dispose()} + if(Test-Path -LiteralPath $descriptor){Remove-Item -LiteralPath $descriptor} + } + } + } + 'PASS: 14 cases; authenticated inventory, current and targeted history, published-team fallback, full/compact JSON, browser authentication and exit codes' +} finally { + $listener.Stop() + $resolved=[IO.Path]::GetFullPath($root) + if(-not $resolved.StartsWith($base,[StringComparison]::OrdinalIgnoreCase) -or + (Split-Path -Leaf $resolved) -notlike 'codexify-monitor-unavailable-*'){throw 'Unsafe test cleanup path'} + Remove-Item -LiteralPath $resolved -Recurse -Force +} diff --git a/scripts/test-windows-browser-graceful-update.ps1 b/scripts/test-windows-browser-graceful-update.ps1 new file mode 100644 index 00000000..2389bff8 --- /dev/null +++ b/scripts/test-windows-browser-graceful-update.ps1 @@ -0,0 +1,73 @@ +param([string]$HelperPath) +$ErrorActionPreference='Stop' +if(-not $HelperPath){$HelperPath=Join-Path $PSScriptRoot 'windows-browser-graceful-update.ps1'} +. $HelperPath +$script:fixtureRoot=Join-Path ([IO.Path]::GetTempPath()) ('graceful-lock-test-'+[guid]::NewGuid().ToString('N')) +$script:fixtureBackup=Join-Path $script:fixtureRoot 'backup' +$script:fixtureNative=Join-Path $script:fixtureRoot 'native.exe' +$script:fixtureChrome=Join-Path $script:fixtureRoot 'runtime/chrome.exe' +$script:fixtureStop=$null +New-Item -ItemType Directory -Path $script:fixtureBackup,(Join-Path $script:fixtureRoot 'runtime'),(Join-Path $script:fixtureRoot 'chrome-profile/Default/Network') -Force|Out-Null +@{endpoint='http://127.0.0.1:1';token='test-only';pid=42000}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $script:fixtureRoot 'descriptor.json') +@{automaticStart=$true}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $script:fixtureRoot 'lifecycle.json') +foreach($relative in @('Local State','Default/Preferences','Default/Network/Cookies')){ + Set-Content -LiteralPath (Join-Path $script:fixtureRoot ('chrome-profile/'+$relative)) -Value 'disposable profile fixture' +} +$stopScript=Join-Path $script:fixtureRoot 'native-stop.ps1' +@' +param([string]$Root) +$ErrorActionPreference='Stop' +@{automaticStart=$false}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $Root 'lifecycle.json') +$file=[IO.File]::Open((Join-Path $Root 'lifecycle.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) +$locked=$false +try { + $deadline=[DateTime]::UtcNow.AddSeconds(20) + do {try{$file.Lock(0,1);$locked=$true}catch{Start-Sleep -Milliseconds 25}}while(-not $locked -and [DateTime]::UtcNow -lt $deadline) + if(-not $locked){throw 'Stop could not acquire lifecycle lock'} + if(-not(Test-Path -LiteralPath (Join-Path $Root 'chrome-closed'))){throw 'Stop ran before Chrome closed gracefully'} + Set-Content -LiteralPath (Join-Path $Root 'stop-complete') -Value 'native stop owns launcher shutdown' +} finally {if($locked){$file.Unlock(0,1)};$file.Dispose()} +'@|Set-Content -LiteralPath $stopScript +function Invoke-RestMethod {return @{authenticated=$true;status='idle';activeActionId=$null;accountFingerprint='fixture-account'}} +function Get-CimInstance { + param($ClassName,$Filter) + if($Filter -eq "Name='chrome.exe'" -or $Filter -eq 'ProcessId=42001'){ + return [pscustomobject]@{ProcessId=42001;ParentProcessId=42000;ExecutablePath=$script:fixtureChrome;CommandLine=($script:fixtureChrome+' '+(Join-Path $script:fixtureRoot 'chrome-profile'))} + } + if($script:fixtureStop -and $Filter -eq ('ProcessId='+$script:fixtureStop.Id)){return [pscustomobject]@{ExecutablePath=$script:fixtureNative}} +} +function Get-Process { + param($Id,$ErrorAction) + if($Id -eq 42001){ + $p=[pscustomobject]@{MainWindowHandle=1} + $p|Add-Member ScriptMethod Refresh {} + $p|Add-Member ScriptMethod CloseMainWindow {Set-Content -LiteralPath (Join-Path $script:fixtureRoot 'chrome-closed') -Value 'clean close';return $true} + $p|Add-Member ScriptMethod WaitForExit {param($timeout);return $true} + return $p + } + if($Id -eq 42000){ + $p=[pscustomobject]@{} + $p|Add-Member ScriptMethod WaitForExit {param($timeout);return (Test-Path -LiteralPath (Join-Path $script:fixtureRoot 'stop-complete'))} + return $p + } +} +function Start-Process { + param($FilePath,$ArgumentList,$WindowStyle,[switch]$PassThru,$RedirectStandardOutput,$RedirectStandardError) + if($ArgumentList[1] -eq 'start'){ + $p=[pscustomobject]@{} + $p|Add-Member ScriptMethod WaitForExit {param($timeout);return $true} + return $p + } + if($ArgumentList[1] -ne 'stop'){throw 'Unexpected lifecycle command'} + $script:fixtureStop=Microsoft.PowerShell.Management\Start-Process -FilePath ([Diagnostics.Process]::GetCurrentProcess().MainModule.FileName) -ArgumentList @('-NoProfile','-File',('"'+$stopScript+'"'),('-Root "'+$script:fixtureRoot+'"')) -WindowStyle Hidden -PassThru -RedirectStandardOutput $RedirectStandardOutput -RedirectStandardError $RedirectStandardError + return $script:fixtureStop +} +try { + $result=Stop-ManagedBrowserGracefully -BrowserRoot $script:fixtureRoot -NativeExe $script:fixtureNative -BackupDirectory $script:fixtureBackup -ExpectedAccountFingerprint 'fixture-account' + if(-not $result.graceful -or -not $result.profileBackupVerified){throw 'Missing verified shutdown receipt'} + if(-not(Test-Path -LiteralPath (Join-Path $script:fixtureRoot 'stop-complete'))){throw 'Native stop did not finish'} + if(-not(Test-Path -LiteralPath (Join-Path $script:fixtureBackup 'browser-profile/Default/Network/Cookies'))){throw 'Closed profile was not retained'} + [pscustomobject]@{passed=$true;realCrossProcessLifecycleLock=$true;chromeClosedBeforeNativeStop=$true;launcherStoppedAfterNativeReceipt=$true;profileBackupVerified=$true;fixture=$script:fixtureRoot}|ConvertTo-Json +} finally { + if($script:fixtureStop -and -not $script:fixtureStop.HasExited){Stop-Process -InputObject $script:fixtureStop -Force} +} diff --git a/scripts/test-windows-browser-only-native-backup.ps1 b/scripts/test-windows-browser-only-native-backup.ps1 new file mode 100644 index 00000000..9055766e --- /dev/null +++ b/scripts/test-windows-browser-only-native-backup.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'windows-browser-only-native-backup.ps1') +$fixture=Join-Path ([IO.Path]::GetTempPath()) ('browser-only-native-test-'+[Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $fixture | Out-Null +$exe=Join-Path $fixture 'installed.exe' +$candidate=Join-Path $fixture 'candidate.exe' +$backup=Join-Path $fixture 'backup.exe' +$stage=Join-Path $fixture 'codexify.incoming-local.exe' +[IO.File]::WriteAllText($exe,'immutable installed native fixture') +Copy-Item -LiteralPath $exe -Destination $candidate +[IO.File]::WriteAllText($stage,'prior staging file must be untouched') +$stamp=[IO.File]::GetLastWriteTimeUtc($exe) +$lease=[IO.File]::Open($exe,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read) +try { + $hash=Backup-NativeForBrowserOnlyUpdate -Executable $exe -Candidate $candidate -BackupExecutable $backup + if((Get-FileHash -LiteralPath $exe).Hash -ne $hash -or (Get-FileHash -LiteralPath $backup).Hash -ne $hash){throw 'Hash preservation failed'} + if([IO.File]::GetLastWriteTimeUtc($exe) -ne $stamp){throw 'Installed file was rewritten'} + if([IO.File]::ReadAllText($stage) -ne 'prior staging file must be untouched'){throw 'Browser-only backup touched native staging'} + $refused=$false;try{Backup-NativeForBrowserOnlyUpdate $exe $candidate $backup}catch{$refused=$true};if(-not $refused){throw 'Reused backup slot was accepted'} + [IO.File]::WriteAllText($candidate,'different native candidate') + $refused=$false;try{Backup-NativeForBrowserOnlyUpdate $exe $candidate (Join-Path $fixture 'wrong-backup.exe')}catch{$refused=$true};if(-not $refused){throw 'Different native candidate was accepted'} + [ordered]@{ok=$true;lockedNativePreserved=$true;unchangedTimestamp=$true;existingStageUntouched=$true;backupHashVerified=$true;wrongCandidateRejected=$true;reusedSlotRejected=$true;fixture=$fixture} | ConvertTo-Json -Compress +} finally {$lease.Dispose()} diff --git a/scripts/test-windows-clean-stop-exit-race.ps1 b/scripts/test-windows-clean-stop-exit-race.ps1 new file mode 100644 index 00000000..04268a61 --- /dev/null +++ b/scripts/test-windows-clean-stop-exit-race.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'windows-browser-graceful-update.ps1') +$fixture=Join-Path ([IO.Path]::GetTempPath()) ('clean-stop-race-test-'+[Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $fixture | Out-Null +$descriptor=Join-Path $fixture 'descriptor.json';$errorFile=Join-Path $fixture 'stderr.txt' +@{pid=42000}|ConvertTo-Json|Set-Content -LiteralPath $descriptor +Set-Content -LiteralPath $errorFile -Value 'Error: Could not verify the browser companion executable' +$script:livePid=$false;$script:otherProcess=$false +function Get-CimInstance { + param($ClassName,$Filter) + if($Filter -eq 'ProcessId=42000' -and $script:livePid){return [pscustomobject]@{ProcessId=42000;ExecutablePath=(Join-Path $fixture 'runtime/browser.exe')}} + if(-not $Filter -and $script:otherProcess){return [pscustomobject]@{ProcessId=42001;ExecutablePath=(Join-Path $fixture 'runtime/browser.exe')}} +} +$check={Test-CleanBrowserStopExitRace $fixture $descriptor 42000 $errorFile} +if(-not(& $check)){throw 'Exact clean-exit race was rejected'} +$script:livePid=$true;if(& $check){throw 'A live original PID was accepted'};$script:livePid=$false +$script:otherProcess=$true;if(& $check){throw 'A remaining managed process was accepted'};$script:otherProcess=$false +@{pid=42001}|ConvertTo-Json|Set-Content -LiteralPath $descriptor +if(& $check){throw 'Replacement descriptor was accepted'} +@{pid=42000}|ConvertTo-Json|Set-Content -LiteralPath $descriptor +Set-Content -LiteralPath $errorFile -Value 'Error: Another genuine failure' +if(& $check){throw 'Unrelated failure was accepted'} +[ordered]@{ok=$true;exactErrorAndGoneOnly=$true;livePidRejected=$true;remainingManagedProcessRejected=$true;replacementDescriptorRejected=$true;otherFailureRejected=$true;fixture=$fixture}|ConvertTo-Json -Compress diff --git a/scripts/test-windows-installer.ps1 b/scripts/test-windows-installer.ps1 index 84b37d44..845eab91 100644 --- a/scripts/test-windows-installer.ps1 +++ b/scripts/test-windows-installer.ps1 @@ -2,7 +2,8 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $ProgressPreference = 'SilentlyContinue' -if (-not $IsWindows) { +# $IsWindows does not exist in Windows PowerShell 5.1 under StrictMode. +if ($env:OS -ne 'Windows_NT') { throw 'This test requires Windows.' } @@ -97,7 +98,7 @@ fn main() { _ => std::process::exit(2), } } -'@ | Set-Content -LiteralPath $Source -Encoding utf8NoBOM +'@ | Set-Content -LiteralPath $Source -Encoding utf8 $FakeBinary = Join-Path $Stage 'codexify.exe' & rustc $Source -o $FakeBinary @@ -174,11 +175,13 @@ fn main() { $env:CODEXIFY_CONFIG = Join-Path $Root 'missing.json' Remove-Item Env:CODEXIFY_SKIP_SERVICE -ErrorAction SilentlyContinue - $NoConfigOutput = (& (Join-Path $RepositoryRoot 'install.ps1') *>&1 | Out-String) + # Windows PowerShell 5.1 wraps formatted output at the host's narrow width, + # destroying exact newline assertions unless a wide capture is requested. + $NoConfigOutput = (& (Join-Path $RepositoryRoot 'install.ps1') *>&1 | Out-String -Width 4096) $Config = Join-Path $Root 'custom-codexify.json' @{ multiProject = $true } | ConvertTo-Json | Set-Content -LiteralPath $Config -Encoding ascii $env:CODEXIFY_CONFIG = $Config - $InvalidConfigOutput = (& (Join-Path $RepositoryRoot 'install.ps1') *>&1 | Out-String) + $InvalidConfigOutput = (& (Join-Path $RepositoryRoot 'install.ps1') *>&1 | Out-String -Width 4096) $Project = Join-Path $Root 'project' New-Item -ItemType Directory -Path $Project | Out-Null @{ workDir = $Project } | ConvertTo-Json | Set-Content -LiteralPath $Config -Encoding ascii diff --git a/scripts/test-windows-launcher.ps1 b/scripts/test-windows-launcher.ps1 new file mode 100644 index 00000000..58d2e4f9 --- /dev/null +++ b/scripts/test-windows-launcher.ps1 @@ -0,0 +1,189 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'windows-launcher-core.ps1') + +function Assert-Launcher([bool]$Condition, [string]$Message) { if (-not $Condition) { throw $Message } } +$temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' +$fixture = Join-Path $temporaryRoot ('codexify-launcher-test-' + [Guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path (Join-Path $fixture 'bin') -Force +$context = New-LauncherContext $fixture +$checks = New-Object 'Collections.Generic.List[string]' +try { + # A real subprocess checks quoting, both redirected pipes and bounded timeouts. + $stubSource = @' +using System; +using System.Threading; +using System.Diagnostics; +using System.IO; +public static class LauncherStub { + public static void Main(string[] args) { + Console.OutputEncoding = new System.Text.UTF8Encoding(false); + if (Array.IndexOf(args, "persistent-child") >= 0) { Thread.Sleep(20000); return; } + int spawn = Array.IndexOf(args, "spawn-child"); + if (spawn >= 0) { + var info = new ProcessStartInfo(Process.GetCurrentProcess().MainModule.FileName, "persistent-child"); + info.UseShellExecute = false; info.CreateNoWindow = true; + var child = Process.Start(info); + File.WriteAllText(args[spawn + 1], child.Id.ToString()); + Console.WriteLine("Parent command completed"); return; + } + if (Array.IndexOf(args, "sleep") >= 0) { Thread.Sleep(5000); return; } + if (Array.IndexOf(args, "flood") >= 0) { + Console.Error.Write(new string('e', 180000)); + Console.Write(new string('o', 180000)); return; + } + if (Array.IndexOf(args, "fail") >= 0) { + Console.Error.Write("token=codexify_PRIVATE sk-PRIVATE Bearer private"); + Environment.Exit(1); + } + Console.Write(string.Join("\n", args)); + } +} +'@ + Add-Type -TypeDefinition $stubSource -OutputAssembly $context.Executable -OutputType ConsoleApplication + [IO.File]::WriteAllText($context.ConfigPath, '{"agentChat":{"enabled":true,"automation":{"enabled":true}},"openaiTunnel":{"tunnelId":"fixture"}}') + $settings = Get-LauncherSettings $context + Assert-Launcher ($settings.MainPort -eq 3000 -and $settings.TunnelCount -eq 1 -and $settings.BrowserEnabled) 'Saved configuration defaults failed.' + $checks.Add('saved configuration and default ports') + $tricky = @('path with spaces', 'quote"inside', 'C:\trailing space\', 'literal $() and ` characters', ('Unicode-' + [char]0x03A9)) + $reply = Invoke-LauncherCli $context $tricky + $decoded = $reply.Output -split '\r?\n' + Assert-Launcher ($decoded.Count -eq ($tricky.Count + 2)) "Subprocess argument count changed: $($decoded.Count) decoded, $($tricky.Count + 2) expected." + Assert-Launcher ($decoded[0] -eq '--config' -and $decoded[1] -eq $context.ConfigPath) 'Explicit saved config was not passed.' + for ($i = 0; $i -lt $tricky.Count; $i++) { Assert-Launcher ($decoded[$i + 2] -ceq $tricky[$i]) "Subprocess argument $i changed." } + $checks.Add('subprocess quoting, spaces, quotes, trailing slashes and Unicode') + $reply = Invoke-LauncherCli $context @('flood') + Assert-Launcher ($reply.Output.Length -eq 180000) 'Redirected process streams blocked or truncated.' + $watch = [Diagnostics.Stopwatch]::StartNew() + $timedOut = $false + try { $null = Invoke-LauncherCli $context @('sleep') -TimeoutSeconds 1 } catch { $timedOut = $_.Exception.Message -like '*timed out*' } + Assert-Launcher ($timedOut -and $watch.Elapsed.TotalSeconds -lt 4) 'Subprocess timeout was not bounded.' + $checks.Add('redirected pipe draining and command timeout') + $childPidFile = Join-Path $fixture 'child-pid.txt' + $watch.Restart() + try { + $null = Invoke-LauncherCli $context @('spawn-child', $childPidFile) -TimeoutSeconds 5 + Assert-Launcher ($watch.Elapsed.TotalSeconds -lt 6) 'A persistent descendant held the launcher command open after its parent exited.' + $childId = [int][IO.File]::ReadAllText($childPidFile) + $child = Get-Process -Id $childId -ErrorAction Stop + Assert-Launcher (-not $child.HasExited) 'The launcher terminated its persistent child.' + } finally { + if (Test-Path -LiteralPath $childPidFile) { + $childId = [int][IO.File]::ReadAllText($childPidFile) + $child = Get-Process -Id $childId -ErrorAction SilentlyContinue + if ($child -and $child.Path -ieq $context.Executable) { $child.Kill(); $null = $child.WaitForExit(3000) } + } + } + $checks.Add('persistent browser descendants cannot hold command completion open') + $failure = '' + try { $null = Invoke-LauncherCli $context @('fail') } catch { $failure = $_.Exception.Message } + Assert-Launcher ($failure -like '*failed*' -and $failure -cnotmatch 'PRIVATE|Bearer private') 'Command errors exposed private values.' + $checks.Add('private token and key redaction') + + $privateToken = 'codexify_' + ('a' * 43) + @{ port = 43120; token = $privateToken } | ConvertTo-Json | Set-Content -LiteralPath $context.RuntimePath + Assert-Launcher ($null -ne (Get-LauncherRuntime $context)) 'Saved owner token was not accepted.' + @{ port = 70000; token = $privateToken } | ConvertTo-Json | Set-Content -LiteralPath $context.RuntimePath + Assert-Launcher ($null -eq (Get-LauncherRuntime $context)) 'Invalid runtime port was accepted.' + @{ port = 43120; token = $privateToken } | ConvertTo-Json | Set-Content -LiteralPath $context.RuntimePath + + $originalHttp = (Get-Command Get-LauncherHttp).ScriptBlock + $script:fakeHealth = [pscustomobject]@{ status = 'ok'; tunnelsReady = 1; tunnelsConfigured = 2 } + function Get-LauncherHttp([string]$Url, [string]$Token = '') { + if ($Url -like '*/api/health') { + Assert-Launcher ($Token -ceq $privateToken) 'Owner health request lost its private bearer.' + return [pscustomobject]@{ status = 'ok' } + } + return $script:fakeHealth + } + $connection = Get-LauncherConnections $context $settings + Assert-Launcher ($connection.ChatReady -and -not $connection.TunnelReady) 'Partial tunnel health was reported as fully connected.' + $script:fakeHealth.tunnelsReady = 2 + Assert-Launcher ((Get-LauncherConnections $context $settings).TunnelReady) 'All-ready tunnel health was rejected.' + $script:fakeHealth = [pscustomobject]@{ status = 'ok' } + Assert-Launcher ((Get-LauncherConnections $context $settings).TunnelReady) 'Older single-tunnel health is unsupported.' + $checks.Add('authenticated owner health, runtime validation and partial tunnel readiness') + Set-Item Function:\Get-LauncherHttp $originalHttp + + $originalCli = (Get-Command Invoke-LauncherCli).ScriptBlock + $originalJson = (Get-Command Get-LauncherJson).ScriptBlock + $originalConnections = (Get-Command Get-LauncherConnections).ScriptBlock + $script:calls = New-Object 'Collections.Generic.List[string]' + $script:fakeService = [pscustomobject]@{ installed = $true; running = $true } + $script:fakeBrowser = [pscustomobject]@{ state = 'ready' } + function Get-LauncherJson($Context, [string[]]$Arguments) { + if ($Arguments[0] -eq 'service') { return $script:fakeService } + return $script:fakeBrowser + } + function Invoke-LauncherCli($Context, [string[]]$Arguments, [int]$TimeoutSeconds = 30, [switch]$AllowFailure) { + $command = $Arguments -join ' ' + $script:calls.Add($command) + switch ($command) { + 'service start' { $script:fakeService.running = $true } + 'service install' { $script:fakeService.installed = $true; $script:fakeService.running = $true } + 'browser start' { $script:fakeBrowser.state = 'ready' } + 'service stop' { $script:fakeService.running = $false } + 'browser stop' { $script:fakeBrowser.state = 'stopped' } + } + [pscustomobject]@{ ExitCode = 0; Output = '' } + } + function Get-LauncherConnections($Context, $Settings) { + [pscustomobject]@{ ServerReady = $script:fakeService.running; ChatReady = $script:fakeService.running; TunnelReady = $script:fakeService.running; ChatPort = 3120; TunnelsReady = 1; TunnelsConfigured = 1 } + } + $result = Invoke-LauncherAction $context 'Start' + Assert-Launcher ($null -eq $result.Error -and $result.Snapshot.Ready -and $script:calls.Count -eq 0) 'Start disturbed an existing healthy service or browser.' + $script:fakeBrowser.state = 'busy' + $result = Invoke-LauncherAction $context 'Start' + Assert-Launcher ($null -eq $result.Error -and $script:calls.Count -eq 0) 'Start disturbed a busy browser.' + $checks.Add('idempotent startup preserves healthy and busy sessions') + $script:fakeService.running = $false + $script:fakeBrowser.state = 'stopped' + $result = Invoke-LauncherAction $context 'Start' + Assert-Launcher ($null -eq $result.Error -and $result.Snapshot.Ready -and ($script:calls -join ',') -eq 'service start,browser start') 'Stopped services were not started in order.' + $script:calls.Clear() + $result = Invoke-LauncherAction $context 'Stop' + Assert-Launcher ($null -eq $result.Error -and -not $result.Snapshot.Ready -and ($script:calls -join ',') -eq 'service stop,browser stop') 'Stop did not stop the supervisor before the browser.' + $checks.Add('start and stop order for the service, tunnel and browser') + $script:calls.Clear() + $script:fakeService.installed = $false + $result = Invoke-LauncherAction $context 'Start' + Assert-Launcher ($null -eq $result.Error -and $script:calls[0] -eq 'service install') 'Missing service was not installed with the saved config.' + $script:calls.Clear() + $script:fakeBrowser.state = 'authentication_required' + $result = Invoke-LauncherAction $context 'Start' + Assert-Launcher ($result.Error -like '*Sign in*' -and -not $result.Snapshot.Ready -and $script:calls.Count -eq 0) 'Expired login did not produce a recoverable sign-in state.' + $checks.Add('service installation and expired sign-in recovery') + [IO.File]::WriteAllText($context.ConfigPath, '{"agentChat":{"enabled":true}}') + $result = Invoke-LauncherAction $context 'Start' + Assert-Launcher ($result.Error -like '*No saved OpenAI tunnel*' -and $script:calls.Count -eq 0) 'A missing tunnel caused unintended startup.' + [IO.File]::WriteAllText($context.ConfigPath, '{"agentChat":{"enabled":false},"openaiTunnel":{}}') + $result = Invoke-LauncherAction $context 'Start' + Assert-Launcher ($result.Error -like '*Agent chat is disabled*' -and $script:calls.Count -eq 0) 'Disabled chat was silently overridden.' + $checks.Add('missing tunnel and disabled chat fail without configuration changes') + Set-Item Function:\Invoke-LauncherCli $originalCli + Set-Item Function:\Get-LauncherJson $originalJson + Set-Item Function:\Get-LauncherConnections $originalConnections + + # Exercise native shortcut persistence without changing Desktop, Start menu or Startup. + $shortcutPath = Join-Path $fixture 'fixture launcher.lnk' + $uiPath = Join-Path $fixture 'a path with spaces\launcher.ps1' + Set-LauncherShortcut $shortcutPath $uiPath $context.Executable + $shell = New-Object -ComObject WScript.Shell + try { + $shortcut = $shell.CreateShortcut($shortcutPath) + Assert-Launcher ($shortcut.Arguments -eq (Get-LauncherShortcutArguments $uiPath)) 'Saved shortcut arguments changed.' + Assert-Launcher ($shortcut.TargetPath -like '*WindowsPowerShell*v1.0*powershell.exe') 'Shortcut does not use the Windows WPF runtime.' + } finally { $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($shell) } + $checks.Add('native shortcut persistence with spaces and STA runtime') + + $preview = Join-Path $fixture 'preview.png' + & (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -NoLogo -NoProfile -STA -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'windows-launcher.ps1') -PreviewPath $preview + Assert-Launcher ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $preview) -and (Get-Item -LiteralPath $preview).Length -gt 10000) 'The WPF preview did not render.' + $checks.Add('native WPF window creation and PNG rendering') + [pscustomobject]@{ ok = $true; checks = $checks.ToArray() } | ConvertTo-Json -Depth 3 +} finally { + $resolvedFixture = [IO.Path]::GetFullPath($fixture) + if ($resolvedFixture.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase) -and (Split-Path -Leaf $resolvedFixture) -match '^codexify-launcher-test-[a-f0-9]{32}$') { + Remove-Item -LiteralPath $resolvedFixture -Recurse -Force + } +} diff --git a/scripts/test-windows-stage-browser-patch-on-exit.ps1 b/scripts/test-windows-stage-browser-patch-on-exit.ps1 new file mode 100644 index 00000000..6caa04c5 --- /dev/null +++ b/scripts/test-windows-stage-browser-patch-on-exit.ps1 @@ -0,0 +1,93 @@ +#Requires -Version 7.0 +$ErrorActionPreference='Stop' +$base=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\' +$fixture=Join-Path $base ('codexify-stage-on-exit-'+[Guid]::NewGuid().ToString('N')) +$root=Join-Path $fixture 'app' +$browser=Join-Path $root 'browser' +$archive=Join-Path $browser 'runtime/fixture/app/resources/app.asar' +$candidate=Join-Path $fixture 'candidate.asar' +$receiptPath=Join-Path $fixture 'receipt.json' +$activationPath=Join-Path $fixture 'activation.json' +$scriptPath=Join-Path $PSScriptRoot 'windows-stage-browser-patch-on-exit.ps1' +$global:StageFixtureProcessCalls=0 +$global:StageFixtureMode='idle' +$global:StageFixtureRoot=$browser +function Get-CimInstance { + param($ClassName) + $global:StageFixtureProcessCalls++ + if($global:StageFixtureMode -eq 'exiting'){ + [pscustomobject]@{ProcessId=$global:StageFixtureChildId;Name='codexify-browser-companion.exe';ExecutablePath=(Join-Path $global:StageFixtureRoot 'runtime/fixture/app/codexify-browser-companion.exe')} + return + } + if($global:StageFixtureMode -eq 'busy' -or + ($global:StageFixtureMode -eq 'started_during_lock' -and $global:StageFixtureProcessCalls -gt 1)){ + [pscustomobject]@{ProcessId=$PID;Name='codexify-browser-companion.exe';ExecutablePath=(Join-Path $global:StageFixtureRoot 'runtime/fixture/app/codexify-browser-companion.exe')} + } +} +function Invoke-Fixture { + $global:StageFixtureProcessCalls=0 + & $scriptPath -Root $root -ReceiptPath $receiptPath -ActivationPath $activationPath | ConvertFrom-Json +} +function Reset-Fixture { + [IO.File]::WriteAllText($archive,'old archive') + [IO.File]::WriteAllText($candidate,'new archive') + [IO.File]::WriteAllText((Join-Path $browser 'lifecycle.json'),'{"automaticStart":true}') + @{version='fixture';live=$archive;candidate=$candidate;previousSha256=(Get-FileHash -LiteralPath $archive).Hash;sha256=(Get-FileHash -LiteralPath $candidate).Hash} | + ConvertTo-Json | Set-Content -LiteralPath $receiptPath + $global:StageFixtureMode='idle' +} +try { + $null=New-Item -ItemType Directory -Path (Split-Path -Parent $archive) + [IO.File]::WriteAllText((Join-Path $browser 'lifecycle.lock'),'') + Reset-Fixture + $global:StageFixtureMode='busy' + if((Invoke-Fixture).state -ne 'waiting_for_browser_exit'){throw 'A running browser must defer staging'} + if((Get-Content -LiteralPath $archive -Raw) -ne 'old archive'){throw 'Busy staging changed the archive'} + $global:StageFixtureMode='started_during_lock' + if((Invoke-Fixture).state -ne 'waiting_for_browser_exit'){throw 'A replacement starting before the lock was ignored'} + Reset-Fixture + [IO.File]::WriteAllText((Join-Path $browser 'lifecycle.json'),'{"automaticStart":false}') + if((Invoke-Fixture).state -ne 'browser_explicitly_stopped'){throw 'An explicit stop must retain its state'} + Reset-Fixture + $watchLock=[IO.File]::Open($activationPath+'.watch.lock',[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) + try {if((Invoke-Fixture).state -ne 'watcher_already_running'){throw 'A second watcher acquired the same receipt'}} + finally {$watchLock.Dispose()} + Reset-Fixture + $lifecycleLock=[IO.File]::Open((Join-Path $browser 'lifecycle.lock'),[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,[IO.FileShare]::ReadWrite) + $lifecycleLock.Lock(0,1) + try {if((Invoke-Fixture).state -ne 'waiting_for_lifecycle_lock'){throw 'An owned native lifecycle lock must defer staging'}} + finally {$lifecycleLock.Unlock(0,1);$lifecycleLock.Dispose()} + Reset-Fixture + [IO.File]::WriteAllText($candidate,'changed candidate') + $rejected=$false + try {$null=Invoke-Fixture} catch {$rejected=$true} + if(-not $rejected -or (Get-Content -LiteralPath $archive -Raw) -ne 'old archive'){throw 'A changed candidate was not rejected'} + Reset-Fixture + $result=Invoke-Fixture + if($result.state -ne 'staged_for_next_launch'){throw 'An idle stopped runtime was not staged'} + if((Get-Content -LiteralPath $archive -Raw) -ne 'new archive' -or (Get-Content -LiteralPath $result.backup -Raw) -ne 'old archive'){throw 'Staging or rollback archive verification failed'} + if((Get-Content -LiteralPath (Join-Path $browser 'lifecycle.json') -Raw|ConvertFrom-Json).automaticStart -ne $true){throw 'Staging changed the lifecycle preference'} + $again=Invoke-Fixture + if($again.state -ne 'already_staged'){throw 'Identical staging was not idempotent'} + if($again.backup -ne $result.backup){throw 'Idempotent verification discarded the rollback receipt'} + Reset-Fixture + $child=Start-Process -FilePath (Get-Command pwsh).Source -ArgumentList @('-NoProfile','-Command','Start-Sleep -Milliseconds 1500') -WindowStyle Hidden -PassThru + try { + $global:StageFixtureChildId=$child.Id + $global:StageFixtureMode='exiting' + & $scriptPath -Root $root -ReceiptPath $receiptPath -ActivationPath $activationPath -Watch -MaxMinutes 1 | Out-Null + $finished=Get-Content -LiteralPath $activationPath -Raw|ConvertFrom-Json + if($finished.state -ne 'staged_for_next_launch' -or (Get-Content -LiteralPath $archive -Raw) -ne 'new archive'){ + throw 'The watcher did not stage after its observed process exited' + } + } finally {$null=$child.WaitForExit(4000);$child.Dispose()} + 'PASS: 9 staging cases covering active processes, launch races, lifecycle ownership, explicit stop, duplicate watcher, changed candidate, backup, idempotency and natural exit' +} finally { + Remove-Item -LiteralPath function:Get-CimInstance + Remove-Variable -Name StageFixtureProcessCalls,StageFixtureMode,StageFixtureRoot,StageFixtureChildId -Scope Global -ErrorAction SilentlyContinue + if(Test-Path -LiteralPath $fixture){ + $resolved=(Resolve-Path -LiteralPath $fixture).Path + if(-not $resolved.StartsWith($base,[StringComparison]::OrdinalIgnoreCase) -or (Split-Path -Leaf $resolved) -notlike 'codexify-stage-on-exit-*'){throw 'Unsafe fixture cleanup path'} + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} diff --git a/scripts/test-windows-stale-browser.ps1 b/scripts/test-windows-stale-browser.ps1 new file mode 100644 index 00000000..fa536720 --- /dev/null +++ b/scripts/test-windows-stale-browser.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'windows-start-local-core.ps1') +$temporaryRoot=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\' +$fixture=Join-Path $temporaryRoot ('codexify-stale-browser-test-'+[Guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $fixture +$path=Join-Path $fixture 'descriptor.json' +$listener=New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback,0) +$listener.Start() +$port=$listener.LocalEndpoint.Port +function Write-FixtureDescriptor([long]$BrowserPid,[bool]$Old=$true) { + [IO.File]::WriteAllText($path,(@{version=1;kind='codexify-browser-companion';pid=$BrowserPid;endpoint=('http://127.0.0.1:'+$port)}|ConvertTo-Json)) + if($Old){(Get-Item -LiteralPath $path).LastWriteTimeUtc=[DateTime]::UtcNow.AddMinutes(-1)} +} +try { + Write-FixtureDescriptor $PID + if(Repair-StartLocalStaleBrowserDescriptor $fixture){throw 'A live process descriptor was moved.'} + Write-FixtureDescriptor 4294967294 + if(Repair-StartLocalStaleBrowserDescriptor $fixture){throw 'A descriptor with a listening port was moved.'} + $listener.Stop() + Write-FixtureDescriptor 4294967294 $false + if(Repair-StartLocalStaleBrowserDescriptor $fixture){throw 'A newly published descriptor was moved.'} + Write-FixtureDescriptor 4294967294 + $original=[IO.File]::ReadAllText($path) + if(-not(Repair-StartLocalStaleBrowserDescriptor $fixture)){throw 'A verified old dead-process descriptor was not recovered.'} + $backups=@(Get-ChildItem -LiteralPath (Join-Path $fixture 'stale-descriptors') -File) + if((Test-Path -LiteralPath $path) -or $backups.Count -ne 1 -or [IO.File]::ReadAllText($backups[0].FullName) -cne $original){throw 'Recovery did not preserve the descriptor exactly.'} + [pscustomobject]@{ok=$true;shellVersion=$PSVersionTable.PSVersion.ToString();checks=@('live PID preserved','listening endpoint preserved','fresh descriptor preserved','dead PID and refused endpoint recovered','old receipt backed up byte-for-byte')}|ConvertTo-Json -Depth 3 +} finally { + $listener.Stop() + $resolved=[IO.Path]::GetFullPath($fixture) + if($resolved.StartsWith($temporaryRoot,[StringComparison]::OrdinalIgnoreCase) -and (Split-Path -Leaf $resolved) -match '^codexify-stale-browser-test-[a-f0-9]{32}$'){ + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} diff --git a/scripts/test-windows-start-local.ps1 b/scripts/test-windows-start-local.ps1 new file mode 100644 index 00000000..c4afee31 --- /dev/null +++ b/scripts/test-windows-start-local.ps1 @@ -0,0 +1,134 @@ +# Run under both Windows PowerShell 5 and PowerShell 7. Never invokes the launcher +# or starts/stops the native service, supervisor, browser, or default browser. +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +. (Join-Path $PSScriptRoot 'windows-start-local-core.ps1') +function Assert-StartLocal([bool]$Condition,[string]$Message) {if(-not $Condition){throw $Message}} +$hostExe=(Get-Process -Id $PID).Path +$temporaryRoot=[IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\')+'\' +$fixture=Join-Path $temporaryRoot ('codexify-start-local-test-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $fixture +$checks=New-Object 'Collections.Generic.List[string]' +try { + $zeroScript='[Console]::Out.Write("fast-zero"); [Console]::Error.Write("diagnostic-zero"); exit 0' + $zeroEncoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($zeroScript)) + $zero=Invoke-StartLocalCli -Executable $hostExe -Arguments @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',$zeroEncoded) + Assert-StartLocal ($null -ne $zero.ExitCode -and $zero.ExitCode -eq 0 -and $zero.Output -eq 'fast-zero' -and $zero.ErrorOutput -eq 'diagnostic-zero') 'A successful child lost its exit code or captured streams.' + $checks.Add('fast zero exit and stdout/stderr capture') + $failureScript='[Console]::Out.Write("fast-failure"); [Console]::Error.Write("diagnostic-failure"); exit 7' + $failureEncoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($failureScript)) + $failure=Invoke-StartLocalCli -Executable $hostExe -Arguments @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',$failureEncoded) + Assert-StartLocal ($failure.ExitCode -eq 7 -and $failure.Output -eq 'fast-failure' -and $failure.ErrorOutput -eq 'diagnostic-failure') 'A failing child was reported as successful or lost diagnostics.' + $checks.Add('nonzero native exit receipt') + $argumentScript=Join-Path $fixture 'argument fixture.ps1' + [IO.File]::WriteAllText($argumentScript,'param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Values)' + "`r`n" + '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); [Console]::Write(($Values -join "`n")); exit 0',[Text.UTF8Encoding]::new($true)) + $arguments=@('path with spaces','quote"inside','C:\trailing space\','literal $() and ` characters',('Unicode-'+[char]0x03A9)) + $quoted=Invoke-StartLocalCli -Executable $hostExe -Arguments (@('-NoLogo','-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',$argumentScript)+$arguments) + Assert-StartLocal ($quoted.ExitCode -eq 0 -and $quoted.Output -ceq ($arguments -join "`n")) 'Windows arguments changed during native process launch.' + $checks.Add('spaces, quotes, trailing slashes, literal shell characters and Unicode') + $priorNoProxy=$env:NO_PROXY + try { + $env:NO_PROXY='existing-bypass.invalid' + $environmentScript='[Console]::Write([Environment]::GetEnvironmentVariable("NO_PROXY")); exit 0' + $environmentEncoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($environmentScript)) + $environment=Invoke-StartLocalCli -Executable $hostExe -Arguments @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',$environmentEncoded) + $bypasses=$environment.Output -split ',' + Assert-StartLocal ($environment.ExitCode -eq 0 -and 'existing-bypass.invalid' -in $bypasses -and '127.0.0.1' -in $bypasses -and 'localhost' -in $bypasses -and '::1' -in $bypasses) 'Existing bypasses or explicit loopback bypasses were lost.' + } finally {$env:NO_PROXY=$priorNoProxy} + $checks.Add('child proxy bypass preserves existing entries and includes loopback endpoints') + $waitScript='Start-Sleep -Seconds 10; exit 0' + $waitEncoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($waitScript)) + $watch=[Diagnostics.Stopwatch]::StartNew() + $timedOut=$false + try {$null=Invoke-StartLocalCli -Executable $hostExe -Arguments @('-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',$waitEncoded) -TimeoutMilliseconds 200} + catch {$timedOut=$_.Exception.Message -like '*timed out*'} + Assert-StartLocal ($timedOut -and $watch.Elapsed.TotalSeconds -lt 4) 'Native command timeout was not bounded.' + $checks.Add('bounded transient command timeout') + foreach($invalid in @('https://127.0.0.1:3120/','http://example.com/','http://localhost.evil/','http://user@localhost/','file:///C:/private',"http://localhost/`nprivate")){ + $rejected=$false + try {$null=Get-StartLocalDashboardUri $invalid} catch {$rejected=$true} + Assert-StartLocal $rejected 'An unsafe dashboard address was accepted.' + } + $privateUri=Get-StartLocalDashboardUri ('http://127.0.0.1:3120/#codexify_'+('a'*43)) + Assert-StartLocal ($privateUri.IsLoopback -and $privateUri.Scheme -eq 'http') 'A local dashboard address was rejected.' + $checks.Add('local HTTP dashboard validation without token output') + $runtimePath=Join-Path $fixture 'owner-chat.json' + $fixtureToken='codexify_'+('a'*43) + $originalDashboardProbe=(Get-Command Read-StartLocalDashboardResponse).ScriptBlock + try { + $script:dashboardProbeCount=0 + $script:dashboardResponse='{"configured":true,"connectorName":"Fixture","canSave":false}' + function Read-StartLocalDashboardResponse([int]$Port,[string]$Token,[int]$TimeoutMilliseconds=5000) { + Assert-StartLocal ($Port -eq 3120 -and $Token -ceq $fixtureToken -and $TimeoutMilliseconds -eq 5000) 'Runtime credentials or timeout were not passed to local verification.' + $script:dashboardProbeCount++ + return $script:dashboardResponse + } + [IO.File]::WriteAllText($runtimePath,(@{port=3120;token=$fixtureToken}|ConvertTo-Json)) + $verified=Get-StartLocalDashboardUriFromRuntime $runtimePath + Assert-StartLocal ($verified.IsLoopback -and $verified.Port -eq 3120 -and $script:dashboardProbeCount -eq 1) 'Valid runtime verification failed.' + $script:dashboardResponse='{"configured":false,"connectorName":null,"canSave":true}' + $null=Get-StartLocalDashboardUriFromRuntime $runtimePath + foreach($badRuntime in @( + @{port=0;token=$fixtureToken},@{port=65536;token=$fixtureToken},@{port='3120';token=$fixtureToken}, + @{port=3120.5;token=$fixtureToken},@{port=3120;token='bad-token'} + )){ + [IO.File]::WriteAllText($runtimePath,($badRuntime|ConvertTo-Json)) + $before=$script:dashboardProbeCount + $rejected=$false + try {$null=Get-StartLocalDashboardUriFromRuntime $runtimePath} catch {$rejected=$true} + Assert-StartLocal ($rejected -and $script:dashboardProbeCount -eq $before) 'Invalid runtime credentials reached the HTTP verifier.' + } + [IO.File]::WriteAllText($runtimePath,(@{port=3120;token=$fixtureToken}|ConvertTo-Json)) + foreach($badResponse in @('{"configured":"true","connectorName":null,"canSave":true}','{"configured":true,"connectorName":4,"canSave":true}','{"configured":true,"connectorName":null}','null','invalid-json')){ + $script:dashboardResponse=$badResponse + $rejected=$false + try {$null=Get-StartLocalDashboardUriFromRuntime $runtimePath} catch {$rejected=$true} + Assert-StartLocal $rejected 'An invalid dashboard response was accepted.' + } + } finally {Set-Item -Path Function:Read-StartLocalDashboardResponse -Value $originalDashboardProbe} + $checks.Add('runtime credentials validated before HTTP and tiny owner-connector schema checked') + $applicationRoot=Join-Path $env:USERPROFILE '.codexify' + $nativeExe=Join-Path $applicationRoot 'bin\codexify.exe' + $configPath=Join-Path $applicationRoot 'codexify.config.json' + $nativeStatusSamples=New-Object 'Collections.Generic.List[object]' + for($sample=1;$sample -le 3;$sample++){ + $status=Invoke-StartLocalCli -Executable $nativeExe -Arguments @('--config',$configPath,'browser','status','--json') -WorkingDirectory $applicationRoot + $state=$null + $detail='' + try {$statusDocument=$status.Output|ConvertFrom-Json; $state=$statusDocument.state; $detail=[string]$statusDocument.detail} catch {} + $safeError=($status.ErrorOutput+' '+$detail).Trim() -replace 'codexify_[A-Za-z0-9_-]+','[private token]' -replace '(?i)Bearer\s+\S+','Bearer [redacted]' -replace '(https?://[^\s#]+)#[^\s]+','$1#[redacted]' + if($safeError.Length -gt 600){$safeError=$safeError.Substring(0,600)} + $expectedExit=if($state -in @('ready','busy')){0}elseif($state -eq 'stopped'){3}elseif($state -in @('disabled','not_installed')){4}elseif($state -in @('incompatible','authentication_required','account_changed','unavailable')){1}else{-1} + $nativeStatusSamples.Add([pscustomobject]@{sample=$sample;exitCode=$status.ExitCode;state=$state;expectedExitCode=$expectedExit;error=$safeError}) + } + $nativeStatusSamples.ToArray() | ConvertTo-Json -Depth 3 | Write-Output + Assert-StartLocal (@($nativeStatusSamples|Where-Object {$null -eq $_.exitCode -or $_.expectedExitCode -lt 0 -or $_.exitCode -ne $_.expectedExitCode}).Count -eq 0) 'A native status receipt disagreed with its JSON state; all diagnostics are recorded above.' + $checks.Add('three independent installed status diagnostics preserve JSON and matching exit receipts, including genuine unavailability') + $liveRuntimePath=Join-Path $applicationRoot 'owner-chat.json' + $dashboard=Get-StartLocalDashboardUriFromRuntime $liveRuntimePath + Assert-StartLocal $dashboard.IsLoopback 'Verified live dashboard was not loopback.' + $liveRuntime=Get-Content -LiteralPath $liveRuntimePath -Raw|ConvertFrom-Json + [IO.File]::WriteAllText($runtimePath,(@{port=$liveRuntime.port;token=$fixtureToken}|ConvertTo-Json)) + $authRejected=$false + try {$null=Get-StartLocalDashboardUriFromRuntime $runtimePath} + catch {$authRejected=$_.Exception.Message -notmatch 'codexify_[A-Za-z0-9_-]+'} + Assert-StartLocal $authRejected 'An incorrect bearer was accepted or exposed in an error.' + $checks.Add('real proxy-free owner HTTP verification and rejected incorrect bearer; browser not opened') + $portConfig=Join-Path $fixture 'port.config.json' + Set-Content -LiteralPath $portConfig -Value '{"port":4123}' + Assert-StartLocal ((Get-StartLocalServerPort $portConfig) -eq 4123) 'Console startup must poll the saved port' + Set-Content -LiteralPath $portConfig -Value '{"agentChat":{}}' + Assert-StartLocal ((Get-StartLocalServerPort $portConfig) -eq 3000) 'Console startup must default to port 3000' + Set-Content -LiteralPath $portConfig -Value '{"port":70000}' + $rejected=$false;try{$null=Get-StartLocalServerPort $portConfig}catch{$rejected=$true} + Assert-StartLocal $rejected 'An invalid saved port must be rejected' + $startScript=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'windows-start-local.ps1') -Raw + Assert-StartLocal ($startScript -notmatch '127\.0\.0\.1:3000') 'Console startup must not hard-code port 3000' + $checks.Add('console startup reads the saved server port') + [pscustomobject]@{ok=$true;shellVersion=$PSVersionTable.PSVersion.ToString();checks=$checks.ToArray()} | ConvertTo-Json -Depth 3 +} finally { + $resolvedFixture=[IO.Path]::GetFullPath($fixture) + if($resolvedFixture.StartsWith($temporaryRoot,[StringComparison]::OrdinalIgnoreCase) -and (Split-Path -Leaf $resolvedFixture) -match '^codexify-start-local-test-[a-f0-9]{32}$'){ + Remove-Item -LiteralPath $resolvedFixture -Recurse -Force + } +} diff --git a/scripts/test-windows-updater-pool-idle.ps1 b/scripts/test-windows-updater-pool-idle.ps1 new file mode 100644 index 00000000..24282606 --- /dev/null +++ b/scripts/test-windows-updater-pool-idle.ps1 @@ -0,0 +1,56 @@ +# Entry-point coverage: every updater's own browser idle predicate must refuse +# a parallel pool whose free page reports idle while another page owns a Send. +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'windows-agent-update-guards.ps1') + +$script:fixtureRoot=Join-Path ([IO.Path]::GetTempPath()) ('codexify-pool-idle-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path (Join-Path $script:fixtureRoot 'browser') -Force +@{endpoint='http://127.0.0.1:1';token='test-only';pid=$PID}|ConvertTo-Json| + Set-Content -LiteralPath (Join-Path $script:fixtureRoot 'browser\descriptor.json') +$script:fixtureHealth=$null +function Invoke-RestMethod { + param([Parameter(Position=0)]$Uri,$Headers,$TimeoutSec) + if("$Uri" -like '*/v1/liveness'){return [pscustomobject]@{available=$true}} + if("$Uri" -notlike '*/v1/health'){throw "Unexpected fixture request $Uri"} + return $script:fixtureHealth +} + +function Import-UpdaterFunction([string]$Script,[string]$Name) { + $ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot $Script),[ref]$null,[ref]$null) + $definition=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $Name},$true) + if(-not $definition){throw "$Script does not define $Name"} + return [scriptblock]::Create($definition.Body.GetScriptBlock().ToString()) +} +function Read-SharedJson([string]$Path){Get-Content -LiteralPath $Path -Raw|ConvertFrom-Json} + +$base=@{kind='codexify-browser-companion';status='idle';authenticated=$true;accountFingerprint='fixture';activeActionId=$null} +$pooledBusy=$base.Clone();$pooledBusy.actionCapacity=2;$pooledBusy.availableActionSlots=1;$pooledBusy.activeActionIds=@('accepted-action') +$pooledIdle=$base.Clone();$pooledIdle.actionCapacity=2;$pooledIdle.availableActionSlots=2;$pooledIdle.activeActionIds=@() +$listOnly=$base.Clone();$listOnly.activeActionIds=@('accepted-action') + +try { + $root=$script:fixtureRoot + foreach($script in @('windows-install-local-update.ps1','windows-install-browser-patch.ps1')){ + $idle=Import-UpdaterFunction $script 'Browser-Idle' + $script:fixtureHealth=[pscustomobject]$pooledBusy + if(& $idle){throw "$script accepted a pool with an active action on another page"} + $script:fixtureHealth=[pscustomobject]$listOnly + if(& $idle){throw "$script ignored activeActionIds"} + $script:fixtureHealth=[pscustomobject]$pooledIdle + if(-not(& $idle)){throw "$script rejected a fully idle pool"} + $script:fixtureHealth=[pscustomobject]$base + if(-not(& $idle)){throw "$script rejected an idle single window"} + } + if(Test-BrowserPoolUnowned ([pscustomobject]$pooledBusy)){throw 'Shared guard accepted a partially owned pool'} + $missingSlots=$base.Clone();$missingSlots.actionCapacity=2 + if(Test-BrowserPoolUnowned ([pscustomobject]$missingSlots)){throw 'Shared guard accepted a pool without slot accounting'} + # The graceful shutdown helper must load the same guard it uses for its + # initial and final rechecks. + . (Join-Path $PSScriptRoot 'windows-browser-graceful-update.ps1') + if(-not(Get-Command Stop-ManagedBrowserGracefully -ErrorAction SilentlyContinue)){throw 'Graceful helper did not load'} + $graceful=Get-Content -LiteralPath (Join-Path $PSScriptRoot 'windows-browser-graceful-update.ps1') -Raw + if(([regex]::Matches($graceful,'Test-BrowserPoolUnowned')).Count -lt 4){throw 'Graceful helper must check pool ownership before and after the stop intent'} + 'Updater pool idle guards: ok' +} finally { + Remove-Item -LiteralPath $script:fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/windows-agent-update-guards.ps1 b/scripts/windows-agent-update-guards.ps1 new file mode 100644 index 00000000..df04f638 --- /dev/null +++ b/scripts/windows-agent-update-guards.ps1 @@ -0,0 +1,93 @@ +# Pure process classification for the guarded updater. A yielded exec call can +# leave a real build running even when HTTP activeRequests is zero. +function Test-BrowserPoolUnowned { + param([object]$Health) + # A pool can expose a free slot while another page owns a Send. Its singular + # activeActionId is then null, so the complete ownership list is required. + # Every updater checks this before any browser shutdown, and again before it. + if (-not $Health -or $Health.activeActionId) { return $false } + if ($Health.PSObject.Properties['activeActionIds'] -and + @($Health.activeActionIds | Where-Object { $_ }).Count) { return $false } + if ($Health.PSObject.Properties['actionCapacity'] -and + ($Health.actionCapacity -lt 1 -or + -not $Health.PSObject.Properties['availableActionSlots'] -or + $Health.availableActionSlots -ne $Health.actionCapacity)) { return $false } + return $true +} + +function Test-AgentUpdateBrowserIdle { + param([object]$Health) + if (-not $Health -or $Health.kind -ne 'codexify-browser-companion' -or + $Health.status -ne 'idle' -or $Health.authenticated -ne $true -or + -not $Health.accountFingerprint) { return $false } + return Test-BrowserPoolUnowned $Health +} + +function Test-AgentUpdateBrowserStopped { + param([object]$Status,[object[]]$LiveProcesses) + return $Status.state -eq 'stopped' -and $Status.running -eq $false -and @($LiveProcesses).Count -eq 0 +} + +function Test-AgentUpdateBrowserQuiescent { + param([object]$Health,[object]$Diagnostics) + if (-not (Test-AgentUpdateBrowserIdle $Health) -or -not $Diagnostics -or + $Diagnostics.generationRunning -isnot [bool] -or $Diagnostics.generationRunning) { return $false } + $pages = if ($Health.PSObject.Properties['actionCapacity']) { [int]$Health.actionCapacity } else { 1 } + return $Diagnostics.observedActionPages -eq $pages +} + +function Test-AgentUpdateProcessRunning { + param([object]$Row) + $process=Get-Process -Id ([int]$Row.ProcessId) -ErrorAction SilentlyContinue + if(-not $process){return $false} + try { + # Windows can retain a terminated process object while an exec session still + # owns its handle. Existence in CIM/OpenProcess is not evidence of work. + if($process.WaitForExit(0)){return $false} + if($Row.CreationDate -and + ([DateTimeOffset]$process.StartTime.ToUniversalTime()).ToUnixTimeMilliseconds() -ne + ([DateTimeOffset]$Row.CreationDate.ToUniversalTime()).ToUnixTimeMilliseconds()){ + return $true # Identity raced: defer rather than risk unrelated work. + } + return $true + } catch {return $true} # Unverifiable owned work must defer. + finally {$process.Dispose()} +} + +function Get-AgentUpdateWorkProcesses { + param([object[]]$Processes,[uint32]$ServerId,[uint32]$BrowserId,[string]$AppRoot,[string]$CuaRuntimeRoot,[string]$SystemRoot) + $browserPrefix=[IO.Path]::GetFullPath((Join-Path $AppRoot 'browser/runtime')).TrimEnd('\')+'\' + $tunnelPrefix=[IO.Path]::GetFullPath((Join-Path $AppRoot 'openai-tunnel')).TrimEnd('\')+'\' + $cuaPrefix=[IO.Path]::GetFullPath($CuaRuntimeRoot).TrimEnd('\')+'\' + $consolePath=[IO.Path]::GetFullPath((Join-Path $SystemRoot 'System32/conhost.exe')) + $seen=[Collections.Generic.HashSet[uint32]]::new() + $privateBrowser=[Collections.Generic.HashSet[uint32]]::new() + $frontier=@($ServerId) + $busy=@() + while($frontier.Count){ + $children=@($Processes|Where-Object {$_.ParentProcessId -in $frontier -and -not $seen.Contains([uint32]$_.ProcessId)}) + $frontier=@() + foreach($row in $children){ + $id=[uint32]$row.ProcessId + $null=$seen.Add($id) + $frontier+=$id + $exe=[string]$row.ExecutablePath + $command=[string]$row.CommandLine + if($privateBrowser.Contains([uint32]$row.ParentProcessId) -or + ($id -eq $BrowserId -and $exe.StartsWith($browserPrefix,[StringComparison]::OrdinalIgnoreCase) -and $row.Name -ieq 'codexify-browser-companion.exe')){ + $null=$privateBrowser.Add($id);continue + } + if($row.Name -ieq 'conhost.exe' -and $exe -ieq $consolePath){continue} + # Only the known idle infrastructure is exempt. Inspect its descendants: + # a user command launched through Node must still defer an update. + $cua=$exe.StartsWith($cuaPrefix,[StringComparison]::OrdinalIgnoreCase) + if($cua -and $row.Name -ieq 'node.exe' -and $row.ParentProcessId -eq $ServerId -and + $command -match '@oai[\\/]cua-repl[\\/]bin[\\/]cua-repl\.mjs'){continue} + if($cua -and $row.Name -ieq 'node_repl.exe' -and $command.Trim().Trim('"') -ieq $exe){continue} + if($row.ParentProcessId -eq $ServerId -and $row.Name -ieq 'tunnel-client-runtime.exe' -and + $exe.StartsWith($tunnelPrefix,[StringComparison]::OrdinalIgnoreCase) -and $command -match '\srun\s+--control-plane\.tunnel-id\s'){continue} + $busy+=$row + } + } + return $busy +} diff --git a/scripts/windows-apply-navigation-stall-patch.ps1 b/scripts/windows-apply-navigation-stall-patch.ps1 new file mode 100644 index 00000000..13a13783 --- /dev/null +++ b/scripts/windows-apply-navigation-stall-patch.ps1 @@ -0,0 +1,106 @@ +# Apply a pinned browser-only repair; leave the native server and goals running. +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +. (Join-Path $PSScriptRoot 'windows-start-local-core.ps1') +. (Join-Path $PSScriptRoot 'windows-agent-update-guards.ps1') +$repo=Split-Path -Parent $PSScriptRoot +$receipt=Get-Content -LiteralPath (Join-Path $repo 'browser-companion/artifacts/navigation-stall-ready.json') -Raw|ConvertFrom-Json +$root=Join-Path $env:USERPROFILE '.codexify' +$native=Join-Path $root 'bin/codexify.exe' +$browser=Join-Path $root 'browser' +$lifecyclePath=Join-Path $browser 'lifecycle.json' +function Test-BrowserAutomaticStart { + # Match the native default when no lifecycle preference has been saved. + if(-not(Test-Path -LiteralPath $lifecyclePath)){return $true} + $lifecycle=Get-Content -LiteralPath $lifecyclePath -Raw|ConvertFrom-Json + if($lifecycle.automaticStart -isnot [bool]){throw 'Browser start preference could not be verified'} + return $lifecycle.automaticStart +} +if(-not(Test-BrowserAutomaticStart)){ + @{state='deferred';reason='browser_explicitly_stopped'}|ConvertTo-Json + return +} +$archive=Join-Path $browser ('runtime/'+$receipt.version+'/app/resources/app.asar') +if($archive -ine $receipt.live){throw 'Unexpected live browser archive'} +if((Get-FileHash -LiteralPath $archive).Hash -ine $receipt.previousSha256){throw 'Browser archive changed since patch preparation'} +if((Get-FileHash -LiteralPath $receipt.candidate).Hash -ine $receipt.sha256){throw 'Candidate checksum mismatch'} +$descriptor=Get-Content -LiteralPath (Join-Path $browser 'descriptor.json') -Raw|ConvertFrom-Json +$endpoint=[uri]$descriptor.endpoint +if($endpoint.Scheme -ne 'http' -or $endpoint.Host -ne '127.0.0.1'){throw 'Unexpected control endpoint'} +$headers=@{Authorization=('Bearer '+$descriptor.token)} +$deadline=[datetime]::UtcNow.AddSeconds(45) +$verifiedStopped=$false +do { + try {$health=Invoke-RestMethod ($descriptor.endpoint+'/v1/health') -Headers $headers -TimeoutSec 5; $verifiedStopped=$false} + catch { + $ownedPrefix=[IO.Path]::GetFullPath((Join-Path $browser 'runtime')).TrimEnd('\')+'\' + $running=@(Get-CimInstance Win32_Process|Where-Object {$_.ExecutablePath -and $_.ExecutablePath.StartsWith($ownedPrefix,[StringComparison]::OrdinalIgnoreCase) -and (Test-AgentUpdateProcessRunning $_)}) + if($running.Count){throw} + $health=$null; $verifiedStopped=$true + } + if($verifiedStopped -or (Test-AgentUpdateBrowserIdle $health)){break} + # Accepted sends and unsent navigation both keep ownership until they settle. + Start-Sleep -Milliseconds 350 +}while([datetime]::UtcNow -lt $deadline) +if(-not $verifiedStopped -and -not (Test-AgentUpdateBrowserIdle $health)){throw 'Browser has not verified an idle window; update deferred'} +if(-not $verifiedStopped){ + $diagnostics=Invoke-RestMethod ($descriptor.endpoint+'/v1/diagnostics/presentation') -Headers $headers -TimeoutSec 8 + if(-not(Test-AgentUpdateBrowserQuiescent $health $diagnostics)){throw 'Browser generation has not been verified stopped on every page; update deferred'} +} +$backup=Join-Path $root ('backups/navigation-stall-'+[guid]::NewGuid().ToString('N')) +$null=New-Item -ItemType Directory -Path $backup +Copy-Item -LiteralPath $archive -Destination (Join-Path $backup 'app.asar') +Copy-Item -LiteralPath (Join-Path $root 'codexify.config.json') -Destination (Join-Path $backup 'codexify.config.json') +# Backup I/O can outlast the idle gap. Recheck ownership immediately before Stop. +if(Test-Path -LiteralPath (Join-Path $browser 'descriptor.json')){ + $health=Invoke-RestMethod ($descriptor.endpoint+'/v1/health') -Headers $headers -TimeoutSec 5 + if(-not (Test-AgentUpdateBrowserIdle $health)){throw 'The browser is no longer verified idle after backup; update deferred'} + $diagnostics=Invoke-RestMethod ($descriptor.endpoint+'/v1/diagnostics/presentation') -Headers $headers -TimeoutSec 8 + if(-not(Test-AgentUpdateBrowserQuiescent $health $diagnostics)){throw 'Browser generation changed or is unverified after backup; update deferred'} +} +if(-not(Test-BrowserAutomaticStart)){ + @{state='deferred';reason='browser_explicitly_stopped'}|ConvertTo-Json + return +} +try { +$stop=Invoke-StartLocalCli $native @('--config',(Join-Path $root 'codexify.config.json'),'browser','stop') -TimeoutMilliseconds 45000 -WorkingDirectory $root +$prefix=[IO.Path]::GetFullPath((Join-Path $browser 'runtime')).TrimEnd('\')+'\' +$stoppingDeadline=[datetime]::UtcNow.AddSeconds(15) +do { + $remaining=@(Get-CimInstance Win32_Process|Where-Object {$_.ExecutablePath -and $_.ExecutablePath.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -and (Test-AgentUpdateProcessRunning $_)}) + if($remaining.Count){Start-Sleep -Milliseconds 250} +}while($remaining.Count -and [datetime]::UtcNow -lt $stoppingDeadline) +if($remaining.Count){throw 'Managed browser is still exiting; runtime preserved'} +foreach($relative in @('Local State','Default/Preferences','Default/Network/Cookies')){ + $source=Join-Path (Join-Path $browser 'chrome-profile') $relative + if(Test-Path -LiteralPath $source){ + $target=Join-Path $backup ('profile/'+$relative) + $null=New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force + try {Copy-Item -LiteralPath $source -Destination $target} + catch [IO.IOException] {Write-Warning ('Optional profile snapshot is locked; existing profile is preserved: '+$relative)} + } +} +$pending=$archive+'.'+[guid]::NewGuid().ToString('N')+'.tmp' +Copy-Item -LiteralPath $receipt.candidate -Destination $pending +[IO.File]::Replace($pending,$archive,(Join-Path $backup 'replaced-app.asar')) +if((Get-FileHash -LiteralPath $archive).Hash -ine $receipt.sha256){throw 'Installed browser checksum mismatch'} +} finally { +$start=Invoke-StartLocalCli $native @('--config',(Join-Path $root 'codexify.config.json'),'browser','start') -TimeoutMilliseconds 45000 -WorkingDirectory $root +} +if($start.ExitCode -ne 0){ + # The CLI observation budget can expire before a healthy launcher publishes + # its descriptor. Verify the existing launch rather than starting it again. + $ready=$false;$readyDeadline=[datetime]::UtcNow.AddSeconds(30) + do { + try { + $fresh=Get-Content -LiteralPath (Join-Path $browser 'descriptor.json') -Raw|ConvertFrom-Json + $freshEndpoint=[uri]$fresh.endpoint + if($freshEndpoint.Scheme -ne 'http' -or $freshEndpoint.Host -ne '127.0.0.1'){throw 'Unexpected restarted endpoint'} + $check=Invoke-RestMethod ($fresh.endpoint+'/v1/health') -Headers @{Authorization=('Bearer '+$fresh.token)} -TimeoutSec 3 + $ready=$check.kind -eq 'codexify-browser-companion' + }catch{} + if(-not $ready){Start-Sleep -Milliseconds 500} + }while(-not $ready -and [datetime]::UtcNow -lt $readyDeadline) + if(-not $ready){throw ('Browser restart failed: '+$start.ErrorOutput)} +} +@{state='installed';backup=$backup;sha256=$receipt.sha256;stopExit=$stop.ExitCode;startExit=$start.ExitCode;startOutput=$start.Output;startError=$start.ErrorOutput}|ConvertTo-Json diff --git a/scripts/windows-autowork-state-files.ps1 b/scripts/windows-autowork-state-files.ps1 new file mode 100644 index 00000000..8bc6005c --- /dev/null +++ b/scripts/windows-autowork-state-files.ps1 @@ -0,0 +1,57 @@ +function Get-AutoworkStateFiles([string]$Root) { + # Test projects live beside real projects. The owner API is the authority + # on which projects are actually registered; recursive scans include their + # nested fixtures and expose fake active goals to recovery monitors. + $owner = Get-Content -LiteralPath (Join-Path $Root 'owner-chat.json') -Raw | ConvertFrom-Json + if ($owner.port -lt 1 -or $owner.port -gt 65535 -or -not $owner.token) { + throw 'Invalid local owner descriptor' + } + Add-Type -AssemblyName System.Net.Http + $handler = [Net.Http.HttpClientHandler]::new() + $handler.UseProxy = $false + $handler.AllowAutoRedirect = $false + $client = [Net.Http.HttpClient]::new($handler) + $client.Timeout = [TimeSpan]::FromSeconds(25) + $client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $owner.token) + try { + $reply = $client.GetAsync(('http://127.0.0.1:' + $owner.port + '/api/chats')).GetAwaiter().GetResult() + try { + if (-not $reply.IsSuccessStatusCode) { throw 'The owner chat inventory is unavailable; no filesystem guesses were used' } + $inventory = $reply.Content.ReadAsStringAsync().GetAwaiter().GetResult() | ConvertFrom-Json + } finally { $reply.Dispose() } + } finally { $client.Dispose(); $handler.Dispose() } + if (-not $inventory.PSObject.Properties['chats']) { throw 'Invalid owner chat inventory' } + Select-AutoworkStateFiles $Root $inventory +} + +function Select-AutoworkStateFiles([string]$Root, $Inventory) { + $allowed = @{} + foreach ($chat in $Inventory.chats) { $allowed[[string]$chat.id] = $true } + $projectRoot = Join-Path $Root 'projects' + foreach ($project in Get-ChildItem -LiteralPath $projectRoot -Directory) { + if ($project.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } + # Only the native storage layout, never worktrees, backups or test trees. + $chatRoot = Join-Path $project.FullName 'chats' + if (-not (Test-Path -LiteralPath $chatRoot -PathType Container)) { continue } + if ((Get-Item -LiteralPath $chatRoot).Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } + foreach ($chat in Get-ChildItem -LiteralPath $chatRoot -Directory) { + if ($chat.Name -notmatch '^[a-f0-9]{64}$' -or ($chat.Attributes -band [IO.FileAttributes]::ReparsePoint)) { continue } + $transcript = Join-Path $chat.FullName 'CHAT.md' + # Native PathBuf keeps the slash in its default '.codexify/projects' + # join. Match the API's exact path hash before accepting either spelling. + $nativeTranscript = $Root.TrimEnd('\','/') + '/projects\' + $project.Name + '\chats\' + $chat.Name + '\CHAT.md' + $sha = [Security.Cryptography.SHA256]::Create() + $registered = $false + try { + foreach ($spelling in @($transcript, $nativeTranscript)) { + $id = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($spelling)) | ForEach-Object { $_.ToString('x2') }) + if ($allowed.ContainsKey($id)) { $registered = $true; break } + } + } + finally { $sha.Dispose() } + if (-not $registered) { continue } + $path = Join-Path $chat.FullName 'automation.json' + if (Test-Path -LiteralPath $path -PathType Leaf) { Get-Item -LiteralPath $path } + } + } +} diff --git a/scripts/windows-autowork-status.ps1 b/scripts/windows-autowork-status.ps1 new file mode 100644 index 00000000..25b0de5e --- /dev/null +++ b/scripts/windows-autowork-status.ps1 @@ -0,0 +1,189 @@ +param( + [string]$Root = (Join-Path $env:USERPROFILE '.codexify'), + [string[]]$ChatPrefix = @(), + [string[]]$FollowAgentsOfChatPrefix = @(), + [int]$Port = 3000, + [string]$SnapshotPath, + [switch]$Compact +) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +. (Join-Path $PSScriptRoot 'windows-autowork-state-files.ps1') + +function Read-SharedJson([string]$Path) { + # Monitoring must not prevent the service from atomically replacing state. + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, + ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + $reader = [IO.StreamReader]::new($stream) + try { return $reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() } +} + +$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +$inventoryAvailable=$true +$inventoryError=$null +try { $files=@(Get-AutoworkStateFiles $Root) } +catch { + $files=@() + $inventoryAvailable=$false + $inventoryError=$_.Exception.Message + if($inventoryError.Length -gt 1000){$inventoryError=$inventoryError.Substring(0,1000)} +} +$uninitializedAgents=@() +if($inventoryAvailable -and $FollowAgentsOfChatPrefix.Count){ + $byKey=@{};foreach($file in $files){$byKey[$file.Directory.Name]=$file} + $pending=[Collections.Generic.Queue[string]]::new() + foreach($key in $byKey.Keys){if(@($FollowAgentsOfChatPrefix|Where-Object {$key.StartsWith($_)}).Count){$pending.Enqueue($key)}} + if(-not $pending.Count){throw 'The requested coordinating chat was not found; no chat state was changed'} + $team=[Collections.Generic.HashSet[string]]::new() + while($pending.Count){ + $key=$pending.Dequeue();if(-not $team.Add($key)){continue} + if($team.Count -gt 512){throw 'Agent membership exceeds the bounded monitor traversal'} + if(-not $byKey.ContainsKey($key)){$uninitializedAgents+=@($key);continue} + $member=Read-SharedJson $byKey[$key].FullName + foreach($child in $member.agents.children){if($child.taskKey -match '^[a-f0-9]{64}$'){$pending.Enqueue($child.taskKey)}} + } + $ChatPrefix=@($ChatPrefix)+@($team) +} +$agents = @($files | ForEach-Object { + $file = $_ + if ($ChatPrefix.Count -and -not @($ChatPrefix | Where-Object { $file.Directory.Name.StartsWith($_) }).Count) { return } + try { + $state = Read-SharedJson $file.FullName + $cursorPath = Join-Path $file.Directory.FullName 'cursor.json' + $cursor = if (Test-Path -LiteralPath $cursorPath) { Read-SharedJson $cursorPath } else { $null } + # Recent changes remain visible, including a new chat with no goal yet. + # An expired pairing or a cancelled and paused retry cannot keep an old + # chat in the default view. Explicit prefix and family views retain it. + $recent = [Math]::Max([long]$cursor.lastAgentCallAtMs, [long]$state.action.updatedAtMs) -ge ($now - 900000) + if (-not $ChatPrefix.Count -and -not $recent) { + $cancelledRetry = $state.action.status -eq 'cancelled' -and $state.action.automaticRetryPaused + $activeGoal = $state.goal.status -eq 'active' -and -not $cancelledRetry + $pairingExpiry = $state.pairing.pending.expiresAtMs + $openPairing = $state.pairing.creatingChat -and ($null -eq $pairingExpiry -or $pairingExpiry -ge $now) + if ($cursor.agentStopRequested -or (-not $activeGoal -and -not $openPairing)) { return } + } + [pscustomobject]@{ + chat = $file.Directory.Name + project = $file.Directory.Parent.Parent.Name + goal = $state.goal.status + action = $state.action.status + actionKind = $state.action.kind + pairingGeneration = $state.pairing.generation + creatingChat = [bool]$state.pairing.creatingChat + pairingExpiresAtMs = $state.pairing.pending.expiresAtMs + lastCallAgeSeconds = if ($cursor.lastAgentCallAtMs) { [math]::Max(0, [math]::Round(($now - $cursor.lastAgentCallAtMs) / 1000)) } else { $null } + toolCalls = $cursor.totalToolCalls + toolEpoch = $cursor.toolCallEpoch + toolSequence = $cursor.toolCallSequence + stopRequested = $cursor.agentStopRequested + busyUntilMs = @($cursor.agentIdleUntilMs,$cursor.agentPresenceUntilMs,$cursor.agentWaitingUntilMs,0 | ForEach-Object {[long]$_} | Measure-Object -Maximum).Maximum + turnCount = $state.goal.turnCount + sendAccepted = $state.action.sendAccepted + error = $state.action.lastError + actionUpdatedAtMs = $state.action.updatedAtMs + goalRevision = $state.goal.revision + actionGoalRevision = $state.action.goalRevision + cooldownUntilMs = $state.action.cooldownUntilMs + handoffReportPending = [bool]($state.turnRenewal.handoff -and -not $state.turnRenewal.handoff.reportComplete) + handoffAgeSeconds = if ($state.turnRenewal.handoff.requestedAtMs) { [math]::Max(0, [math]::Round(($now - $state.turnRenewal.handoff.requestedAtMs) / 1000)) } else { $null } + recoveryAttempts = $state.goalWake.recoveryAttempts + automaticRetryAttempts = $state.action.automaticRetryAttempts + accessRetryAttempts = $state.action.chatgptAccessRetryAttempts + automaticRetryPaused = $state.action.automaticRetryPaused + } + } catch { + [pscustomobject]@{chat=$file.Directory.Name; stateReadFailed=$true} + } +}) + +$browser = try { + $descriptor = Read-SharedJson (Join-Path $Root 'browser\descriptor.json') + $endpoint = [uri]$descriptor.endpoint + if ($endpoint.Scheme -ne 'http' -or $endpoint.Host -notin @('127.0.0.1', '[::1]', '::1', 'localhost')) { + throw 'Browser descriptor is not a loopback endpoint' + } + Invoke-RestMethod -Uri ($descriptor.endpoint.TrimEnd('/') + '/v1/health') -TimeoutSec 15 ` + -Headers @{Authorization=('Bearer ' + $descriptor.token)} | + Select-Object build,status,authenticated,activeActionId,@{Name='authenticationState';Expression={ + # The passive browser API returns busy/false while identity is warming. + if ($_.status -eq 'authentication_required') { 'required' } + elseif ($_.authenticated -is [bool] -and $_.authenticated) { 'verified' } + elseif ($_.status -eq 'busy') { 'pending' } + else { 'unknown' } + }} +} catch { [pscustomobject]@{status='unreachable';authenticationState='unknown'} } +$server = try { + $health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 5 + $health.status +} catch { 'unreachable' } +$exe = Join-Path $Root 'bin\codexify.exe' +$processes = @(Get-CimInstance Win32_Process -Filter "Name = 'codexify.exe'" | + Where-Object {$_.ExecutablePath -ieq $exe}) +$supervisors = @($processes | Where-Object {$_.CommandLine -match '\sservice run\s'}) +$agentTeams=$null +if(-not $inventoryAvailable){ + $agentTeams=[pscustomobject]@{available=$false;discoveryComplete=$false;allStatusesAvailable=$false;teams=$null;error='Published agent client or Node.js is unavailable'} + $helper=Join-Path $Root 'agents\client.mjs' + if((Test-Path -LiteralPath $helper -PathType Leaf) -and (Get-Command node -ErrorAction SilentlyContinue)){ + try { + $nativePreference=$PSNativeCommandUseErrorActionPreference + try { + # Partial team observations are valid JSON even when the helper exits 1. + $PSNativeCommandUseErrorActionPreference=$false + $reply=& node (Join-Path $PSScriptRoot 'windows-autowork-team-status.mjs') $helper + } finally {$PSNativeCommandUseErrorActionPreference=$nativePreference} + $agentTeams=$reply|ConvertFrom-Json + if(-not $agentTeams.PSObject.Properties['discoveryComplete']){throw 'Invalid published team observation'} + } catch { + $agentTeams=[pscustomobject]@{available=$false;discoveryComplete=$false;allStatusesAvailable=$false;teams=$null;error=$_.Exception.Message} + } + } +} +$snapshot = [pscustomobject]@{ + atUtc = [DateTime]::UtcNow.ToString('o') + inventoryAvailable = $inventoryAvailable + inventoryError = $inventoryError + server = $server + supervisorCount = $supervisors.Count + freeDiskGiB = [math]::Round(([IO.DriveInfo]::new([IO.Path]::GetPathRoot($Root))).AvailableFreeSpace / 1GB, 2) + browser = $browser + # Keep known empty/single collections as arrays; null means unknown inventory. + uninitializedAgents = if($inventoryAvailable){,@($uninitializedAgents)}else{$null} + agents = if($inventoryAvailable){,@($agents)}else{$null} + # Independent published teams do not make unknown all-chat inventory complete. + agentTeams = $agentTeams +} +$json = $snapshot | ConvertTo-Json -Depth 9 +if ($SnapshotPath) { [IO.File]::WriteAllText([IO.Path]::GetFullPath($SnapshotPath), $json) } +if ($Compact) { + # Full ownership/proof details are retained in the saved snapshot above. + foreach($team in $agentTeams.teams){ + foreach($worker in $team.workers){ + $worker.PSObject.Properties.Remove('scope') + $worker.PSObject.Properties.Remove('blockedReason') + } + } + [pscustomobject]@{ + atUtc=$snapshot.atUtc;server=$server;supervisors=$supervisors.Count;freeGiB=$snapshot.freeDiskGiB + inventoryAvailable=$inventoryAvailable;inventoryError=$inventoryError + browser=$browser.status;authenticated=$browser.authenticated;actionId=$browser.activeActionId + browserAuthentication=$browser.authenticationState + uninitializedAgents=$snapshot.uninitializedAgents + agentTeams=$agentTeams + agents=if($inventoryAvailable){,@($agents|ForEach-Object{[pscustomobject]@{ + chat=$_.chat.Substring(0,[Math]::Min(8,$_.chat.Length));goal=$_.goal;action=$_.action + actionKind=$_.actionKind;sendAccepted=$_.sendAccepted + pairingGeneration=$_.pairingGeneration;creatingChat=$_.creatingChat;pairingExpiresAtMs=$_.pairingExpiresAtMs + calls=$_.toolCalls;age=$_.lastCallAgeSeconds;busyUntilMs=$_.busyUntilMs + stopped=$_.stopRequested;paused=$_.automaticRetryPaused;error=$_.error;stateReadFailed=[bool]$_.stateReadFailed + goalRevision=$_.goalRevision;actionGoalRevision=$_.actionGoalRevision + cooldownUntilMs=$_.cooldownUntilMs;automaticRetryAttempts=$_.automaticRetryAttempts + accessRetryAttempts=$_.accessRetryAttempts;recoveryAttempts=$_.recoveryAttempts + handoffReportPending=$_.handoffReportPending;handoffAgeSeconds=$_.handoffAgeSeconds + }})}else{$null} + }|ConvertTo-Json -Depth 9 -Compress + if(-not $inventoryAvailable){exit 1} + return +} +$json +if(-not $inventoryAvailable){exit 1} diff --git a/scripts/windows-autowork-team-status.mjs b/scripts/windows-autowork-team-status.mjs new file mode 100644 index 00000000..47878ac8 --- /dev/null +++ b/scripts/windows-autowork-team-status.mjs @@ -0,0 +1,78 @@ +import {resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; + +const message = error => String(error?.message ?? error).slice(0, 1000); + +// Independent, authenticated team observations. These never replace the owner +// inventory or authorize filesystem discovery, setup, control or Send retries. +export async function monitorTeams({helperPath, timeoutMs = 20_000}) { + const started = Date.now(); + const result = { + available:false, discoveryComplete:false, allStatusesAvailable:false, + checkedAtUtc:new Date().toISOString(), elapsedMs:0, teams:null, error:null, + }; + try { + const signal = AbortSignal.timeout(timeoutMs); + const {agent} = await import(pathToFileURL(resolve(helperPath)).href); + const teams = []; + const identities = new Set(), cursors = new Set(); + let afterTeamId; + try { + for (;;) { + signal.throwIfAborted(); + const page = await agent({action:'list', ...(afterTeamId ? {afterTeamId} : {})}, {signal, timeoutMs}); + if (!Array.isArray(page.teams)) throw new Error('Invalid published team inventory'); + result.available = true; + result.teams = teams; + for (const item of page.teams) { + if (typeof item.teamId !== 'string' || !item.teamId || identities.has(item.teamId)) { + throw new Error('Invalid or repeated published team identity'); + } + identities.add(item.teamId); + teams.push({teamId:item.teamId, clientName:item.clientName, ready:item.ready, + statusAvailable:false, capacity:null, allowDelegation:null, workers:null, error:null}); + } + if (!page.hasMore) { result.discoveryComplete = true; break; } + if (typeof page.nextAfterTeamId !== 'string' || !page.nextAfterTeamId || cursors.has(page.nextAfterTeamId)) { + throw new Error('Published team pagination did not advance'); + } + afterTeamId = page.nextAfterTeamId; + cursors.add(afterTeamId); + } + } catch (error) { result.error = message(error); } + + let next = 0; + // Bound monitoring I/O, independently of the runtime's worker capacity. + await Promise.all(Array.from({length:Math.min(4, teams.length)}, async () => { + for (;;) { + const item = teams[next++]; + if (!item) return; + try { + signal.throwIfAborted(); + const {team} = await agent({action:'status', teamId:item.teamId}, {signal, timeoutMs}); + if (!Array.isArray(team?.workers)) throw new Error('Invalid published team status'); + item.capacity = team.capacity; + item.allowDelegation = team.allowDelegation; + item.workers = team.workers.map(worker => ({ + taskKey:worker.taskKey, role:worker.role, scope:worker.scope, goal:worker.goal?.status ?? null, + goalRevision:worker.goal?.revision ?? null, stopped:worker.stopped, + retired:worker.retired, action:worker.action, error:worker.lastError ?? null, + calls:worker.toolCallCount, lastCallAtMs:worker.lastCallAtMs ?? null, + queue:worker.queuePosition ?? null, + blockedReason:worker.goal?.blockedReason ? String(worker.goal.blockedReason).slice(0, 1500) : null, + })); + item.statusAvailable = true; + } catch (error) { item.error = message(error); } + } + })); + result.allStatusesAvailable = result.discoveryComplete && teams.every(team => team.statusAvailable); + } catch (error) { result.error = message(error); } + result.elapsedMs = Date.now() - started; + return result; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const result = await monitorTeams({helperPath:process.argv[2]}); + console.log(JSON.stringify(result)); + if (!result.allStatusesAvailable) process.exitCode = 1; +} diff --git a/scripts/windows-browser-graceful-update.ps1 b/scripts/windows-browser-graceful-update.ps1 new file mode 100644 index 00000000..fd825df8 --- /dev/null +++ b/scripts/windows-browser-graceful-update.ps1 @@ -0,0 +1,127 @@ +# Dot-sourced by the coordinated updater. Never terminate a live Chrome profile. +. (Join-Path $PSScriptRoot 'windows-agent-update-guards.ps1') +function Test-CleanBrowserStopExitRace([string]$BrowserRoot,[string]$DescriptorPath,[int]$OriginalLauncherPid,[string]$StopErrorPath) { + # Native Stop can receive a health reply just before the gracefully closing + # launcher exits, then fail its PID/path lookup. Accept only that exact error + # plus independently verified absence of EVERY managed browser process. + if(-not(Test-Path -LiteralPath $StopErrorPath)){return $false} + if(([string](Get-Content -LiteralPath $StopErrorPath -Raw)).Trim() -ne 'Error: Could not verify the browser companion executable'){return $false} + if(Get-CimInstance Win32_Process -Filter ("ProcessId="+$OriginalLauncherPid)){return $false} + $runtime=[IO.Path]::GetFullPath((Join-Path $BrowserRoot 'runtime')).TrimEnd('\')+'\' + $remaining=@(Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($runtime,[StringComparison]::OrdinalIgnoreCase)}) + if($remaining.Count){return $false} + if(Test-Path -LiteralPath $DescriptorPath){ + try {$current=Get-Content -LiteralPath $DescriptorPath -Raw|ConvertFrom-Json;if([int]$current.pid -ne $OriginalLauncherPid){return $false}}catch{return $false} + } + return $true +} +function Stop-ManagedBrowserGracefully([string]$BrowserRoot,[string]$NativeExe,[string]$BackupDirectory,[string]$ExpectedAccountFingerprint) { + $ErrorActionPreference='Stop' + $descriptorPath=Join-Path $BrowserRoot 'descriptor.json' + $descriptor=Get-Content -LiteralPath $descriptorPath -Raw | ConvertFrom-Json + $runtime=[IO.Path]::GetFullPath((Join-Path $BrowserRoot 'runtime')).TrimEnd('\')+'\' + $profile=[IO.Path]::GetFullPath((Join-Path $BrowserRoot 'chrome-profile')) + $endpoint=[uri]$descriptor.endpoint + if($endpoint.Scheme -ne 'http' -or $endpoint.Host -ne '127.0.0.1'){throw 'Unexpected browser endpoint'} + $headers=@{Authorization=('Bearer '+$descriptor.token)} + $until=[DateTime]::UtcNow.AddSeconds(30) + do { + $health=Invoke-RestMethod ($descriptor.endpoint.TrimEnd('/')+'/v1/health') -Headers $headers -TimeoutSec 15 + if($health.authenticated -and $health.status -eq 'idle' -and (Test-BrowserPoolUnowned $health)){break} + Start-Sleep -Milliseconds 250 + }while([DateTime]::UtcNow -lt $until) + if(-not $health.authenticated -or $health.status -ne 'idle' -or -not (Test-BrowserPoolUnowned $health)){throw 'Browser must be authenticated and idle before clean shutdown'} + if($ExpectedAccountFingerprint -and $health.accountFingerprint -ne $ExpectedAccountFingerprint){throw 'Browser account changed before the update'} + $account=$health.accountFingerprint + $chrome=@(Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { + $_.ParentProcessId -eq [int]$descriptor.pid -and $_.ExecutablePath -and + [IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($runtime,[StringComparison]::OrdinalIgnoreCase) -and + $_.CommandLine.Contains($profile) -and $_.CommandLine -notlike '*--type=*' + }) + if($chrome.Count -ne 1){throw 'Could not verify exactly one managed Chrome profile owner'} + $chromeId=[int]$chrome[0].ProcessId + $owner=Get-Process -Id $chromeId + if(-not $owner.MainWindowHandle){throw 'Managed Chrome has no window for a clean close; live browser preserved'} + $lifecycle=Join-Path $BrowserRoot 'lifecycle.json' + $previous=$true + if(Test-Path -LiteralPath $lifecycle){$previous=(Get-Content -LiteralPath $lifecycle -Raw|ConvertFrom-Json).automaticStart} + $lock=$null;$locked=$false;$stop=$null;$success=$false + try { + $lock=[IO.File]::Open((Join-Path $BrowserRoot 'lifecycle.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + $deadline=[DateTime]::UtcNow.AddSeconds(15) + do {try{$lock.Lock(0,1);$locked=$true}catch{Start-Sleep -Milliseconds 100}}while(-not $locked -and [DateTime]::UtcNow -lt $deadline) + if(-not $locked){throw 'Another browser lifecycle operation owns the profile'} + # The supported Stop command records automaticStart=false before waiting + # for this lock. It cannot force-stop Chrome while we close it normally. + $stop=Start-Process -FilePath $NativeExe -ArgumentList @('browser','stop') -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $BackupDirectory 'graceful-stop.stdout') -RedirectStandardError (Join-Path $BackupDirectory 'graceful-stop.stderr') + # Windows PowerShell 5.1 reports a null ExitCode unless the handle is opened early. + $null=$stop.Handle + $deadline=[DateTime]::UtcNow.AddSeconds(10);$disabled=$false + do { + if(Test-Path -LiteralPath $lifecycle){$disabled=-not (Get-Content -LiteralPath $lifecycle -Raw|ConvertFrom-Json).automaticStart} + if(-not $disabled){Start-Sleep -Milliseconds 100} + }while(-not $disabled -and [DateTime]::UtcNow -lt $deadline) + if(-not $disabled){throw 'Native browser stop did not settle its lifecycle intent'} + $until=[DateTime]::UtcNow.AddSeconds(15) + do { + $fresh=Invoke-RestMethod ($descriptor.endpoint.TrimEnd('/')+'/v1/health') -Headers $headers -TimeoutSec 15 + if($fresh.authenticated -and $fresh.accountFingerprint -eq $account -and $fresh.status -eq 'idle' -and (Test-BrowserPoolUnowned $fresh)){break} + Start-Sleep -Milliseconds 250 + }while([DateTime]::UtcNow -lt $until) + if(-not $fresh.authenticated -or $fresh.accountFingerprint -ne $account -or $fresh.status -ne 'idle' -or -not (Test-BrowserPoolUnowned $fresh)){throw 'Browser activity or account changed before clean shutdown; browser preserved'} + $current=Get-CimInstance Win32_Process -Filter "ProcessId=$chromeId" + if($current.ExecutablePath -ine $chrome[0].ExecutablePath -or $current.ParentProcessId -ne $descriptor.pid){throw 'Managed Chrome identity changed'} + $owner.Refresh() + if(-not $owner.CloseMainWindow()){throw 'Chrome declined clean shutdown; no forced termination attempted'} + if(-not $owner.WaitForExit(45000)){ + # Closing the last window can leave Chrome's background process alive. + # Accept that only when no window or renderer remains, then allow a + # further 30 seconds for profile and cookie writes before native stop. + $owner.Refresh() + $renderers=@(Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object {$_.ParentProcessId -eq $chromeId -and $_.CommandLine -like '*--type=renderer*'}) + if($owner.MainWindowHandle -ne [IntPtr]::Zero -or $renderers.Count){throw 'Chrome did not close cleanly; no forced termination attempted'} + if(-not $owner.WaitForExit(30000)){ + @{windowClosed=$true;backgroundProcessRetained=$chromeId;atUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $BackupDirectory 'chrome-background-after-close.json') + } + } + $launcher=Get-Process -Id ([int]$descriptor.pid) -ErrorAction SilentlyContinue + # Chrome has saved and closed its profile. The native stop command owns + # launcher shutdown and must acquire this lock before it can finish. + # Waiting for the launcher while holding the lock deadlocks that command. + $lock.Unlock(0,1);$locked=$false + if(-not $stop.WaitForExit(15000)){throw 'Native stop receipt did not finish after clean shutdown'} + $stop.Refresh() + if($stop.ExitCode -ne 0){ + $settle=[DateTime]::UtcNow.AddSeconds(30);$gone=$false + do { + $gone=Test-CleanBrowserStopExitRace -BrowserRoot $BrowserRoot -DescriptorPath $descriptorPath -OriginalLauncherPid ([int]$descriptor.pid) -StopErrorPath (Join-Path $BackupDirectory 'graceful-stop.stderr') + if(-not $gone){Start-Sleep -Milliseconds 250} + }while(-not $gone -and [DateTime]::UtcNow -lt $settle) + if(-not $gone){throw 'Native stop rejected the clean-shutdown receipt'} + @{verifiedGone=$true;originalLauncherPid=$descriptor.pid;atUtc=[DateTime]::UtcNow.ToString('o')}|ConvertTo-Json|Set-Content -LiteralPath (Join-Path $BackupDirectory 'native-stop-clean-exit-race.json') + } + if($launcher -and -not $launcher.WaitForExit(15000)){throw 'Browser launcher did not finish after the native stop receipt'} + $target=Join-Path $BackupDirectory 'browser-profile' + if(Test-Path -LiteralPath $target){throw 'Browser profile backup slot already exists'} + Copy-Item -LiteralPath $profile -Destination $target -Recurse + foreach($relative in @('Local State','Default/Preferences','Default/Network/Cookies')){ + $source=Join-Path $profile $relative;$copy=Join-Path $target $relative + if((Test-Path -LiteralPath $source) -and (Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $copy).Hash){throw 'Closed browser profile backup failed verification'} + } + $success=$true + return @{graceful=$true;profileBackupVerified=$true;chromePid=$chromeId} + } finally { + if(-not $success -and $stop -and -not $stop.HasExited){ + $client=Get-CimInstance Win32_Process -Filter ("ProcessId="+$stop.Id) + if($client.ExecutablePath -ieq $NativeExe){Stop-Process -Id $stop.Id} + } + if($locked){$lock.Unlock(0,1)} + if($lock){$lock.Dispose()} + if(-not $success -and $previous){ + # Start is idempotent for a healthy existing browser; restore the prior + # supported lifecycle choice after an aborted maintenance attempt. + $restore=Start-Process -FilePath $NativeExe -ArgumentList @('browser','start') -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $BackupDirectory 'restore-browser.stdout') -RedirectStandardError (Join-Path $BackupDirectory 'restore-browser.stderr') + $null=$restore.WaitForExit(30000) + } + } +} diff --git a/scripts/windows-browser-only-native-backup.ps1 b/scripts/windows-browser-only-native-backup.ps1 new file mode 100644 index 00000000..509418e5 --- /dev/null +++ b/scripts/windows-browser-only-native-backup.ps1 @@ -0,0 +1,14 @@ +# Browser-only deployments preserve the installed native file, including any +# read/image lease Windows has not released after the service exits. +function Backup-NativeForBrowserOnlyUpdate([string]$Executable,[string]$Candidate,[string]$BackupExecutable) { + $ErrorActionPreference='Stop' + $source=[IO.Path]::GetFullPath($Executable) + $target=[IO.Path]::GetFullPath($BackupExecutable) + if($source -ieq $target -or [IO.Path]::GetFullPath($Candidate) -ieq $target){throw 'Native backup must have its own path'} + if(Test-Path -LiteralPath $target){throw 'Native backup slot already exists'} + $originalHash=(Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + if((Get-FileHash -LiteralPath $Candidate -Algorithm SHA256).Hash -ne $originalHash){throw 'Browser-only deployment requires an unchanged native executable'} + Copy-Item -LiteralPath $source -Destination $target + if((Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -ne $originalHash){throw 'Native backup hash mismatch'} + return $originalHash +} diff --git a/scripts/windows-install-browser-patch.ps1 b/scripts/windows-install-browser-patch.ps1 new file mode 100644 index 00000000..22d33a69 --- /dev/null +++ b/scripts/windows-install-browser-patch.ps1 @@ -0,0 +1,153 @@ +param( + [Parameter(Mandatory=$true)][string]$Archive, + [Parameter(Mandatory=$true)][string]$RollbackArchive, + [Parameter(Mandatory=$true)][string]$Backup, + [switch]$Deploy +) +$ErrorActionPreference='Stop' +. (Join-Path $PSScriptRoot 'windows-agent-update-guards.ps1') +$ProgressPreference='SilentlyContinue' +$root=Join-Path $env:USERPROFILE '.codexify' +$exe=Join-Path $root 'bin\codexify.exe' +$counter=0 +$servers=@() +$stopped=$false +$installed=$false +$previousReleaseRoot=$env:CODEXIFY_BROWSER_RELEASE_ROOT +New-Item -ItemType Directory -Path $Backup -Force | Out-Null +function Record([string]$Stage,[string]$Detail) { + @{stage=$Stage;detail=$Detail;atUtc=[DateTime]::UtcNow.ToString('o')} | ConvertTo-Json -Compress | + Set-Content -LiteralPath (Join-Path $Backup 'browser-patch-status.json') + Write-Output "$Stage`: $Detail" +} +function Read-SharedJson([string]$Path) { + $stream=[IO.File]::Open($Path,[IO.FileMode]::Open,[IO.FileAccess]::Read,([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + $reader=[IO.StreamReader]::new($stream) + try {return $reader.ReadToEnd() | ConvertFrom-Json} finally {$reader.Dispose()} +} +function Invoke-Cli([string[]]$Arguments) { + $script:counter++ + $out=Join-Path $Backup "cli-$counter.stdout" + $err=Join-Path $Backup "cli-$counter.stderr" + $process=Start-Process -FilePath $exe -ArgumentList $Arguments -WindowStyle Hidden -PassThru -RedirectStandardOutput $out -RedirectStandardError $err + # Windows PowerShell 5.1 reports a null ExitCode unless the handle is opened early. + $null=$process.Handle + if(-not $process.WaitForExit(180000)){throw "CLI timed out: $($Arguments -join ' ')"} + $process.Refresh() + if($process.ExitCode -ne 0){throw "CLI failed: $($Arguments -join ' '); see $err"} + $stream=[IO.File]::Open($out,[IO.FileMode]::Open,[IO.FileAccess]::Read,([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + $reader=[IO.StreamReader]::new($stream) + try {return $reader.ReadToEnd()} finally {$reader.Dispose()} +} +function Start-ReleaseServer([string]$Path,[string]$Label) { + $ready=Join-Path $Backup ($Label+'-release.json') + if(Test-Path -LiteralPath $ready){throw 'Release server ready slot already used'} + $serverScript=Join-Path $PSScriptRoot 'windows-update-file-server.cjs' + $process=Start-Process -FilePath (Get-Command node.exe).Source -ArgumentList @(('"'+$serverScript+'"'),('"'+$Path+'"'),('"'+$ready+'"')) -WindowStyle Hidden -PassThru + $script:servers+=@($process) + for($i=0;$i -lt 80 -and -not(Test-Path -LiteralPath $ready);$i++){Start-Sleep -Milliseconds 250} + if(-not(Test-Path -LiteralPath $ready)){throw 'Local release server did not start'} + return Read-SharedJson $ready +} +function Browser-Idle { + try { + $path=Join-Path $root 'browser\descriptor.json' + if(-not(Test-Path -LiteralPath $path)){return $true} + $descriptor=Read-SharedJson $path + if(-not(Get-CimInstance Win32_Process -Filter ("ProcessId="+[int]$descriptor.pid))){return $true} + $endpoint=[uri]$descriptor.endpoint + if($endpoint.Scheme -ne 'http' -or $endpoint.Host -ne '127.0.0.1'){return $false} + $health=Invoke-RestMethod ($descriptor.endpoint.TrimEnd('/')+'/v1/health') -TimeoutSec 10 -Headers @{Authorization=('Bearer '+$descriptor.token)} + if(-not (Test-BrowserPoolUnowned $health)){return $false} + if($health.authenticated -and $health.status -eq 'unavailable'){ + # An idle signed-in page can be unusable during hydration/rate limits. + # Verify the control surface is live before replacing that broken runtime. + $liveness=Invoke-RestMethod ($descriptor.endpoint.TrimEnd('/')+'/v1/liveness') -TimeoutSec 5 -Headers @{Authorization=('Bearer '+$descriptor.token)} + return [bool]$liveness.available + } + return $health.status -in @('idle','authentication_required') + } catch {return $false} +} +function Wait-BrowserReady([string]$Version) { + $deadline=[DateTime]::UtcNow.AddSeconds(60) + do { + try { + $descriptor=Read-SharedJson (Join-Path $root 'browser\descriptor.json') + $endpoint=[uri]$descriptor.endpoint + if($endpoint.Scheme -ne 'http' -or $endpoint.Host -ne '127.0.0.1'){throw 'Unexpected browser endpoint'} + $health=Invoke-RestMethod ($descriptor.endpoint.TrimEnd('/')+'/v1/health') -TimeoutSec 5 -Headers @{Authorization=('Bearer '+$descriptor.token)} + if($health.authenticated -and $health.status -in @('idle','busy') -and $health.build.StartsWith($Version+'+protocol.')){return} + } catch {} + Start-Sleep -Seconds 1 + } while([DateTime]::UtcNow -lt $deadline) + throw 'Browser did not finish startup and account verification before the readiness deadline' +} +function Wait-BrowserStopped { + $runtime=[IO.Path]::GetFullPath((Join-Path $root 'browser\runtime')).TrimEnd('\')+'\' + $deadline=[DateTime]::UtcNow.AddSeconds(30) + do { + $remaining=@(Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($runtime,[StringComparison]::OrdinalIgnoreCase)}) + if(-not $remaining.Count){Start-Sleep -Milliseconds 750;return} + Start-Sleep -Seconds 1 + }while([DateTime]::UtcNow -lt $deadline) + throw 'Private browser processes are still exiting; runtime files were preserved' +} +function Install-WithRetry { + for($attempt=0;$attempt -lt 3;$attempt++){ + try {$null=Invoke-Cli @('browser','install');return} + catch {if($attempt -eq 2){throw};Start-Sleep -Seconds 3} + } +} +try { + foreach($path in @($Archive,$RollbackArchive,$exe)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw "Missing artifact: $path"}} + if((Get-FileHash -LiteralPath $Archive).Hash -eq (Get-FileHash -LiteralPath $RollbackArchive).Hash){throw 'Patch and rollback packages must differ'} + $listener=Get-NetTCPConnection -State Listen -LocalPort 3000 | Select-Object -First 1 + $live=Get-CimInstance Win32_Process -Filter "ProcessId=$($listener.OwningProcess)" + if($live.ExecutablePath -ine $exe){throw 'Unexpected live server executable'} + Record 'preflight_verified' 'Browser packages and live server verified' + if(-not $Deploy){return} + $release=Start-ReleaseServer $Archive 'patch' + $env:CODEXIFY_BROWSER_RELEASE_ROOT=$release.root + Record 'waiting_for_idle' 'Waiting for the shared browser to release action ownership' + $ready=$false + for($i=0;$i -lt 150;$i++){ + if(Browser-Idle){$ready=$true;break} + Start-Sleep -Seconds 2 + } + if(-not $ready){throw 'Browser did not become idle; live work was not interrupted'} + $stopped=$true + try {$null=Invoke-Cli @('browser','stop')} + catch { + # Windows may report the shutdown deadline just before Electron exits. + # Verify every private runtime process actually left before proceeding. + Wait-BrowserStopped + } + Wait-BrowserStopped + Record 'installing_browser' 'Installing checksum-verified browser patch; native server stays running' + Install-WithRetry + $installed=$true + $null=Invoke-Cli @('browser','start') + Wait-BrowserReady $release.version + if(-not(Get-CimInstance Win32_Process -Filter "ProcessId=$($live.ProcessId)")){throw 'Native server changed during browser patch'} + Record 'completed' ('Browser '+$release.version+' patch installed; native server and agent commands preserved') +} catch { + $failure=$_.Exception.Message + Record 'failed' $failure + if($stopped){ + try { + if($installed){ + $rollback=Start-ReleaseServer $RollbackArchive 'rollback' + $env:CODEXIFY_BROWSER_RELEASE_ROOT=$rollback.root + $null=Invoke-Cli @('browser','stop') + Wait-BrowserStopped + Install-WithRetry + } + $null=Invoke-Cli @('browser','start') + Record 'rolled_back' 'Previous browser package restored; native server and chat state retained' + } catch {Record 'rollback_failed' $_.Exception.Message} + } + throw $failure +} finally { + $env:CODEXIFY_BROWSER_RELEASE_ROOT=$previousReleaseRoot + foreach($process in $servers){if(-not $process.HasExited){Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue}} +} diff --git a/scripts/windows-install-local-update.ps1 b/scripts/windows-install-local-update.ps1 new file mode 100644 index 00000000..1bb0d710 --- /dev/null +++ b/scripts/windows-install-local-update.ps1 @@ -0,0 +1,439 @@ +param( + [Parameter(Mandatory=$true)][string]$Candidate, + [Parameter(Mandatory=$true)][string]$Archive, + [Parameter(Mandatory=$true)][string]$Backup, + [switch]$Deploy, + [switch]$AllowSameNative, + [switch]$TuneAutowork, + [switch]$FastSetup, + [switch]$NativeOnly, + [switch]$UseExistingTaskRecovery, + [string[]]$ConfigSet=@(), + # Interrupt running native commands instead of waiting for them. Agents rerun + # interrupted commands after the restart; the browser must still be idle. + [switch]$AllowActiveCommands, + [string]$ExpectedAccountFingerprint +) +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +# An enabled RestartCount/PT1M policy did not restart the task after a verified +# native failure. Never stop live work on that assumption again. Reject before +# profile shutdown, file changes, or any process/service management. +if($UseExistingTaskRecovery){ + throw 'UseExistingTaskRecovery is disabled: task failure recovery did not restart the service. Updates require permitted normal scheduled-task stop/start control; preserve the live service until manual maintenance is arranged.' +} +$root=Join-Path $env:USERPROFILE '.codexify' +$exe=Join-Path $root 'bin\codexify.exe' +$oldExe=Join-Path $Backup 'codexify.before-update.exe' +$server=$null +$stopped=$false +$browserQuiesced=$false +$replaced=$false +$counter=0 +$previousReleaseRoot=$env:CODEXIFY_BROWSER_RELEASE_ROOT +. (Join-Path $PSScriptRoot 'windows-browser-graceful-update.ps1') +. (Join-Path $PSScriptRoot 'windows-browser-only-native-backup.ps1') +New-Item -ItemType Directory -Path $Backup -Force | Out-Null +function Record([string]$Stage,[string]$Detail) { + @{stage=$Stage;detail=$Detail;atUtc=[DateTime]::UtcNow.ToString('o')} | + ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $Backup 'update-status.json') + Write-Output "$Stage`: $Detail" +} +function Invoke-Cli([string]$File,[string[]]$Arguments) { + $script:counter++ + $out=Join-Path $Backup "cli-$counter.stdout" + $err=Join-Path $Backup "cli-$counter.stderr" + $process=Start-Process -FilePath $File -ArgumentList $Arguments -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput $out -RedirectStandardError $err + # Windows PowerShell 5.1 reports a null ExitCode unless the handle is opened + # before the process exits. + $null=$process.Handle + # Wait for the CLI itself, not its persistent browser/service descendants. + if(-not $process.WaitForExit(180000)){throw "CLI timed out: $($Arguments -join ' ')"} + $process.Refresh() + if($process.ExitCode -ne 0){throw "CLI failed: $($Arguments -join ' '); see $err"} + $stream=[IO.File]::Open($out,[IO.FileMode]::Open,[IO.FileAccess]::Read, + ([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete)) + $reader=[IO.StreamReader]::new($stream) + try {return $reader.ReadToEnd()} finally {$reader.Dispose()} +} +function Browser-Idle { + try { + $descriptorPath=Join-Path $root 'browser\descriptor.json' + if(-not(Test-Path -LiteralPath $descriptorPath)){return $true} + $descriptor=Get-Content -LiteralPath $descriptorPath -Raw | ConvertFrom-Json + # A dead descriptor owner cannot own an active browser Send. A replacement + # launcher still starting has not published any control/action surface yet. + if(-not(Get-CimInstance Win32_Process -Filter ("ProcessId="+[int]$descriptor.pid))){return $true} + $endpoint=[uri]$descriptor.endpoint + if($endpoint.Scheme -ne 'http' -or $endpoint.Host -ne '127.0.0.1'){return $false} + $health=Invoke-RestMethod -Uri ($descriptor.endpoint.TrimEnd('/')+'/v1/health') -TimeoutSec 10 ` + -Headers @{Authorization=('Bearer '+$descriptor.token)} + return $health.status -eq 'idle' -and $health.authenticated -and (Test-BrowserPoolUnowned $health) + } catch { return $false } +} +function Active-Commands([int]$ServerId) { + $processes=@(Get-CimInstance Win32_Process) + $descendants=[Collections.Generic.HashSet[int]]::new() + $null=$descendants.Add($ServerId) + do { + $changed=$false + foreach($process in $processes){ + if($descendants.Contains([int]$process.ParentProcessId) -and $descendants.Add([int]$process.ProcessId)){$changed=$true} + } + } while($changed) + return @($processes | Where-Object {$_.ProcessId -ne $ServerId -and $descendants.Contains([int]$_.ProcessId) ` + -and $_.Name -match '^(cargo|rustc|link|clang|cmake|make|ninja|wsl|python|cmd|powershell|pwsh)(\.exe)?$'}) +} +function Wait-ManagedBrowserStopped { + $runtime=[IO.Path]::GetFullPath((Join-Path $root 'browser\runtime')).TrimEnd('\')+'\' + $deadline=[DateTime]::UtcNow.AddSeconds(30) + do { + $remaining=@(Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($runtime,[StringComparison]::OrdinalIgnoreCase)}) + if(-not $remaining.Count){Start-Sleep -Milliseconds 750;return} + Start-Sleep -Seconds 1 + }while([DateTime]::UtcNow -lt $deadline) + throw 'Private browser processes are still exiting; runtime files were preserved' +} +function Server-RequestsIdle { + try { + $health=Invoke-RestMethod 'http://127.0.0.1:3000/health' -TimeoutSec 3 + if($health.status -ne 'ok' -or $health.filesystem.occupiedWorkerSlots -gt 0 -or $health.filesystem.queuedRequests -gt 0){return $false} + foreach($upstream in $health.upstreams.PSObject.Properties){ + if($upstream.Value.activeRequests -gt 0 -or $upstream.Value.queuedRequests -gt 0){return $false} + } + return $true + }catch{return $false} +} +function Restore-NativePriority { + # The native launcher now starts the server at Normal even when an existing + # task definition is locked. Normalize the older wrapper processes too. + Get-CimInstance Win32_Process -Filter "Name='codexify.exe'" | + Where-Object {$_.ExecutablePath -ieq $exe} | + ForEach-Object { + $targetProcess=Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue + if($targetProcess){ + try {$targetProcess.PriorityClass='Normal'} + catch {if(-not $targetProcess.HasExited){throw}} + } + } +} +function Confirm-ExistingTaskRecovery([object]$Listener) { + $tasks=@(Get-ScheduledTask -TaskName 'Codexify' -TaskPath '\' -ErrorAction Stop) + if($tasks.Count -ne 1){throw 'Expected the existing root Codexify scheduled task'} + $task=$tasks[0] + if($task.TaskName -cne 'Codexify' -or $task.TaskPath -ne '\' -or -not $task.Settings.Enabled ` + -or [int]$task.State -ne 4 -or [int]$task.Settings.RestartCount -lt 1 ` + -or $task.Settings.RestartInterval -cne 'PT1M'){ + throw 'Existing Codexify task is not running with enabled one-minute failure recovery; live service preserved' + } + $currentIdentity=[Security.Principal.WindowsIdentity]::GetCurrent() + $principal=[string]$task.Principal.UserId + if($principal -match '^S-1-'){$principalSid=[Security.Principal.SecurityIdentifier]::new($principal)} + else {$principalSid=[Security.Principal.NTAccount]::new($principal).Translate([Security.Principal.SecurityIdentifier])} + if($principalSid.Value -ne $currentIdentity.User.Value -or [int]$task.Principal.RunLevel -ne 0 ` + -or [int]$task.Principal.LogonType -ne 3){throw 'Existing Codexify task must run interactively as this user without elevation'} + $actions=@($task.Actions) + if($actions.Count -ne 1){throw 'Existing Codexify task must have exactly one verified action'} + $action=$actions[0] + $engine=[IO.Path]::GetFullPath([string]$action.Execute) + $trustedEngines=@((Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe')) + if($env:ProgramFiles){$trustedEngines+=Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'} + if($engine -notin $trustedEngines -or -not(Test-Path -LiteralPath $engine -PathType Leaf) ` + -or [IO.Path]::GetFullPath([string]$action.WorkingDirectory) -ine [IO.Path]::GetFullPath($root)){ + throw 'Existing Codexify task action or working directory is not the verified user installation' + } + $arguments=[string]$action.Arguments + if($arguments -notmatch '(?i)^\s*-NoLogo\s+-NoProfile\s+-NonInteractive\s+-ExecutionPolicy\s+Bypass\s+-WindowStyle\s+Hidden\s+-EncodedCommand\s+([A-Za-z0-9+/]+={0,2})\s*$'){ + throw 'Existing Codexify task does not use the expected hidden encoded PowerShell wrapper' + } + $encoded=$matches[1] + $bytes=[Convert]::FromBase64String($encoded) + if($bytes.Length -gt 16384 -or $bytes.Length % 2 -ne 0){throw 'Existing task wrapper encoding is invalid'} + $wrapper=[Text.Encoding]::Unicode.GetString($bytes).Replace("`r`n","`n").Trim() + $quotedExe="'"+$exe.Replace("'","''")+"'" + $quotedConfig="'"+(Join-Path $root 'codexify.config.json').Replace("'","''")+"'" + $expected='$ErrorActionPreference = ''Stop'''+"`n& $quotedExe service run --config $quotedConfig`n"+'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }' + if($wrapper -cne $expected){throw 'Existing task wrapper must invoke the exact native/config paths and propagate native failure'} + $supervisor=Get-CimInstance Win32_Process -Filter ('ProcessId='+[int]$Listener.ParentProcessId) + $wrapperProcess=if($supervisor){Get-CimInstance Win32_Process -Filter ('ProcessId='+[int]$supervisor.ParentProcessId)} + if(-not $supervisor -or $supervisor.ExecutablePath -ine $exe -or -not $wrapperProcess ` + -or $wrapperProcess.ExecutablePath -ine $engine -or -not $wrapperProcess.CommandLine.Contains($encoded)){ + throw 'Live native listener ancestry does not match the verified existing task wrapper' + } + return $task +} +function Stop-VerifiedNativePair([object]$Listener) { + # Only the native supervisor and its verified listener are owned here. The + # unchanged PowerShell task wrapper propagates their failure to its recovery. + foreach($nativeId in @($Listener.ParentProcessId,$Listener.ProcessId)){ + $nativeProcess=Get-CimInstance Win32_Process -Filter ('ProcessId='+[int]$nativeId) + if($nativeProcess -and $nativeProcess.ExecutablePath -ieq $exe){Stop-Process -Id $nativeId -Force} + } +} +function Get-VerifiedNativeListener { + $owners=@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object {$_.LocalPort -in @(3000,3120)} | Select-Object -ExpandProperty OwningProcess -Unique) + if(-not $owners.Count){return $null} + if($owners.Count -ne 1){throw 'Codexify listeners no longer share one verified native process'} + $listener=Get-CimInstance Win32_Process -Filter ('ProcessId='+[int]$owners[0]) + if(-not $listener -or $listener.ExecutablePath -ine $exe){throw 'Unexpected native listener during existing task recovery'} + return $listener +} +function Wait-NativeHealthy([int]$Seconds) { + $deadline=[DateTime]::UtcNow.AddSeconds($Seconds) + do { + try { + if((Invoke-RestMethod 'http://127.0.0.1:3000/health' -TimeoutSec 3).status -eq 'ok'){ + $listener=Get-VerifiedNativeListener + if($listener){return $true} + } + }catch{} + Start-Sleep -Seconds 1 + }while([DateTime]::UtcNow -lt $deadline) + return $false +} +function Wait-BrowserReady([string]$Version) { + $deadline=[DateTime]::UtcNow.AddSeconds(120) + do { + try { + $descriptor=Get-Content -LiteralPath (Join-Path $root 'browser\descriptor.json') -Raw | ConvertFrom-Json + $endpoint=[uri]$descriptor.endpoint + if($endpoint.Scheme -ne 'http' -or $endpoint.Host -ne '127.0.0.1'){throw 'Unexpected browser endpoint'} + $health=Invoke-RestMethod ($descriptor.endpoint.TrimEnd('/')+'/v1/health') -TimeoutSec 5 -Headers @{Authorization=('Bearer '+$descriptor.token)} + if($health.authenticated -and $health.status -in @('idle','busy') -and $health.build.StartsWith($Version+'+protocol.') -and (-not $ExpectedAccountFingerprint -or $health.accountFingerprint -eq $ExpectedAccountFingerprint)){return $true} + } catch {} + Start-Sleep -Seconds 1 + } while([DateTime]::UtcNow -lt $deadline) + return $false +} +try { + foreach($path in @($Candidate,$Archive,$exe)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw "Missing artifact: $path"}} + if(Test-Path -LiteralPath $oldExe){throw 'Backup slot already used; choose a fresh backup directory'} + $candidateHash=(Get-FileHash -LiteralPath $Candidate -Algorithm SHA256).Hash + $oldHash=(Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash + if($candidateHash -eq $oldHash -and -not $AllowSameNative){throw 'Candidate equals the installed binary; use AllowSameNative only for a verified browser-only patch'} + if($candidateHash -ne $oldHash -and (Test-Path -LiteralPath (Join-Path $root 'bin\codexify.incoming-local.exe'))){throw 'Binary staging path already exists; resolve it before stopping the live service'} + $null=Invoke-Cli $Candidate @('--help') + $listeners=@(Get-NetTCPConnection -State Listen | Where-Object {$_.LocalPort -in @(3000,3120)} | Select-Object -ExpandProperty OwningProcess -Unique) + if($listeners.Count -ne 1){throw 'Expected one server owning the two Codexify listeners'} + $live=Get-CimInstance Win32_Process -Filter "ProcessId=$($listeners[0])" + if($live.ExecutablePath -ine $exe){throw 'Unexpected listener executable'} + if($UseExistingTaskRecovery){ + $null=Confirm-ExistingTaskRecovery $live + Record 'existing_task_recovery_verified' 'Existing user task, encoded native wrapper and one-minute failure recovery verified; task definition will remain unchanged' + } + Record 'preflight_verified' 'Candidate and live listener verified; current chats retained' + if(-not $Deploy){return} + # Backup directories may retain prior attempt records. Read only this new + # file-server invocation's readiness receipt; keep the old receipts intact. + $readyFile=Join-Path $Backup ('release-server-'+[guid]::NewGuid().ToString('N')+'.json') + $serverScript=Join-Path $PSScriptRoot 'windows-update-file-server.cjs' + $node=(Get-Command node.exe -ErrorAction Stop).Source + $server=Start-Process -FilePath $node -ArgumentList @(('"'+$serverScript+'"'),('"'+$Archive+'"'),('"'+$readyFile+'"')) -WindowStyle Hidden -PassThru + for($i=0;$i -lt 60 -and -not(Test-Path -LiteralPath $readyFile);$i++){Start-Sleep -Milliseconds 250} + if(-not(Test-Path -LiteralPath $readyFile)){throw 'Local release server did not start'} + $release=Get-Content -LiteralPath $readyFile -Raw | ConvertFrom-Json + $env:CODEXIFY_BROWSER_RELEASE_ROOT=$release.root + Record 'waiting_for_idle' 'Waiting for an idle browser and completion of active native commands' + $ready=$false + for($i=0;$i -lt 300;$i++){ + if((Browser-Idle) -and ($AllowActiveCommands -or ((Active-Commands $live.ProcessId).Count -eq 0 -and (Server-RequestsIdle)))){$ready=$true;break} + Start-Sleep -Seconds 2 + } + if(-not $ready){throw 'Active work did not settle; live service was not interrupted'} + Record 'closing_browser_cleanly' 'Closing the verified private Chrome profile normally before stopping its Windows job' + $runtimeRoot=[IO.Path]::GetFullPath((Join-Path $root 'browser\runtime')).TrimEnd('\')+'\' + $managedBrowser=@(Get-CimInstance Win32_Process | Where-Object {$_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath).StartsWith($runtimeRoot,[StringComparison]::OrdinalIgnoreCase)}) + if(-not $managedBrowser.Count){ + # Already stopped: no Chrome owns the profile, so back it up directly. + $profileCopy=Join-Path $Backup 'browser-profile' + Copy-Item -LiteralPath (Join-Path $root 'browser\chrome-profile') -Destination $profileCopy -Recurse + $receipt=@{graceful=$true;profileBackupVerified=(Test-Path -LiteralPath (Join-Path $profileCopy 'Local State'))} + } else { + $receipt=Stop-ManagedBrowserGracefully -BrowserRoot (Join-Path $root 'browser') -NativeExe $exe -BackupDirectory $Backup -ExpectedAccountFingerprint $ExpectedAccountFingerprint + } + if(-not $receipt.graceful -or -not $receipt.profileBackupVerified){throw 'Clean browser shutdown was not verified'} + $browserQuiesced=$true + if($UseExistingTaskRecovery -and (Get-Content -LiteralPath (Join-Path $root 'browser\lifecycle.json') -Raw | ConvertFrom-Json).automaticStart -ne $false){throw 'Browser autostart was not disabled; existing task recovery update deferred'} + Record 'browser_profile_saved' 'Chrome closed cleanly; encrypted cookies, Local State and profile files backed up and verified' + if(-not $AllowActiveCommands -and ((Active-Commands $live.ProcessId).Count -ne 0 -or -not (Server-RequestsIdle))){throw 'Native work became active during the profile snapshot; service replacement was deferred'} + if($UseExistingTaskRecovery){$null=Confirm-ExistingTaskRecovery $live} + $stopped=$true + if(-not $UseExistingTaskRecovery){$null=Invoke-Cli $exe @('service','stop')} + # Task Scheduler can leave an old wrapper detached. Stop only the two + # verified live processes; check identity again to avoid PID reuse. + Stop-VerifiedNativePair $live + $nativeExitDeadline=[DateTime]::UtcNow.AddSeconds(15) + do { + $remainingNative=@(Get-CimInstance Win32_Process -Filter "Name='codexify.exe'" | Where-Object {$_.ExecutablePath -ieq $exe}) + if(-not $remainingNative.Count){break} + Start-Sleep -Milliseconds 500 + }while([DateTime]::UtcNow -lt $nativeExitDeadline) + if($remainingNative.Count){throw ('An old Codexify process is still running: '+(($remainingNative | Select-Object -ExpandProperty ProcessId) -join ','))} + # Chrome has already saved its profile and native autostart is disabled. + # The watchdog is now stopped before replacing any browser runtime files. + try {$null=Invoke-Cli $exe @('browser','stop')} catch {Wait-ManagedBrowserStopped} + Wait-ManagedBrowserStopped + Record 'snapshot' 'Saving configuration and durable chat state before replacement' + foreach($name in @('codexify.config.json','owner-chat.json','projects','conversation-continuations','conversation-projects')){ + $source=Join-Path $root $name + if(Test-Path -LiteralPath $source -PathType Leaf){Copy-Item -LiteralPath $source -Destination (Join-Path $Backup $name)} + elseif(Test-Path -LiteralPath $source){ + # Chat state can exceed MAX_PATH; robocopy handles long paths natively. + $null=& robocopy.exe $source (Join-Path $Backup $name) /E /R:1 /W:1 /NFL /NDL /NJH /NJS /NP + if($LASTEXITCODE -ge 8){throw "Snapshot copy failed for $name (robocopy $LASTEXITCODE)"} + } + } + if($candidateHash -eq $oldHash){ + $null=Backup-NativeForBrowserOnlyUpdate -Executable $exe -Candidate $Candidate -BackupExecutable $oldExe + Record 'native_preserved' 'Browser-only update: native executable backed up and verified without staging or replacing it' + } else { + $stage=Join-Path $root 'bin\codexify.incoming-local.exe' + if(Test-Path -LiteralPath $stage){throw 'Binary staging path already exists'} + Copy-Item -LiteralPath $Candidate -Destination $stage + if((Get-FileHash -LiteralPath $stage -Algorithm SHA256).Hash -ne $candidateHash){throw 'Staged binary hash mismatch'} + Move-Item -LiteralPath $exe -Destination $oldExe + Move-Item -LiteralPath $stage -Destination $exe + $replaced=$true + } + if($TuneAutowork){ + $configPath=Join-Path $root 'codexify.config.json' + $config=Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json -AsHashtable + $config.agentChat.automation.automaticRetries=$true + $config.agentChat.automation.retryTimings=@{ + infiniteRetries=$true + actionDelaysSeconds=@(10,30,60,120) + chatgptAccessDelaysSeconds=@(30,60,120,300) + goalRecoveryDelaysSeconds=@(30,60,120) + conversationLoadIntervalSeconds=30 + conversationUnavailableCooldownSeconds=60 + rateLimitCooldownSeconds=180 + approvalResumeGraceSeconds=180 + jitterPercent=10 + } + $configStage=Join-Path $root 'codexify.config.incoming-autowork.json' + [IO.File]::WriteAllText($configStage,($config | ConvertTo-Json -Depth 100),[Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath $configStage -Destination $configPath -Force + $null=Invoke-Cli $exe @('config','validate') + Record 'autowork_tuned' 'Infinite retries enabled; bounded backoff and 30-second shared conversation load interval' + } + if($FastSetup){ + $null=Invoke-Cli $exe @('--config',(Join-Path $root 'codexify.config.json'),'config','fast-setup') + Record 'fast_setup_applied' 'Validated fast setup applied; model choices and renewal limits preserved' + } + if($ConfigSet.Count){ + # Apply validated settings between replacement and restart, so the updated + # server and browser start once with the final configuration. + foreach($entry in $ConfigSet){ + $separator=$entry.IndexOf('=') + if($separator -lt 1){throw "ConfigSet entries must be key=value: $entry"} + $null=Invoke-Cli $exe @('--config',(Join-Path $root 'codexify.config.json'),'config','set',$entry.Substring(0,$separator),$entry.Substring($separator+1)) + } + $null=Invoke-Cli $exe @('--config',(Join-Path $root 'codexify.config.json'),'config','validate') + Record 'config_set_applied' ('Applied validated settings: '+($ConfigSet -join ', ')) + } + if($NativeOnly){ + Record 'browser_runtime_preserved' 'Native-only update: keeping the existing installed browser runtime' + } else { + Record 'installing_browser' 'Installing checksum-verified local browser package through the native installer' + $null=Invoke-Cli $exe @('browser','install') + } + if($UseExistingTaskRecovery){ + # Installation may exceed the task's existing one-minute restart interval. + # Refresh only an idle verified server, so startup reads the final settings. + $recovered=Get-VerifiedNativeListener + if(-not $recovered -and @(Get-CimInstance Win32_Process -Filter "Name='codexify.exe'" | Where-Object {$_.ExecutablePath -ieq $exe}).Count){ + Record 'installed_waiting_for_idle_restart' 'Native recovery is starting without verified listeners; preserving its processes and deferring settings refresh' + return + } + if($recovered){ + if((Active-Commands $recovered.ProcessId).Count -ne 0 -or -not (Server-RequestsIdle) -or -not (Browser-Idle)){ + Record 'installed_waiting_for_idle_restart' 'Existing task recovered during installation and native work is active; preserving it and deferring settings refresh' + return + } + if((Get-Content -LiteralPath (Join-Path $root 'browser\lifecycle.json') -Raw | ConvertFrom-Json).automaticStart -ne $false){ + Record 'installed_waiting_for_idle_restart' 'Browser lifecycle changed during installation; preserving the live service and deferring settings refresh' + return + } + $null=Confirm-ExistingTaskRecovery $recovered + Stop-VerifiedNativePair $recovered + $exitDeadline=[DateTime]::UtcNow.AddSeconds(15) + while((Get-CimInstance Win32_Process -Filter ('ProcessId='+[int]$recovered.ProcessId)) -and [DateTime]::UtcNow -lt $exitDeadline){Start-Sleep -Milliseconds 250} + if(Get-CimInstance Win32_Process -Filter ('ProcessId='+[int]$recovered.ProcessId)){throw 'Recovered native server did not exit; configuration refresh remains pending'} + } + Record 'waiting_for_existing_task_recovery' 'Waiting up to 120 seconds for the unchanged scheduled task to recover the updated native service' + } else { + try {$null=Invoke-Cli $exe @('service','start')} + catch { + # A newer binary upgrades the task launcher on start. An elevated task + # cannot be modified without admin rights; it can still be started, and + # its unchanged action runs the replaced executable. + $startError=Get-Content -LiteralPath (Join-Path $Backup "cli-$counter.stderr") -Raw + if($startError -notmatch 'Access is denied'){throw} + Record 'task_launcher_preserved' 'Scheduled task launcher could not be upgraded without admin rights; starting the existing task' + Start-ScheduledTask -TaskName 'Codexify' + } + } + $healthy=Wait-NativeHealthy $(if($UseExistingTaskRecovery){120}else{60}) + if(-not $healthy){throw 'Updated server never became healthy'} + $newServerId=Get-NetTCPConnection -State Listen -LocalPort 3000 | Select-Object -First 1 -ExpandProperty OwningProcess + $initialPriority=(Get-Process -Id $newServerId).PriorityClass + Record 'native_server_priority' ("Server started at $initialPriority priority before maintenance normalization") + # Task Scheduler can publish its process after Start-ScheduledTask returns. + # Apply priority only once the new native listener actually exists. + Restore-NativePriority + $null=Invoke-Cli $exe @('browser','start') + if(-not (Wait-BrowserReady $release.version)){ + Record 'installed_waiting_for_browser_account' 'Native server and installed hashes verified; keeping the current browser alive while account verification settles' + return + } + Record 'completed' ('Installed local '+$release.version+' update; server and browser healthy; rollback binary retained') +} catch { + $failure=$_.Exception.Message + @{message=$failure;position=$_.InvocationInfo.PositionMessage;stack=$_.ScriptStackTrace;atUtc=[DateTime]::UtcNow.ToString('o')} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Backup 'failure-detail.json') + Record 'failed' $failure + if($stopped){ + try { + if($replaced){ + if($UseExistingTaskRecovery){ + $rollbackLive=Get-VerifiedNativeListener + if($rollbackLive){ + if((Active-Commands $rollbackLive.ProcessId).Count -ne 0 -or -not (Server-RequestsIdle) -or -not (Browser-Idle)){throw 'Rollback deferred because the recovered native service is working; its processes and files were preserved'} + $null=Confirm-ExistingTaskRecovery $rollbackLive + try {$null=Invoke-Cli $exe @('browser','stop')} catch {} + Stop-VerifiedNativePair $rollbackLive + } elseif(@(Get-CimInstance Win32_Process -Filter "Name='codexify.exe'" | Where-Object {$_.ExecutablePath -ieq $exe}).Count){ + throw 'Rollback deferred while a native process is starting without verified listeners; live files preserved' + } elseif(-not (Browser-Idle)){ + throw 'Rollback deferred because the browser is active without verified native listeners; live browser preserved' + } else { + try {$null=Invoke-Cli $exe @('browser','stop')} catch {} + } + } else { + try {$null=Invoke-Cli $exe @('browser','stop')} catch {} + try {$null=Invoke-Cli $exe @('service','stop')} catch {} + Get-CimInstance Win32_Process -Filter "Name='codexify.exe'" | Where-Object {$_.ExecutablePath -ieq $exe} | ForEach-Object {Stop-Process -Id $_.ProcessId -Force} + } + Start-Sleep -Seconds 1 + Move-Item -LiteralPath $exe -Destination (Join-Path $Backup 'codexify.failed-update.exe') + Move-Item -LiteralPath $oldExe -Destination $exe + } elseif(-not(Test-Path -LiteralPath $exe) -and (Test-Path -LiteralPath $oldExe)) {Move-Item -LiteralPath $oldExe -Destination $exe} + if(($TuneAutowork -or $FastSetup -or $ConfigSet.Count) -and (Test-Path -LiteralPath (Join-Path $Backup 'codexify.config.json'))){ + Copy-Item -LiteralPath (Join-Path $Backup 'codexify.config.json') -Destination (Join-Path $root 'codexify.config.json') -Force + } + if($UseExistingTaskRecovery){ + Record 'waiting_for_existing_task_rollback' 'Waiting for the unchanged task to recover the restored native binary and configuration' + if(-not (Wait-NativeHealthy 120)){throw 'Restored native service did not become healthy through existing task recovery'} + } else {$null=Invoke-Cli $exe @('service','start')} + $null=Invoke-Cli $exe @('browser','start') + Restore-NativePriority + Record 'rolled_back' 'Previous binary restored and service/browser restarted; chat state preserved' + } catch {Record 'rollback_failed' $_.Exception.Message} + } elseif($browserQuiesced){ + try {$null=Invoke-Cli $exe @('browser','start');Record 'deferred' 'Native service preserved and cleanly closed browser reopened after deferred update'} catch {Record 'browser_resume_pending' $_.Exception.Message} + } + throw $failure +} finally { + $env:CODEXIFY_BROWSER_RELEASE_ROOT=$previousReleaseRoot + if($server -and -not $server.HasExited){Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue} +} diff --git a/scripts/windows-launcher-core.ps1 b/scripts/windows-launcher-core.ps1 new file mode 100644 index 00000000..e6684d65 --- /dev/null +++ b/scripts/windows-launcher-core.ps1 @@ -0,0 +1,267 @@ +# Shared by the native Windows launcher and its smoke tests. Windows PowerShell 5.1 compatible. +Set-StrictMode -Version Latest + +function Get-LauncherProperty($Object, [string]$Name, $Default = $null) { + if ($null -ne $Object -and $null -ne $Object.PSObject.Properties[$Name]) { return $Object.$Name } + return $Default +} + +function New-LauncherContext([string]$HomeDirectory = (Join-Path $env:USERPROFILE '.codexify')) { + $root = [IO.Path]::GetFullPath($HomeDirectory) + [pscustomobject]@{ + HomeDirectory = $root + Executable = Join-Path $root 'bin\codexify.exe' + ConfigPath = Join-Path $root 'codexify.config.json' + RuntimePath = Join-Path $root 'owner-chat.json' + LauncherDirectory = Join-Path $root 'launcher' + } +} + +function Get-LauncherSettings($Context) { + if (-not (Test-Path -LiteralPath $Context.Executable -PathType Leaf)) { + throw "Codexify is missing from $($Context.Executable). Install Codexify first." + } + if (-not (Test-Path -LiteralPath $Context.ConfigPath -PathType Leaf)) { + throw 'No saved configuration found. Run codexify quickstart first.' + } + try { $config = Get-Content -LiteralPath $Context.ConfigPath -Raw | ConvertFrom-Json } + catch { throw 'The saved Codexify configuration is not valid JSON.' } + $chat = Get-LauncherProperty $config 'agentChat' + $automation = Get-LauncherProperty $chat 'automation' + $tunnels = @(Get-LauncherProperty $config 'openaiTunnels' @() | Where-Object { $null -ne $_ }) + $single = Get-LauncherProperty $config 'openaiTunnel' + if ($null -ne $single) { $tunnels += $single } + $port = Get-LauncherProperty $config 'port' 3000 + if ($null -eq $port) { $port = 3000 } + [pscustomobject]@{ + MainPort = [int]$port + ChatEnabled = (Get-LauncherProperty $chat 'enabled' $true) -ne $false + BrowserEnabled = (Get-LauncherProperty $automation 'enabled' $false) -eq $true + TunnelCount = $tunnels.Count + WorkDirectory = [string](Get-LauncherProperty $config 'workDir' '') + ConnectorName = [string](Get-LauncherProperty $automation 'connectorName' '') + } +} + +function Protect-LauncherText([string]$Value) { + $Value = $Value -replace '(?i)((?:api[_-]?key|token|secret|password)\s*[:=]\s*)[^\s,;]+', '$1[redacted]' + $Value = $Value -replace 'codexify_[A-Za-z0-9_-]+', '[private token]' + $Value = $Value -replace 'sk-[A-Za-z0-9_-]+', '[private key]' + $Value = $Value -replace '(?i)(Bearer\s+)\S+', '$1[redacted]' + return $Value +} + +function ConvertTo-LauncherArgument([string]$Value) { + # Windows argv quoting, with no cmd.exe or PowerShell evaluation. + return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"' +} + +function Invoke-LauncherCli($Context, [string[]]$Arguments, [int]$TimeoutSeconds = 30, [switch]$AllowFailure) { + $info = New-Object System.Diagnostics.ProcessStartInfo + $info.FileName = $Context.Executable + $info.Arguments = (@('--config', $Context.ConfigPath) + $Arguments | ForEach-Object { ConvertTo-LauncherArgument $_ }) -join ' ' + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + $info.StandardOutputEncoding = [Text.Encoding]::UTF8 + $info.StandardErrorEncoding = [Text.Encoding]::UTF8 + $info.WorkingDirectory = $Context.HomeDirectory + $info.EnvironmentVariables['NO_COLOR'] = '1' + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $info + try { + if (-not $process.Start()) { throw 'Could not launch Codexify.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + $process.Kill() + throw 'Codexify command timed out. Check service logs before retrying.' + } + # A persistent browser descendant can inherit a copy of the CLI's pipe + # handles. Parent exit is the command receipt; do not wait forever for + # every descendant to close those handles before checking live health. + $output = if ($stdout.Wait(1500)) { $stdout.GetAwaiter().GetResult().Trim() } else { '' } + $errorText = if ($stderr.Wait(1500)) { $stderr.GetAwaiter().GetResult().Trim() } else { '' } + if ($process.ExitCode -ne 0 -and -not $AllowFailure) { + $safeError = Protect-LauncherText $errorText + if ($safeError.Length -gt 600) { $safeError = $safeError.Substring(0, 600) } + throw "Codexify command failed ($($process.ExitCode)). $safeError" + } + return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = $output } + } finally { $process.Dispose() } +} + +function Get-LauncherJson($Context, [string[]]$Arguments) { + $reply = Invoke-LauncherCli $Context $Arguments -AllowFailure + try { return $reply.Output | ConvertFrom-Json } + catch { throw 'Codexify returned an unreadable status. Check the installed version and service logs.' } +} + +function Get-LauncherRuntime($Context) { + try { + $runtime = Get-Content -LiteralPath $Context.RuntimePath -Raw -ErrorAction Stop | ConvertFrom-Json + $port = [int]$runtime.port + if ($port -lt 1 -or $port -gt 65535 -or $runtime.token -cnotmatch '^codexify_[A-Za-z0-9_-]{43}$') { return $null } + return $runtime + } catch { return $null } +} + +function Get-LauncherHttp([string]$Url, [string]$Token = '') { + # Never follow redirects with the private owner token, and bypass machine proxy settings. + $request = [Net.HttpWebRequest]::Create($Url) + $request.Proxy = $null + $request.AllowAutoRedirect = $false + $request.Timeout = 2500 + $request.ReadWriteTimeout = 2500 + if ($Token) { $request.Headers['Authorization'] = 'Bearer ' + $Token } + $response = $null + $reader = $null + try { + try { $response = $request.GetResponse() } + catch [Net.WebException] { $response = $_.Exception.Response; if ($null -eq $response) { return $null } } + if ([int]$response.StatusCode -notin @(200, 503)) { return $null } + $reader = New-Object IO.StreamReader($response.GetResponseStream()) + return $reader.ReadToEnd() | ConvertFrom-Json + } catch { return $null } + finally { if ($reader) { $reader.Dispose() }; if ($response) { $response.Dispose() } } +} + +function Get-LauncherConnections($Context, $Settings) { + $health = Get-LauncherHttp "http://127.0.0.1:$($Settings.MainPort)/health" + $runtime = Get-LauncherRuntime $Context + $owner = $null + if ($runtime) { $owner = Get-LauncherHttp "http://127.0.0.1:$($runtime.port)/api/health" $runtime.token } + $serverReady = (Get-LauncherProperty $health 'status') -eq 'ok' + $count = Get-LauncherProperty $health 'tunnelsConfigured' + $ready = Get-LauncherProperty $health 'tunnelsReady' + # Older single-tunnel releases expose readiness through status, without counts. + $tunnelReady = $Settings.TunnelCount -gt 0 -and $serverReady + if ($null -ne $count) { $tunnelReady = $tunnelReady -and [int]$count -gt 0 -and [int]$ready -ge [int]$count } + [pscustomobject]@{ + ServerReady = $serverReady + ChatReady = $null -ne $owner -and (Get-LauncherProperty $owner 'status') -in @('ok', 'starting') + ChatPort = if ($runtime) { [int]$runtime.port } else { $null } + TunnelReady = $tunnelReady + TunnelsReady = $ready + TunnelsConfigured = if ($null -ne $count) { [int]$count } else { $Settings.TunnelCount } + } +} + +function Get-LauncherSnapshot($Context) { + $settings = Get-LauncherSettings $Context + $service = Get-LauncherJson $Context @('service', 'status', '--json') + $browser = Get-LauncherJson $Context @('browser', 'status', '--json') + $connections = Get-LauncherConnections $Context $settings + $browserReady = -not $settings.BrowserEnabled -or (Get-LauncherProperty $browser 'state') -in @('ready', 'busy') + [pscustomobject]@{ + Settings = $settings + Service = $service + BrowserState = [string](Get-LauncherProperty $browser 'state' 'unavailable') + BrowserReady = $browserReady + Connections = $connections + Ready = $connections.ChatReady -and $connections.TunnelReady -and $browserReady + CheckedAt = [DateTime]::Now.ToString('HH:mm:ss') + } +} + +function Set-LauncherProgress($ProgressState, [string]$Message) { + if ($null -ne $ProgressState) { $ProgressState.Message = $Message } +} + +function Invoke-LauncherAction($Context, [ValidateSet('Start', 'Stop', 'Refresh', 'Login', 'Reveal')][string]$Action, $ProgressState = $null) { + $problem = $null + $snapshot = $null + try { + $settings = Get-LauncherSettings $Context + switch ($Action) { + 'Start' { + Set-LauncherProgress $ProgressState 'Reading saved configuration...' + if (-not $settings.ChatEnabled) { throw 'Agent chat is disabled in the saved configuration. Enable agentChat.enabled first.' } + if ($settings.TunnelCount -eq 0) { throw 'No saved OpenAI tunnel found. Run codexify quickstart to connect one first.' } + $service = Get-LauncherJson $Context @('service', 'status', '--json') + Set-LauncherProgress $ProgressState 'Starting the Codexify service and tunnel...' + if (-not $service.installed) { + $null = Invoke-LauncherCli $Context @('service', 'install') -TimeoutSeconds 60 + } elseif (-not $service.running) { + $null = Invoke-LauncherCli $Context @('service', 'start') + } + if ($settings.BrowserEnabled) { + Set-LauncherProgress $ProgressState 'Restoring the saved ChatGPT browser session...' + $browser = Get-LauncherJson $Context @('browser', 'status', '--json') + if ($browser.state -notin @('ready', 'busy', 'authentication_required', 'account_changed')) { + $null = Invoke-LauncherCli $Context @('browser', 'start') -TimeoutSeconds 120 + } + } + Set-LauncherProgress $ProgressState 'Waiting for the chat listener and tunnel connection...' + $deadline = [DateTime]::UtcNow.AddSeconds(90) + do { + $connections = Get-LauncherConnections $Context $settings + if ($connections.ChatReady -and $connections.TunnelReady) { break } + Start-Sleep -Milliseconds 1000 + } while ([DateTime]::UtcNow -lt $deadline) + if (-not $connections.ChatReady -or -not $connections.TunnelReady) { + throw 'Startup is still incomplete. Check the connection cards and service logs; you can retry Start.' + } + } + 'Stop' { + Set-LauncherProgress $ProgressState 'Stopping the service and its tunnel...' + $service = Get-LauncherJson $Context @('service', 'status', '--json') + if ($service.installed) { $null = Invoke-LauncherCli $Context @('service', 'stop') -TimeoutSeconds 60 } + if ($settings.BrowserEnabled) { + Set-LauncherProgress $ProgressState 'Stopping the browser companion...' + $null = Invoke-LauncherCli $Context @('browser', 'stop') -TimeoutSeconds 60 + } + } + 'Login' { + Set-LauncherProgress $ProgressState 'Opening the saved ChatGPT sign-in window...' + $null = Invoke-LauncherCli $Context @('browser', 'login') -TimeoutSeconds 120 + } + 'Reveal' { + Set-LauncherProgress $ProgressState 'Showing the ChatGPT browser...' + $null = Invoke-LauncherCli $Context @('browser', 'reveal') -TimeoutSeconds 120 + } + } + Set-LauncherProgress $ProgressState 'Checking service, chat, tunnel and browser...' + $snapshot = Get-LauncherSnapshot $Context + if ($Action -eq 'Start' -and -not $snapshot.BrowserReady) { + if ($snapshot.BrowserState -in @('authentication_required', 'account_changed')) { + throw 'Chat and tunnel are online. Use Sign in to restore the ChatGPT session.' + } + throw 'Chat and tunnel are online. The browser companion still needs attention; check its status or use Sign in.' + } + } catch { + $problem = Protect-LauncherText $_.Exception.Message + if ($null -eq $snapshot) { try { $snapshot = Get-LauncherSnapshot $Context } catch {} } + } + [pscustomobject]@{ Action = $Action; Snapshot = $snapshot; Error = $problem } +} + +function Open-LauncherChat($Context) { + $runtime = Get-LauncherRuntime $Context + if (-not $runtime) { throw 'No private chat listener was found. Start Codexify first.' } + $health = Get-LauncherHttp "http://127.0.0.1:$($runtime.port)/api/health" $runtime.token + if ($null -eq $health) { throw 'The chat listener is offline. Start Codexify first.' } + $info = New-Object Diagnostics.ProcessStartInfo + $info.FileName = "http://127.0.0.1:$($runtime.port)/#$($runtime.token)" + $info.UseShellExecute = $true + $null = [Diagnostics.Process]::Start($info) +} + +function Get-LauncherShortcutArguments([string]$ScriptPath) { + return '-NoLogo -NoProfile -STA -ExecutionPolicy Bypass -WindowStyle Hidden -File ' + (ConvertTo-LauncherArgument $ScriptPath) +} + +function Set-LauncherShortcut([string]$ShortcutPath, [string]$ScriptPath, [string]$ExecutablePath) { + $shell = New-Object -ComObject WScript.Shell + try { + $link = $shell.CreateShortcut($ShortcutPath) + $link.TargetPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $link.Arguments = Get-LauncherShortcutArguments $ScriptPath + $link.WorkingDirectory = Split-Path -Parent $ScriptPath + $link.Description = 'Start Codexify chat, OpenAI tunnel and the saved ChatGPT browser session' + $link.IconLocation = "$ExecutablePath,0" + $link.WindowStyle = 7 + $link.Save() + } finally { $null = [Runtime.InteropServices.Marshal]::ReleaseComObject($shell) } +} diff --git a/scripts/windows-launcher.ps1 b/scripts/windows-launcher.ps1 new file mode 100644 index 00000000..ce0c2e29 --- /dev/null +++ b/scripts/windows-launcher.ps1 @@ -0,0 +1,241 @@ +[CmdletBinding()] +param( + [switch]$NoAutoStart, + [string]$PreviewPath, + [string]$CapturePath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase +. (Join-Path $PSScriptRoot 'windows-launcher-core.ps1') + +$context = New-LauncherContext +$mutex = $null +$activation = $null +$ownsMutex = $false +$script:job = $null +$script:pendingAction = $null +$script:snapshot = $null +$script:lastError = $null +$script:lastPhase = '' +$script:nextRefresh = [DateTime]::UtcNow +$script:capture = $CapturePath +$script:progressState = [hashtable]::Synchronized(@{ Message = '' }) +$script:preferences = @{ OpenChat = $true } +$script:startupPath = Join-Path ([Environment]::GetFolderPath('Startup')) 'Codexify Launcher.lnk' +$scriptPath = $PSCommandPath +$corePath = Join-Path $PSScriptRoot 'windows-launcher-core.ps1' +$timer = $null + +function Write-LauncherActivity([string]$Message) { + $safe = Protect-LauncherText $Message + $line = '[{0}] {1}' -f [DateTime]::Now.ToString('HH:mm:ss'), $safe + $ui.ActivityText.AppendText($line + [Environment]::NewLine) + if ($ui.ActivityText.LineCount -gt 120) { $ui.ActivityText.Text = ($ui.ActivityText.Text -split '\r?\n' | Select-Object -Last 90) -join [Environment]::NewLine } + $ui.ActivityText.ScrollToEnd() + if ($script:capture) { Add-Content -LiteralPath ($script:capture + '.log') -Value $line -Encoding UTF8 } +} + +function Save-LauncherPreferences { + $null = New-Item -ItemType Directory -Path $context.LauncherDirectory -Force + $script:preferences.OpenChat = $ui.OpenChatCheck.IsChecked -eq $true + $path = Join-Path $context.LauncherDirectory 'preferences.json' + $temporary = $path + '.tmp' + $script:preferences | ConvertTo-Json | Set-Content -LiteralPath $temporary -Encoding UTF8 + Move-Item -LiteralPath $temporary -Destination $path -Force +} + +function Set-LauncherCard([string]$Name, [string]$Text, [string]$Detail, [bool]$Ready) { + $ui[$Name + 'State'].Text = $Text + $ui[$Name + 'State'].Foreground = if ($Ready) { '#92E3C0' } else { '#D8B47A' } + $ui[$Name + 'Detail'].Text = $Detail +} + +function Update-LauncherDisplay { + $busy = $null -ne $script:job + foreach ($name in @('StartButton', 'StopButton', 'LoginButton', 'RevealButton', 'RefreshButton')) { $ui[$name].IsEnabled = -not $busy } + $ui.BusyBar.Visibility = if ($busy) { 'Visible' } else { 'Hidden' } + $ui.OpenButton.IsEnabled = $null -ne $script:snapshot -and $script:snapshot.Connections.ChatReady + if ($null -eq $script:snapshot) { + if ($script:lastError) { + $ui.Headline.Text = 'Your setup needs attention.' + $ui.ServiceBadge.Text = 'Setup unavailable' + foreach ($name in @('Chat', 'Tunnel', 'Browser')) { Set-LauncherCard $name 'Unavailable' 'Check the message below' $false } + } + return + } + $state = $script:snapshot + $con = $state.Connections + $ui.ServiceBadge.Text = if ($state.Service.running) { 'Service running' } else { 'Service stopped' } + $ui.Headline.Text = if ($state.Ready) { 'Everything is connected.' } elseif ($busy) { 'Getting your workspace ready...' } else { 'Your workspace is waiting.' } + $chatLabel = if ($con.ChatReady) { 'Online' } else { 'Offline' } + $chatDetail = if ($con.ChatPort) { 'Localhost port ' + $con.ChatPort } else { 'Waiting for the chat listener' } + Set-LauncherCard 'Chat' $chatLabel $chatDetail $con.ChatReady + $tunnelLabel = if ($con.TunnelReady) { 'Connected' } elseif ($state.Settings.TunnelCount -eq 0) { 'No tunnel' } else { 'Offline' } + $tunnelDetail = if ($null -ne $con.TunnelsReady) { '{0} of {1} tunnels ready' -f $con.TunnelsReady, $con.TunnelsConfigured } else { '{0} saved tunnel(s)' -f $state.Settings.TunnelCount } + Set-LauncherCard 'Tunnel' $tunnelLabel $tunnelDetail $con.TunnelReady + $browserLabel = switch ($state.BrowserState) { + 'ready' { 'Ready' }; 'busy' { 'Working' }; 'authentication_required' { 'Sign in' } + 'account_changed' { 'Sign in' }; 'stopped' { 'Stopped' }; 'not_installed' { 'Missing' } + 'disabled' { 'Disabled' }; default { 'Check browser' } + } + $browserDetail = if ($state.BrowserState -in @('authentication_required', 'account_changed')) { 'Use Sign in to restore your session' } elseif ($state.BrowserReady -and $state.Settings.BrowserEnabled) { 'Saved ChatGPT session restored' } else { 'Use Browser or Sign in below' } + Set-LauncherCard 'Browser' $browserLabel $browserDetail ($state.BrowserReady -and $state.Settings.BrowserEnabled) + $ui.LoginButton.IsEnabled = -not $busy -and $state.Settings.BrowserEnabled + $ui.RevealButton.IsEnabled = -not $busy -and $state.Settings.BrowserEnabled + $ui.ConfigText.Text = $context.ConfigPath + $ui.ConfigText.ToolTip = $context.ConfigPath + $project = $state.Settings.WorkDirectory -replace '^\\\\\?\\', '' + $ui.ProjectText.Text = if ($project) { 'Projects: ' + $project } else { 'Using the saved project settings' } + $ui.ProjectText.ToolTip = $ui.ProjectText.Text + $ui.LastChecked.Text = 'Checked ' + $state.CheckedAt +} + +function Save-LauncherCapture([string]$Path) { + $window.UpdateLayout() + $bitmap = New-Object Windows.Media.Imaging.RenderTargetBitmap([int]$window.ActualWidth, [int]$window.ActualHeight, 96, 96, [Windows.Media.PixelFormats]::Pbgra32) + $bitmap.Render($window) + $encoder = New-Object Windows.Media.Imaging.PngBitmapEncoder + $encoder.Frames.Add([Windows.Media.Imaging.BitmapFrame]::Create($bitmap)) + $stream = [IO.File]::Create([IO.Path]::GetFullPath($Path)) + try { $encoder.Save($stream) } finally { $stream.Dispose() } +} + +function Start-LauncherAction([string]$Action) { + if ($null -ne $script:job) { $script:pendingAction = $Action; return } + $script:lastError = $null + $script:lastPhase = '' + $script:progressState.Message = 'Checking saved setup...' + $pipeline = [PowerShell]::Create() + $null = $pipeline.AddScript('param($core, $ctx, $action, $progress); $ErrorActionPreference = "Stop"; . $core; Invoke-LauncherAction $ctx $action $progress').AddArgument($corePath).AddArgument($context).AddArgument($Action).AddArgument($script:progressState) + try { + $script:job = @{ Pipeline = $pipeline; Handle = $pipeline.BeginInvoke(); Action = $Action } + } catch { $pipeline.Dispose(); throw } + if ($Action -ne 'Refresh') { Write-LauncherActivity ($Action + ' requested.') } + Update-LauncherDisplay +} + +try { + if (-not $PreviewPath) { + $created = $false + $mutex = New-Object Threading.Mutex($false, 'Local\Codexify.WindowsLauncher', [ref]$created) + $activation = New-Object Threading.EventWaitHandle($false, [Threading.EventResetMode]::ManualReset, 'Local\Codexify.WindowsLauncher.Activate') + try { $ownsMutex = $mutex.WaitOne(0) } catch [Threading.AbandonedMutexException] { $ownsMutex = $true } + if (-not $ownsMutex) { $null = $activation.Set(); return } + $prefsPath = Join-Path $context.LauncherDirectory 'preferences.json' + if (Test-Path -LiteralPath $prefsPath) { + try { $saved = Get-Content -LiteralPath $prefsPath -Raw | ConvertFrom-Json; $script:preferences.OpenChat = (Get-LauncherProperty $saved 'OpenChat' $true) -eq $true } catch {} + } + } + [xml]$xaml = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'windows-launcher.xaml') -Raw + $reader = New-Object Xml.XmlNodeReader $xaml + $window = [Windows.Markup.XamlReader]::Load($reader) + $ui = @{} + $names = New-Object Xml.XmlNamespaceManager($xaml.NameTable) + $names.AddNamespace('x', 'http://schemas.microsoft.com/winfx/2006/xaml') + foreach ($node in $xaml.SelectNodes('//*[@x:Name]', $names)) { $name = $node.GetAttribute('Name', 'http://schemas.microsoft.com/winfx/2006/xaml'); $ui[$name] = $window.FindName($name) } + $ui.ConfigText.Text = $context.ConfigPath + $ui.OpenChatCheck.IsChecked = $script:preferences.OpenChat + $ui.StartupCheck.IsChecked = -not $PreviewPath -and (Test-Path -LiteralPath $script:startupPath) + $ui.StartButton.Add_Click({ Start-LauncherAction 'Start' }) + $ui.StopButton.Add_Click({ Start-LauncherAction 'Stop' }) + $ui.RefreshButton.Add_Click({ Start-LauncherAction 'Refresh' }) + $ui.LoginButton.Add_Click({ Start-LauncherAction 'Login' }) + $ui.RevealButton.Add_Click({ Start-LauncherAction 'Reveal' }) + $ui.OpenButton.Add_Click({ try { Open-LauncherChat $context; Write-LauncherActivity 'Opened the private chat in your default browser.' } catch { $ui.StatusText.Text = Protect-LauncherText $_.Exception.Message } }) + $ui.OpenChatCheck.Add_Click({ try { Save-LauncherPreferences } catch { $ui.StatusText.Text = 'Could not save the launcher preference.' } }) + $ui.StartupCheck.Add_Click({ + try { + if ($ui.StartupCheck.IsChecked) { + Set-LauncherShortcut $script:startupPath $scriptPath $context.Executable + Write-LauncherActivity 'Launcher will open and start services at Windows sign-in.' + } else { + if (Test-Path -LiteralPath $script:startupPath) { Remove-Item -LiteralPath $script:startupPath } + Write-LauncherActivity 'Launcher sign-in shortcut removed.' + } + } catch { $ui.StartupCheck.IsChecked = Test-Path -LiteralPath $script:startupPath; $ui.StatusText.Text = Protect-LauncherText $_.Exception.Message } + }) + if ($PreviewPath) { + $script:snapshot = [pscustomobject]@{ + Service = [pscustomobject]@{ running = $true }; BrowserState = 'ready'; BrowserReady = $true; Ready = $true; CheckedAt = '12:00:00' + Settings = [pscustomobject]@{ TunnelCount = 1; BrowserEnabled = $true; WorkDirectory = 'C:\Projects'; ConnectorName = 'Codexify' } + Connections = [pscustomobject]@{ ChatReady = $true; ChatPort = 3120; TunnelReady = $true; TunnelsReady = 1; TunnelsConfigured = 1 } + } + Update-LauncherDisplay + $ui.StatusText.Text = 'Design preview. No services have been started.' + Write-LauncherActivity 'Preview of the connected launcher.' + $window.Add_ContentRendered({ Save-LauncherCapture $PreviewPath; $window.Close() }) + } else { + $timer = New-Object Windows.Threading.DispatcherTimer + $timer.Interval = [TimeSpan]::FromMilliseconds(200) + $timer.Add_Tick({ + try { + if ($activation.WaitOne(0)) { + $null = $activation.Reset() + if ($window.WindowState -eq 'Minimized') { $window.WindowState = 'Normal' } + $null = $window.Activate() + } + if ($script:job) { + $phase = [string]$script:progressState.Message + if ($phase -ne $script:lastPhase) { + $script:lastPhase = $phase + $ui.StatusText.Text = $phase + if ($script:job.Action -ne 'Refresh') { Write-LauncherActivity $phase } + } + if ($script:job.Handle.IsCompleted) { + $completed = $script:job + $script:job = $null + try { + $results = $completed.Pipeline.EndInvoke($completed.Handle) + if ($results.Count -ne 1) { throw 'The launcher did not receive a status result.' } + $result = $results[0] + $script:snapshot = $result.Snapshot + $script:lastError = $result.Error + } catch { $script:lastError = Protect-LauncherText $_.Exception.Message } + finally { $completed.Pipeline.Dispose() } + $script:nextRefresh = [DateTime]::UtcNow.AddSeconds(10) + if ($script:lastError) { + $ui.StatusText.Text = $script:lastError + $ui.StatusText.Foreground = '#E8B37F' + Write-LauncherActivity $script:lastError + } else { + $ui.StatusText.Foreground = '#BDC9D5' + $ui.StatusText.Text = if ($script:snapshot.Ready) { 'Chat, tunnel and browser are ready.' } else { 'Use Start all to connect, or check the status above.' } + if ($completed.Action -ne 'Refresh') { Write-LauncherActivity ($completed.Action + ' finished. ' + $ui.StatusText.Text) } + if ($completed.Action -eq 'Start' -and $script:preferences.OpenChat -and $script:snapshot.Connections.ChatReady) { + try { Open-LauncherChat $context; Write-LauncherActivity 'Opened the private chat in your default browser.' } catch { Write-LauncherActivity (Protect-LauncherText $_.Exception.Message) } + } + } + Update-LauncherDisplay + if ($script:capture) { try { Save-LauncherCapture $script:capture; $script:capture = '' } catch { Write-LauncherActivity ('Could not save the UI capture: ' + $_.Exception.Message) } } + } + } elseif ($script:pendingAction) { + $action = $script:pendingAction; $script:pendingAction = $null; Start-LauncherAction $action + } elseif ([DateTime]::UtcNow -ge $script:nextRefresh) { Start-LauncherAction 'Refresh' } + } catch { $ui.StatusText.Text = Protect-LauncherText $_.Exception.Message; if ($script:capture) { Add-Content -LiteralPath ($script:capture + '.log') -Value $ui.StatusText.Text -Encoding UTF8 } } + }) + $window.Add_ContentRendered({ + Write-LauncherActivity 'Using the installed Codexify configuration and saved browser profile.' + if ($NoAutoStart) { Start-LauncherAction 'Refresh' } else { Start-LauncherAction 'Start' } + $timer.Start() + }) + } + $window.Add_Closing({ + if ($script:job -and $script:job.Action -ne 'Refresh') { + $_.Cancel = $true + $ui.StatusText.Text = 'Please let the current action finish before closing.' + } + }) + $null = $window.ShowDialog() +} catch { + $safe = Protect-LauncherText $_.Exception.Message + if (-not $PreviewPath -and -not $CapturePath) { $null = [Windows.MessageBox]::Show($safe, 'Codexify Launcher', 'OK', 'Error') } + throw +} finally { + if ($timer) { $timer.Stop() } + if ($script:job) { $script:job.Pipeline.Stop(); $script:job.Pipeline.Dispose() } + if ($activation) { $activation.Dispose() } + if ($ownsMutex) { $mutex.ReleaseMutex() } + if ($mutex) { $mutex.Dispose() } +} diff --git a/scripts/windows-launcher.xaml b/scripts/windows-launcher.xaml new file mode 100644 index 00000000..50be3d84 --- /dev/null +++ b/scripts/windows-launcher.xaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +