diff --git a/windows/CHANGELOG.md b/windows/CHANGELOG.md index 5ec35427..d857c49d 100644 --- a/windows/CHANGELOG.md +++ b/windows/CHANGELOG.md @@ -4,6 +4,7 @@ ### 修复 +- Windows 托盘新增安全的 injector 恢复监视:Codex 关闭重开导致原 browser identity 与 watcher 一同退出后,只有在记录的 injector 已确认停止、且新 CDP 端点仍由已注册的官方 Codex 包持有时,才会通过现有启动流程重新建立 watcher;恢复模式禁止启动或重启 Codex,避免托盘在用户主动关闭应用后把它重新拉起(#200)。 - 启动编排三处加固(#222):① 启动后的一次性验证改为 90 秒重试窗口——慢机器上 Codex 首屏尚未渲染完时不再被误判失败,进而不再连带停掉刚拉起的 watcher、也不再把 Codex 无调试口重启(此前皮肤因此完全不出现);② 启动失败回滚改用自有进程对象停止注入器并把等待延长到 15 秒——过早判定「did not stop」曾遗留互相清除对方运行时的双 watcher;③ 端点归属校验放宽到任一已注册 OpenAI.Codex 版本——商店自动更新中途换包目录后,不再拒认仍在运行的健康皮肤会话(`verify-dream-skin.ps1` 同步生效,托盘经由 start 脚本自动继承全部修复)。新增回归断言锁定重试窗口、回滚等待与版本回退逻辑。 - 修复 `--verify` 缺少 `--theme-dir` 参数的隐性错配:注入器在无该参数时回退到引擎 `assets` 内置主题作为期望值,源码安装下 watcher 应用的是暂存激活主题,二者永不一致——验证从一开始就注定失败(重试窗口暴露了这一点)。start 与 `verify-dream-skin.ps1` 现与 watcher 使用同一 `--theme-dir`(macOS 包装器一直如此),并新增断言防回退。 diff --git a/windows/scripts/common-windows.ps1 b/windows/scripts/common-windows.ps1 index 6783ea3a..197672b2 100644 --- a/windows/scripts/common-windows.ps1 +++ b/windows/scripts/common-windows.ps1 @@ -830,12 +830,30 @@ function Get-DreamSkinProcessStartedAt { } } -function Stop-DreamSkinRecordedInjector { +function Get-DreamSkinRecordedInjectorProcess { param([AllowNull()][object]$State) - if ($null -eq $State -or -not $State.injectorPid) { return $true } + if ($null -eq $State -or -not $State.injectorPid) { return $null } $processId = [int]$State.injectorPid - $process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue - if (-not $process) { return $true } + $processHandle = Get-Process -Id $processId -ErrorAction SilentlyContinue + if ($null -eq $processHandle) { return $null } + try { + $processInfo = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction Stop + } catch { + if ($processHandle.HasExited) { + $processHandle.Dispose() + return $null + } + $processHandle.Dispose() + throw "The recorded injector PID $processId is running, but its identity cannot be inspected. State was preserved." + } + if (-not $processInfo) { + if ($processHandle.HasExited) { + $processHandle.Dispose() + return $null + } + $processHandle.Dispose() + throw "The recorded injector PID $processId is running, but its identity cannot be inspected. State was preserved." + } $expectedInjector = if ($State.injectorPath) { "$($State.injectorPath)" @@ -844,9 +862,10 @@ function Stop-DreamSkinRecordedInjector { } else { $null } - $processPath = Get-DreamSkinProcessExecutablePath -ProcessInfo $process - $commandLine = "$($process.CommandLine)" + $processPath = Get-DreamSkinProcessExecutablePath -ProcessInfo $processInfo + $commandLine = "$($processInfo.CommandLine)" if (-not $processPath -or -not $commandLine) { + $processHandle.Dispose() throw "The recorded injector PID $processId is running, but its identity cannot be inspected. State was preserved." } $isNodeExecutable = [System.IO.Path]::GetFileName("$processPath") -ieq 'node.exe' @@ -865,18 +884,76 @@ function Stop-DreamSkinRecordedInjector { $browserPattern = '(?:^|\s)(?i:--browser-id)(?:=|\s+)' + [regex]::Escape("$($State.browserId)") + '(?=$|\s)' $injectorMatches = $injectorMatches -and [regex]::IsMatch($commandLine, $browserPattern) } - $startedAt = Get-DreamSkinProcessStartedAt -ProcessId $processId + try { + $startedAt = $processHandle.StartTime.ToUniversalTime().ToString('o') + } catch { + if ($processHandle.HasExited) { + $processHandle.Dispose() + return $null + } + $processHandle.Dispose() + throw "The recorded injector PID $processId is running, but its start time cannot be inspected. State was preserved." + } $startMatches = -not $State.injectorStartedAt -or $startedAt -eq "$($State.injectorStartedAt)" $identityMatches = [bool]($isNodeExecutable -and $nodeMatches -and $injectorMatches -and $startMatches) if (-not $identityMatches) { + $processHandle.Dispose() throw "The recorded injector PID $processId is running, but its visible identity does not match the saved Dream Skin process. State was preserved." } - Stop-Process -Id $processId -Force -ErrorAction Stop - try { Wait-Process -Id $processId -Timeout 5 -ErrorAction Stop } catch {} - if (Get-Process -Id $processId -ErrorAction SilentlyContinue) { - throw "The recorded Dream Skin injector did not stop: PID $processId" + return $processHandle +} + +function Get-DreamSkinInjectorRecoveryContext { + param([AllowNull()][object]$State) + if ($null -eq $State -or -not $State.port) { return $null } + $recordedInjector = Get-DreamSkinRecordedInjectorProcess -State $State + if ($null -ne $recordedInjector) { + $recordedInjector.Dispose() + return $null + } + + $port = 0 + if (-not [int]::TryParse("$($State.port)", [ref]$port)) { return $null } + Assert-DreamSkinPort -Port $port + if (Test-DreamSkinPortAvailable -Port $port) { return $null } + + $codex = Get-DreamSkinCodexInstallFromState -State $State + $identity = if ($null -ne $codex) { + Get-DreamSkinVerifiedCdpIdentity -Port $port -Codex $codex + } else { + $null + } + if ($null -eq $identity) { + $registered = Get-DreamSkinVerifiedCdpIdentityForAnyRegistered -Port $port + if ($null -eq $registered) { return $null } + $codex = $registered.Codex + $identity = $registered.Identity + } + + return [pscustomobject]@{ + Port = $port + BrowserId = $identity.BrowserId + Codex = $codex + } +} + +function Stop-DreamSkinRecordedInjector { + param([AllowNull()][object]$State) + $process = Get-DreamSkinRecordedInjectorProcess -State $State + if ($null -eq $process) { return $true } + $processId = [int]$process.Id + try { + if (-not $process.HasExited) { + Stop-Process -InputObject $process -Force -ErrorAction Stop + } + [void]$process.WaitForExit(15000) + if (-not $process.HasExited) { + throw "The recorded Dream Skin injector did not stop: PID $processId" + } + } finally { + $process.Dispose() } return $true } diff --git a/windows/scripts/start-dream-skin.ps1 b/windows/scripts/start-dream-skin.ps1 index 8925d552..c04577f9 100644 --- a/windows/scripts/start-dream-skin.ps1 +++ b/windows/scripts/start-dream-skin.ps1 @@ -4,7 +4,8 @@ param( [switch]$RestartExisting, [switch]$PromptRestart, [string]$ProfilePath, - [switch]$ForegroundInjector + [switch]$ForegroundInjector, + [switch]$RecoverExisting ) $ErrorActionPreference = 'Stop' @@ -16,6 +17,9 @@ $Injector = Join-Path $PSScriptRoot 'injector.mjs' $operationLock = Enter-DreamSkinOperationLock try { Assert-DreamSkinPort -Port $Port + if ($RecoverExisting -and ($RestartExisting -or $PromptRestart)) { + throw 'RecoverExisting cannot restart Codex or prompt for restart consent.' + } if ($ProfilePath) { $ProfilePath = [System.IO.Path]::GetFullPath($ProfilePath) } $node = Get-DreamSkinNodeRuntime $currentCodex = Get-DreamSkinCodexInstall @@ -29,8 +33,12 @@ try { $VerifyPath = Join-Path $StateRoot 'verify.log' $themePaths = Initialize-DreamSkinThemeStore -SkillRoot (Split-Path -Parent $PSScriptRoot) -StateRoot $StateRoot $pauseWasSet = Test-DreamSkinPaused -StateRoot $StateRoot + $pauseCleared = $false $previousState = Read-DreamSkinState -Path $StatePath + if ($RecoverExisting -and -not $ProfilePath -and $null -ne $previousState -and $previousState.profilePath) { + $ProfilePath = [System.IO.Path]::GetFullPath("$($previousState.profilePath)") + } if (-not $PortExplicit -and $null -ne $previousState -and $previousState.port) { $savedPort = [int]$previousState.port Assert-DreamSkinPort -Port $savedPort @@ -94,6 +102,9 @@ try { Get-DreamSkinCodexProcesses -Codex $codexToStop } $closedExistingCodex = $false + if ($RecoverExisting -and -not $debugReady) { + throw 'No verified existing Codex CDP session is available for injector recovery.' + } if (-not $debugReady -and $codexProcesses.Count -gt 0) { $restartAuthorized = [bool]$RestartExisting if (-not $restartAuthorized -and $PromptRestart) { @@ -113,7 +124,11 @@ try { $launchedWithCdp = $false try { - if ($null -eq (Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex)) { + $existingIdentity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex + if ($RecoverExisting -and $null -eq $existingIdentity) { + throw 'The verified Codex CDP session closed before injector recovery completed.' + } + if ($null -eq $existingIdentity) { if (-not (Test-DreamSkinPortAvailable -Port $Port)) { if ($PortExplicit) { throw "Port $Port is already occupied by an unverified listener. Choose another port." } $Port = Select-DreamSkinPort -PreferredPort $Port @@ -175,8 +190,14 @@ try { # Keep a paused, already-running watcher paused until all state checks and any # restart consent have succeeded. A cancelled prompt must be side-effect free. - Set-DreamSkinPaused -Paused $false -StateRoot $StateRoot | Out-Null - $pauseCleared = $true + if ($RecoverExisting) { + if (Test-DreamSkinPaused -StateRoot $StateRoot) { + throw 'Injector recovery stopped because Dream Skin was paused before it could attach.' + } + } else { + Set-DreamSkinPaused -Paused $false -StateRoot $StateRoot | Out-Null + $pauseCleared = $true + } if ($ForegroundInjector) { try { diff --git a/windows/scripts/tray-dream-skin.ps1 b/windows/scripts/tray-dream-skin.ps1 index 5d2e62eb..6cc60f3c 100644 --- a/windows/scripts/tray-dream-skin.ps1 +++ b/windows/scripts/tray-dream-skin.ps1 @@ -23,6 +23,11 @@ $mutex = [System.Threading.Mutex]::new($false, "Local\CodexDreamSkin.$sid.Tray") $acquired = $false $notify = $null $trayIcon = $null +$recoveryTimer = $null +$recoveryMonitor = @{ + Process = $null + NextAttemptAt = [datetime]::MinValue +} try { try { $acquired = $mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { $acquired = $true } if (-not $acquired) { exit 0 } @@ -55,7 +60,31 @@ try { $scriptToken = ConvertTo-DreamSkinProcessArgument -Value $Script $argumentLine = '-NoProfile -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File ' + $scriptToken if ($Arguments.Count -gt 0) { $argumentLine += ' ' + ($Arguments -join ' ') } - Start-Process -FilePath $powershell -ArgumentList $argumentLine -WindowStyle Hidden | Out-Null + return Start-Process -FilePath $powershell -ArgumentList $argumentLine -WindowStyle Hidden -PassThru + } + + function Invoke-DreamSkinTrayRecovery { + if (Test-DreamSkinPaused -StateRoot $StateRoot) { return } + if ($null -ne $recoveryMonitor.Process) { + try { + if (-not $recoveryMonitor.Process.HasExited) { return } + $recoveryMonitor.Process.Dispose() + } catch {} + $recoveryMonitor.Process = $null + } + $now = Get-Date + if ($now -lt $recoveryMonitor.NextAttemptAt) { return } + + $state = Read-DreamSkinState -Path $paths.State + $context = Get-DreamSkinInjectorRecoveryContext -State $state + if ($null -eq $context) { return } + + # The start script repeats the official-package/CDP ownership checks and + # RecoverExisting prevents this monitor from launching or restarting Codex. + $recoveryMonitor.NextAttemptAt = $now.AddSeconds(30) + $recoveryMonitor.Process = Start-DreamSkinPowerShell -Script $startScript -Arguments @( + '-Port', "$($context.Port)", '-RecoverExisting' + ) } function Add-DreamSkinTrayItem { @@ -245,8 +274,25 @@ try { Show-DreamSkinTrayError -Message $_.Exception.Message } }) + $recoveryTimer = [System.Windows.Forms.Timer]::new() + $recoveryTimer.Interval = 5000 + $recoveryTimer.add_Tick({ + try { + Invoke-DreamSkinTrayRecovery + } catch { + $recoveryMonitor.NextAttemptAt = (Get-Date).AddSeconds(30) + } + }) + $recoveryTimer.Start() [System.Windows.Forms.Application]::Run() } finally { + if ($null -ne $recoveryTimer) { + $recoveryTimer.Stop() + $recoveryTimer.Dispose() + } + if ($null -ne $recoveryMonitor.Process) { + try { $recoveryMonitor.Process.Dispose() } catch {} + } if ($null -ne $notify) { $notify.Dispose() } if ($null -ne $trayIcon) { $trayIcon.Dispose() } if ($acquired) { try { $mutex.ReleaseMutex() } catch {} } diff --git a/windows/tests/run-tests.ps1 b/windows/tests/run-tests.ps1 index 1667165d..4b6a74fb 100644 --- a/windows/tests/run-tests.ps1 +++ b/windows/tests/run-tests.ps1 @@ -1084,6 +1084,17 @@ try { if (-not $startSource.Contains('Get-DreamSkinVerifiedCdpIdentityForAnyRegistered')) { throw 'Start lost the any-registered endpoint fallback for Store auto-updates.' } + $recoveryGuardIndex = $startSource.IndexOf( + 'if ($RecoverExisting -and $null -eq $existingIdentity)', [System.StringComparison]::Ordinal + ) + $codexLaunchIndex = $startSource.IndexOf( + '$null = Start-DreamSkinCodex -Codex $codex -Arguments $arguments', [System.StringComparison]::Ordinal + ) + if (-not $startSource.Contains("RecoverExisting cannot restart Codex") -or + -not $startSource.Contains('Injector recovery stopped because Dream Skin was paused') -or + $recoveryGuardIndex -lt 0 -or $codexLaunchIndex -le $recoveryGuardIndex) { + throw 'Injector recovery can still launch, restart, or resume Codex after its verified endpoint disappears.' + } $verifyScriptSource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\verify-dream-skin.ps1') if (-not $verifyScriptSource.Contains('Get-DreamSkinVerifiedCdpIdentityForAnyRegistered')) { throw 'Verify lost the any-registered endpoint fallback for Store auto-updates.' @@ -1154,6 +1165,146 @@ try { if (-not $commonSource.Contains('State was preserved.')) { throw 'Mismatched live injector identity does not fail closed with preserved state.' } + if (-not $commonSource.Contains('Stop-Process -InputObject $process') -or + -not $commonSource.Contains('WaitForExit(15000)')) { + throw 'Recorded injector cleanup no longer waits on the validated process handle.' + } + $traySource = Read-DreamSkinUtf8File -Path (Join-Path $Root 'scripts\tray-dream-skin.ps1') + foreach ($requiredRecoveryBehavior in @( + 'Get-DreamSkinRecordedInjectorProcess', + 'Get-DreamSkinInjectorRecoveryContext', + 'Get-DreamSkinVerifiedCdpIdentity', + 'Get-DreamSkinVerifiedCdpIdentityForAnyRegistered', + '[System.Windows.Forms.Timer]::new()', + " '-RecoverExisting'" + )) { + $source = if ($requiredRecoveryBehavior -in @( + '[System.Windows.Forms.Timer]::new()', + " '-RecoverExisting'" + )) { $traySource } else { $commonSource } + if (-not $source.Contains($requiredRecoveryBehavior)) { + throw "Safe tray recovery behavior is missing: $requiredRecoveryBehavior" + } + } + + $realRecordedInjectorProbe = (Get-Command Get-DreamSkinRecordedInjectorProcess -CommandType Function).ScriptBlock + $realPortAvailabilityProbe = (Get-Command Test-DreamSkinPortAvailable -CommandType Function).ScriptBlock + $realStateInstallProbe = (Get-Command Get-DreamSkinCodexInstallFromState -CommandType Function).ScriptBlock + $realIdentityProbe = (Get-Command Get-DreamSkinVerifiedCdpIdentity -CommandType Function).ScriptBlock + $realRegisteredIdentityProbe = ( + Get-Command Get-DreamSkinVerifiedCdpIdentityForAnyRegistered -CommandType Function + ).ScriptBlock + $recoveryProbe = @{ + InjectorRunning = $false + PortAvailable = $false + Identity = [pscustomobject]@{ BrowserId = 'browser-reopened' } + RegisteredIdentity = $null + } + try { + function Get-DreamSkinRecordedInjectorProcess { + param([AllowNull()][object]$State) + if ($recoveryProbe.InjectorRunning) { + return [System.Diagnostics.Process]::GetCurrentProcess() + } + return $null + } + function Test-DreamSkinPortAvailable { + param([int]$Port) + return [bool]$recoveryProbe.PortAvailable + } + function Get-DreamSkinCodexInstallFromState { + param([AllowNull()][object]$State) + return [pscustomobject]@{ Executable = 'official-codex-fixture.exe' } + } + function Get-DreamSkinVerifiedCdpIdentity { + param([int]$Port, [Parameter(Mandatory = $true)][object]$Codex) + return $recoveryProbe.Identity + } + function Get-DreamSkinVerifiedCdpIdentityForAnyRegistered { + param([int]$Port) + return $recoveryProbe.RegisteredIdentity + } + + $recoveryState = [pscustomobject]@{ port = 9335; injectorPid = 1234 } + $recoveryContext = Get-DreamSkinInjectorRecoveryContext -State $recoveryState + if ($null -eq $recoveryContext -or $recoveryContext.Port -ne 9335 -or + $recoveryContext.BrowserId -cne 'browser-reopened') { + throw 'A stopped injector with a verified official Codex endpoint was not recoverable.' + } + $recoveryProbe.InjectorRunning = $true + if ($null -ne (Get-DreamSkinInjectorRecoveryContext -State $recoveryState)) { + throw 'Tray recovery would start a second injector while the recorded watcher is alive.' + } + $recoveryProbe.InjectorRunning = $false + $recoveryProbe.PortAvailable = $true + if ($null -ne (Get-DreamSkinInjectorRecoveryContext -State $recoveryState)) { + throw 'Tray recovery would launch Codex when the saved CDP port has closed.' + } + $recoveryProbe.PortAvailable = $false + $recoveryProbe.Identity = $null + if ($null -ne (Get-DreamSkinInjectorRecoveryContext -State $recoveryState)) { + throw 'Tray recovery accepted a CDP endpoint without official Codex ownership.' + } + } finally { + Set-Item -Path Function:\Get-DreamSkinRecordedInjectorProcess -Value $realRecordedInjectorProbe + Set-Item -Path Function:\Test-DreamSkinPortAvailable -Value $realPortAvailabilityProbe + Set-Item -Path Function:\Get-DreamSkinCodexInstallFromState -Value $realStateInstallProbe + Set-Item -Path Function:\Get-DreamSkinVerifiedCdpIdentity -Value $realIdentityProbe + Set-Item -Path Function:\Get-DreamSkinVerifiedCdpIdentityForAnyRegistered ` + -Value $realRegisteredIdentityProbe + } + + $fakeInjectorPath = Join-Path $temporaryRoot 'recorded-injector-fixture.mjs' + [System.IO.File]::WriteAllText( + $fakeInjectorPath, + 'setInterval(() => {}, 1000);', + [System.Text.UTF8Encoding]::new($false) + ) + $fakeInjector = Start-Process -FilePath $node.Path -ArgumentList @( + (ConvertTo-DreamSkinProcessArgument -Value $fakeInjectorPath), + '--watch', '--port', '9335', '--browser-id', 'browser-recovery-test' + ) -WindowStyle Hidden -PassThru + try { + Start-Sleep -Milliseconds 300 + $fakeStartedAt = Get-DreamSkinProcessStartedAt -ProcessId $fakeInjector.Id + if (-not $fakeStartedAt) { throw 'The injector identity fixture did not start.' } + $fakeState = [pscustomobject]@{ + port = 9335 + injectorPid = $fakeInjector.Id + injectorStartedAt = $fakeStartedAt + injectorPath = $fakeInjectorPath + nodePath = $node.Path + browserId = 'browser-recovery-test' + } + $recordedProcess = Get-DreamSkinRecordedInjectorProcess -State $fakeState + if ($null -eq $recordedProcess -or [int]$recordedProcess.Id -ne $fakeInjector.Id) { + throw 'A live recorded injector did not pass exact process-identity validation.' + } + $recordedProcess.Dispose() + $mismatchedState = $fakeState.PSObject.Copy() + $mismatchedState.browserId = 'browser-other' + $mismatchRejected = $false + try { $null = Get-DreamSkinRecordedInjectorProcess -State $mismatchedState } catch { + $mismatchRejected = $true + } + if (-not $mismatchRejected -or $fakeInjector.HasExited) { + throw 'A mismatched PID was not preserved and rejected during recovery probing.' + } + if (-not (Stop-DreamSkinRecordedInjector -State $fakeState)) { + throw 'The exact recorded injector was not stopped.' + } + [void]$fakeInjector.WaitForExit(5000) + if (-not $fakeInjector.HasExited -or + $null -ne (Get-DreamSkinRecordedInjectorProcess -State $fakeState)) { + throw 'A stopped injector still appears live to the recovery monitor.' + } + } finally { + if (-not $fakeInjector.HasExited) { + Stop-Process -Id $fakeInjector.Id -Force -ErrorAction SilentlyContinue + [void]$fakeInjector.WaitForExit(5000) + } + $fakeInjector.Dispose() + } $stderrProbe = Invoke-DreamSkinNative -FilePath $node.Path -ArgumentList @( '-e', "process.stderr.write('dream-skin-stderr-probe\n'); process.exit(7)")