diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1 index de456e7..9837167 100644 --- a/Activate-CcodRemoteFix.ps1 +++ b/Activate-CcodRemoteFix.ps1 @@ -6,6 +6,8 @@ param( [switch]$ValidateReceiptOnly, [switch]$ValidateReceiptWithTimeout, [ValidateRange(1,300000)][int]$ValidationTimeoutMilliseconds = 2000, + [ValidateRange(1,300000)][int]$FirstReceiptTimeoutMilliseconds = 90000, + [ValidateRange(1,300000)][int]$ActivationTimeoutMilliseconds = 300000, [switch]$Prompt, [switch]$NoUi ) @@ -134,6 +136,88 @@ function ConvertTo-CcodNativeProcessArgument { return $quoted.ToString() } +function Stop-CcodOwnedInstallProcess { + param([Parameter(Mandatory)][Diagnostics.Process]$Process) + $boundedStopProven = $false + try { + if (-not $Process.HasExited) { $Process.Kill() } + $boundedStopProven = $Process.WaitForExit(5000) -and $Process.HasExited + } catch { } + if ($boundedStopProven) { return } + while ($true) { + try { + if (-not $Process.HasExited) { $Process.Kill() } + [void]$Process.WaitForExit() + if ($Process.HasExited) { return } + } catch { } + [Threading.Thread]::Sleep(100) + } +} + +function Invoke-CcodOwnedInstallWorker { + param( + [Parameter(Mandatory)][string]$PowerShellPath, + [Parameter(Mandatory)][string]$InstallScript, + [Parameter(Mandatory)][string]$StateRoot, + [Parameter(Mandatory)][int]$FirstReceiptTimeout, + [Parameter(Mandatory)][int]$ActivationTimeout + ) + $process = $null + $clock = [Diagnostics.Stopwatch]::StartNew() + $firstReceiptObserved = $false + try { + $arguments = @( + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + $InstallScript, + '-InstallRoot', + $StateRoot, + '-EnableCandidateCompatibleUpdates', + '-ActivationId', + $ActivationId + ) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $PowerShellPath + $startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ') + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw 'CCOD_ACTIVATION_WORKER_START_FAILED' } + while (-not $process.HasExited) { + if (-not $firstReceiptObserved) { + try { + $null = Read-CcodTerminalActivationReceipt -Root $StateRoot -ExpectedActivationId $ActivationId + $firstReceiptObserved = $true + } catch { } + } + if (-not $firstReceiptObserved -and $clock.ElapsedMilliseconds -ge $FirstReceiptTimeout) { + Stop-CcodOwnedInstallProcess -Process $process + throw 'CCOD_ACTIVATION_FIRST_RECEIPT_TIMEOUT' + } + if ($clock.ElapsedMilliseconds -ge $ActivationTimeout) { + Stop-CcodOwnedInstallProcess -Process $process + throw 'CCOD_ACTIVATION_TIMEOUT' + } + [void]$process.WaitForExit(50) + } + [void]$process.WaitForExit() + return [int]$process.ExitCode + } finally { + if ($null -ne $process) { + try { + if (-not $process.HasExited) { Stop-CcodOwnedInstallProcess -Process $process } + } finally { + $process.Dispose() + } + } + } +} + function Invoke-CcodBoundedReceiptValidator { param([Parameter(Mandatory)][int]$TimeoutMilliseconds) $process = $null @@ -207,6 +291,7 @@ if ([string]::IsNullOrWhiteSpace($ActivationId)) { $ActivationId = [guid]::NewGuid().ToString('D') } if (-not (Test-CcodCanonicalGuid $ActivationId)) { Write-Error 'CCOD_ACTIVATION_ID_INVALID' -ErrorAction Continue; exit 3 } +if ($FirstReceiptTimeoutMilliseconds -gt $ActivationTimeoutMilliseconds) { Write-Error 'CCOD_ACTIVATION_TIMEOUT_INVALID' -ErrorAction Continue; exit 3 } if ($ValidateReceiptWithTimeout) { $validationResult = Invoke-CcodBoundedReceiptValidator -TimeoutMilliseconds $ValidationTimeoutMilliseconds exit $validationResult @@ -232,8 +317,16 @@ try { if (-not [IO.File]::Exists($installScript)) { throw 'CCOD_ACTIVATION_INSTALL_SCRIPT_MISSING' } $powershell = (Get-Command powershell.exe -ErrorAction Stop).Source Write-CcodActivationRecord -Code 'STARTED' -DurationMilliseconds $clock.ElapsedMilliseconds - $null = & $powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -InstallRoot $stateRoot -EnableCandidateCompatibleUpdates -ActivationId $ActivationId - if ($LASTEXITCODE -ne 0) { throw 'CCOD_ACTIVATION_RUNTIME_FAILED' } + $installExitCode = Invoke-CcodOwnedInstallWorker -PowerShellPath $powershell -InstallScript $installScript -StateRoot $stateRoot -FirstReceiptTimeout $FirstReceiptTimeoutMilliseconds -ActivationTimeout $ActivationTimeoutMilliseconds + if ($installExitCode -ne 0) { + $reportedErrorCode = $null + try { + $failedReceipt = Read-CcodTerminalActivationReceipt -Root $stateRoot -ExpectedActivationId $ActivationId + if ($failedReceipt.phase -ceq 'Failed') { $reportedErrorCode = [string]$failedReceipt.errorCode } + } catch { } + if ($reportedErrorCode -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { throw $reportedErrorCode } + throw 'CCOD_ACTIVATION_RUNTIME_FAILED' + } $activationReceipt = Read-CcodFinalActivationReceipt -Root $stateRoot -ExpectedActivationId $ActivationId Write-CcodActivationRecord -Code 'RUNTIME_ACTIVATED' -DurationMilliseconds $clock.ElapsedMilliseconds } catch { diff --git a/CHANGELOG.md b/CHANGELOG.md index 60bd566..efe008c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,137 @@ This file keeps the release record. GitHub Release bodies are generated from the No unreleased changes. +## v2.5.21 + +### English + +- Repaired stale-status recovery around one exact live Codex root, rejecting ambiguous roots and stale identity correlation. +- Added exact, safe lifecycle diagnostics that explain rejected repair evidence without broad process actions. + +## v2.5.20 + +### English + +- TrayHost's native message loop now preserves the target HWND when redispatching posted work, so presentation updates reach the owner window and exact revision acknowledgements complete; About and language actions no longer time out. +- The acknowledged initial revision now serves as the current capability baseline until the first Supervisor projection, preventing startup or recovery windows from rejecting enabled About and language commands. +- Guardian now treats correlated transient static probe failures as retryable instead of permanently classifying the entire Supervisor lifetime as incompatible, so a stable replacement Codex root is inspected and taken over after logon races. + +### 简体中文 + +- TrayHost 原生消息循环在重新分发 posted work 时会保留目标 HWND,使 presentation 更新真正到达 owner window 并完成精确 revision acknowledgement;“关于”和语言操作不再超时。 +- 在 Supervisor 首次 projection 前,已确认的初始 revision 会作为当前能力基线,避免启动或恢复窗口错误拒绝菜单中已启用的“关于”和语言命令。 +- Guardian 会把相关联的瞬态 StaticProbe 失败保留为可重试状态,不再把整个 Supervisor 生命周期永久判为不兼容;登录启动竞态结束并出现稳定的新 Codex 根后,会重新检查并接管。 + +## v2.5.19 + +### English + +- Fixed installer readiness identity on Windows: CIM exposes process creation at microsecond precision while `Get-Process` retains 100-nanosecond ticks. The verifier now accepts only the same truncated microsecond (a 0–9 tick forward delta), so the exact new Supervisor can reach its authenticated TrayHost Ready signal without weakening PID-reuse protection. + +### 简体中文 + +- 修复 Windows 安装就绪身份校验:CIM 只提供微秒精度的进程创建时间,而 `Get-Process` 保留 100 纳秒 tick。校验器现在只接受同一截断微秒(向前 0–9 tick),使精确的新 Supervisor 能等待到已认证的 TrayHost Ready 信号,同时不放宽 PID 复用防护。 + +## v2.5.18 + +### English + +- Restored About, language selection, and Open logs by authorizing every tray action against the exact displayed and acknowledged presentation plus the current capability set; rejected or failed actions now surface localized feedback instead of disappearing silently. +- Removed the post-copy Setup pause: activation receipts are compact and tolerate legal JSON whitespace, new-runtime readiness retries the early Ready-event creation race, and Setup waits for an owned, deadline-bounded installer process to exit before a separate strict terminal validator can authorize success. + +### 简体中文 + +- 恢复“关于”、语言切换和“打开日志”:每个托盘操作都会同时校验实际显示并确认过的 presentation 与当前能力;被拒绝或执行失败的操作会显示本地化反馈,不再静默消失。 +- 消除文件复制后的安装等待:activation receipt 使用紧凑 JSON 并接受合法空白,新 runtime 就绪检查会重试早期 Ready event 创建竞态;Setup 会等待受控且有截止时间的 installer 进程退出,再由独立严格终态校验器决定是否成功。 + +## v2.5.17 + +### English + +- Fixed LifecycleWorker request construction: ordinary observation now supplies an explicit schema-one empty status object, while `restartOrdinary` remains disabled for Close and enabled for Apply/Inspect repair actions. +- Ordinary observation now uses the durable lifecycle creation time as its lower bound, so an automatically restored Codex root that appears just before the AppsFolder request remains eligible after the prior Close was proven. +- Supervisor now rebuilds special-process proof through one manifest-bound strong Inspect after lifecycle verification or restart; rebind failures produce one terminal receipt and suppress same-root Guardian retry loops. + +### 简体中文 + +- 修复 LifecycleWorker 请求构造:普通根观察会传入必需的空状态对象;`restartOrdinary` 对 Close 保持禁用,对 Apply/Inspect 修复动作启用。 +- 普通根观察现在使用持久生命周期创建时间作为下限;此前 Close 已证明完成时,即使 Codex 自动恢复略早于 AppsFolder 请求,该普通根仍可被接受。 +- Supervisor 现在只通过一次 manifest-bound 强 Inspect 在生命周期验证或自身重启后重建 special 进程证明;重绑定失败会生成一个终态回执,并抑制同根 Guardian 重试循环。 + +## v2.5.15 + +### English + +- Normalized the Windows native process-query exit race that can return Win32 error 31 immediately after an exact controller stop. The query is treated as exited only when an independent PID probe also proves the process absent or exited; a live or reused PID still fails closed. +- Added regression coverage for the absent-PID, live/PID-reuse, and unrelated native-error boundaries so a successful Close can advance instead of ending as `CCOD_SESSION_FAILED`. + +### 简体中文 + +- 规范化 Windows 原生进程查询在精确停止后可能返回 Win32 error 31 的退出竞态。只有独立 PID 探针也证明进程不存在或已退出时才视为退出;PID 仍存活或被复用时继续失败关闭。 +- 增加 PID 不存在、PID 存活/复用及其他原生错误的回归覆盖,使成功的 Close 能继续推进,而不再以 `CCOD_SESSION_FAILED` 结束。 + +## v2.5.14 + +### English + +- Bound the LifecycleWorker delegated-ownership callback before dot-sourcing `SessionController.ps1`. Its validated `Action` parameter can no longer invalidate `GetNewClosure()`, so Close, Apply, and VerifyRemote now reach the manifest-bound controller instead of failing with `CCOD_LIFECYCLE_OPERATION_FAILED`. +- Added a real scope-metadata regression that reproduces the PowerShell `ValidationMetadataException` and proves the correlated controller result survives the dot-source boundary. + +### 简体中文 + +- 在 dot-source `SessionController.ps1` 前绑定 LifecycleWorker 的 delegated-ownership 回调。控制器带验证的 `Action` 参数不再使 `GetNewClosure()` 失效,Close、Apply 与 VerifyRemote 现在能进入 manifest 绑定的控制器,而不会以 `CCOD_LIFECYCLE_OPERATION_FAILED` 失败。 +- 增加真实作用域元数据回归,复现 PowerShell `ValidationMetadataException`,并证明相关控制器结果可安全跨越 dot-source 边界。 + +## v2.5.13 + +### English + +- Preserved the real nonzero lifecycle-worker exit code after its PID disappears by arming and retaining the exact process handle before resume. A failed worker result now reduces to its durable terminal phase instead of being misread as exit code 0 and terminating the detached Supervisor. +- Recovered `CloseRequested` transactions no longer treat a still-running ordinary Codex root as closed. The Supervisor repeats the verified close operation before repair, preventing a restart from skipping directly to Apply. + +### 简体中文 + +- 在 lifecycle worker 恢复运行前启用并保留精确进程句柄,使 PID 消失后仍能读取真实的非零退出码。失败结果现在会归约到持久终态,不再被误读为退出码 0 并导致脱离计划任务的 Supervisor 退出。 +- 恢复中的 `CloseRequested` 事务不再把仍在运行的普通 Codex 根进程当作已关闭;Supervisor 会先重复执行经验证的关闭操作,再进入修复,避免重启后直接跳到 Apply。 + +## v2.5.12 + +### English + +- Fixed bootstrap readiness accounting for a long-lived Supervisor: once the verified Ready event is signaled, a still-running Supervisor now returns bootstrap success so post-install activation can complete instead of reporting `CCOD_INSTALL_NEW_RUNTIME_NOT_READY`. +- Added regression coverage for the persistent Supervisor handoff while preserving nonzero propagation for a Supervisor that exits abnormally after readiness. + +### 简体中文 + +- 修复长驻 Supervisor 的 bootstrap 就绪计数:经验证的 Ready 事件触发后,即使 Supervisor 仍在运行,bootstrap 现在也会返回成功,安装后激活不再错误报告 `CCOD_INSTALL_NEW_RUNTIME_NOT_READY`。 +- 增加长驻 Supervisor 交接回归覆盖,同时继续保留就绪后异常退出码的非零传播。 + +## v2.5.11 + +### English + +- Fixed the manifest-bound lifecycle worker so its JSON, runtime, lifecycle-fence, and process-control dependencies are bound to the verified module exports before worker adapter scriptblocks are created. Supervisor VerifyRemote now publishes a correlated result instead of failing with `CCOD_LIFECYCLE_WORKER_REQUEST_INVALID` in a real `-File` process. +- Added a production-scope regression test for the worker dependency binding; the installer and portable launcher continue to share the same verified runtime closure. + +### 简体中文 + +- 修复 manifest 绑定的 lifecycle worker:在创建 worker 适配器脚本块前,将 JSON、runtime、生命周期围栏和进程控制依赖绑定到已验证的模块导出。真实 `-File` 进程中的 Supervisor VerifyRemote 现在会发布相关结果,不再错误返回 `CCOD_LIFECYCLE_WORKER_REQUEST_INVALID`。 +- 增加生产脚本作用域回归测试;安装包和便携启动器继续共享同一份经验证的 runtime 闭包。 + +## v2.5.10 + +### English + +- Fixed the Inno Setup installer so it bundles the portable launcher with TrayHost; post-install activation now passes source validation instead of failing closed. +- Fixed bootstrap fallback sequencing so each verified runtime acquires and releases the transition lease around its launch, allowing the previous runtime to recover cleanly after an early exit. +- Increased the Supervisor startup proof budget and retained the ready Supervisor long enough for installer verification, while preserving lifecycle fencing and the DPAPI device-key store. + +### 简体中文 + +- 修复 Inno Setup 安装包:安装时会与 TrayHost 一起安装便携启动器,安装后激活现在能通过源校验,而不再失败关闭。 +- 修复启动回退时序:每个经验证的 runtime 只在启动边界内获取并释放 transition lease,活动 runtime 提前退出时可安全回退到 previous runtime。 +- 增加 Supervisor 启动证明预算,并在安装器验证窗口内保留 ready Supervisor,同时继续保护生命周期围栏和 DPAPI 设备密钥存储。 + ## v2.5.9 ### English diff --git a/README.md b/README.md index 2f06387..7728700 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ MIT License

+> [!NOTE] +> **Release status:** v2.5.21 is the current stable release. The setup installer, EXE-based portable ZIP, tray actions, restart-and-repair flow, and persistent remote connection are covered by the release validation gates. + CodexRemote-fix enables the UI that ships with Codex Desktop for Windows but is hidden by a runtime defect: **Settings → Connections → Control other devices** @@ -47,7 +50,7 @@ that existing page and leaves the Codex UI, account authorization, and enrollmen ## Quick start -1. Download either `CodexRemote-fix-2.5.9-setup.exe` (recommended installer) or `CodexRemote-fix-2.5.9-windows-x64.zip` from [Releases](https://github.com/naipi11/CodexRemote-fix/releases). Download and verify the matching `.sha256.txt` before continuing. +1. Download either `CodexRemote-fix-2.5.21-setup.exe` (recommended installer) or `CodexRemote-fix-2.5.21-windows-x64.zip` from [Releases](https://github.com/naipi11/CodexRemote-fix/releases). Download and verify the matching `.sha256.txt` before continuing. 2. If you chose the setup installer, run it and follow the wizard. If you chose the portable ZIP, extract it into a new empty folder and double-click `CodexRemote-fix.exe`. Both paths validate the payload, run a Microsoft Defender custom scan, and preserve the DPAPI device-key store. @@ -58,9 +61,8 @@ The current portable bundle, checksum, release manifest, and payload manifest ar Before replacing a portable build, use its installed `Uninstall-CodexControlOtherDevices.ps1`; settings and the DPAPI\r\ndevice-key store stay in place. -Verified on Windows 11 · Codex Desktop `26.818.2441.0` · Node.js `22.23.1`: -the hidden controller tab, native tray menu, bilingual menu switching, and persistent -supervisor are working. +Verified on Windows 11 · Codex Desktop `26.820.7780.0` · Node.js `22.23.1`: +the hidden controller tab, restart-and-repair recovery, and persistent remote connection are working. ## Everyday use @@ -68,7 +70,7 @@ supervisor are working. - The desktop shortcut **CodexRemote-fix** is an optional way to start the tray supervisor if its icon is not visible. It does not start a repair or change a Codex session by itself. - When the connection status is **Connected**, open **Settings → Connections → Control other devices** to enroll or use it. - New Codex builds start with `--remote-debugging-port` but no `--inspect`; the supervisor recognizes that launch shape and performs the takeover automatically. -- The tray menu supports Follow system, Chinese, and English; switching applies immediately without restarting anything. +- The tray menu supports Follow system, Chinese, English, Open logs, and About. Actions are authorized against the exact menu presentation that was displayed and acknowledged, while current capabilities remain fail-closed. - Updating this project or Codex does not require reinstalling the supervisor; the installer atomically switches versioned runtimes. - If an explicit repair is needed, use **Check and repair remote connection**. Codex may close and relaunch once, and existing device pairing and authorization are preserved. A completed recovery left behind by an interrupted upgrade is cleared safely on the next supervisor start. @@ -83,12 +85,12 @@ The tray reports two independent, truthful status lines instead of inferring rea ## Releases -Every tagged release ships a Windows setup installer and a portable ZIP, each with its SHA-256 checksum, release manifest, and the shared payload manifest. The `.github/workflows/release.yml` workflow builds both from the tag automatically, so v2.5.9 includes: +Every tagged release ships a Windows setup installer and an EXE-entry portable ZIP, each with its SHA-256 checksum, release manifest, and the shared payload manifest. The `.github/workflows/release.yml` workflow builds both from the tag automatically, so v2.5.21 includes: -- `CodexRemote-fix-2.5.9-setup.exe` and its checksum -- `CodexRemote-fix-2.5.9-windows-x64.zip` and its checksum -- `CodexRemote-fix-2.5.9-payload-manifest.json` -- `CodexRemote-fix-2.5.9-release-manifest.json` and `CodexRemote-fix-2.5.9-setup-release-manifest.json` +- `CodexRemote-fix-2.5.21-setup.exe` and its checksum +- `CodexRemote-fix-2.5.21-windows-x64.zip` and its checksum +- `CodexRemote-fix-2.5.21-payload-manifest.json` +- `CodexRemote-fix-2.5.21-release-manifest.json` and `CodexRemote-fix-2.5.21-setup-release-manifest.json` Each release appends a short English change summary to the GitHub release body. The bilingual release history is kept in [CHANGELOG.md](CHANGELOG.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 9178558..c8db449 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,6 +15,9 @@ MIT License

+> [!NOTE] +> **发布状态:** v2.5.21 是当前稳定版。安装包、以 EXE 启动的便携 ZIP、托盘操作、重启并修复流程和常驻远程连接均纳入发布验证门禁。 + CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、但因运行时缺陷被隐藏的: **设置 → 连接 → 控制其他设备(Control other devices)** @@ -43,7 +46,7 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、 ## 快速开始 -1. 从 [Releases](https://github.com/naipi11/CodexRemote-fix/releases) 下载 `CodexRemote-fix-2.5.9-setup.exe`(推荐安装包)或便携 ZIP `CodexRemote-fix-2.5.9-windows-x64.zip`;下载并核对对应的 `.sha256.txt`。 +1. 从 [Releases](https://github.com/naipi11/CodexRemote-fix/releases) 下载 `CodexRemote-fix-2.5.21-setup.exe`(推荐安装包)或便携 ZIP `CodexRemote-fix-2.5.21-windows-x64.zip`;下载并核对对应的 `.sha256.txt`。 2. 选择安装包时直接运行并跟随向导;选择便携 ZIP 时解压到新空目录,然后双击 `CodexRemote-fix.exe`。 两条路径都会校验载荷、执行 Microsoft Defender 自定义扫描,并保留 DPAPI 设备密钥。 @@ -54,8 +57,8 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、 替换便携版本前,请先运行已安装的 `Uninstall-CodexControlOtherDevices.ps1`;设置和 DPAPI 设备密钥会保留。 -已验证:Windows 11 · Codex Desktop `26.818.2441.0` · Node.js `22.23.1`; -隐藏的控制器标签、原生托盘菜单、双语菜单切换和常驻守护程序均可用。 +已验证:Windows 11 · Codex Desktop `26.820.7780.0` · Node.js `22.23.1`; +隐藏的控制器标签、重启并修复流程以及常驻远程连接均可用。 ## 日常使用 @@ -63,7 +66,7 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、 - 若未看到托盘图标,可双击桌面上的 **CodexRemote-fix** 启动托盘守护程序;它本身不会启动修复,也不会改变当前 Codex 会话。 - 连接状态显示 **已连接** 时,当前会话已生效;打开 **设置 → 连接 → 控制其他设备** 即可注册或使用。 - 新版 Codex 正常启动自带 `--remote-debugging-port`(没有 `--inspect`),守护程序已能识别这种启动方式并自动完成接管。 -- 托盘菜单支持跟随系统、中文、English,切换即时生效,无需重启。 +- 托盘菜单支持跟随系统、中文、English、打开日志和关于。每个操作同时校验实际显示并确认过的菜单 presentation 与当前能力;能力已撤销时仍会失败关闭。 - 升级本项目或 Codex 后无需重装守护程序本体,安装器只会原子切换版本化运行时。 - 如需显式修复,请从托盘菜单选择 **检查并修复远程连接**。Codex 可能会关闭并重新启动一次;已有设备配对和授权会被保留。若升级中断后遗留了已完成的恢复事务,守护程序会在下次启动时安全清理它。 @@ -78,12 +81,12 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、 ## 发布(Releases) -每个带 tag 的发布都会附带 Windows 安装包和便携 ZIP,各自带 SHA-256 校验文件、release manifest,并共享 payload manifest。`.github/workflows/release.yml` 会在 tag 上自动构建两者,因此 v2.5.9 提供: +每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,各自带 SHA-256 校验文件、release manifest,并共享 payload manifest。`.github/workflows/release.yml` 会在 tag 上自动构建两者,因此 v2.5.21 提供: -- `CodexRemote-fix-2.5.9-setup.exe` 及其校验文件 -- `CodexRemote-fix-2.5.9-windows-x64.zip` 及其校验文件 -- `CodexRemote-fix-2.5.9-payload-manifest.json` -- `CodexRemote-fix-2.5.9-release-manifest.json` 和 `CodexRemote-fix-2.5.9-setup-release-manifest.json` +- `CodexRemote-fix-2.5.21-setup.exe` 及其校验文件 +- `CodexRemote-fix-2.5.21-windows-x64.zip` 及其校验文件 +- `CodexRemote-fix-2.5.21-payload-manifest.json` +- `CodexRemote-fix-2.5.21-release-manifest.json` 和 `CodexRemote-fix-2.5.21-setup-release-manifest.json` GitHub Release 正文只发布英文更新说明;本 README 继续保留中英文使用说明。 完整历史见 [CHANGELOG.md](CHANGELOG.md)。 diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss index 05dc022..49445df 100644 --- a/build/CodexControlOtherDevices.iss +++ b/build/CodexControlOtherDevices.iss @@ -2,6 +2,9 @@ #ifndef TrayHostArtifactDirectory #define TrayHostArtifactDirectory SourcePath + "\generated\trayhost" #endif +#ifndef PortableArtifactDirectory +#define PortableArtifactDirectory SourcePath + "\generated\portable" +#endif AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F} AppName=CodexRemote-fix AppVersion={#ProjectVersion} @@ -49,6 +52,9 @@ Source: "..\build\trayhost-packages.lock.json"; DestDir: "{app}\build"; Flags: i Source: "{#TrayHostArtifactDirectory}\CodexRemote.TrayHost.exe"; DestDir: "{app}\bin"; Flags: ignoreversion Source: "{#TrayHostArtifactDirectory}\CodexRemote.TrayHost.exe.config"; DestDir: "{app}\bin"; Flags: ignoreversion Source: "{#TrayHostArtifactDirectory}\trayhost-build-provenance.json"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "{#PortableArtifactDirectory}\CodexRemote.Portable.exe"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "{#PortableArtifactDirectory}\CodexRemote.Portable.exe.config"; DestDir: "{app}\bin"; Flags: ignoreversion +Source: "{#PortableArtifactDirectory}\portable-launcher-provenance.json"; DestDir: "{app}\bin"; Flags: ignoreversion Source: "..\.github\workflows\release.yml"; DestDir: "{app}\.github\workflows"; Flags: ignoreversion Source: "..\assets\codexremote-fix\codexremote-fix.ico"; DestDir: "{app}\assets"; DestName: "CodexRemote-fix.ico"; Flags: ignoreversion Source: "..\assets\codexremote-fix\codexremote-fix.ico"; DestDir: "{app}\assets\codexremote-fix"; Flags: ignoreversion @@ -83,6 +89,7 @@ Name: "{userdesktop}\CodexRemote-fix"; Filename: "{sys}\WindowsPowerShell\v1.0\p [Code] const ACTIVATION_TIMEOUT_MILLISECONDS = 300000; + FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS = 90000; ACTIVATION_POLL_MILLISECONDS = 50; VALIDATION_RETRY_MILLISECONDS = 500; VALIDATION_TIMEOUT_MILLISECONDS = 2000; @@ -171,10 +178,49 @@ begin Result := LoadStringFromFile(ReceiptPath, Content); end; +function IsJsonWhitespace(const Character: Char): Boolean; +begin + Result := (Character = ' ') or (Character = #9) or + (Character = #10) or (Character = #13); +end; + function HasJsonStringValue(const Content, FieldName, FieldValue: String): Boolean; +var + FieldIndex, RelativeIndex, SearchOffset, ValueIndex: Integer; + FieldToken, ValueToken: String; begin - Result := (Pos('"' + FieldName + '": "' + FieldValue + '"', Content) > 0) or - (Pos('"' + FieldName + '":"' + FieldValue + '"', Content) > 0); + Result := False; + FieldToken := '"' + FieldName + '"'; + ValueToken := '"' + FieldValue + '"'; + SearchOffset := 1; + while SearchOffset <= Length(Content) do + begin + RelativeIndex := Pos(FieldToken, Copy(Content, SearchOffset, + Length(Content) - SearchOffset + 1)); + if RelativeIndex = 0 then Exit; + FieldIndex := SearchOffset + RelativeIndex - 1; + ValueIndex := FieldIndex + Length(FieldToken); + while ValueIndex <= Length(Content) do + begin + if not IsJsonWhitespace(Content[ValueIndex]) then Break; + ValueIndex := ValueIndex + 1; + end; + if (ValueIndex <= Length(Content)) and (Content[ValueIndex] = ':') then + begin + ValueIndex := ValueIndex + 1; + while ValueIndex <= Length(Content) do + begin + if not IsJsonWhitespace(Content[ValueIndex]) then Break; + ValueIndex := ValueIndex + 1; + end; + if Copy(Content, ValueIndex, Length(ValueToken)) = ValueToken then + begin + Result := True; + Exit; + end; + end; + SearchOffset := FieldIndex + Length(FieldToken); + end; end; function DetectActivationPhase(const Content: String): TActivationPhase; @@ -194,6 +240,13 @@ begin Result := apFailed; end; +procedure UpdateActivationStartingPresentation(); +begin + WizardForm.StatusLabel.Caption := 'Starting CodexRemote-fix activation...'; + WizardForm.ProgressGauge.Position := 70; + WizardForm.Update; +end; + procedure UpdateActivationPresentation(const Phase: TActivationPhase); begin case Phase of @@ -228,86 +281,63 @@ end; procedure CurStepChanged(CurStep: TSetupStep); var - LaunchResultCode: Integer; + ActivationResultCode: Integer; ValidationResultCode: Integer; PromptResultCode: Integer; Parameters, ReceiptPath, ActivationId: String; - Phase: TActivationPhase; - DeadlineTick, NextValidationAttemptTick: Int64; - LastValidationResultCode: Integer; begin if CurStep <> ssPostInstall then Exit; ActivationId := NewActivationId(); ReceiptPath := ExpandConstant('{localappdata}\CodexControlOtherDevices\state\post-install-activation.json'); RefuseStaleActivationReceipt(ReceiptPath); - DeadlineTick := GetTickCount64() + ACTIVATION_TIMEOUT_MILLISECONDS; - Parameters := '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ActivationId "' + ActivationId + '"'; - if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, ewNoWait, LaunchResultCode) then + UpdateActivationStartingPresentation(); + Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + + ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + + '" -ActivationId "' + ActivationId + '" -FirstReceiptTimeoutMilliseconds ' + + IntToStr(FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS) + ' -ActivationTimeoutMilliseconds ' + + IntToStr(ACTIVATION_TIMEOUT_MILLISECONDS); + if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, + ewWaitUntilTerminated, ActivationResultCode) then begin - Log('CodexRemote-fix activation worker could not be started: ' + SysErrorMessage(LaunchResultCode)); + Log('CodexRemote-fix activation owner could not be started: ' + SysErrorMessage(ActivationResultCode)); RaiseException('CodexRemote-fix activation process could not be started.'); end; - NextValidationAttemptTick := 0; - LastValidationResultCode := -1; - while True do + if ActivationResultCode <> 0 then begin - if GetTickCount64() >= DeadlineTick then - begin - Log('CodexRemote-fix activation timed out before a validated current-id terminal result.'); - RaiseException('CodexRemote-fix activation timed out.'); - end; - Phase := ReadActivationProgressPhase(ReceiptPath, ActivationId); - UpdateActivationPresentation(Phase); - if (Phase in [apReady, apFailed]) and (GetTickCount64() >= NextValidationAttemptTick) then + Log('CodexRemote-fix activation owner exited unsuccessfully, result code ' + IntToStr(ActivationResultCode) + '.'); + SuppressibleMsgBox('CodexRemote-fix activation failed safely. Use the support code in post-install-activation.log.', mbError, MB_OK, IDOK); + RaiseException('CodexRemote-fix activation failed.'); + end; + Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + + ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + + '" -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds ' + IntToStr(VALIDATION_TIMEOUT_MILLISECONDS) + + ' -ActivationId "' + ActivationId + '"'; + if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, + ewWaitUntilTerminated, ValidationResultCode) then + begin + Log('CodexRemote-fix activation validator could not be started: ' + SysErrorMessage(ValidationResultCode)); + RaiseException('CodexRemote-fix activation validator could not be started.'); + end; + if ValidationResultCode <> 0 then + begin + Log('CodexRemote-fix activation validator rejected the terminal receipt, result code ' + IntToStr(ValidationResultCode) + '.'); + SuppressibleMsgBox('CodexRemote-fix activation failed safe validation. Use the support code in post-install-activation.log.', mbError, MB_OK, IDOK); + RaiseException('CodexRemote-fix activation receipt validation failed.'); + end; + if ValidationResultCode = 0 then + begin + WizardForm.StatusLabel.Caption := 'CodexRemote-fix activation is ready.'; + WizardForm.ProgressGauge.Position := 100; + WizardForm.Update; + if not WizardSilent then begin - if GetTickCount64() + VALIDATION_LAUNCH_BUDGET_MILLISECONDS >= DeadlineTick then - begin - Log('CodexRemote-fix activation has insufficient remaining budget for strict receipt validation.'); - RaiseException('CodexRemote-fix activation timed out.'); - end; - Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds ' + IntToStr(VALIDATION_TIMEOUT_MILLISECONDS) + ' -ActivationId "' + ActivationId + '"'; - if not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, ewWaitUntilTerminated, ValidationResultCode) then - begin - Log('CodexRemote-fix activation validator could not be started: ' + SysErrorMessage(ValidationResultCode)); - RaiseException('CodexRemote-fix activation validator could not be started.'); - end; - if GetTickCount64() >= DeadlineTick then - begin - Log('CodexRemote-fix activation validator exceeded the activation deadline.'); - RaiseException('CodexRemote-fix activation timed out.'); - end; - case ValidationResultCode of - 0: - begin - WizardForm.StatusLabel.Caption := 'CodexRemote-fix activation is ready.'; - WizardForm.ProgressGauge.Position := 100; - WizardForm.Update; - if not WizardSilent then - begin - Parameters := '-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\Prompt-CcodRestart.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ActivationId "' + ActivationId + '"'; - if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, ewWaitUntilTerminated, PromptResultCode)) or (PromptResultCode <> 0) then - SuppressibleMsgBox('Codex restart was not submitted. Restart Codex manually when convenient.', mbInformation, MB_OK, IDOK); - end; - Exit; - end; - 2: - begin - Log('CodexRemote-fix activation validator reported a strict Failed receipt.'); - SuppressibleMsgBox('CodexRemote-fix activation failed safely. Use the support code in post-install-activation.log.', mbError, MB_OK, IDOK); - RaiseException('CodexRemote-fix activation reported Failed.'); - end; - else - begin - if ValidationResultCode <> LastValidationResultCode then - Log('CodexRemote-fix activation validator has not accepted a strict terminal receipt; verification will retry.'); - LastValidationResultCode := ValidationResultCode; - NextValidationAttemptTick := GetTickCount64() + VALIDATION_RETRY_MILLISECONDS; - end; - end; + Parameters := '-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\Prompt-CcodRestart.ps1') + '" -AppRoot "' + ExpandConstant('{app}') + '" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') + '" -ActivationId "' + ActivationId + '"'; + if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), Parameters, '', SW_HIDE, ewWaitUntilTerminated, PromptResultCode)) or (PromptResultCode <> 0) then + SuppressibleMsgBox('Codex restart was not submitted. Restart Codex manually when convenient.', mbInformation, MB_OK, IDOK); end; - WizardForm.Update; - Sleep(ACTIVATION_POLL_MILLISECONDS); end; end; diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1 index ba6d597..cbfd380 100644 --- a/build/TrayHostBuild.psm1 +++ b/build/TrayHostBuild.psm1 @@ -110,10 +110,10 @@ function Invoke-CcodPortableLauncherBuild { if($GitCommit -cnotmatch '^[0-9a-f]{40}$'){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'} if([string]::IsNullOrWhiteSpace($BuildTimestampUtc)){$BuildTimestampUtc=[datetime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture)} if(-not (Test-CcodTrayHostCanonicalUtc $BuildTimestampUtc)){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'} - $sourceRoot=Join-Path $repo 'src\portable';$launcher=Join-Path $sourceRoot 'PortableLauncher.cs'; + $sourceRoot=Join-Path $repo 'src\portable';$launcher=Join-Path $sourceRoot 'PortableLauncher.cs';$assemblyInfo=Join-Path $sourceRoot 'AssemblyInfo.cs'; $manifest=Join-Path $sourceRoot 'CodexRemote.Portable.manifest';$config=Join-Path $sourceRoot 'CodexRemote.Portable.exe.config'; $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico' - foreach($required in @($launcher,$manifest,$config,$icon)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING'}} + foreach($required in @($launcher,$assemblyInfo,$manifest,$config,$icon)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING'}} Import-Module (Join-Path $repo 'build\TrayHostReferencePack.psm1') -Force $reference=Resolve-CcodTrayHostReferencePack -LockPath (Join-Path $repo 'build\trayhost-packages.lock.json') -CacheRoot (Join-Path $env:TEMP 'ccod-trayhost-reference-pack') $compiler=Get-CcodTrayHostCompiler @@ -121,11 +121,11 @@ function Invoke-CcodPortableLauncherBuild { try{ $exe=Join-Path $work 'CodexRemote.Portable.exe';$compilerArgs=@('/nologo','/noconfig','/nostdlib+','/target:winexe','/platform:anycpu','/optimize+','/debug-','/checked+','/warn:4','/warnaserror+',('/out:{0}' -f $exe),('/main:PortableLauncher'),('/win32manifest:{0}' -f $manifest),('/win32icon:{0}' -f $icon)) foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$compilerArgs+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))} - $compilerArgs+=@($launcher) + $compilerArgs+=@($launcher,$assemblyInfo) & $compiler @compilerArgs if($LASTEXITCODE -ne 0 -or -not(Test-Path -LiteralPath $exe -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_COMPILE_FAILED'} $configOut=Join-Path $work 'CodexRemote.Portable.exe.config';Copy-Item -LiteralPath $config -Destination $configOut -Force - $provenance=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$Version;gitCommit=$GitCommit;buildTimestampUtc=$BuildTimestampUtc;targetFramework='net48';compiler=[ordered]@{name='csc.exe';sha256=(Get-CcodTrayHostHash $compiler)};referenceRoot='locked-net48';sourceFiles=@([ordered]@{name='PortableLauncher.cs';sha256=(Get-CcodTrayHostHash $launcher)});iconSha256=(Get-CcodTrayHostHash $icon);manifestSha256=(Get-CcodTrayHostHash $manifest);configSha256=(Get-CcodTrayHostHash $config);artifactSha256=(Get-CcodTrayHostHash $exe);configArtifactSha256=(Get-CcodTrayHostHash $configOut)} + $provenance=[ordered]@{schemaVersion=1;product='CodexRemote-fix';version=$Version;gitCommit=$GitCommit;buildTimestampUtc=$BuildTimestampUtc;targetFramework='net48';compiler=[ordered]@{name='csc.exe';sha256=(Get-CcodTrayHostHash $compiler)};referenceRoot='locked-net48';sourceFiles=@([ordered]@{name='PortableLauncher.cs';sha256=(Get-CcodTrayHostHash $launcher)},[ordered]@{name='AssemblyInfo.cs';sha256=(Get-CcodTrayHostHash $assemblyInfo)});iconSha256=(Get-CcodTrayHostHash $icon);manifestSha256=(Get-CcodTrayHostHash $manifest);configSha256=(Get-CcodTrayHostHash $config);artifactSha256=(Get-CcodTrayHostHash $exe);configArtifactSha256=(Get-CcodTrayHostHash $configOut)} $provenancePath=Join-Path $work 'portable-launcher-provenance.json';$provenance|ConvertTo-Json -Depth 8|Set-Content -LiteralPath $provenancePath -Encoding UTF8 if(Test-Path -LiteralPath $out){Remove-Item -LiteralPath $out -Recurse -Force} New-Item -ItemType Directory -Path $out -Force|Out-Null diff --git a/build/build.ps1 b/build/build.ps1 index 757803e..3fc8236 100644 --- a/build/build.ps1 +++ b/build/build.ps1 @@ -256,7 +256,8 @@ $isccCandidates = @( $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and [IO.File]::Exists($_) } | Select-Object -First 1 if (-not $iscc) { throw 'Inno Setup 6 (ISCC.exe) was not found. Install it with: winget install --id JRSoftware.InnoSetup --exact' } $issPath = Join-Path $PSScriptRoot 'CodexControlOtherDevices.iss' -& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/O$dist\." $issPath +$portableArtifact = Join-Path $PSScriptRoot 'generated\portable' +& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/O$dist\." $issPath if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) { throw "Inno Setup compilation failed with exit code $LASTEXITCODE" } diff --git a/docs/superpowers/plans/2026-08-27-cim-creationdate-compatibility.md b/docs/superpowers/plans/2026-08-27-cim-creationdate-compatibility.md new file mode 100644 index 0000000..d70f7af --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-cim-creationdate-compatibility.md @@ -0,0 +1,96 @@ +# CIM CreationDate Compatibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the v2.5.21 installed lifecycle gate accept the real `System.DateTime` returned by Windows PowerShell CIM while retaining strict DMTF compatibility and fail-closed invalid evidence. + +**Architecture:** Add one private bounded conversion boundary in the installed harness. It accepts only a valid `System.DateTime` with `Local` or `Utc` kind, or a strictly shaped DMTF string, and returns canonical UTC; every other type/value fails installed fact capture. Test the real caller shape with DateTime-valued CIM fixtures, then remove the remaining unsupported CHANGELOG evidence claim. + +**Tech Stack:** Windows PowerShell 5.1, CIM `Win32_Process`, PowerShell self-tests, existing installed lifecycle harness. + +**Spec:** `docs/superpowers/specs/2026-08-23-lifecycle-and-tray-redesign-design.md` + +## Global Constraints + +- Version remains `2.5.21` / `2.5.21.0`; no new release number is introduced before publication. +- Installed process evidence remains fail-closed, bounded, current-user only, and stores only PID plus canonical creation time. +- Command lines remain transient and never enter facts/evidence; device-key content, SID, authentication ID, tokens, endpoints, and private content remain excluded. +- DMTF-string compatibility remains for deterministic fixtures/older providers, but broad string coercion is forbidden. +- `DateTimeKind.Unspecified`, invalid/out-of-range DateTime, malformed DMTF, non-string/non-DateTime, null, duplicate/ambiguous process facts, or conversion errors fail with `CCOD_INTEGRATION_FACTS_INVALID`. +- README distribution/no-What's-new policy, lifecycle code, journal/fence behavior, and release workflow remain unchanged. + +--- + +### Task 1: Normalize real CIM creation time and revalidate the release gate + +**Files:** +- Modify: `tests/installed/Invoke-InstalledLifecycleIntegration.ps1` +- Modify: `tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: `Win32_Process.CreationDate` as real `System.DateTime` or strict DMTF string. +- Produces: private `ConvertTo-CcodInstalledLifecycleCreationTimeUtc` returning one canonical UTC string or throwing `CCOD_INTEGRATION_FACTS_INVALID`. + +- [ ] **Step 1: Write the real-caller RED test** + +Build a complete fake `ChatGPT.exe` CIM root with: + +```powershell +CreationDate = [DateTime]::SpecifyKind([DateTime]'2030-02-03T12:05:06', [DateTimeKind]::Local) +``` + +Run the actual `Get-CcodInstalledLifecycleFacts`/root-capture boundary and assert one root with PID and the independently calculated UTC `ToUniversalTime().ToString('o', InvariantCulture)`. The current implementation must fail `CCOD_INTEGRATION_FACTS_INVALID` before production edit. + +Add table cases for a UTC DateTime and a complete DMTF string that must succeed, and null, `DateTimeKind.Unspecified`, malformed DMTF, integer, and arbitrary string that must fail closed. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 +``` + +Expected: FAIL because real DateTime-valued CIM creation evidence is rejected. + +- [ ] **Step 3: Implement the minimal converter** + +Implement a private helper that: + +```powershell +if ($Value -is [DateTime]) { + # accept only Local or Utc; convert and emit exact canonical UTC +} elseif ($Value -is [string]) { + # require exact DMTF lexical shape, convert with ManagementDateTimeConverter, + # and emit exact canonical UTC +} else { + # throw the existing stable facts error +} +``` + +Use it for every installed `ChatGPT.exe` creation-time conversion. Do not convert through `[string]$Value` before type validation. + +- [ ] **Step 4: Make CHANGELOG truthful** + +Remove the remaining claim that the FreshRestart gate validates Supervisor/TrayHost lifecycle evidence; retain only gates actually required by the harness. Do not add new release bullets or README history. + +- [ ] **Step 5: Run GREEN and regressions** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1 +``` + +Also run a read-only live boundary probe against the current `ChatGPT.exe` root and prove facts capture no longer raises the prior error without mutating the installed application. + +- [ ] **Step 6: Run full isolated validation** + +Perform exact Supervisor/TrayHost isolation, run `npm test`, restore the registered task in `finally`, and prove task `Ready/0` plus unchanged ChatGPT PID/creation/listener. Retain a complete transcript. + +- [ ] **Step 7: Commit and report** + +```powershell +git add tests/installed/Invoke-InstalledLifecycleIntegration.ps1 tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 CHANGELOG.md +git commit -m "test: support real CIM creation timestamps" +``` + +Write RED/GREEN, live probe, transcript hash, protection recovery, privacy, and self-review evidence to this plan's SDD report. diff --git a/docs/superpowers/plans/2026-08-27-stale-status-repair-and-release-validation.md b/docs/superpowers/plans/2026-08-27-stale-status-repair-and-release-validation.md new file mode 100644 index 0000000..c9b39f1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-stale-status-repair-and-release-validation.md @@ -0,0 +1,364 @@ +# Stale Status Repair and Release Validation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `CheckAndRepair` safely recover after a Codex package update or Windows restart leaves `state/status.json` pointing at a dead prior-package root, and prevent release validation from passing unless the real `ChatGPT.exe` lifecycle reaches the current runtime. + +**Architecture:** Preserve the existing single-target, fail-closed transition journal. Add a strict native PID/creation-time observation boundary, and permit the Close preflight to replace a stale recorded identity only when that old identity is proven absent or reused and exactly one current-package top-level root is fully verified. Keep multiple roots, indeterminate native queries, invalid debug ports, and incomplete trees fail-closed. Harden diagnostics and installed validation so the exact failure predicate and lifecycle terminal phase are durable. + +**Tech Stack:** Windows PowerShell 5.1, PowerShell modules, Win32 process identity APIs, JSONL diagnostics, C# TrayHost transport, Inno Setup, Node/npm validation, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-08-23-lifecycle-and-tray-redesign-design.md` + +## Global Constraints + +- Supervisor remains the sole lifecycle owner; every state, journal, and process mutation remains fenced by exact runtime generation, lease epoch, owner PID, and owner creation time. +- A recorded process may be declared stale only when a strict native query proves the PID absent or proves a different creation time. Access denial, malformed output, package/CIM incompleteness, and other uncertainty remain fail-closed. +- Stale-status adoption is allowed only for exactly one current-user, current-session, current-package top-level root. Multiple roots remain `CCOD_CLOSE_UNPROVEN`; this release does not introduce a root-forest journal schema. +- A replacement ordinary root must have no special renderer/main pair. A replacement debug root must have one valid distinct renderer/main pair, and the journal must record the replacement identity and replacement ports rather than stale values. +- No process is stopped and no status is cleared before the lifecycle fence and single-target proof pass. `IntentWritten` and `CloseRequested` remain durable before the first external stop. +- Device keys, authentication tokens, remote endpoints, command lines, user profile paths, and private content are never written to diagnostics or release evidence. +- The product remains current-user only, non-elevated, offline, manifest-bound, source auditable, and must not directly execute the protected WindowsApps Codex binary. +- README stays concise and contains no per-version “What’s new” history; release details go to CHANGELOG and the GitHub Release page. +- No completion claim is allowed until the final official installer is installed, the real `ChatGPT.exe` root changes as expected, the current runtime owns an Active/RemoteVerified session, device-key hash is preserved, and the affected computer succeeds after a Windows reboot. + +--- + +### Task 1: Strictly adopt one replacement root after stale Active status + +**Files:** +- Modify: `src/persistence/modules/ProcessControl.psm1` +- Modify: `src/persistence/modules/SessionEngine.psm1` +- Test: `tests/persistence/ProcessControl.SelfTest.ps1` +- Test: `tests/persistence/SessionEngine.SelfTest.ps1` + +**Interfaces:** +- Consumes: the existing native `ProcessIdentityV1.Query`, `Get-CcodCurrentPackageRoots`, lifecycle fence adapter, and single-target transition journal. +- Produces: `Get-CcodProcessIdentityObservation -ProcessId -ExpectedCreationTimeUtc ` returning the exact ordered shape `{ Outcome, Pid, CreationTimeUtc }`, where `Outcome` is `Absent`, `SameIdentity`, or `IdentityChanged`; and SessionEngine adapter `ObserveProcessIdentity` using that function. + +- [ ] **Step 1: Add the ProcessControl RED tests** + +Add table-driven tests that exercise the real new public boundary through injected native-query adapters. Hand-derived expected results: + +```powershell +@( + @{ Name='absent'; Native=$null; Want='Absent'; Creation=$null }, + @{ Name='same'; Native=[pscustomobject][ordered]@{Pid=10664;CreationTimeUtc='2026-08-26T04:49:22.1551350Z';SessionId=1;UserSid='S-1-5-21-test';Path='C:\Program Files\WindowsApps\OpenAI.Codex_test\app\ChatGPT.exe';PackageFamilyName='OpenAI.Codex_test'}; Want='SameIdentity'; Creation='2026-08-26T04:49:22.1551350Z' }, + @{ Name='reused'; Native=[pscustomobject][ordered]@{Pid=10664;CreationTimeUtc='2026-08-27T04:49:22.1551350Z';SessionId=1;UserSid='S-1-5-21-test';Path='C:\Program Files\WindowsApps\OpenAI.Codex_test\app\ChatGPT.exe';PackageFamilyName='OpenAI.Codex_test'}; Want='IdentityChanged'; Creation='2026-08-27T04:49:22.1551350Z' } +) +``` + +Also assert that access failure, wrong PID, noncanonical time, extra/missing fields, and adapter output on the success stream throw rather than report `Absent`. + +- [ ] **Step 2: Run ProcessControl RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1 +``` + +Expected: FAIL because `Get-CcodProcessIdentityObservation` is not exported/implemented. + +- [ ] **Step 3: Add the affected-machine SessionEngine RED test** + +Use literal evidence matching the failure shape while keeping user identifiers synthetic: + +```powershell +$oldStatus = New-CcodEngineActiveStatus -RuntimeId '2.5.19-old' +$oldStatus.session.codex.pid = 10664 +$oldStatus.session.codex.creationTimeUtc = '2026-08-26T04:49:22.1551350Z' +$oldStatus.session.codex.packageFullName = 'OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0' +$oldStatus.session.codex.packageVersion = '26.820.7780.0' +$replacement = New-CcodEngineSnapshot -Pid 13948 -CreationTimeUtc '2026-08-27T07:00:17.0000000Z' -Mode Ordinary +``` + +The current static probe must identify `OpenAI.Codex_26.820.9563.0_x64__2p2nqsd0c76g0`; `ObserveProcessIdentity` returns `Absent` for PID 10664; process enumeration returns exactly the replacement root and its verified child tree. Assert observable behavior, not mock existence: + +```powershell +Assert-CcodEqual 'Closed' $result.outcome 'proven stale status adopts and closes the unique current ordinary root' +Assert-CcodEqual 'IntentWritten,CloseRequested,Stop:13948,Closed,WriteStatus,Complete:Closed' ($events -join ',') 'journal precedes stop and stale status is cleared only at durable completion' +``` + +Add separate negative cases proving zero mutations for `SameIdentity`, native-query exception/invalid receipt, two top-level roots, replacement debug root with a missing/equal port pair, and empty verified tree. Add positive cases for PID reuse with a different creation time and one replacement debug root with new distinct ports. + +- [ ] **Step 4: Run SessionEngine RED** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1 +``` + +Expected: the affected-machine case FAILS with `CCOD_CLOSE_UNPROVEN`, proving the test catches the current unconditional recorded-root branch. + +- [ ] **Step 5: Implement the minimal ProcessControl observation boundary** + +Implement and export: + +```powershell +function Get-CcodProcessIdentityObservation { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateRange(1,2147483647)][int]$ProcessId, + [Parameter(Mandatory)][string]$ExpectedCreationTimeUtc, + [hashtable]$Adapters + ) + # Validate the expected canonical UTC value, invoke exactly one strict + # native identity query, reject incidental output/malformed receipts, + # and return only Outcome/Pid/CreationTimeUtc. +} +``` + +`Get-CcodDefaultNativeProcess` remains the only production native query. Its existing proven-absence handling is reused; no broad `catch { return $null }` is allowed. + +- [ ] **Step 6: Implement the minimal SessionEngine stale-status branch** + +Add `ObserveProcessIdentity` to `Merge-CcodSessionAdapters`. In `Invoke-CcodCloseSession`, retain the current exact match path. Only when that path fails, call the strict observation for the recorded PID and: + +```powershell +if ($identity.Outcome -in @('Absent','IdentityChanged') -and $roots.Count -eq 1) { + $target = $roots[0] + $statusCodex = $null + $isSpecial = $target.Mode -cne 'Ordinary' + # For a debug replacement, require valid distinct ports already parsed + # from the replacement command line. Never reuse old status ports. +} else { + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Recorded Active root cannot be safely replaced' $null +} +``` + +Do not accept zero roots in this task, because the existing rich enumeration can silently omit an unclassified `ChatGPT.exe`; do not accept more than one root because journal schema 1 is single-target. + +- [ ] **Step 7: Run GREEN and regression suites** + +Run: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionController.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/LifecycleWorker.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/LifecycleCoordinator.SelfTest.ps1 +``` + +Expected: all pass with no incidental output and no warning introduced by Task 1. The repository's pre-existing `LifecycleCoordinator` import warning about unapproved verbs may remain recorded for the final release review; Task 1 does not change that module or warning surface. + +- [ ] **Step 8: Commit Task 1** + +```powershell +git add src/persistence/modules/ProcessControl.psm1 src/persistence/modules/SessionEngine.psm1 tests/persistence/ProcessControl.SelfTest.ps1 tests/persistence/SessionEngine.SelfTest.ps1 +git commit -m "fix: recover one root after stale Codex status" +``` + +--- + +### Task 2: Preserve the exact safe failure predicate in diagnostics + +**Files:** +- Modify: `src/persistence/modules/SessionEngine.psm1` +- Modify: `src/persistence/Supervisor.ps1` +- Test: `tests/persistence/SessionEngine.SelfTest.ps1` +- Test: `tests/persistence/Supervisor.SelfTest.ps1` + +**Interfaces:** +- Consumes: static SessionEngine exception messages/codes and lifecycle request terminal error. +- Produces: diagnostic schema 2 ordered shape `{ schemaVersion, timestampUtc, action, transactionId, stage, code, reason }`; `reason` is one allow-listed enum and never raw exception text. Supervisor sends the validated terminal `request.error` to TrayHost instead of replacing it with `CCOD_LIFECYCLE_ACTION_FAILED`. + +- [ ] **Step 1: Write diagnostics RED tests** + +Add tests for these literal mappings: + +```powershell +@{ + 'Recorded Active root is missing, changed, or accompanied by another root' = 'RecordedActiveMismatch' + 'Recorded Active root cannot be safely replaced' = 'RecordedActiveMismatch' + 'Multiple current-package close roots are ambiguous' = 'MultipleRoots' + 'A status-less debug root lacks one valid distinct port pair' = 'MissingPortPair' + 'Requested close source is not the one verified current root' = 'RequestedSourceMismatch' + 'Current close target tree is not exact and verified' = 'EmptyVerifiedTree' +} +``` + +Unknown messages must map to `Unclassified`; newline/path/token-bearing exception text must never appear in the record. Add a Supervisor test where a `CloseFailed` request with `error='CCOD_CLOSE_UNPROVEN'` produces a terminal action result with that exact code. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1 +``` + +Expected: FAIL because diagnostics have no `reason` and Supervisor currently emits only `CCOD_LIFECYCLE_ACTION_FAILED`. + +- [ ] **Step 3: Implement allow-listed reason mapping and exact terminal code propagation** + +Add a private mapper that matches only the stable code plus exact static message. Bump diagnostic records to schema 2 and add only the enum. In Supervisor, validate `request.error` against `^CCOD_[A-Z0-9_]{1,91}$`; pass it to `Complete-CcodSupervisorLifecycleTrayAction`, otherwise use the existing generic fallback. + +- [ ] **Step 4: Run GREEN** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostClient.SelfTest.ps1 +``` + +Expected: all pass, and redaction tests prove no raw target, command line, SID, path, or exception is logged. + +- [ ] **Step 5: Commit Task 2** + +```powershell +git add src/persistence/modules/SessionEngine.psm1 src/persistence/Supervisor.ps1 tests/persistence/SessionEngine.SelfTest.ps1 tests/persistence/Supervisor.SelfTest.ps1 +git commit -m "fix: retain safe repair failure diagnostics" +``` + +--- + +### Task 3: Make installed validation observe the real Codex lifecycle + +**Files:** +- Modify: `tests/installed/Invoke-InstalledLifecycleIntegration.ps1` +- Modify: `tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` + +**Interfaces:** +- Consumes: physical `ChatGPT.exe` CIM process facts, `state/status.json`, `active.json`, `state/transition.json`, and `state/lifecycle/receipts/*.json`. +- Produces: installed facts that contain only bounded nonsensitive identities and terminal lifecycle state; FreshRestart verifies one current top-level `ChatGPT.exe`, current-runtime Active status correlation, no active transition, and a current-runtime Completed installer restart receipt. + +- [ ] **Step 1: Write installed-harness RED tests** + +Add a fixture with one `ChatGPT.exe` root plus children, stale status from runtime `2.5.19-old`, current active runtime `2.5.21-new`, and a latest installer receipt in `CloseFailed`. Assert FreshRestart is unverified. Add the success fixture where the root PID/creation matches status, status runtime matches active runtime, transition is null, and the latest current-runtime Installer `RestartAndRepair` receipt is `Completed`. + +Add a mutation-catching assertion that a `Codex.exe`-only fixture does not satisfy Codex process evidence. + +- [ ] **Step 2: Run RED** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 +``` + +Expected: FAIL because the harness enumerates `Codex.exe` and does not bind FreshRestart to status or receipt completion. + +- [ ] **Step 3: Implement real process and terminal-state capture** + +Enumerate `ChatGPT.exe`, retain only top-level roots whose command line has no Electron `--type=` switch, and record only PID/creation time. Strictly parse status schema 1, active runtime schema 2, transition schema 1, and lifecycle receipt schema 1. Reject malformed/duplicate/latest-ambiguous receipts. + +FreshRestart must require all of: + +```text +app present +expected version and active runtime present +task Ready or Running +exactly one current ChatGPT.exe top-level root +status.session Active +status.session.runtimeId == activeRuntimeId +status codex PID/creation == the one captured root +transition.activeTransaction == null +latest current-runtime Installer RestartAndRepair receipt phase == Completed +device-key hash unchanged when it existed before +``` + +- [ ] **Step 4: Run GREEN and package preflight** + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1 +``` + +- [ ] **Step 5: Commit Task 3** + +```powershell +git add tests/installed/Invoke-InstalledLifecycleIntegration.ps1 tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 +git commit -m "test: require the real installed Codex lifecycle" +``` + +--- + +### Task 4: Prepare and validate the v2.5.21 release candidate + +**Files:** +- Modify: `package.json` +- Modify: `src/trayhost/AssemblyInfo.cs` +- Modify: manifest/version files found by the existing release preflight +- Modify: `CHANGELOG.md` +- Modify: `README.md` +- Modify: `README.zh-CN.md` + +**Interfaces:** +- Consumes: Tasks 1–3 commits and existing release/build/checksum/provenance workflow. +- Produces: one clean, tested `v2.5.21` release commit with synchronized metadata and concise documentation. + +- [ ] **Step 1: Bump version metadata and concise documentation** + +Set project version to `2.5.21` and native file/product version to `2.5.21.0`. Add concise English CHANGELOG bullets for stale-status single-root repair, exact safe diagnostics, and installed lifecycle validation. Update README current-stable references only; add no “What’s new” section. + +- [ ] **Step 2: Commit the release candidate metadata** + +```powershell +git add package.json src/trayhost/AssemblyInfo.cs CHANGELOG.md README.md README.zh-CN.md +git commit -m "release: CodexRemote-fix v2.5.21 stale-status recovery" +``` + +- [ ] **Step 3: Run focused and full local validation** + +Run every focused suite from Tasks 1–3, native TrayHost suites, release workflow tests, and: + +```powershell +npm test +``` + +The live installed Supervisor holds the real user AccountTransition mutex, so Bootstrap validation must run in an isolated Windows identity/task environment or after a separately proven safe Supervisor stop/restart. A mutex-collision failure is not a pass and must not be suppressed. + +- [ ] **Step 4: Commit any test-driven metadata correction** + +If validation exposes a metadata or documentation defect, write the failing release-contract test first, make only the minimal correction, rerun the covering test, and commit it before Task 4 review. Leave the worktree clean. + +--- + +### Task 5: Review, build, publish, and reinstall v2.5.21 + +**Files:** +- No planned source edits; any final-review correction must be implemented in one reviewed fix wave with covering tests. + +**Interfaces:** +- Consumes: the clean Task 4 release commit, all task reports/reviews, existing release workflow, Inno builder, portable builder, Defender validator, and installed lifecycle harness. +- Produces: official installer and EXE-entry portable ZIP for `v2.5.21`, both manifest-bound to the release commit, plus current-computer installed evidence. + +- [ ] **Step 1: Request independent whole-branch review** + +Review the complete diff from `v2.5.20` through HEAD for process-identity safety, fence ordering, multiple-root rejection, diagnostics redaction, harness correctness, version metadata, and documentation scope. Resolve every P0/P1 and re-run covering tests. + +- [ ] **Step 2: Build and validate clean official artifacts** + +Build from a clean commit using the existing Inno/portable pipeline. Validate installer, ZIP, checksums, release manifest, payload manifest, TrayHost provenance, every archived payload file, EXE portable entry point, Defender scan, Zone metadata, and version resources. + +- [ ] **Step 3: Publish v2.5.21 and verify release readback** + +Push the reviewed release commit/tag through the existing GitHub Actions release workflow. Confirm all jobs pass, the release is neither draft nor prerelease, all expected assets exist, and downloaded official hashes match uploaded digests. + +- [ ] **Step 4: Install the official asset on the current computer** + +Record the device-key SHA-256 before install, perform the full reinstall through the physical host boundary, verify active runtime `2.5.21-*`, current Supervisor and TrayHost paths, exact completed installer lifecycle receipt, real `ChatGPT.exe` top-level identity/status correlation, bridge validation, and unchanged device-key hash. + +--- + +### Task 6: Validate the affected computer after reboot + +**Files:** +- No repository edits unless the affected-machine evidence proves a new reproducible defect; any such defect starts a new RED/GREEN/review cycle before another release. + +**Interfaces:** +- Consumes: official v2.5.21 installer, affected-machine process/status/receipt/log evidence, and a real remote device. +- Produces: final acceptance evidence that the original stale-status failure is repaired across a Windows reboot. + +- [ ] **Step 1: Install, reboot, and verify the affected computer** + +Install the official `v2.5.21` asset on the affected computer, reboot Windows, and require: + +```text +Supervisor and TrayHost start from 2.5.21 +old 26.820.7780 status no longer blocks the unique 26.820.9563-or-newer ordinary root +RestartAndRepair/CheckAndRepair reaches Completed +status runtime matches active runtime and real ChatGPT.exe PID/creation +remote connection is restored from another device +About, language, and Open logs complete without a failure dialog +``` diff --git a/package.json b/package.json index c4d45b2..6edd7f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexremote-fix", - "version": "2.5.9", + "version": "2.5.21", "private": true, "description": "CodexRemote-fix persistent current-user tray supervisor for Windows", "license": "MIT", diff --git a/src/persistence/LifecycleWorker.ps1 b/src/persistence/LifecycleWorker.ps1 index d8df903..019de68 100644 --- a/src/persistence/LifecycleWorker.ps1 +++ b/src/persistence/LifecycleWorker.ps1 @@ -7,6 +7,40 @@ $ProgressPreference='SilentlyContinue' $VerbosePreference='SilentlyContinue' $InformationPreference='SilentlyContinue' +# Import and bind the worker dependency closure before defining adapter +# scriptblocks. A scriptblock created in this script must call the exact +# manifest-bound module export; relying on an unqualified command later in the +# script can leave the worker without PersistenceIO/RuntimeManifest commands +# when it is launched as a separate -File process. +$script:CcodLifecycleWorkerModuleRoot=Join-Path $PSScriptRoot 'modules' +$script:CcodLifecycleWorkerModuleBindings=@{} +foreach($specification in @( + [pscustomobject]@{Name='PersistenceIO';File='PersistenceIO.psm1';Exports=@('Read-CcodStrictJson','Write-CcodAtomicJson')}, + [pscustomobject]@{Name='RuntimeManifest';File='RuntimeManifest.psm1';Exports=@('Resolve-CcodActiveRuntimeContext')}, + [pscustomobject]@{Name='LifecycleEpoch';File='LifecycleEpoch.psm1';Exports=@('Enter-CcodLifecycleDelegation','Exit-CcodLifecycleDelegation','Assert-CcodLifecycleFence')}, + [pscustomobject]@{Name='ProcessControl';File='ProcessControl.psm1';Exports=@('Request-CcodOrdinaryPackagedLaunch','Wait-CcodVerifiedOrdinaryRoot')}, + [pscustomobject]@{Name='WorkerRuntime';File='WorkerRuntime.psm1';Exports=@()} +)){ + $modulePath=[IO.Path]::GetFullPath((Join-Path $script:CcodLifecycleWorkerModuleRoot $specification.File)) + $loaded=@(Import-Module -Name $modulePath -PassThru -Force -Scope Local -ErrorAction Stop -WarningAction SilentlyContinue) + if($loaded.Count -ne 1 -or [IO.Path]::GetFullPath($loaded[0].Path) -cne $modulePath){throw 'The lifecycle worker dependency module path is not manifest-bound.'} + $exports=[ordered]@{} + foreach($functionName in @($specification.Exports)){ + $command=$loaded[0].ExportedFunctions[[string]$functionName] + if($null -eq $command -or $command -isnot [Management.Automation.FunctionInfo] -or $command.ScriptBlock -isnot [scriptblock] -or + $null -eq $command.Module -or [IO.Path]::GetFullPath($command.Module.Path) -cne $modulePath){throw 'The lifecycle worker dependency export is not manifest-bound.'} + $exports[[string]$functionName]=$command.ScriptBlock + } + $script:CcodLifecycleWorkerModuleBindings[[string]$specification.Name]=$exports +} + +function Invoke-CcodLifecycleWorkerModuleFunction { + param([Parameter(Mandatory)][string]$Module,[Parameter(Mandatory)][string]$Function,[object[]]$Arguments) + $binding=$script:CcodLifecycleWorkerModuleBindings[$Module] + if($null -eq $binding -or -not $binding.Contains($Function) -or $binding[$Function] -isnot [scriptblock]){throw 'The lifecycle worker dependency export is unavailable.'} + & $binding[$Function] @Arguments +} + $script:CcodLifecycleWorkerScriptPath=if([string]::IsNullOrWhiteSpace($PSCommandPath)){$null}else{[IO.Path]::GetFullPath($PSCommandPath)} $script:CcodLifecycleWorkerRequestFields=@('schemaVersion','transactionId','action','runtimeId','runtimeGeneration','leaseEpoch','ownerIdentity','notBeforeUtc','timeoutMilliseconds') $script:CcodLifecycleWorkerResultFields=@('schemaVersion','transactionId','action','ok','outcome','observation','error') @@ -172,7 +206,7 @@ function Assert-CcodLifecycleWorkerRuntimeClosure { function Assert-CcodLifecycleWorkerFence { param($Context,$Request,$Delegation) $ownership=if($null-ne$Delegation){$Delegation}else{[pscustomobject][ordered]@{schemaVersion=1;lease=[pscustomobject][ordered]@{Released=$false};epoch=[UInt64]$Request.leaseEpoch;runtimeId=$Request.runtimeId;runtimeGeneration=[UInt64]$Request.runtimeGeneration;ownerIdentity=$Request.ownerIdentity;released=$false}} - Assert-CcodLifecycleFence -InstallRoot $Context.InstallRoot -Ownership $ownership + Invoke-CcodLifecycleWorkerModuleFunction -Module 'LifecycleEpoch' -Function 'Assert-CcodLifecycleFence' -Arguments @($Context.InstallRoot,$ownership)|Out-Null } function Invoke-CcodLifecycleControllerFacade { @@ -183,10 +217,17 @@ function Invoke-CcodLifecycleControllerFacade { $controllerRequest=[pscustomobject][ordered]@{ schemaVersion=2;action=$controllerAction;transactionId=$Request.transactionId;runtimeId=$Request.runtimeId;runtimeGeneration=[UInt64]$Request.runtimeGeneration;leaseEpoch=[UInt64]$Request.leaseEpoch ownerIdentity=$Request.ownerIdentity;supervisorIdentity=[pscustomobject][ordered]@{pid=[int]$Request.ownerIdentity.pid;creationTimeUtc=$Request.ownerIdentity.creationTimeUtc;sessionId=$identity.SessionId.ToString([Globalization.CultureInfo]::InvariantCulture)} - source=$null;existingOnly=[bool]($controllerAction -cne 'Apply');rendererPort=$null;mainPort=$null;timeoutMilliseconds=[Math]::Max(500,[Math]::Min(120000,$Request.timeoutMilliseconds));restartOrdinary=$false + source=$null;existingOnly=[bool]($controllerAction -cne 'Apply');rendererPort=$null;mainPort=$null;timeoutMilliseconds=[Math]::Max(500,[Math]::Min(120000,$Request.timeoutMilliseconds));restartOrdinary=[bool]($controllerAction -cne 'Close') } $paths=[pscustomobject][ordered]@{StateRoot=[IO.Path]::GetFullPath((Join-Path $Context.InstallRoot 'state'));TransitionPath=[IO.Path]::GetFullPath((Join-Path $Context.InstallRoot 'state\transition.json'));TransitionLogPath=[IO.Path]::GetFullPath((Join-Path $Context.InstallRoot 'logs\transactions.log'));SessionLogPath=[IO.Path]::GetFullPath((Join-Path $Context.InstallRoot 'logs\session.log'));CheckerPath=[IO.Path]::GetFullPath((Join-Path $Context.RuntimeRoot 'src\check-package.mjs'));OrchestratorPath=[IO.Path]::GetFullPath((Join-Path $Context.RuntimeRoot 'src\runtime\orchestrator.js'));MainPayloadPath=[IO.Path]::GetFullPath((Join-Path $Context.RuntimeRoot 'src\runtime\main-payload.js'))} - $run=& {param($Path,$Value,$ControllerPaths,$Delegated). $Path;$noWrite={param($P,$V)};$noLine={param($Line)};$bound=$Delegated;Invoke-CcodSessionController -Request $Value -Paths $ControllerPaths -ResultPath ([IO.Path]::GetFullPath((Join-Path $ControllerPaths.StateRoot 'workers\controller-unused.result.json'))) -Adapters @{WriteResult=$noWrite;WriteStdout=$noLine;WriteStderr=$noLine;GetDelegatedOwnership={$bound}.GetNewClosure()}} $controllerPath $controllerRequest $paths $Delegation + $run=& { + param($Path,$Value,$ControllerPaths,$Delegated) + $bound=$Delegated + $getDelegatedOwnership={$bound}.GetNewClosure() + . $Path + $noWrite={param($P,$V)};$noLine={param($Line)} + Invoke-CcodSessionController -Request $Value -Paths $ControllerPaths -ResultPath ([IO.Path]::GetFullPath((Join-Path $ControllerPaths.StateRoot 'workers\controller-unused.result.json'))) -Adapters @{WriteResult=$noWrite;WriteStdout=$noLine;WriteStderr=$noLine;GetDelegatedOwnership=$getDelegatedOwnership} + } $controllerPath $controllerRequest $paths $Delegation return $run.Result } @@ -206,16 +247,16 @@ function Get-CcodLifecycleWorkerAdapters { $resolved=@{ GetScriptPath={$script:CcodLifecycleWorkerScriptPath} GetBootstrapContext={param($Path)Get-CcodLifecycleWorkerBootstrapContext $Path} - ReadRequest={param($Path)Read-CcodStrictJson -Path $Path -ExpectedSchema 1 -Kind 'lifecycle worker request'} - ResolveRuntime={param($Path,$Runtime,$Generation)$bootstrap=Get-CcodLifecycleWorkerBootstrapContext $Path;Resolve-CcodActiveRuntimeContext -InstallRoot $bootstrap.InstallRoot -ExpectedRuntimeId $Runtime -ExpectedGeneration $Generation -ExpectedScriptPath $Path -ScriptRelativePath 'src/persistence/LifecycleWorker.ps1'} - EnterDelegation={param($Context,$Request)$identity=Get-CcodLifecycleWorkerCurrentIdentity;Enter-CcodLifecycleDelegation -InstallRoot $Context.InstallRoot -RuntimeId $Request.runtimeId -RuntimeGeneration $Request.runtimeGeneration -LeaseEpoch $Request.leaseEpoch -OwnerIdentity $Request.ownerIdentity -UserSid $identity.UserSid -SessionId $identity.SessionId -TimeoutMilliseconds ([Math]::Min(15000,$Request.timeoutMilliseconds))} - ExitDelegation={param($Delegation)Exit-CcodLifecycleDelegation -Delegation $Delegation} + ReadRequest={param($Path)Invoke-CcodLifecycleWorkerModuleFunction -Module 'PersistenceIO' -Function 'Read-CcodStrictJson' -Arguments @($Path,1,'lifecycle worker request')} + ResolveRuntime={param($Path,$Runtime,$Generation)$bootstrap=Get-CcodLifecycleWorkerBootstrapContext $Path;Invoke-CcodLifecycleWorkerModuleFunction -Module 'RuntimeManifest' -Function 'Resolve-CcodActiveRuntimeContext' -Arguments @($bootstrap.InstallRoot,$Runtime,$Generation,$Path,'src/persistence/LifecycleWorker.ps1')} + EnterDelegation={param($Context,$Request)$identity=Get-CcodLifecycleWorkerCurrentIdentity;Invoke-CcodLifecycleWorkerModuleFunction -Module 'LifecycleEpoch' -Function 'Enter-CcodLifecycleDelegation' -Arguments @($Context.InstallRoot,$Request.runtimeId,$Request.runtimeGeneration,$Request.leaseEpoch,$Request.ownerIdentity,$identity.UserSid,$identity.SessionId,[Math]::Min(15000,$Request.timeoutMilliseconds))} + ExitDelegation={param($Delegation)Invoke-CcodLifecycleWorkerModuleFunction -Module 'LifecycleEpoch' -Function 'Exit-CcodLifecycleDelegation' -Arguments @($Delegation)} AssertFence={param($Context,$Request,$Delegation)Assert-CcodLifecycleWorkerFence $Context $Request $Delegation} GetCurrentIdentity={Get-CcodLifecycleWorkerCurrentIdentity} InvokeController={param($Action,$Request,$Context,$Delegation)Invoke-CcodLifecycleControllerFacade $Action $Request $Context $Delegation} - RequestOrdinaryLaunch={param($Request,$Context,$Delegation)$ownership=[pscustomobject][ordered]@{runtimeGeneration=[UInt64]$Request.runtimeGeneration;leaseEpoch=[UInt64]$Request.leaseEpoch;ownerIdentity=$Request.ownerIdentity};$boundContext=$Context;$boundRequest=$Request;$boundDelegation=$Delegation;Request-CcodOrdinaryPackagedLaunch -RequestedAtUtc $Request.notBeforeUtc -Ownership $ownership -AssertLifecycleFence {param($Generation,$Epoch,$Owner)Assert-CcodLifecycleWorkerFence $boundContext $boundRequest $boundDelegation}.GetNewClosure()} - ObserveOrdinary={param($Request,$Context)$identity=Get-CcodLifecycleWorkerCurrentIdentity;Wait-CcodVerifiedOrdinaryRoot -NotBeforeUtc $Request.notBeforeUtc -ExpectedUserSid $identity.UserSid -ExpectedSessionId $identity.SessionId -StatusEvidence $null -TimeoutMilliseconds ([Math]::Min(45000,$Request.timeoutMilliseconds))} - WriteResult={param($Path,$Value)Write-CcodAtomicJson -Path $Path -Value $Value} + RequestOrdinaryLaunch={param($Request,$Context,$Delegation)$ownership=[pscustomobject][ordered]@{runtimeGeneration=[UInt64]$Request.runtimeGeneration;leaseEpoch=[UInt64]$Request.leaseEpoch;ownerIdentity=$Request.ownerIdentity};$boundContext=$Context;$boundRequest=$Request;$boundDelegation=$Delegation;Invoke-CcodLifecycleWorkerModuleFunction -Module 'ProcessControl' -Function 'Request-CcodOrdinaryPackagedLaunch' -Arguments @($Request.notBeforeUtc,$ownership,{param($Generation,$Epoch,$Owner)Assert-CcodLifecycleWorkerFence $boundContext $boundRequest $boundDelegation}.GetNewClosure())} + ObserveOrdinary={param($Request,$Context)$identity=Get-CcodLifecycleWorkerCurrentIdentity;$status=[pscustomobject][ordered]@{schemaVersion=1;session=$null};Invoke-CcodLifecycleWorkerModuleFunction -Module 'ProcessControl' -Function 'Wait-CcodVerifiedOrdinaryRoot' -Arguments @($Request.notBeforeUtc,$identity.UserSid,$identity.SessionId,$status,[Math]::Min(45000,$Request.timeoutMilliseconds))} + WriteResult={param($Path,$Value)Invoke-CcodLifecycleWorkerModuleFunction -Module 'PersistenceIO' -Function 'Write-CcodAtomicJson' -Arguments @($Path,$Value)} WriteStdout={param($Line)[Console]::Out.WriteLine($Line)};WriteStderr={param($Line)[Console]::Error.WriteLine($Line)} } if($null-ne$Adapters){foreach($key in $Adapters.Keys){$resolved[$key]=$Adapters[$key]}} @@ -273,16 +314,6 @@ function Invoke-CcodLifecycleWorker { } if($MyInvocation.InvocationName -ne '.'){ - Import-Module (Join-Path $PSScriptRoot 'modules\PersistenceIO.psm1') -Force - Import-Module (Join-Path $PSScriptRoot 'modules\RuntimeManifest.psm1') -Force - Import-Module (Join-Path $PSScriptRoot 'modules\WorkerRuntime.psm1') -Force - Import-Module (Join-Path $PSScriptRoot 'modules\LifecycleEpoch.psm1') -Force - Import-Module (Join-Path $PSScriptRoot 'modules\ProcessControl.psm1') -Force $run=Invoke-CcodLifecycleWorker -RequestPath $RequestPath -ResultPath $ResultPath exit $run.ExitCode -} else { - Import-Module (Join-Path $PSScriptRoot 'modules\PersistenceIO.psm1') -Force - Import-Module (Join-Path $PSScriptRoot 'modules\RuntimeManifest.psm1') -Force - Import-Module (Join-Path $PSScriptRoot 'modules\LifecycleEpoch.psm1') -Force - Import-Module (Join-Path $PSScriptRoot 'modules\ProcessControl.psm1') -Force } diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1 index 89609c9..0dbfb21 100644 --- a/src/persistence/Supervisor.ps1 +++ b/src/persistence/Supervisor.ps1 @@ -60,12 +60,20 @@ function Test-CcodSupervisorStaticProbeResult { $signatureFields=@('invertedGate','deviceKeyModuleReference','macOnlyGuard','windowsControllerUi') if(-not (Test-CcodSupervisorExactProperties $Result $resultFields) -or ($Result.schemaVersion -isnot [int] -and $Result.schemaVersion -isnot [long]) -or $Result.schemaVersion -ne 1 -or - $Result.action -isnot [string] -or $Result.action -cne 'StaticProbe' -or $Result.ok -isnot [bool] -or -not $Result.ok -or $null -ne $Result.error -or + $Result.action -isnot [string] -or $Result.action -cne 'StaticProbe' -or $Result.ok -isnot [bool] -or $Result.requestId -isnot [string] -or $Result.requestId -cne $Request.requestId -or $Result.runtimeId -isnot [string] -or $Result.runtimeId -cne $Request.runtimeId -or -not (Test-CcodSupervisorExactProperties $Result.targetIdentity @('pid','creationTimeUtc')) -or ($Result.targetIdentity.pid -isnot [int] -and $Result.targetIdentity.pid -isnot [long]) -or $Result.targetIdentity.pid -ne $Request.targetIdentity.pid -or - $Result.targetIdentity.creationTimeUtc -isnot [string] -or $Result.targetIdentity.creationTimeUtc -cne $Request.targetIdentity.creationTimeUtc -or - -not (Test-CcodSupervisorExactProperties $Result.probe $probeFields)){return $false} + $Result.targetIdentity.creationTimeUtc -isnot [string] -or $Result.targetIdentity.creationTimeUtc -cne $Request.targetIdentity.creationTimeUtc){return $false} + if(-not$Result.ok){ + $errorCodes=@('CCOD_STATIC_REQUEST_INVALID','CCOD_STATIC_PATH_INVALID','CCOD_STATIC_RUNTIME_UNAUTHORIZED','CCOD_STATIC_MODULE_LOAD_FAILED','CCOD_STATIC_STATE_INVALID','CCOD_STATIC_SUPERVISOR_CHANGED','CCOD_STATIC_TARGET_CHANGED','CCOD_STATIC_PACKAGE_CHANGED','CCOD_STATIC_PROBE_FAILED','CCOD_STATIC_PROBE_TIMEOUT','CCOD_STATIC_RESULT_INVALID') + $errorStages=@('InputValidation','RuntimeAuthorization','ModuleLoad','StateRead','SupervisorPreflight','TargetPreflight','StaticProbe','SupervisorPostflight','TargetPostflight','RuntimePostflight','ResultValidation') + return $null-eq$Result.probe -and (Test-CcodSupervisorExactProperties $Result.error @('code','stage','message')) -and + $Result.error.code-is[string] -and $errorCodes-ccontains$Result.error.code -and + $Result.error.stage-is[string] -and $errorStages-ccontains$Result.error.stage -and + $Result.error.message-is[string] -and $Result.error.message-ceq'The static probe worker failed safely.' + } + if($null-ne$Result.error -or -not(Test-CcodSupervisorExactProperties $Result.probe $probeFields)){return $false} $probe=$Result.probe if($probe.ready -isnot [bool] -or $probe.code -isnot [string] -or $probe.code -cne 'CHECKER_OK' -or $probe.staticClassification -isnot [string] -or @('CandidateCompatible','NativeModulePresent','UnknownOrIncompatible') -cnotcontains $probe.staticClassification -or @@ -181,10 +189,10 @@ function Import-CcodSupervisorModules { if($null -eq $script:CcodSupervisorScriptPath){throw 'supervisor script path is unavailable'} $moduleRoot=Join-Path (Split-Path $script:CcodSupervisorScriptPath -Parent) 'modules' foreach($leaf in @('KernelObjects.psm1','PersistenceIO.psm1','RuntimeManifest.psm1','LifecycleEpoch.psm1','LifecycleTransaction.psm1','LifecycleCoordinator.psm1','LifecycleRequest.psm1','TrustedLogonIdentity.psm1','StateStore.psm1','TransitionJournal.psm1','ProcessControl.psm1','RendererIntegration.psm1','SupervisorEngine.psm1','UiLocalization.psm1','UiPreferences.psm1','TrayUi.psm1','TrayHostClient.psm1','WorkerRuntime.psm1')){ - Import-Module -Name (Join-Path $moduleRoot $leaf) -Force -ErrorAction Stop + Import-Module -Name (Join-Path $moduleRoot $leaf) -Force -ErrorAction Stop -WarningAction SilentlyContinue } foreach($leaf in @('KernelObjects.psm1','LifecycleTransaction.psm1')){ - Import-Module -Name (Join-Path $moduleRoot $leaf) -Force -ErrorAction Stop + Import-Module -Name (Join-Path $moduleRoot $leaf) -Force -ErrorAction Stop -WarningAction SilentlyContinue } } @@ -242,7 +250,7 @@ function Get-CcodSupervisorDefaultAdapters { $defaults.GetUtcNow={[DateTime]::UtcNow} $defaults.EnterLease={param($Kind,$UserSid,$SessionId,$TimeoutMilliseconds)if($Kind -ceq 'AccountSupervisor'){Enter-CcodMutex -Kind $Kind -UserSid $UserSid -TimeoutMilliseconds $TimeoutMilliseconds}else{Enter-CcodMutex -Kind $Kind -UserSid $UserSid -SessionId $SessionId -TimeoutMilliseconds $TimeoutMilliseconds}} $defaults.ExitLease={param($Lease)Exit-CcodMutex -Lease $Lease} - $defaults.OpenReadyEvent={param($UserSid,$SessionId,$Token)Open-CcodEvent -Kind Ready -UserSid $UserSid -SessionId $SessionId -ReadyToken $Token} + $defaults.OpenReadyEvent={param($UserSid,$SessionId,$Token) $kernelModule=Get-Module -Name KernelObjects|Select-Object -First 1;if($null -eq $kernelModule){throw 'kernel-object module unavailable'};& $kernelModule {param($Sid,$Sess,$Tok)Open-CcodEvent -Kind Ready -UserSid $Sid -SessionId $Sess -ReadyToken $Tok} ([string]$UserSid) ([int]$SessionId) ([string]$Token)} $defaults.OpenShutdownEvent={param($UserSid,$SessionId)New-CcodEvent -Kind Shutdown -UserSid $UserSid -SessionId $SessionId} $defaults.OpenLifecycleWakeEvent={param($UserSid,$SessionId)New-CcodSupervisorLifecycleWakeEvent -UserSid $UserSid -SessionId $SessionId} $defaults.ResetLifecycleWakeEvent={param($Event)[void]$Event.Handle.Reset()} @@ -459,8 +467,8 @@ function Get-CcodSupervisorRemainingBudget { param($Clock,[hashtable]$Adapters) $elapsed=Invoke-CcodSupervisorAdapter $Adapters.GetElapsedMilliseconds @($Clock) 1 if(($elapsed -isnot [int] -and $elapsed -isnot [long]) -or $elapsed -lt 0){throw 'monotonic clock is invalid'} - if([long]$elapsed -ge 5000){return [int]0} - return [int](5000-[long]$elapsed) + if([long]$elapsed -ge 30000){return [int]0} + return [int](30000-[long]$elapsed) } function Invoke-CcodSupervisorDrainQueue { @@ -491,7 +499,7 @@ function New-CcodSupervisorHostState { ObservedKeys=[ordered]@{};AttemptKeys=[ordered]@{};RecoveryIgnoreKeys=[ordered]@{};SuppressionKeys=[ordered]@{};TrayActionIds=[ordered]@{} StaticCache=[ordered]@{};TransportRetries=[ordered]@{};TerminalRecoveries=[ordered]@{} PackageFullName=$null;AppAsarSha256=$null;Classification=$null - Ordinary=[object[]]@();Special=[object[]]@();SpecialNeedsInspect=$false;SpecialProof=$null;StaleReconciliationCandidate=$null;FailedStaleRepairKey=$null + Ordinary=[object[]]@();Special=[object[]]@();SpecialNeedsInspect=$false;SpecialProof=$null;FailedSpecialProofKey=$null;StaleReconciliationCandidate=$null;FailedStaleRepairKey=$null SessionState='Idle';BlockAutomaticActions=$false;Reason='Idle';ObservationDirty=$false;NextObservationMilliseconds=[long]0 ForceReconcile=$true;NextReconcileMilliseconds=[long]0;LastDecision=$null RuntimeCleanupCodes=[Collections.Generic.List[string]]::new() @@ -508,7 +516,7 @@ function Get-CcodSupervisorNowUtc { function Test-CcodSupervisorActiveRuntime { param($Pointer,[string]$RuntimeId) return (Test-CcodSupervisorExactProperties $Pointer @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc')) -and - $Pointer.schemaVersion -is [int] -and $Pointer.schemaVersion -eq 2 -and $Pointer.activeRuntime -is [string] -and $Pointer.activeRuntime -cmatch '^[A-Za-z0-9._-]{1,96}$' -and $Pointer.activeRuntime -ceq $RuntimeId -and + ($Pointer.schemaVersion -is [int] -or $Pointer.schemaVersion -is [int64] -or $Pointer.schemaVersion -is [long]) -and [int]$Pointer.schemaVersion -eq 2 -and $Pointer.activeRuntime -is [string] -and $Pointer.activeRuntime -cmatch '^[A-Za-z0-9._-]{1,96}$' -and $Pointer.activeRuntime -ceq $RuntimeId -and ($null -eq $Pointer.previousRuntime -or ($Pointer.previousRuntime -is [string] -and $Pointer.previousRuntime -cmatch '^[A-Za-z0-9._-]{1,96}$')) -and (Test-CcodSupervisorCanonicalUtc $Pointer.updatedAtUtc) -and ($Pointer.generation -is [int] -or $Pointer.generation -is [long] -or $Pointer.generation -is [uint64] -or $Pointer.generation -is [decimal]) -and [decimal]$Pointer.generation -ge 1 -and [decimal]$Pointer.generation -le [decimal][UInt64]::MaxValue -and [decimal]::Truncate([decimal]$Pointer.generation) -eq [decimal]$Pointer.generation @@ -614,18 +622,81 @@ function New-CcodSupervisorLifecycleWorkerPaths { } } +function Get-CcodSupervisorSpecialProofKey { + param($Snapshot) + if($null-eq$Snapshot-or$Snapshot.Pid-isnot[int]-or$Snapshot.Pid-lt1-or-not(Test-CcodSupervisorCanonicalUtc $Snapshot.CreationTimeUtc)){return $null} + return ('{0}|{1}'-f$Snapshot.Pid,$Snapshot.CreationTimeUtc) +} + +function Test-CcodSupervisorExactProcessSnapshotMatch { + param($Expected,$Actual) + $fields=@('Pid','CreationTimeUtc','SessionId','UserSid','Path','PackageFamilyName','CommandLine','ParentPid','IsTopLevel','Mode','RendererPort','MainPort') + if(-not(Test-CcodSupervisorExactProperties $Expected $fields)-or-not(Test-CcodSupervisorExactProperties $Actual $fields)){return $false} + foreach($field in $fields){if(-not[object]::Equals($Expected.$field,$Actual.$field)){return $false}} + return $true +} + +function Update-CcodSupervisorProofObservations { + param($HostState,[hashtable]$Adapters) + $arguments=[object[]]::new(2);$arguments[0]=$HostState.Layout.StateRoot + if(-not[string]::IsNullOrWhiteSpace([string]$HostState.PackageFullName)-and-not[string]::IsNullOrWhiteSpace([string]$HostState.AppAsarSha256)){ + $arguments[1]=('{0}|{1}|{2}'-f$HostState.PackageFullName,$HostState.AppAsarSha256,$HostState.Layout.RuntimeId) + }else{$arguments[1]=$null} + $state=Invoke-CcodSupervisorAdapter $Adapters.ReadState $arguments 1 + if($null-eq$state){throw 'special proof state refresh is invalid'} + $HostState.State=$state + Invoke-CcodSupervisorRefreshObservations $HostState $Adapters +} + +function Get-CcodSupervisorLifecycleProofCandidate { + param($HostState,[hashtable]$Adapters,[string]$Action) + if(@('Inspect','VerifyRemote')-cnotcontains$Action){return $null} + $fallback=$null + if(@($HostState.Special).Count-eq1-and$HostState.Special[0].Snapshot.Mode-ceq'Special'){$fallback=$HostState.Special[0].Snapshot} + try{Update-CcodSupervisorProofObservations $HostState $Adapters}catch{return $fallback} + if(@($HostState.Special).Count-ne1){return $null} + return $HostState.Special[0].Snapshot +} + +function Confirm-CcodSupervisorLifecycleProofCandidate { + param($HostState,[hashtable]$Adapters,$Candidate) + $candidateKey=Get-CcodSupervisorSpecialProofKey $Candidate + if($null-eq$candidateKey){return $false} + Update-CcodSupervisorProofObservations $HostState $Adapters + if(@($HostState.Special).Count-ne1-or-not(Test-CcodSupervisorExactProcessSnapshotMatch $Candidate $HostState.Special[0].Snapshot)){ + $HostState.SpecialProof=$null;$HostState.FailedSpecialProofKey=$candidateKey;$HostState.SpecialNeedsInspect=$false + return $false + } + $HostState.SpecialProof=$HostState.Special[0].Snapshot;$HostState.Special[0].ProbeValid=$true;$HostState.SpecialNeedsInspect=$false + $HostState.FailedSpecialProofKey=$null;$HostState.LifecycleObservation='RemoteVerified';$HostState.ConnectionState=ConvertTo-CcodSupervisorLifecycleObservation RemoteVerified + return $true +} + +function New-CcodSupervisorLifecycleProofFailure { + param($Result) + return [pscustomobject][ordered]@{ + schemaVersion=1;transactionId=$Result.transactionId;action=$Result.action;ok=$false;outcome='Error';observation='Error' + error=[pscustomobject][ordered]@{code='CCOD_REMOTE_PROOF_REBIND_FAILED';stage='ResultValidation';message='The lifecycle worker failed safely.'} + } +} + function Start-CcodSupervisorLifecycleWorkerSlot { param($HostState,[hashtable]$Adapters,[string]$Action,[AllowNull()]$DeadlineUtc) if($null-ne$HostState.LifecycleWorkerSlot -or $null-ne$HostState.WorkerSlot -or $null-eq$HostState.LifecycleRequest){throw 'lifecycle worker slot is unavailable'} - $requestState=$HostState.LifecycleRequest;$now=Get-CcodSupervisorNowUtc $Adapters;$timeout=30000 + $requestState=$HostState.LifecycleRequest;$now=Get-CcodSupervisorNowUtc $Adapters;$timeout=30000;$notBefore=$now + if($Action-ceq'ObserveOrdinary'){ + if(-not(Test-CcodSupervisorCanonicalUtc $requestState.createdAtUtc)){throw 'lifecycle observation lower bound is invalid'} + $notBefore=$requestState.createdAtUtc + } if($null-ne$DeadlineUtc){ if(-not(Test-CcodSupervisorCanonicalUtc $DeadlineUtc)){throw 'lifecycle deadline is invalid'} $deadline=[DateTime]::ParseExact($DeadlineUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) $current=[DateTime]::ParseExact($now,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) $timeout=[int][Math]::Max(1,[Math]::Min(600000,[Math]::Ceiling(($deadline-$current).TotalMilliseconds))) } + $proofCandidate=Get-CcodSupervisorLifecycleProofCandidate $HostState $Adapters $Action $paths=New-CcodSupervisorLifecycleWorkerPaths $HostState $requestState.transactionId - $workerRequest=Invoke-CcodSupervisorAdapter $Adapters.NewLifecycleWorkerRequest @($requestState.transactionId,$Action,$requestState.runtimeId,[UInt64]$requestState.runtimeGeneration,[UInt64]$requestState.leaseEpoch,$requestState.ownerIdentity,$now,$timeout) 1 + $workerRequest=Invoke-CcodSupervisorAdapter $Adapters.NewLifecycleWorkerRequest @($requestState.transactionId,$Action,$requestState.runtimeId,[UInt64]$requestState.runtimeGeneration,[UInt64]$requestState.leaseEpoch,$requestState.ownerIdentity,$notBefore,$timeout) 1 $owned=[Collections.Generic.List[string]]::new();$suspended=$false try{ Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null @@ -648,7 +719,7 @@ function Start-CcodSupervisorLifecycleWorkerSlot { $suspended=$true $started=Invoke-CcodSupervisorAdapter $Adapters.StartWorker @('Lifecycle',$HostState.Layout.LifecycleWorkerPath,$paths.RequestPath,$paths.ResultPath,$null,$workerRequest,$HostState.Layout.PowerShellPath) 1 if(-not(Test-CcodSupervisorExactProperties $started @('ProcessId','CreationTimeUtc','Handle','JobHandle')) -or $started.ProcessId-isnot[int] -or $started.ProcessId-lt1 -or -not(Test-CcodSupervisorCanonicalUtc $started.CreationTimeUtc) -or $null-eq$started.Handle -or $null-eq$started.JobHandle){throw 'lifecycle worker start receipt is invalid'} - $HostState.LifecycleWorkerSlot=[pscustomobject][ordered]@{Kind='Lifecycle';Action=$Action;RequestId=$requestState.transactionId;RuntimeId=$requestState.runtimeId;Request=$workerRequest;RequestPath=$paths.RequestPath;ResultPath=$paths.ResultPath;StderrPath=$null;ProcessId=[int]$started.ProcessId;CreationTimeUtc=$started.CreationTimeUtc;Handle=$started.Handle;JobHandle=$started.JobHandle} + $HostState.LifecycleWorkerSlot=[pscustomobject][ordered]@{Kind='Lifecycle';Action=$Action;RequestId=$requestState.transactionId;RuntimeId=$requestState.runtimeId;Request=$workerRequest;ProofCandidate=$proofCandidate;RequestPath=$paths.RequestPath;ResultPath=$paths.ResultPath;StderrPath=$null;ProcessId=[int]$started.ProcessId;CreationTimeUtc=$started.CreationTimeUtc;Handle=$started.Handle;JobHandle=$started.JobHandle} return $HostState.LifecycleWorkerSlot }catch{ if($suspended){ @@ -722,8 +793,10 @@ function Complete-CcodSupervisorLifecycleTerminal { } Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null Invoke-CcodSupervisorAdapter $Adapters.CompleteLifecycleRequest @($HostState.Layout.StateRoot,$request) 0 - [void](Complete-CcodSupervisorLifecycleTrayAction $HostState $Adapters $request $successful) - if(-not$successful){$HostState.ConnectionState='Error'} + $failureCode='CCOD_LIFECYCLE_ACTION_FAILED' + if($request.error -is [string] -and $request.error -cmatch '^CCOD_[A-Z0-9_]{1,91}\z'){$failureCode=$request.error} + [void](Complete-CcodSupervisorLifecycleTrayAction $HostState $Adapters $request $successful $failureCode) + if(-not$successful){$HostState.ConnectionState=$(if($null-ne$HostState.FailedSpecialProofKey){'RepairNeeded'}else{'Error'})} $HostState.ProtectionState='Running';$HostState.LifecycleRequest=$null } @@ -747,6 +820,20 @@ function Invoke-CcodSupervisorPollLifecycleSlot { $expectedExitCode=if($result.ok){[int]0}else{[int]1} if($poll.ExitCode-ne$expectedExitCode){throw 'lifecycle worker exit code does not match its result frame'} Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null + if(@('Inspect','VerifyRemote')-ccontains$result.action){ + if($result.ok-and$result.observation-ceq'RemoteVerified'){ + $confirmed=$false + try{$confirmed=Confirm-CcodSupervisorLifecycleProofCandidate $HostState $Adapters $slot.ProofCandidate}catch{ + $failedKey=Get-CcodSupervisorSpecialProofKey $slot.ProofCandidate + if($null-ne$failedKey){$HostState.FailedSpecialProofKey=$failedKey;$HostState.SpecialNeedsInspect=$false} + } + if(-not$confirmed){$result=New-CcodSupervisorLifecycleProofFailure $result} + Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null + }elseif(-not$result.ok-or$result.action-ceq'VerifyRemote'){ + $failedKey=Get-CcodSupervisorSpecialProofKey $slot.ProofCandidate;if($null-ne$failedKey){$HostState.FailedSpecialProofKey=$failedKey;$HostState.SpecialNeedsInspect=$false} + } + Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleWorkerResult @($result,$slot.Request) 0 + } $now=Get-CcodSupervisorNowUtc $Adapters $reduced=Invoke-CcodSupervisorAdapter $Adapters.ReduceLifecycleWorkerResult @($HostState.LifecycleRequest,$result,$now) 1 Invoke-CcodSupervisorAdapter $Adapters.AssertLifecycleFence @($HostState.Layout.InstallRoot,$HostState.LifecycleOwnership) 1|Out-Null @@ -925,21 +1012,34 @@ function Invoke-CcodSupervisorRendererHandoff { }catch{Write-CcodSupervisorRendererHandoffFailure $HostState $Adapters} } +function Set-CcodSupervisorStaticProbeFramingFailure { + param($HostState,[AllowNull()][string]$AttemptKey) + $HostState.Classification=$null + if($null-ne$AttemptKey){$HostState.AttemptKeys[$AttemptKey]=$true} + $HostState.SessionState='Error';$HostState.BlockAutomaticActions=$true;$HostState.Reason='WorkerFramingFailed' +} + function Invoke-CcodSupervisorPollSlot { param($HostState,[hashtable]$Adapters) $slot=$HostState.WorkerSlot $staleRepairKey=$null + $staticAttemptKey=$null if($slot.Kind -ceq 'Controller' -and $slot.Action -ceq 'RepairStale' -and $null -ne $slot.Request.source){ $staleRepairKey=('{0}|{1}' -f $slot.Request.source.Pid,$slot.Request.source.CreationTimeUtc) } + if($slot.Kind-ceq'StaticProbe'-and$null-ne$slot.Request.targetIdentity){$staticAttemptKey=('{0}|{1}'-f$slot.Request.targetIdentity.pid,$slot.Request.targetIdentity.creationTimeUtc)} $poll=Invoke-CcodSupervisorAdapter $Adapters.PollWorker @($slot) 1 $fields=@('Completed','ExitCode','StdoutText','StdoutByteCount','StdoutOverflow','StderrByteCount','StderrOverflow') if(-not (Test-CcodSupervisorExactProperties $poll $fields) -or $poll.Completed -isnot [bool] -or $poll.StdoutText -isnot [string] -or $poll.StdoutByteCount -isnot [int] -or $poll.StdoutByteCount -lt 0 -or $poll.StdoutOverflow -isnot [bool] -or - $poll.StderrByteCount -isnot [int] -or $poll.StderrByteCount -lt 0 -or $poll.StderrOverflow -isnot [bool]){Clear-CcodSupervisorWorkerSlot $HostState $Adapters;return} + $poll.StderrByteCount -isnot [int] -or $poll.StderrByteCount -lt 0 -or $poll.StderrOverflow -isnot [bool]){ + if($slot.Kind-ceq'StaticProbe'){Set-CcodSupervisorStaticProbeFramingFailure $HostState $staticAttemptKey} + Clear-CcodSupervisorWorkerSlot $HostState $Adapters;return + } if(-not $poll.Completed){return} try{ if($poll.ExitCode -isnot [int] -or $poll.StdoutByteCount -gt 1048576 -or $poll.StderrByteCount -gt 65536 -or $poll.StdoutOverflow -or $poll.StderrOverflow){throw 'worker framing failed'} + if($slot.Kind-ceq'StaticProbe'-and[string]::IsNullOrEmpty($poll.StdoutText)){throw 'static probe result is missing'} if(-not [string]::IsNullOrEmpty($poll.StdoutText)){ $result=Invoke-CcodSupervisorNullableAdapter $Adapters.ReadWorkerResult @($slot.ResultPath) if($null -eq $result){throw 'worker result is missing'} @@ -966,8 +1066,8 @@ function Invoke-CcodSupervisorPollSlot { } Invoke-CcodSupervisorRendererHandoff $HostState $slot $result $Adapters }elseif($slot.Kind -ceq 'StaticProbe'){ - if($result.ok -and $null -ne $result.probe){ - if(-not (Test-CcodSupervisorStaticProbeResult $result $slot.Request)){$HostState.Classification='UnknownOrIncompatible';throw 'static probe framing failed'} + if(-not (Test-CcodSupervisorStaticProbeResult $result $slot.Request) -or ($result.ok-and$poll.ExitCode-ne0) -or (-not$result.ok-and$poll.ExitCode-ne1)){$HostState.Classification=$null;throw 'static probe framing failed'} + if($result.ok){ $HostState.PackageFullName=[string]$result.probe.packageFullName $HostState.AppAsarSha256=[string]$result.probe.appAsarSha256 $HostState.Classification=[string]$result.probe.staticClassification @@ -975,16 +1075,19 @@ function Invoke-CcodSupervisorPollSlot { $attemptKey=('{0}|{1}' -f $slot.Request.targetIdentity.pid,$slot.Request.targetIdentity.creationTimeUtc) $HostState.StaticCache[$attemptKey]=$result } - }elseif($result.ok){ - $HostState.Classification='UnknownOrIncompatible' - throw 'static probe framing failed' }else{ - $HostState.Classification='UnknownOrIncompatible' + $retryable=@('CCOD_STATIC_TARGET_CHANGED','CCOD_STATIC_PACKAGE_CHANGED','CCOD_STATIC_PROBE_FAILED','CCOD_STATIC_PROBE_TIMEOUT') + $HostState.Classification=$null + if($retryable-cnotcontains$result.error.code){ + if($null-ne$staticAttemptKey){$HostState.AttemptKeys[$staticAttemptKey]=$true} + $HostState.SessionState='Error';$HostState.BlockAutomaticActions=$true;$HostState.Reason='StaticProbeFailed' + } } } } }catch{ - $HostState.SessionState='Error';$HostState.BlockAutomaticActions=$true;$HostState.Reason='WorkerFramingFailed' + if($slot.Kind-ceq'StaticProbe'){Set-CcodSupervisorStaticProbeFramingFailure $HostState $staticAttemptKey} + else{$HostState.SessionState='Error';$HostState.BlockAutomaticActions=$true;$HostState.Reason='WorkerFramingFailed'} if($null -ne $staleRepairKey){$HostState.FailedStaleRepairKey=$staleRepairKey} } finally{Clear-CcodSupervisorWorkerSlot $HostState $Adapters} @@ -1054,6 +1157,31 @@ function Send-CcodSupervisorTrayActionResult { return $result } +function Get-CcodSupervisorAcknowledgedTrayPresentation { + param($Tray,[UInt64]$Revision) + if($null-eq$Tray){return $null} + $property=$Tray.PSObject.Properties['AcknowledgedPresentations'] + if($null-eq$property-or$property.Value-isnot[Collections.IDictionary]){return $null} + $key=[string]$Revision + if(-not$property.Value.Contains($key)){return $null} + return $property.Value[$key] +} + +function Test-CcodSupervisorTrayPresentationAllowsAction { + param($Presentation,[string]$Command) + if($null-eq$Presentation){return $false} + $capability=switch($Command){ + 'CheckAndRepair' {'RepairEnabled'} + {$_-in@('SetLanguageSystem','SetLanguageChinese','SetLanguageEnglish')} {'LanguageEnabled'} + 'OpenLogs' {'OpenLogsEnabled'} + 'ShowAbout' {'AboutEnabled'} + 'Exit' {'ExitEnabled'} + default {return $false} + } + $property=$Presentation.PSObject.Properties[$capability] + return $null-ne$property-and$property.Value-is[bool]-and$property.Value +} + function Complete-CcodSupervisorLifecycleTrayAction { param($HostState,[hashtable]$Adapters,$Request,[bool]$Successful,[AllowNull()][string]$FailureCode='CCOD_LIFECYCLE_ACTION_FAILED') foreach($key in @($HostState.TrayActionIds.Keys)){ @@ -1101,8 +1229,11 @@ function Invoke-CcodSupervisorLanguageAction { function Invoke-CcodSupervisorCommand { param($HostState,[hashtable]$Adapters,$Command) if(-not(Test-CcodSupervisorTrayAction $Command)){Throw-CcodSupervisorCommandError 'CCOD_SUPERVISOR_COMMAND_INVALID' 'Tray action is invalid' $Command} - $revisionProperty=$HostState.Tray.PSObject.Properties['CurrentRevision'] - if($null-eq$revisionProperty-or$revisionProperty.Value-isnot[UInt64]-or$revisionProperty.Value-eq0-or[UInt64]$revisionProperty.Value-ne[UInt64]$Command.Revision){return Send-CcodSupervisorTrayActionResult $HostState $Adapters $Command Rejected 'CCOD_TRAY_ACTION_STALE' $null} + $displayedPresentation=Get-CcodSupervisorAcknowledgedTrayPresentation $HostState.Tray ([UInt64]$Command.Revision) + if($null-eq$displayedPresentation){return Send-CcodSupervisorTrayActionResult $HostState $Adapters $Command Rejected 'CCOD_TRAY_ACTION_STALE' $null} + $currentPresentation=$HostState.LastAcknowledgedPresentation + if($null-eq$currentPresentation-and[UInt64]$Command.Revision-eq[UInt64]1){$currentPresentation=$displayedPresentation} + if(-not(Test-CcodSupervisorTrayPresentationAllowsAction $displayedPresentation $Command.Command)-or-not(Test-CcodSupervisorTrayPresentationAllowsAction $currentPresentation $Command.Command)){return Send-CcodSupervisorTrayActionResult $HostState $Adapters $Command Rejected 'CCOD_TRAY_ACTION_UNAVAILABLE' $null} if($HostState.TrayActionIds.Contains($Command.ActionId.ToString('D'))){Throw-CcodSupervisorCommandError 'CCOD_SUPERVISOR_COMMAND_INVALID' 'Tray action was already handled' $Command} $actionEntry=[pscustomobject][ordered]@{Action=$Command;TransactionId=$null;TerminalSent=$false} $HostState.TrayActionIds[$Command.ActionId.ToString('D')]=$actionEntry @@ -1110,6 +1241,7 @@ function Invoke-CcodSupervisorCommand { switch($Command.Command){ 'CheckAndRepair' { if($busy-or$HostState.ConnectionState-cne'RepairNeeded'){return Send-CcodSupervisorTrayActionResult $HostState $Adapters $Command Rejected 'CCOD_TRAY_ACTION_UNAVAILABLE' $null} + $HostState.FailedSpecialProofKey=$null if(-not(New-CcodSupervisorInternalLifecycleRequest $HostState $Adapters CheckAndRepair Tray)){return Send-CcodSupervisorTrayActionResult $HostState $Adapters $Command Rejected 'CCOD_LIFECYCLE_SUPERVISOR_BUSY' $null} $actionEntry.TransactionId=$HostState.LifecycleRequest.transactionId return Send-CcodSupervisorTrayActionResult $HostState $Adapters $Command Accepted $null $HostState.LifecycleRequest.transactionId @@ -1269,14 +1401,15 @@ function Invoke-CcodSupervisorRefreshObservations { } $proofMatches=$false if($special.Count -eq 1 -and $null -ne $HostState.SpecialProof -and - $null -ne $HostState.SpecialProof.PSObject.Properties['Pid'] -and $null -ne $HostState.SpecialProof.PSObject.Properties['CreationTimeUtc'] -and - $HostState.SpecialProof.Pid -eq $special[0].Snapshot.Pid -and $HostState.SpecialProof.CreationTimeUtc -ceq $special[0].Snapshot.CreationTimeUtc){$proofMatches=$true} - if(-not $proofMatches){$HostState.SpecialProof=$null} + (Test-CcodSupervisorExactProcessSnapshotMatch $HostState.SpecialProof $special[0].Snapshot)){$proofMatches=$true} + if($proofMatches){$special[0].ProbeValid=$true}else{$HostState.SpecialProof=$null} + $specialKey=if($special.Count-eq1){Get-CcodSupervisorSpecialProofKey $special[0].Snapshot}else{$null} + if(($null-ne$HostState.FailedSpecialProofKey)-and($HostState.FailedSpecialProofKey-cne$specialKey)){$HostState.FailedSpecialProofKey=$null} $HostState.Ordinary=[object[]]@($ordinary);$HostState.Special=[object[]]@($special) $HostState.StaleReconciliationCandidate=if($ordinary.Count -eq 0 -and $special.Count -eq 0 -and $staleCandidates.Count -eq 1){$staleCandidates[0]}else{$null} - $HostState.SpecialNeedsInspect=$special.Count -gt 0 -and $null -eq $HostState.SpecialProof + $HostState.SpecialNeedsInspect=($special.Count-eq1)-and($null-eq$HostState.SpecialProof)-and($HostState.FailedSpecialProofKey-cne$specialKey) $HostState.LifecycleObservation=if($ordinary.Count-eq1 -and $special.Count-eq0){'Ordinary'}elseif($ordinary.Count-eq0 -and $special.Count-eq1 -and $null-ne$HostState.SpecialProof){'RemoteVerified'}elseif($ordinary.Count-eq0 -and $special.Count-eq1){'Special'}elseif($ordinary.Count-eq0 -and $special.Count-eq0){'NoCodex'}else{'Error'} - $HostState.ConnectionState=ConvertTo-CcodSupervisorLifecycleObservation $HostState.LifecycleObservation + $HostState.ConnectionState=if($null-ne$specialKey-and$HostState.FailedSpecialProofKey-ceq$specialKey){'RepairNeeded'}else{ConvertTo-CcodSupervisorLifecycleObservation $HostState.LifecycleObservation} } function Invoke-CcodSupervisorTick { @@ -1435,7 +1568,8 @@ function Invoke-CcodSupervisorHost { if(-not(Test-CcodSupervisorLogonIdentity $logonIdentity $identity)){throw 'trusted logon identity contract is invalid'} $remaining=Get-CcodSupervisorRemainingBudget $clock $adapter $ownerIdentity=[pscustomobject][ordered]@{pid=[int]$identity.Pid;creationTimeUtc=[string]$identity.CreationTimeUtc} - $lifecycleOwnership=Invoke-CcodSupervisorAdapter $adapter.EnterLifecycleOwnership @($layout.InstallRoot,$layout.RuntimeId,[UInt64]$activeRuntime.generation,$ownerIdentity,$identity.UserSid,[int]$identity.SessionId,[int]$remaining) 1 + $runtimeGeneration=[UInt64][decimal]$activeRuntime.generation + $lifecycleOwnership=Invoke-CcodSupervisorAdapter $adapter.EnterLifecycleOwnership @($layout.InstallRoot,$layout.RuntimeId,$runtimeGeneration,$ownerIdentity,$identity.UserSid,[int]$identity.SessionId,[int]$remaining) 1 if(-not(Test-CcodSupervisorLifecycleOwnership $lifecycleOwnership $identity $activeRuntime)){throw 'lifecycle ownership contract is invalid'} $lifecycleOwned=$true $readyEvent=Invoke-CcodSupervisorAdapter $adapter.OpenReadyEvent @($identity.UserSid,[int]$identity.SessionId,$ReadyToken) 1 diff --git a/src/persistence/bootstrap.ps1 b/src/persistence/bootstrap.ps1 index cf7000a..24a1c49 100644 --- a/src/persistence/bootstrap.ps1 +++ b/src/persistence/bootstrap.ps1 @@ -567,7 +567,7 @@ function Start-CcodBootstrapSupervisor { ) $powershell = Get-CcodBootstrapPowerShellPath - $arguments = '-NoProfile -ExecutionPolicy Bypass -STA -File "' + $SupervisorPath + '" -ReadyToken ' + $ReadyToken + $arguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', $SupervisorPath, '-ReadyToken', $ReadyToken) try { return Start-Process -FilePath $powershell -ArgumentList $arguments -WindowStyle Hidden -WorkingDirectory $WorkingDirectory -PassThru } catch { @@ -807,6 +807,7 @@ $kernelModule = $null $identity = $null $currentProcess = $null $safeExitSuppressed = $false +$readyConfirmed = $false try { $root = Get-CcodBootstrapCanonicalRoot -InstallRoot $InstallRoot $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -827,19 +828,13 @@ try { } AcquireLaunchLease = { $module = Import-CcodBootstrapKernelObjects -InstallRoot $root -Pointer $initialPointer - $lease = & $module { param($Sid,$Timeout) Enter-CcodMutex -Kind 'AccountTransition' -UserSid $Sid -TimeoutMilliseconds $Timeout } $userSid ([int]($ReadyTimeoutSeconds * 1000)) - [pscustomobject]@{ KernelModule = $module; LaunchLease = $lease } + [pscustomobject]@{ KernelModule = $module; LaunchLease = $null } } } $safeExitSuppressed = $prelaunch.Suppressed if($safeExitSuppressed){Throw-CcodBootstrapError 'CCOD_SAFE_EXIT_SUPPRESSED' 'Task startup was intentionally suppressed by the same-logon safe-exit marker' $root} $kernelModule = $prelaunch.KernelModule - $launchLease = $prelaunch.LaunchLease - if ($launchLease.Outcome -cne 'Acquired') { - Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_LAUNCH_BUSY' 'An installation transition is in progress; bootstrap will retry through the scheduled task' $root - } - $readyEvent = New-CcodBootstrapReadyEvent -UserSid $userSid -SessionId $sessionId -Token $token if (-not $readyEvent.CreatedNew) { Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_READY_EVENT_EXISTS' 'A stale ready event already exists; bootstrap refuses to reuse it' $readyEvent.Name @@ -861,21 +856,29 @@ try { Write-CcodBootstrapLog -InstallRoot $root -Message ("Runtime {0} rejected before launch: {1}" -f $runtimeId, $validation.Code) continue } - + $launchLease = & $kernelModule { param($Sid,$Timeout) Enter-CcodMutex -Kind 'AccountTransition' -UserSid $Sid -TimeoutMilliseconds $Timeout } $userSid ([int]($ReadyTimeoutSeconds * 1000)) + if ($launchLease.Outcome -cne 'Acquired') { + Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_LAUNCH_BUSY' 'An installation transition is in progress; bootstrap will retry through the scheduled task' $root + } $child = Start-CcodBootstrapSupervisor -SupervisorPath $validation.SupervisorPath -ReadyToken $token -WorkingDirectory $root + $released = & $kernelModule { param($Lease) Exit-CcodMutex -Lease $Lease } $launchLease + if ($released -isnot [bool] -or -not $released) { + Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_LAUNCH_RELEASE_FAILED' 'The launch serialization lease could not be released before Supervisor readiness' $root + } + $launchLease = $null $outcome = Wait-CcodBootstrapReady -Event $readyEvent -Process $child -TimeoutSeconds $ReadyTimeoutSeconds if ($outcome -ceq 'Ready') { if ($runtimeId -cne $pointer.ActiveRuntime) { $updated = Invoke-CcodBootstrapFencedPointerPromotion -InstallRoot $root -Pointer $pointer -Validation $validation -NewRuntimeId $runtimeId -UserSid $userSid -SessionId $sessionId Write-CcodBootstrapLog -InstallRoot $root -Message ("Runtime {0} signaled ready; active pointer switched from {1}" -f $runtimeId, $pointer.ActiveRuntime) } - $released = & $kernelModule { param($Lease) Exit-CcodMutex -Lease $Lease } $launchLease - if ($released -isnot [bool] -or -not $released) { - Throw-CcodBootstrapError 'CCOD_BOOTSTRAP_LAUNCH_RELEASE_FAILED' 'The launch serialization lease could not be released after Supervisor readiness' $root - } - $exitCode = Wait-CcodBootstrapChild -Process $child - Write-CcodBootstrapLog -InstallRoot $root -Message ("Runtime {0} exited after readiness with code {1}" -f $runtimeId, $exitCode) - $child = $null + + $readyConfirmed = $true + if (-not $child.HasExited) { [void]$child.WaitForExit(2000) } + if ($child.HasExited) { $exitCode = [int]$child.ExitCode } + else { $exitCode = 0 } + if ($exitCode -eq 0) { Start-Sleep -Milliseconds 3000 } + Write-CcodBootstrapLog -InstallRoot $root -Message ("Runtime {0} signaled ready; supervisor retained for installer verification" -f $runtimeId) break } @@ -890,10 +893,6 @@ try { $child = $null } - if ($null -ne $child) { - $exitCode = Wait-CcodBootstrapChild -Process $child - $child = $null - } if ($exitCode -eq 1) { Write-CcodBootstrapLog -InstallRoot $root -Message 'No verified runtime signaled ready; bootstrap failed closed' } @@ -907,15 +906,14 @@ try { $exitCode = 1 } } finally { - if ($null -ne $child) { - try { Stop-CcodBootstrapChild -Process $child } catch { } - $child = $null - } - if ($null -ne $readyEvent -and $null -ne $readyEvent.Handle) { - try { $readyEvent.Handle.Dispose() } catch { } - } - if ($null -ne $launchLease -and $launchLease.Outcome -ceq 'Acquired' -and -not $launchLease.Released) { - try { & $kernelModule { param($Lease) Exit-CcodMutex -Lease $Lease | Out-Null } $launchLease } catch { } + if (-not $readyConfirmed) { + if ($null -ne $child) { + try { Stop-CcodBootstrapChild -Process $child } catch { } + $child = $null + } + if ($null -ne $readyEvent -and $null -ne $readyEvent.Handle) { + try { $readyEvent.Handle.Dispose() } catch { } + } } if ($null -ne $currentProcess) { $currentProcess.Dispose() } if ($null -ne $identity) { $identity.Dispose() } diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1 index e143c8e..ffc7656 100644 --- a/src/persistence/modules/InstallLifecycle.psm1 +++ b/src/persistence/modules/InstallLifecycle.psm1 @@ -94,7 +94,7 @@ function Write-CcodActivationReceiptFile { [void](Assert-CcodActivationReceipt $Receipt) $stateRoot = Join-Path $InstallRoot 'state' [IO.Directory]::CreateDirectory($stateRoot) | Out-Null - Write-CcodAtomicJson -Path (Join-Path $stateRoot 'post-install-activation.json') -Value $Receipt + Write-CcodAtomicJson -Path (Join-Path $stateRoot 'post-install-activation.json') -Value $Receipt -Compress } function Write-CcodInstallActivationPhase { @@ -1315,6 +1315,17 @@ function Start-CcodLifecycleTask { & $Adapters.StartSupervisorTask } +function Test-CcodLifecycleCimCreationTime { + param([AllowNull()][string]$ExpectedUtc,[Parameter(Mandatory)][DateTime]$ActualUtc) + if ([string]::IsNullOrWhiteSpace($ExpectedUtc)) { return $false } + $expected = [DateTime]::MinValue + if (-not [DateTime]::TryParseExact($ExpectedUtc,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind,[ref]$expected) -or + $expected.Kind -ne [DateTimeKind]::Utc) { return $false } + $actualTicks = $ActualUtc.ToUniversalTime().Ticks + $expectedTicks = $expected.ToUniversalTime().Ticks + return $actualTicks -ge $expectedTicks -and ($actualTicks - $expectedTicks) -le 9 +} + function Get-CcodLifecycleReadinessAdapters { param([hashtable]$Adapters) $defaults = @{ @@ -1333,7 +1344,7 @@ function Get-CcodLifecycleReadinessAdapters { try { $process = Get-Process -Id $SupervisorIdentity.Pid -ErrorAction SilentlyContinue if ($null -eq $process) { return $false } - return $process.StartTime.ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) -ceq $SupervisorIdentity.CreationTimeUtc + return Test-CcodLifecycleCimCreationTime -ExpectedUtc $SupervisorIdentity.CreationTimeUtc -ActualUtc $process.StartTime.ToUniversalTime() } catch { return $false } finally { if ($null -ne $process) { $process.Dispose() } } } @@ -1383,7 +1394,7 @@ function Wait-CcodLifecycleNewRuntimeReady { $supervisorPath = [IO.Path]::GetFullPath((Join-Path $runtimeRoot 'src\persistence\Supervisor.ps1')) $bootstrapPath = [IO.Path]::GetFullPath((Join-Path $root 'bootstrap.ps1')) $hostPrefix = '^\s*(?:"[^"]*powershell\.exe"|[^\s"]*powershell\.exe)' - $supervisorPattern = $hostPrefix + '\s+-NoProfile\s+-ExecutionPolicy\s+Bypass\s+-STA\s+-File\s+"(?[^"]+)"\s+-ReadyToken\s+(?[0-9a-f]{64})\s*$' + $supervisorPattern = $hostPrefix + '\s+-NoProfile\s+-ExecutionPolicy\s+Bypass\s+-STA\s+-File\s+(?:\"(?[^\"]+)\"|(?[^\s]+))\s+-ReadyToken\s+(?[0-9a-f]{64})\s*$' $bootstrapPattern = $hostPrefix + '\s+-NoProfile\s+-ExecutionPolicy\s+Bypass\s+-STA\s+-WindowStyle\s+Hidden\s+-File\s+"(?[^"]+)"\s+-InstallRoot\s+"(?[^"]+)"\s+-EntryMode\s+Task\s*$' $started = $TaskStartedAtUtc.ToUniversalTime() $clock = & $adapter.StartClock @@ -1436,7 +1447,10 @@ function Wait-CcodLifecycleNewRuntimeReady { if (-not (& $adapter.IsSupervisorIdentityCurrent $candidate.Identity)) { return New-CcodLifecycleNotReadyProof } $readyToken = [string]$candidate.Token try { $readyEvent = & $adapter.OpenReadyEvent $Identity.UserSid $Identity.SessionId $readyToken } - catch { return New-CcodLifecycleNotReadyProof } + catch { + & $adapter.Sleep 20 + continue + } while ([long](& $adapter.GetElapsedMilliseconds $clock) -lt $TimeoutMilliseconds) { if (-not (& $adapter.IsSupervisorIdentityCurrent $candidate.Identity)) { return New-CcodLifecycleNotReadyProof } $remaining = $TimeoutMilliseconds - [long](& $adapter.GetElapsedMilliseconds $clock) @@ -1697,20 +1711,20 @@ function Invoke-CcodInstall { if ($DoNotStart) { Throw-CcodLifecycleError 'CCOD_INSTALL_NEW_RUNTIME_NOT_READY' 'Activation cannot succeed without starting and proving the new runtime' $null } - $taskStartedAt = & $adapters.UtcNow - try { & $adapters.StartSupervisorTask } - catch { Throw-CcodLifecycleError 'CCOD_INSTALL_SUPERVISOR_START_FAILED' 'The existing scheduled task could not start the new runtime' $null } $ownershipReleased = & $adapters.ExitLifecycleOwnership $lifecycleOwnership if ($ownershipReleased -isnot [bool] -or -not $ownershipReleased -or -not $lifecycleOwnership.released) { - Throw-CcodLifecycleError 'CCOD_INSTALL_LIFECYCLE_RELEASE_FAILED' 'Generation ownership could not be released for scheduled-task bootstrap' $null + Throw-CcodLifecycleError 'CCOD_INSTALL_LIFECYCLE_RELEASE_FAILED' 'Generation ownership could not be released before scheduled-task bootstrap' $null } if ($null -ne $installLease -and $installLease.Outcome -ceq 'Acquired') { $installReleased = & $adapters.ExitInstallLease $installLease if ($installReleased -isnot [bool] -or -not $installReleased) { - Throw-CcodLifecycleError 'CCOD_INSTALL_LIFECYCLE_RELEASE_FAILED' 'Installation lease could not be released for scheduled-task bootstrap' $null + Throw-CcodLifecycleError 'CCOD_INSTALL_LIFECYCLE_RELEASE_FAILED' 'Installation lease could not be released before scheduled-task bootstrap' $null } $installLease = $null } + $taskStartedAt = & $adapters.UtcNow + try { & $adapters.StartSupervisorTask } + catch { Throw-CcodLifecycleError 'CCOD_INSTALL_SUPERVISOR_START_FAILED' 'The existing scheduled task could not start the new runtime' $null } $readyProof = & $adapters.WaitNewRuntimeReady $root $runtimeId ([UInt64]$pointer.generation) $identity $taskStartedAt 15000 if ($null -eq $readyProof -or $readyProof.SupervisorReady -isnot [bool] -or $readyProof.TrayReady -isnot [bool] -or -not $readyProof.SupervisorReady -or -not $readyProof.TrayReady) { diff --git a/src/persistence/modules/KernelObjects.psm1 b/src/persistence/modules/KernelObjects.psm1 index 7f551f9..165e658 100644 --- a/src/persistence/modules/KernelObjects.psm1 +++ b/src/persistence/modules/KernelObjects.psm1 @@ -280,10 +280,10 @@ function New-CcodEventResult { function New-CcodEvent { [CmdletBinding()] - param($Kind,$UserSid,$SessionId,$Adapters) - if($Kind -isnot [string] -or $Kind -cne 'Shutdown'){Throw-CcodKernelError 'CCOD_KERNEL_INPUT_INVALID' ([Management.Automation.ErrorCategory]::InvalidArgument)} - $hasSession=$PSBoundParameters.ContainsKey('SessionId');$adapter=Get-CcodKernelAdapters $Adapters - $name=Resolve-CcodKernelName $Kind $UserSid $SessionId $null $hasSession $false $adapter + param($Kind,$UserSid,$SessionId,$ReadyToken,$Adapters) + if($Kind -isnot [string] -or @('Shutdown','Ready') -cnotcontains $Kind){Throw-CcodKernelError 'CCOD_KERNEL_INPUT_INVALID' ([Management.Automation.ErrorCategory]::InvalidArgument)} + $hasSession=$PSBoundParameters.ContainsKey('SessionId');$hasToken=$PSBoundParameters.ContainsKey('ReadyToken');$adapter=Get-CcodKernelAdapters $Adapters + $name=Resolve-CcodKernelName $Kind $UserSid $SessionId $ReadyToken $hasSession $hasToken $adapter $security=New-CcodEventSecurity -UserSid $UserSid;$handle=$null try{ try{$opened=& $adapter.CreateEvent $name $security}catch [UnauthorizedAccessException]{Throw-CcodKernelError 'CCOD_KERNEL_ACCESS_DENIED' ([Management.Automation.ErrorCategory]::SecurityError)}catch [Threading.WaitHandleCannotBeOpenedException]{if(& $adapter.TestOtherMutexType $name){Throw-CcodKernelError 'CCOD_KERNEL_OBJECT_TYPE_MISMATCH' ([Management.Automation.ErrorCategory]::InvalidType)};Throw-CcodKernelError 'CCOD_KERNEL_OPEN_FAILED' ([Management.Automation.ErrorCategory]::OpenError)} diff --git a/src/persistence/modules/LifecycleCoordinator.psm1 b/src/persistence/modules/LifecycleCoordinator.psm1 index e15b1c6..0fceb56 100644 --- a/src/persistence/modules/LifecycleCoordinator.psm1 +++ b/src/persistence/modules/LifecycleCoordinator.psm1 @@ -67,6 +67,9 @@ function Get-CcodLifecycleStep { if($Request.kind -ceq 'CheckAndRepair' -and $Request.phase -ceq 'Requested' -and $Observation -ceq 'RemoteVerified'){ return New-CcodLifecycleStepResult Completed CancelledBeforeClose None $null $null } + if($Request.kind -ceq 'CheckAndRepair' -and $Request.phase -ceq 'Requested' -and $Observation -ceq 'Special'){ + return New-CcodLifecycleStepResult Operation $null Inspect $null $null + } if($Request.kind -ceq 'SafeExit' -and $Request.phase -ceq 'Requested' -and @('Ordinary','NoCodex') -ccontains $Observation){ return New-CcodLifecycleStepResult Completed CancelledBeforeClose None $null $null } @@ -75,7 +78,7 @@ function Get-CcodLifecycleStep { 'Requested' { return New-CcodLifecycleStepResult Operation CloseRequested Close $null $null } 'CloseRequested' { if($Observation -ceq 'CloseFailed' -or $Observation -ceq 'Error'){return New-CcodLifecycleStepResult Failed CloseFailed None $null $(if($Request.error){$Request.error}else{'CCOD_CLOSE_FAILED'})} - if(@('Closed','NoCodex','Ordinary') -ccontains $Observation){return New-CcodLifecycleStepResult Progress CloseConfirmed None $null $null} + if(@('Closed','NoCodex') -ccontains $Observation){return New-CcodLifecycleStepResult Progress CloseConfirmed None $null $null} return New-CcodLifecycleStepResult Operation $null Close $null $null } 'CloseConfirmed' { @@ -206,7 +209,7 @@ function Reduce-CcodLifecycleWorkerResult { } $nextPhase=$null;$stableError=$null switch($Result.action){ - 'Inspect'{if($Result.observation -ceq 'RemoteVerified'){$nextPhase='CancelledBeforeClose'}} + 'Inspect'{if($Result.observation -ceq 'RemoteVerified'){$nextPhase='CancelledBeforeClose'}elseif($Result.observation -ceq 'Special'){$nextPhase='CloseRequested'}} 'Close'{$nextPhase='CloseConfirmed'} 'RequestOrdinaryLaunch'{ $nextPhase=$null diff --git a/src/persistence/modules/PersistenceIO.psm1 b/src/persistence/modules/PersistenceIO.psm1 index 68fd3bb..a69517b 100644 --- a/src/persistence/modules/PersistenceIO.psm1 +++ b/src/persistence/modules/PersistenceIO.psm1 @@ -357,6 +357,7 @@ function Write-CcodAtomicJson { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$Value, + [switch]$Compress, [hashtable]$Adapters ) @@ -364,7 +365,7 @@ function Write-CcodAtomicJson { $Adapters = Get-CcodAtomicWriteAdapters -Adapters $Adapters $directory = Split-Path -Path $Path -Parent [IO.Directory]::CreateDirectory($directory) | Out-Null - $json = ($Value | ConvertTo-Json -Depth 16) + "`n" + $json = ($Value | ConvertTo-Json -Depth 16 -Compress:$Compress) + "`n" $encoding = [Text.UTF8Encoding]::new($false) $temporary = New-CcodAtomicOwnedFile -Directory $directory -Purpose 'replacement' -Adapters $Adapters $oldBytes = $null diff --git a/src/persistence/modules/ProcessControl.psm1 b/src/persistence/modules/ProcessControl.psm1 index de94d05..f723324 100644 --- a/src/persistence/modules/ProcessControl.psm1 +++ b/src/persistence/modules/ProcessControl.psm1 @@ -39,6 +39,46 @@ namespace Ccod.Persistence.Native { public string PackageFamilyName { get; set; } } + public sealed class ProcessCreationQueryResult { + public string Stage { get; set; } + public int Pid { get; set; } + public string CreationTimeUtc { get; set; } + public int ErrorCode { get; set; } + } + + public static class ProcessCreationV1 { + private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; + + [StructLayout(LayoutKind.Sequential)] + private struct FILETIME { public uint Low; public uint High; } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inheritHandle, int processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetProcessTimes(IntPtr process, out FILETIME creation, out FILETIME exit, out FILETIME kernel, out FILETIME user); + + private static ProcessCreationQueryResult Result(string stage, int pid, string creationTimeUtc, int errorCode) { + return new ProcessCreationQueryResult { Stage = stage, Pid = pid, CreationTimeUtc = creationTimeUtc, ErrorCode = errorCode }; + } + + public static ProcessCreationQueryResult Query(int processId) { + IntPtr process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, processId); + if (process == IntPtr.Zero) return Result("OpenProcessFailed", processId, null, Marshal.GetLastWin32Error()); + try { + FILETIME creation, exit, kernel, user; + if (!GetProcessTimes(process, out creation, out exit, out kernel, out user)) { + return Result("GetProcessTimesFailed", processId, null, Marshal.GetLastWin32Error()); + } + long fileTime = ((long)creation.High << 32) | creation.Low; + return Result("Found", processId, DateTime.FromFileTimeUtc(fileTime).ToString("o"), 0); + } finally { CloseHandle(process); } + } + } + public static class ProcessIdentityV1 { private const uint PROCESS_QUERY_LIMITED_INFORMATION = 0x1000; private const uint TOKEN_QUERY = 0x0008; @@ -272,6 +312,46 @@ namespace Ccod.Persistence.Native { '@ } +function Get-CcodDefaultProcessProbe { + param([Parameter(Mandatory)][ValidateRange(1,2147483647)][int]$ProcessId) + try { + return [pscustomobject][ordered]@{Outcome='Found';Process=(Get-Process -Id $ProcessId -ErrorAction Stop)} + } catch { + $id=([string]$_.FullyQualifiedErrorId -split ',')[0] + if($id -ceq 'NoProcessFoundForGivenId'){return [pscustomobject][ordered]@{Outcome='Absent';Process=$null}} + throw + } +} + +function Test-CcodNativeProcessQueryProvesExit { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Failure, + [Parameter(Mandatory)][ValidateRange(1,2147483647)][int]$ProcessId, + [scriptblock]$GetProcess = { param($Id) Get-CcodDefaultProcessProbe -ProcessId $Id } + ) + + $exception = $Failure.Exception + while ($null -ne $exception -and $exception -isnot [ComponentModel.Win32Exception]) { $exception = $exception.InnerException } + if ($null -eq $exception -or $exception.NativeErrorCode -ne 31) { return $false } + $probe = $null + try { + $output=@(& $GetProcess $ProcessId 2>&1) + if($output.Count-ne1-or$output[0]-is[Management.Automation.ErrorRecord]){return $false} + $state=$output[0] + if($state-isnot[pscustomobject]-or(@($state.PSObject.Properties.Name)-join',')-cne'Outcome,Process'){return $false} + if($state.Outcome-ceq'Absent'){return $null-eq$state.Process} + if($state.Outcome-cne'Found'-or$state.Process-isnot[Diagnostics.Process]){return $false} + $probe=$state.Process + $probe.Refresh() + return [bool]$probe.HasExited + } catch { + return $false + } finally { + if ($probe -is [Diagnostics.Process]) { $probe.Dispose() } + } +} + function Get-CcodDefaultNativeProcess { param([int]$ProcessId) @@ -280,10 +360,74 @@ function Get-CcodDefaultNativeProcess { return [Ccod.Persistence.Native.ProcessIdentityV1]::Query($ProcessId) } catch [ComponentModel.Win32Exception] { if ($_.Exception.NativeErrorCode -in @(6, 87, 1168)) { return $null } + if (Test-CcodNativeProcessQueryProvesExit -Failure $_ -ProcessId $ProcessId) { return $null } throw } } +function Get-CcodDefaultMinimalNativeProcess { + param([Parameter(Mandatory)][ValidateRange(1,2147483647)][int]$ProcessId) + + Initialize-CcodProcessNativeApi + $result = [Ccod.Persistence.Native.ProcessCreationV1]::Query($ProcessId) + if ($null -eq $result -or $result.Pid -ne $ProcessId -or $result.Stage -isnot [string] -or $result.ErrorCode -isnot [int]) { + throw [IO.InvalidDataException]::new('Minimal native process query returned a malformed receipt.') + } + switch -CaseSensitive ($result.Stage) { + 'Found' { + if ($result.ErrorCode -ne 0 -or -not (Test-CcodCanonicalUtcTimestamp -Value $result.CreationTimeUtc)) { + throw [IO.InvalidDataException]::new('Minimal native process query returned malformed creation evidence.') + } + return [pscustomobject][ordered]@{Outcome='Found';Pid=$ProcessId;CreationTimeUtc=[string]$result.CreationTimeUtc} + } + 'OpenProcessFailed' { + if ($result.ErrorCode -in @(87,1168) -and $null -eq $result.CreationTimeUtc) { + return [pscustomobject][ordered]@{Outcome='Absent';Pid=$ProcessId;CreationTimeUtc=$null} + } + throw [ComponentModel.Win32Exception]::new([int]$result.ErrorCode) + } + 'GetProcessTimesFailed' { + throw [ComponentModel.Win32Exception]::new([int]$result.ErrorCode) + } + default { throw [IO.InvalidDataException]::new('Minimal native process query returned an unknown stage.') } + } +} + +function Get-CcodProcessIdentityObservation { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateRange(1,2147483647)][int]$ProcessId, + [Parameter(Mandatory)][string]$ExpectedCreationTimeUtc, + [hashtable]$Adapters + ) + + if (-not (Test-CcodCanonicalUtcTimestamp -Value $ExpectedCreationTimeUtc)) { + throw [IO.InvalidDataException]::new('ExpectedCreationTimeUtc must be a canonical UTC timestamp.') + } + $adapter = Get-CcodProcessAdapters -Adapters $Adapters + $output = @(& $adapter.GetMinimalNativeProcess $ProcessId 2>&1) + if ($output.Count -ne 1 -or $output[0] -is [Management.Automation.ErrorRecord]) { + throw [IO.InvalidDataException]::new('Native process identity query emitted an invalid result stream.') + } + $identity = $output[0] + if (-not (Test-CcodExactProperties -Value $identity -Names @('Outcome','Pid','CreationTimeUtc')) -or + $identity.Outcome -isnot [string] -or @('Absent','Found') -cnotcontains $identity.Outcome -or + $identity.Pid -isnot [int] -or $identity.Pid -ne $ProcessId -or + ($identity.Outcome -ceq 'Absent' -and $null -ne $identity.CreationTimeUtc) -or + ($identity.Outcome -ceq 'Found' -and -not (Test-CcodCanonicalUtcTimestamp -Value $identity.CreationTimeUtc))) { + throw [IO.InvalidDataException]::new('Native process identity query returned a malformed receipt.') + } + if ($identity.Outcome -ceq 'Absent') { + return [pscustomobject][ordered]@{ Outcome='Absent'; Pid=$ProcessId; CreationTimeUtc=$null } + } + $outcome = if ($identity.CreationTimeUtc -ceq $ExpectedCreationTimeUtc) { 'SameIdentity' } else { 'IdentityChanged' } + return [pscustomobject][ordered]@{ + Outcome = $outcome + Pid = $ProcessId + CreationTimeUtc = [string]$identity.CreationTimeUtc + } +} + function Get-CcodProcessRendererProbe { param([int]$RendererPort, [hashtable]$Adapters) try { @@ -311,6 +455,8 @@ function Get-CcodProcessAdapters { GetCurrentSessionId = { [Diagnostics.Process]::GetCurrentProcess().SessionId } GetCurrentUserSid = { [Security.Principal.WindowsIdentity]::GetCurrent().User.Value } GetNativeProcess = { param($ProcessId) Get-CcodDefaultNativeProcess -ProcessId $ProcessId } + GetMinimalNativeProcess = { param($ProcessId) Get-CcodDefaultMinimalNativeProcess -ProcessId $ProcessId } + ObserveProcessIdentity = { param($ProcessId,$ExpectedCreationTimeUtc) Get-CcodProcessIdentityObservation -ProcessId $ProcessId -ExpectedCreationTimeUtc $ExpectedCreationTimeUtc } GetCimProcess = { param($ProcessId) Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $([int]$ProcessId)" -ErrorAction Stop @@ -864,10 +1010,25 @@ function Get-CcodStalePackageRootResult { if ($null -eq $parts) { return New-CcodStalePackageRootResult -Outcome Incomplete -Snapshot $null } $adapter = Get-CcodProcessAdapters -Adapters $Adapters $rawMode = $PSBoundParameters.ContainsKey('ProcessIds') -or -not $PSBoundParameters.ContainsKey('Snapshots') + $rawIncomplete = $false $observed = if ($rawMode) { $ids = if ($PSBoundParameters.ContainsKey('ProcessIds')) { @($ProcessIds) } else { @(& $adapter.ListProcessIds) } - @($ids | Sort-Object -Unique | ForEach-Object { Get-CcodStalePackageProcessSnapshot -ProcessId $_ -Package $Package -Adapter $adapter }) + $rawSnapshots = [Collections.Generic.List[object]]::new() + foreach ($rawProcessId in @($ids | Sort-Object -Unique)) { + $convertedProcessId = ConvertTo-CcodProcessIdValue -Value $rawProcessId + if (-not $convertedProcessId.Valid) { $rawIncomplete = $true; continue } + try { + $snapshot = Get-CcodStalePackageProcessSnapshot -ProcessId $convertedProcessId.Value -Package $Package -Adapter $adapter + } catch { + $rawIncomplete = $true + continue + } + if ($null -eq $snapshot) { $rawIncomplete = $true; continue } + $rawSnapshots.Add($snapshot) + } + @($rawSnapshots.ToArray()) } else { @($Snapshots) } + if ($rawIncomplete) { return New-CcodStalePackageRootResult -Outcome Incomplete -Snapshot $null } $currentSessionId = & $adapter.GetCurrentSessionId $currentUserSid = & $adapter.GetCurrentUserSid $suspicious = [Collections.Generic.List[object]]::new() @@ -911,6 +1072,38 @@ function New-CcodStopResult { } } +function Get-CcodStrictStopIdentityObservation { + param( + [Parameter(Mandatory)]$Expected, + [Parameter(Mandatory)][hashtable]$Adapter + ) + + if ($null -eq $Expected -or $Expected.Pid -isnot [int] -or $Expected.Pid -lt 1 -or + $Expected.CreationTimeUtc -isnot [string] -or -not (Test-CcodCanonicalUtcTimestamp -Value $Expected.CreationTimeUtc)) { + return $null + } + try { $output = @(& $Adapter.ObserveProcessIdentity ([int]$Expected.Pid) ([string]$Expected.CreationTimeUtc) 2>&1) } catch { return $null } + if ($output.Count -ne 1 -or $output[0] -is [Management.Automation.ErrorRecord]) { return $null } + $observation = $output[0] + if (-not (Test-CcodExactProperties -Value $observation -Names @('Outcome','Pid','CreationTimeUtc')) -or + $observation.Outcome -isnot [string] -or @('Absent','SameIdentity','IdentityChanged') -cnotcontains $observation.Outcome -or + $observation.Pid -isnot [int] -or $observation.Pid -ne $Expected.Pid) { return $null } + switch -CaseSensitive ($observation.Outcome) { + 'Absent' { if ($null -ne $observation.CreationTimeUtc) { return $null } } + 'SameIdentity' { if ($observation.CreationTimeUtc -isnot [string] -or $observation.CreationTimeUtc -cne $Expected.CreationTimeUtc) { return $null } } + 'IdentityChanged' { + if ($observation.CreationTimeUtc -isnot [string] -or -not (Test-CcodCanonicalUtcTimestamp -Value $observation.CreationTimeUtc) -or + $observation.CreationTimeUtc -ceq $Expected.CreationTimeUtc) { return $null } + } + } + return $observation +} + +function Test-CcodStrictStopExitObservation { + param($Observation) + return $null -ne $Observation -and @('Absent','IdentityChanged') -ccontains $Observation.Outcome +} + function Stop-CcodProcessIfMatch { [CmdletBinding()] param( @@ -922,7 +1115,11 @@ function Stop-CcodProcessIfMatch { $adapter = Get-CcodProcessAdapters -Adapters $Adapters $actual = & $adapter.GetProcess ([int]$Expected.Pid) $StatusEvidence - if ($null -eq $actual) { return New-CcodStopResult -Outcome 'SourceExited' -Snapshot $null } + if ($null -eq $actual) { + $observation = Get-CcodStrictStopIdentityObservation -Expected $Expected -Adapter $adapter + if (Test-CcodStrictStopExitObservation -Observation $observation) { return New-CcodStopResult -Outcome 'SourceExited' -Snapshot $null } + return New-CcodStopResult -Outcome 'StopUnconfirmed' -Snapshot $null + } if (-not (Test-CcodProcessMatch -Expected $Expected -Actual $actual)) { return New-CcodStopResult -Outcome 'IdentityChanged' -Snapshot $actual } @@ -947,7 +1144,11 @@ function Stop-CcodProcessIfMatch { } return New-CcodStopResult -Outcome 'StopUnconfirmed' -Snapshot $actual } - 'ExitedBeforeStop' { return New-CcodStopResult -Outcome 'SourceExited' -Snapshot $null } + 'ExitedBeforeStop' { + $observation = Get-CcodStrictStopIdentityObservation -Expected $Expected -Adapter $adapter + if (Test-CcodStrictStopExitObservation -Observation $observation) { return New-CcodStopResult -Outcome 'SourceExited' -Snapshot $null } + return New-CcodStopResult -Outcome 'StopUnconfirmed' -Snapshot $actual + } 'IdentityChanged' { return New-CcodStopResult -Outcome 'IdentityChanged' -Snapshot $actual } 'AccessDenied' { return New-CcodStopResult -Outcome 'StopUnconfirmed' -Snapshot $actual } 'TimedOut' { return New-CcodStopResult -Outcome 'StopUnconfirmed' -Snapshot $actual } @@ -1134,6 +1335,34 @@ function Get-CcodReachableProcessIds { return @($included) } +function Test-CcodIndeterminateProcessBlocksVerifiedTree { + param( + [Parameter(Mandatory)][int]$ProcessId, + [Parameter(Mandatory)]$Root, + [Parameter(Mandatory)][hashtable]$Adapter + ) + + try{$minimalOutput=@(& $Adapter.GetMinimalNativeProcess $ProcessId 2>&1)}catch{return $true} + if($minimalOutput.Count -ne 1 -or $minimalOutput[0] -is [Management.Automation.ErrorRecord]){return $true} + $minimal=$minimalOutput[0] + if(-not (Test-CcodExactProperties -Value $minimal -Names @('Outcome','Pid','CreationTimeUtc')) -or + $minimal.Outcome -isnot [string] -or @('Absent','Found') -cnotcontains $minimal.Outcome -or + $minimal.Pid -isnot [int] -or $minimal.Pid -ne $ProcessId){return $true} + if($minimal.Outcome -ceq 'Absent'){return $null -ne $minimal.CreationTimeUtc} + if(-not (Test-CcodCanonicalUtcTimestamp -Value $minimal.CreationTimeUtc)){return $true} + try{$nativeOutput=@(& $Adapter.GetNativeProcess $ProcessId 2>&1)}catch{return $true} + if($nativeOutput.Count -ne 1 -or $nativeOutput[0] -is [Management.Automation.ErrorRecord]){return $true} + $native=$nativeOutput[0] + if(-not (Test-CcodExactProperties -Value $native -Names @('Pid','CreationTimeUtc','SessionId','UserSid','Path','PackageFamilyName')) -or + $native.Pid -isnot [int] -or $native.Pid -ne $ProcessId -or $native.CreationTimeUtc -isnot [string] -or + $native.CreationTimeUtc -cne $minimal.CreationTimeUtc -or $native.SessionId -isnot [int] -or $native.SessionId -lt 0 -or + $native.UserSid -isnot [string] -or [string]::IsNullOrWhiteSpace($native.UserSid) -or + $native.Path -isnot [string] -or [string]::IsNullOrWhiteSpace($native.Path) -or + ($null -ne $native.PackageFamilyName -and ($native.PackageFamilyName -isnot [string] -or [string]::IsNullOrWhiteSpace($native.PackageFamilyName)))){return $true} + return $native.SessionId -eq $Root.SessionId -and $native.UserSid -ceq $Root.UserSid -and + (Test-CcodOrdinalIgnoreCase $native.Path $Root.Path) -and $native.PackageFamilyName -ceq $Root.PackageFamilyName +} + function Get-CcodVerifiedProcessTree { [CmdletBinding()] param( @@ -1153,7 +1382,11 @@ function Get-CcodVerifiedProcessTree { foreach ($processId in @(& $adapter.ListProcessIds | Select-Object -Unique)) { if ($null -eq $processId -or [int]$processId -eq [int]$actualRoot.Pid) { continue } $candidate = & $adapter.GetProcess ([int]$processId) $null - if ($null -eq $candidate -or -not (Test-CcodSameProcessOwnerAndPackage -Root $actualRoot -Candidate $candidate)) { continue } + if ($null -eq $candidate) { + if(Test-CcodIndeterminateProcessBlocksVerifiedTree -ProcessId ([int]$processId) -Root $actualRoot -Adapter $adapter){return @()} + continue + } + if (-not (Test-CcodSameProcessOwnerAndPackage -Root $actualRoot -Candidate $candidate)) { continue } $candidateTime = ConvertTo-CcodDateTimeOffset -Value $candidate.CreationTimeUtc if ($null -eq $candidateTime -or $candidateTime -lt $rootTime) { continue } $verifiedByPid[[int]$candidate.Pid] = $candidate @@ -1165,8 +1398,8 @@ function Get-CcodVerifiedProcessTree { $evidence = if ([int]$processId -eq [int]$actualRoot.Pid) { $StatusEvidence } else { $null } $current = & $adapter.GetProcess ([int]$processId) $evidence $previous = $verifiedByPid[[int]$processId] - if ($null -eq $current -or -not (Test-CcodProcessMatch -Expected $previous -Actual $current)) { continue } - if (-not (Test-CcodSameProcessOwnerAndPackage -Root $actualRoot -Candidate $current)) { continue } + if ($null -eq $current -or -not (Test-CcodProcessMatch -Expected $previous -Actual $current) -or + -not (Test-CcodSameProcessOwnerAndPackage -Root $actualRoot -Candidate $current)) { return @() } $rereadByPid[[int]$processId] = $current } if (-not $rereadByPid.ContainsKey([int]$actualRoot.Pid)) { return @() } @@ -1192,7 +1425,11 @@ function Get-CcodVerifiedStaleProcessTree { foreach ($processId in @(& $adapter.ListProcessIds | Select-Object -Unique)) { if ($processId -isnot [int] -or $processId -eq [int]$actualRoot.Pid) { continue } $candidate = Get-CcodStalePackageProcessSnapshot -ProcessId $processId -Package $Package -Adapter $adapter - if ($null -eq $candidate -or -not (Test-CcodSameProcessOwnerAndPackage -Root $actualRoot -Candidate $candidate)) { continue } + if ($null -eq $candidate) { + if(Test-CcodIndeterminateProcessBlocksVerifiedTree -ProcessId $processId -Root $actualRoot -Adapter $adapter){return @()} + continue + } + if (-not (Test-CcodSameProcessOwnerAndPackage -Root $actualRoot -Candidate $candidate)) { continue } $candidateTime = ConvertTo-CcodDateTimeOffset -Value $candidate.CreationTimeUtc if ($null -eq $candidateTime -or $candidateTime -lt $rootTime) { continue } $verifiedByPid[[int]$candidate.Pid] = $candidate @@ -1203,7 +1440,7 @@ function Get-CcodVerifiedStaleProcessTree { $current = Get-CcodStalePackageProcessSnapshot -ProcessId ([int]$processId) -Package $Package -Adapter $adapter $previous = $verifiedByPid[[int]$processId] if ($null -eq $current -or -not (Test-CcodProcessMatch -Expected $previous -Actual $current) -or - -not (Test-CcodSameProcessOwnerAndPackage -Root $actualRoot -Candidate $current)) { continue } + -not (Test-CcodSameProcessOwnerAndPackage -Root $actualRoot -Candidate $current)) { return @() } $rereadByPid[[int]$processId] = $current } if (-not $rereadByPid.ContainsKey([int]$actualRoot.Pid)) { return @() } @@ -1758,4 +1995,4 @@ function Start-CcodProcess { return New-CcodStartResult -Outcome 'Started' -Snapshot $null -Process $process } -Export-ModuleMember -Function Get-CcodProcessSnapshot, Test-CcodProcessMatch, Get-CcodStalePackageProcessSnapshot, Get-CcodStalePackageRootResult, Get-CcodVerifiedProcessTree, Get-CcodVerifiedStaleProcessTree, Get-CcodTransactionProcessResult, Find-CcodTransactionProcess, Stop-CcodProcessIfMatch, Stop-CcodStaleProcessIfMatch, Request-CcodProcessGracefulCloseIfMatch, Request-CcodStaleProcessGracefulCloseIfMatch, Wait-CcodProcessExitIfMatch, Wait-CcodStaleProcessExitIfMatch, Start-CcodProcess, Request-CcodOrdinaryPackagedLaunch, Wait-CcodVerifiedOrdinaryRoot, Get-CcodAvailableLoopbackPort, Wait-CcodPortClosed +Export-ModuleMember -Function Get-CcodProcessIdentityObservation, Get-CcodProcessSnapshot, Test-CcodProcessMatch, Get-CcodStalePackageProcessSnapshot, Get-CcodStalePackageRootResult, Get-CcodVerifiedProcessTree, Get-CcodVerifiedStaleProcessTree, Get-CcodTransactionProcessResult, Find-CcodTransactionProcess, Stop-CcodProcessIfMatch, Stop-CcodStaleProcessIfMatch, Request-CcodProcessGracefulCloseIfMatch, Request-CcodStaleProcessGracefulCloseIfMatch, Wait-CcodProcessExitIfMatch, Wait-CcodStaleProcessExitIfMatch, Start-CcodProcess, Request-CcodOrdinaryPackagedLaunch, Wait-CcodVerifiedOrdinaryRoot, Get-CcodAvailableLoopbackPort, Wait-CcodPortClosed diff --git a/src/persistence/modules/SessionEngine.psm1 b/src/persistence/modules/SessionEngine.psm1 index ab50b43..35440fd 100644 --- a/src/persistence/modules/SessionEngine.psm1 +++ b/src/persistence/modules/SessionEngine.psm1 @@ -252,10 +252,26 @@ function Set-CcodSessionFailure($Result, $Record, [string]$Stage) { return $Result } +function Get-CcodSessionDiagnosticReason { + param($Record,[string]$Code) + if($Code -cne 'CCOD_CLOSE_UNPROVEN' -or $null -eq $Record -or $null -eq $Record.Exception){return 'Unclassified'} + switch -CaseSensitive ([string]$Record.Exception.Message) { + 'Recorded Active root is missing, changed, or accompanied by another root' {return 'RecordedActiveMismatch'} + 'Recorded Active root cannot be safely replaced' {return 'RecordedActiveMismatch'} + 'Multiple current-package close roots are ambiguous' {return 'MultipleRoots'} + 'A status-less debug root lacks one valid distinct port pair' {return 'MissingPortPair'} + 'A replacement debug root lacks one valid distinct port pair' {return 'MissingPortPair'} + 'Requested close source is not the one verified current root' {return 'RequestedSourceMismatch'} + 'Current close target tree is not exact and verified' {return 'EmptyVerifiedTree'} + default {return 'Unclassified'} + } +} + function Write-CcodSessionDiagnostic { - param($Result,[string]$Action,[string]$TransactionId,[string]$Stage,[string]$Code,$Paths,[hashtable]$Adapter) + param($Result,[string]$Action,[string]$TransactionId,[string]$Stage,[string]$Code,$Record,$Paths,[hashtable]$Adapter) if($null -eq $Paths -or $null -eq $Adapter -or $null -eq $Adapter.WriteLog){return} - $record=[pscustomobject][ordered]@{schemaVersion=1;timestampUtc=[DateTime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture);action=$Action;transactionId=$TransactionId;stage=$Stage;code=$Code} + $reason=Get-CcodSessionDiagnosticReason $Record $Code + $record=[pscustomobject][ordered]@{schemaVersion=2;timestampUtc=[DateTime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture);action=$Action;transactionId=$TransactionId;stage=$Stage;code=$Code;reason=$reason} try{& $Adapter.WriteLog $Paths.SessionLogPath ($record|ConvertTo-Json -Depth 4 -Compress)|Out-Null;$Result.logFile=$Paths.SessionLogPath}catch{} } @@ -460,7 +476,27 @@ function Get-CcodCurrentPackageRoots { if($null -eq $identity -or [string]$identity.SessionId -cne [string]$Request.supervisorIdentity.sessionId -or [string]::IsNullOrWhiteSpace([string]$identity.UserSid)){ Throw-CcodSessionError 'CCOD_SOURCE_AMBIGUOUS' 'The request supervisor does not match the current Windows session identity' $Request.supervisorIdentity } - return @(& $Adapter.ListProcesses $StatusEvidence|Where-Object{ + try { $enumerationOutput=@(& $Adapter.ListProcessResult $StatusEvidence 2>&1) } catch { + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current ChatGPT process enumeration was indeterminate' $null + } + if($enumerationOutput.Count -ne 1 -or $enumerationOutput[0] -is [Management.Automation.ErrorRecord]){ + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current ChatGPT process enumeration was malformed' $null + } + $enumeration=$enumerationOutput[0] + Assert-CcodSessionExactProperties $enumeration @('Outcome','Snapshots') 'CCOD_CLOSE_UNPROVEN' 'current ChatGPT process enumeration' + if($enumeration.Outcome -isnot [string] -or @('Complete','Incomplete') -cnotcontains $enumeration.Outcome -or + $null -eq $enumeration.Snapshots -or $enumeration.Snapshots -is [string]){ + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current ChatGPT process enumeration receipt was invalid' $enumeration + } + if($enumeration.Outcome -ceq 'Incomplete'){ + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current ChatGPT process enumeration was incomplete' $null + } + $snapshots=@($enumeration.Snapshots) + foreach($snapshot in $snapshots){ + if($null -eq $snapshot){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current ChatGPT process enumeration contained an indeterminate member' $null} + Assert-CcodSessionExactProperties $snapshot @('Pid','CreationTimeUtc','SessionId','UserSid','Path','PackageFamilyName','CommandLine','ParentPid','IsTopLevel','Mode','RendererPort','MainPort') 'CCOD_CLOSE_UNPROVEN' 'current ChatGPT process snapshot' + } + return @($snapshots|Where-Object{ $_.IsTopLevel -and [string]$_.SessionId -ceq [string]$Request.supervisorIdentity.sessionId -and $_.UserSid -ceq $identity.UserSid -and @@ -558,14 +594,39 @@ function Invoke-CcodCloseVerifiedTree { $ordered=@($Tree|Sort-Object @{Expression={-(Get-CcodTreeDepth $_ $byPid)}},@{Expression={$_.Pid}}) foreach($member in $ordered){ $current=& $Adapter.GetProcess $member.Pid $StatusEvidence - if($null -eq $current){continue} - if(-not (& $Adapter.ProcessMatch $member $current)){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree identity changed before stop' $member} + if($null -eq $current){ + $identity=Get-CcodStrictProcessIdentityObservation -ProcessId ([int]$member.Pid) -ExpectedCreationTimeUtc ([string]$member.CreationTimeUtc) -Adapter $Adapter + if($identity.Outcome -notin @('Absent','IdentityChanged')){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree member exit was not strictly proven before stop' $member} + continue + } + if(-not (& $Adapter.ProcessMatch $member $current)){ + $identity=Get-CcodStrictProcessIdentityObservation -ProcessId ([int]$member.Pid) -ExpectedCreationTimeUtc ([string]$member.CreationTimeUtc) -Adapter $Adapter + if($identity.Outcome -in @('Absent','IdentityChanged')){continue} + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree identity changed before stop' $member + } $receipt=Invoke-CcodSessionMutation $Request $Adapter 'StopProcess' @($member,$StatusEvidence,$processTimeout) if($receipt.Outcome -cne 'Stopped' -and $receipt.Outcome -cne 'SourceExited'){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree member did not stop safely' $member} + $after=& $Adapter.GetProcess $member.Pid $StatusEvidence + if($null -eq $after){ + $identity=Get-CcodStrictProcessIdentityObservation -ProcessId ([int]$member.Pid) -ExpectedCreationTimeUtc ([string]$member.CreationTimeUtc) -Adapter $Adapter + if($identity.Outcome -notin @('Absent','IdentityChanged')){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree member exit was not strictly proven after stop' $member} + }elseif(& $Adapter.ProcessMatch $member $after){ + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree member remains alive after stop' $member + }else{ + $identity=Get-CcodStrictProcessIdentityObservation -ProcessId ([int]$member.Pid) -ExpectedCreationTimeUtc ([string]$member.CreationTimeUtc) -Adapter $Adapter + if($identity.Outcome -notin @('Absent','IdentityChanged')){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree member replacement was not strictly proven after stop' $member} + } } foreach($member in $Tree){ $current=& $Adapter.GetProcess $member.Pid $StatusEvidence - if($null -ne $current -and (& $Adapter.ProcessMatch $member $current)){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree member remains alive after stop' $member} + if($null -eq $current){ + $identity=Get-CcodStrictProcessIdentityObservation -ProcessId ([int]$member.Pid) -ExpectedCreationTimeUtc ([string]$member.CreationTimeUtc) -Adapter $Adapter + if($identity.Outcome -notin @('Absent','IdentityChanged')){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Final close tree absence was not strictly proven' $member} + continue + } + if(& $Adapter.ProcessMatch $member $current){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Close tree member remains alive after stop' $member} + $identity=Get-CcodStrictProcessIdentityObservation -ProcessId ([int]$member.Pid) -ExpectedCreationTimeUtc ([string]$member.CreationTimeUtc) -Adapter $Adapter + if($identity.Outcome -notin @('Absent','IdentityChanged')){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Final close tree identity replacement was not strictly proven' $member} } if($null -ne $RendererPort -and $null -ne $MainPort){ foreach($port in @($RendererPort,$MainPort)){if(-not (& $Adapter.WaitPortClosed $port $processTimeout)){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'A recorded close port did not explicitly refuse' $port}} @@ -580,11 +641,56 @@ function Complete-CcodCloseResult { return $Result } +function Get-CcodStrictProcessIdentityObservation { + param( + [Parameter(Mandatory)][int]$ProcessId, + [Parameter(Mandatory)][string]$ExpectedCreationTimeUtc, + [Parameter(Mandatory)][hashtable]$Adapter + ) + + try { $output=@(& $Adapter.ObserveProcessIdentity $ProcessId $ExpectedCreationTimeUtc 2>&1) } catch { + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Recorded Active process identity could not be observed strictly' $ProcessId + } + if($output.Count -ne 1 -or $output[0] -is [Management.Automation.ErrorRecord]){ + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Recorded Active process identity observation was malformed' $output + } + $identity=$output[0] + Assert-CcodSessionExactProperties $identity @('Outcome','Pid','CreationTimeUtc') 'CCOD_CLOSE_UNPROVEN' 'process identity observation' + if($identity.Outcome -isnot [string] -or @('Absent','SameIdentity','IdentityChanged') -cnotcontains $identity.Outcome -or + $identity.Pid -isnot [int] -or $identity.Pid -ne $ProcessId){ + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Recorded Active process identity observation was invalid' $identity + } + switch -CaseSensitive ($identity.Outcome) { + 'Absent' { + if($null -ne $identity.CreationTimeUtc){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Absent identity observation carried a creation time' $identity} + } + 'SameIdentity' { + if($identity.CreationTimeUtc -isnot [string] -or $identity.CreationTimeUtc -cne $ExpectedCreationTimeUtc){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Same identity observation did not match the recorded creation time' $identity} + } + 'IdentityChanged' { + if(-not (Test-CcodSessionCanonicalUtc $identity.CreationTimeUtc) -or $identity.CreationTimeUtc -ceq $ExpectedCreationTimeUtc){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Changed identity observation lacked a distinct canonical creation time' $identity} + } + } + return $identity +} + function Merge-CcodSessionAdapters($Adapters) { $defaults = @{ ReadState={ param($StateRoot,$SuppressionKey) Read-CcodState -StateRoot $StateRoot -CurrentSuppressionKey $SuppressionKey } GetPackageIdentity={ Get-CcodPackageIdentity } StaticProbe={ param($NodeCandidates,$CheckerPath) Invoke-CcodStaticProbe -NodeCandidates $NodeCandidates -CheckerPath $CheckerPath } + ListProcessResult={ param($StatusEvidence) + $current=[Diagnostics.Process]::GetCurrentProcess();try{$sessionId=$current.SessionId}finally{$current.Dispose()} + $identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()} + $snapshots=[Collections.Generic.List[object]]::new();$incomplete=$false + foreach($process in @(Get-Process -Name ChatGPT -ErrorAction SilentlyContinue)){ + try{$snapshot=Get-CcodProcessSnapshot -ProcessId $process.Id -StatusEvidence $StatusEvidence}catch{$snapshot=$null} + if($null -eq $snapshot){$incomplete=$true;continue} + if($snapshot.PSObject.Properties['SessionId'] -eq $null -or $snapshot.PSObject.Properties['UserSid'] -eq $null){$incomplete=$true;continue} + if($snapshot.SessionId -eq $sessionId -and $snapshot.UserSid -ceq $sid){$snapshots.Add($snapshot)} + } + [pscustomobject][ordered]@{Outcome=if($incomplete){'Incomplete'}else{'Complete'};Snapshots=@($snapshots.ToArray())} + } ListProcesses={ param($StatusEvidence) $current=[Diagnostics.Process]::GetCurrentProcess();try{$sessionId=$current.SessionId}finally{$current.Dispose()} $identity=[Security.Principal.WindowsIdentity]::GetCurrent();try{$sid=$identity.User.Value}finally{$identity.Dispose()} @@ -596,6 +702,7 @@ function Merge-CcodSessionAdapters($Adapters) { return $snapshots.ToArray() } GetProcess={ param($ProcessId,$StatusEvidence) Get-CcodProcessSnapshot -ProcessId $ProcessId -StatusEvidence $StatusEvidence } + ObserveProcessIdentity={ param($ProcessId,$ExpectedCreationTimeUtc) Get-CcodProcessIdentityObservation -ProcessId $ProcessId -ExpectedCreationTimeUtc $ExpectedCreationTimeUtc } ProcessMatch={ param($Expected,$Actual) Test-CcodProcessMatch -Expected $Expected -Actual $Actual } NewTransition={ param($Path,$Source,$Package,$RuntimeId,$RendererPort,$MainPort,$TransactionId) New-CcodTransition -Path $Path -Source $Source -Package $Package -RuntimeId $RuntimeId -RendererPort $RendererPort -MainPort $MainPort -TransactionId $TransactionId } SetTransition={ param($Path,$TransactionId,$ExpectedStage,$NewStage,$SpecialIdentity,$RecoveryIdentity,$RendererPort,$MainPort) @@ -651,6 +758,15 @@ function Merge-CcodSessionAdapters($Adapters) { if ($null -ne $Adapters) { foreach($key in $Adapters.Keys){$defaults[$key]=$Adapters[$key]} if($Adapters.ContainsKey('ObserveSpecial') -and -not $Adapters.ContainsKey('ObserveSpecialIsDefault')){$defaults.ObserveSpecialIsDefault=$false} + if($Adapters.ContainsKey('ListProcesses') -and -not $Adapters.ContainsKey('ListProcessResult')){ + $listProcesses=$defaults.ListProcesses + $defaults.ListProcessResult={ + param($StatusEvidence) + try{$output=@(& $listProcesses $StatusEvidence 2>&1)}catch{return [pscustomobject][ordered]@{Outcome='Incomplete';Snapshots=@()}} + if(@($output|Where-Object{$_ -is [Management.Automation.ErrorRecord]}).Count -gt 0){return [pscustomobject][ordered]@{Outcome='Incomplete';Snapshots=@()}} + [pscustomobject][ordered]@{Outcome='Complete';Snapshots=@($output)} + }.GetNewClosure() + } } return $defaults } @@ -961,7 +1077,7 @@ function Invoke-CcodSessionCore { } catch { if(Test-CcodSessionLifecycleFenceFailure $_){throw} $result=Set-CcodSessionFailure -Result $result -Record $_ -Stage $result.stage - if($pathsValidated -and -not $SuppressDiagnostic){Write-CcodSessionDiagnostic $result $Action $result.transactionId $result.stage $result.error.code $Paths $adapter} + if($pathsValidated -and -not $SuppressDiagnostic){Write-CcodSessionDiagnostic $result $Action $result.transactionId $result.stage $result.error.code $_ $Paths $adapter} return $result } } @@ -1116,7 +1232,7 @@ function Invoke-CcodActivationSession { $result.ok=$true;$result.outcome='Activated';$result.safeState='SpecialValidated';$result.stage='Completed';return $result } catch { if(Test-CcodSessionLifecycleFenceFailure $_){throw} - Write-CcodSessionDiagnostic $result $Action $Request.transactionId $result.stage (Get-CcodSessionErrorCode $_) $Paths $adapter + Write-CcodSessionDiagnostic $result $Action $Request.transactionId $result.stage (Get-CcodSessionErrorCode $_) $_ $Paths $adapter if($recoveryAttempted){Throw-CcodSessionError 'CCOD_RECOVERY_UNPROVEN' 'Recursive recovery is forbidden' $_} $recoveryAttempted=$true $recoveryRenderer=$null;$recoveryMain=$null @@ -1193,7 +1309,7 @@ function Invoke-CcodRepairRenderer { $result.ok=$true;$result.outcome='NoAction';$result.safeState='SpecialValidated';$result.stage='RendererRepaired';return $result }catch{ if(Test-CcodSessionLifecycleFenceFailure $_){throw} - Write-CcodSessionDiagnostic $result 'RepairRenderer' $Request.transactionId $result.stage (Get-CcodSessionErrorCode $_) $Paths $adapter + Write-CcodSessionDiagnostic $result 'RepairRenderer' $Request.transactionId $result.stage (Get-CcodSessionErrorCode $_) $_ $Paths $adapter if($recoveryAttempted){Throw-CcodSessionError 'CCOD_RECOVERY_UNPROVEN' 'Recursive renderer recovery is forbidden' $_} $recoveryAttempted=$true $journalPackage=ConvertTo-CcodJournalPackage $probe @@ -1218,8 +1334,24 @@ function Invoke-CcodCloseSession { $target=$null;$isSpecial=$false if($null -ne $statusCodex){ $candidate=& $adapter.GetProcess $statusCodex.pid $state.Status - if($roots.Count -ne 1 -or $null -eq $candidate -or $candidate.CreationTimeUtc -cne $statusCodex.creationTimeUtc -or -not (& $adapter.ProcessMatch $roots[0] $candidate)){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Recorded Active root is missing, changed, or accompanied by another root' $statusCodex} - $target=$candidate;$isSpecial=$true + $recordedExact=$roots.Count -eq 1 -and $null -ne $candidate -and $candidate.CreationTimeUtc -ceq $statusCodex.creationTimeUtc -and (& $adapter.ProcessMatch $roots[0] $candidate) + if($recordedExact){ + $target=$candidate;$isSpecial=$true + }else{ + $identity=Get-CcodStrictProcessIdentityObservation -ProcessId ([int]$statusCodex.pid) -ExpectedCreationTimeUtc ([string]$statusCodex.creationTimeUtc) -Adapter $adapter + if($identity.Outcome -in @('Absent','IdentityChanged') -and $roots.Count -eq 1){ + $target=$roots[0] + $statusCodex=$null + $isSpecial=$target.Mode -cne 'Ordinary' + if($isSpecial -and ($target.RendererPort -isnot [int] -or $target.MainPort -isnot [int] -or + $target.RendererPort -lt 1 -or $target.RendererPort -gt 65535 -or $target.MainPort -lt 1 -or $target.MainPort -gt 65535 -or + $target.RendererPort -eq $target.MainPort)){ + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'A replacement debug root lacks one valid distinct port pair' $target + } + }else{ + Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Recorded Active root cannot be safely replaced' $statusCodex + } + } }else{ if($roots.Count -gt 1){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Multiple current-package close roots are ambiguous' $roots} if($roots.Count -eq 1){ diff --git a/src/persistence/modules/TrayHostClient.psm1 b/src/persistence/modules/TrayHostClient.psm1 index 1014d27..b0f82b1 100644 --- a/src/persistence/modules/TrayHostClient.psm1 +++ b/src/persistence/modules/TrayHostClient.psm1 @@ -67,6 +67,41 @@ function New-CcodTrayHostInitialPresentation { return [pscustomobject][ordered]@{Color='Gray';ConnectionState='WaitingForCodex';ProtectionState='Running';RepairEnabled=$false;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$false;Busy=$false} } +function Copy-CcodTrayHostPresentation { + param($Presentation) + return [pscustomobject][ordered]@{ + Color=[string]$Presentation.Color;ConnectionState=[string]$Presentation.ConnectionState;ProtectionState=[string]$Presentation.ProtectionState + RepairEnabled=[bool]$Presentation.RepairEnabled;LanguageEnabled=[bool]$Presentation.LanguageEnabled;OpenLogsEnabled=[bool]$Presentation.OpenLogsEnabled + AboutEnabled=[bool]$Presentation.AboutEnabled;ExitEnabled=[bool]$Presentation.ExitEnabled;Busy=[bool]$Presentation.Busy + } +} + +function Test-CcodTrayHostEquivalentSnapshot { + param($Left,$Right) + if($null-eq$Left-or$null-eq$Right-or$Left.Color-ne$Right.Color-or$Left.Connection-ne$Right.Connection-or$Left.Protection-ne$Right.Protection-or$Left.Language-ne$Right.Language-or$Left.Flags-ne$Right.Flags){return $false} + if($Left.Strings.Count-ne$Right.Strings.Count){return $false} + for($index=0;$index-lt$Left.Strings.Count;$index++){if([string]$Left.Strings[$index]-cne[string]$Right.Strings[$index]){return $false}} + return $true +} + +function Wait-CcodTrayHostPresentationAcknowledgement { + param($Context,[UInt64]$Revision) + $key=[string]$Revision + $deadline=[DateTime]::UtcNow.AddMilliseconds(1250) + while(-not$Context.AcknowledgedPresentations.Contains($key)-and[DateTime]::UtcNow-lt$deadline){ + Receive-CcodTrayHostEvents $Context + if($Context.AcknowledgedPresentations.Contains($key)){break} + [void]$Context.Client.WaitForActivity([TimeSpan]::FromMilliseconds(25)) + } + if(-not$Context.AcknowledgedPresentations.Contains($key)){throw 'CCOD_TRAYHOST_PRESENTATION_ACK_TIMEOUT'} +} + +function Add-CcodTrayHostBoundedPresentation { + param($Map,[string]$Revision,$Presentation) + $Map[$Revision]=$Presentation + while($Map.Count-gt32){$oldest=@($Map.Keys)[0];[void]$Map.Remove($oldest)} +} + function New-CcodTrayHostContext { param($CommandQueue,$OnTick,$Catalog,[string]$LanguageMode,[string]$SystemCultureName) Import-CcodTrayHostAssembly @@ -78,7 +113,8 @@ function New-CcodTrayHostContext { $options=[TrayHostStartOptions]::new();$options.ExePath=$exe;$options.RuntimeId=$runtimeId;$options.ParentPid=$process.Id;$options.ParentCreationFileTimeUtc=$process.StartTime.ToFileTimeUtc();$options.InitialPresentation=$initial $client=[TrayHostParentClient]::Start($options) }finally{$process.Dispose()} - return [pscustomobject][ordered]@{Client=$client;CommandQueue=$CommandQueue;OnTick=$OnTick;Catalog=$Catalog;LanguageMode=$LanguageMode;SystemCultureName=$SystemCultureName;RuntimeId=$runtimeId;CurrentRevision=[UInt64]1;LastAcknowledgedRevision=[UInt64]1;MenuOpen=$false;Exited=$false;LastError=$null;ApplicationContext=[pscustomobject]@{}} + $acknowledged=[ordered]@{};Add-CcodTrayHostBoundedPresentation $acknowledged '1' (Copy-CcodTrayHostPresentation $initialPresentation) + return [pscustomobject][ordered]@{Client=$client;CommandQueue=$CommandQueue;OnTick=$OnTick;Catalog=$Catalog;LanguageMode=$LanguageMode;SystemCultureName=$SystemCultureName;RuntimeId=$runtimeId;CurrentRevision=[UInt64]1;LastAcknowledgedRevision=[UInt64]1;LastPublishedSnapshot=$initial;PublishedPresentations=[ordered]@{};AcknowledgedPresentations=$acknowledged;MenuOpen=$false;Exited=$false;LastError=$null;ApplicationContext=[pscustomobject]@{}} } function Set-CcodTrayHostPresentation { @@ -86,16 +122,16 @@ function Set-CcodTrayHostPresentation { if($null -eq $Context -or $null -eq $Context.Client){throw 'CCOD_TRAYHOST_CONTEXT_INVALID'} $revision=[UInt64]([UInt64]$Context.CurrentRevision + [UInt64]1) $snapshot=New-CcodTrayHostSnapshot $Presentation $Catalog $LanguageMode $SystemCultureName $revision $Context.RuntimeId + if(Test-CcodTrayHostEquivalentSnapshot $Context.LastPublishedSnapshot $snapshot){ + $Context.Catalog=$Catalog;$Context.LanguageMode=$LanguageMode;$Context.SystemCultureName=$SystemCultureName + if($WaitForAcknowledgement){Wait-CcodTrayHostPresentationAcknowledgement $Context ([UInt64]$Context.CurrentRevision)} + return + } if(-not $Context.Client.TryPublish($snapshot)){throw 'CCOD_TRAYHOST_PRESENTATION_FAILED'} - $Context.Catalog=$Catalog;$Context.LanguageMode=$LanguageMode;$Context.SystemCultureName=$SystemCultureName;$Context.CurrentRevision=$revision + $Context.Catalog=$Catalog;$Context.LanguageMode=$LanguageMode;$Context.SystemCultureName=$SystemCultureName;$Context.CurrentRevision=$revision;$Context.LastPublishedSnapshot=$snapshot + Add-CcodTrayHostBoundedPresentation $Context.PublishedPresentations ([string]$revision) (Copy-CcodTrayHostPresentation $Presentation) if($WaitForAcknowledgement){ - $deadline=[DateTime]::UtcNow.AddMilliseconds(1250) - while([UInt64]$Context.LastAcknowledgedRevision -lt $revision -and [DateTime]::UtcNow -lt $deadline){ - Receive-CcodTrayHostEvents $Context - if([UInt64]$Context.LastAcknowledgedRevision -ge $revision){break} - [void]$Context.Client.WaitForActivity([TimeSpan]::FromMilliseconds(25)) - } - if([UInt64]$Context.LastAcknowledgedRevision -lt $revision){throw 'CCOD_TRAYHOST_PRESENTATION_ACK_TIMEOUT'} + Wait-CcodTrayHostPresentationAcknowledgement $Context $revision } } @@ -106,7 +142,16 @@ function Receive-CcodTrayHostEvents { if(-not $Context.Client.TryDequeueEvent([ref]$event)){break} if($null -eq $event){continue} switch($event.Kind.ToString()){ - 'PresentationAck' {if($event.Revision -is [UInt64] -or $event.Revision -is [long] -or $event.Revision -is [int]){if([UInt64]$event.Revision -gt [UInt64]$Context.LastAcknowledgedRevision){$Context.LastAcknowledgedRevision=[UInt64]$event.Revision}}} + 'PresentationAck' { + if($event.Revision-is[UInt64]-or$event.Revision-is[long]-or$event.Revision-is[int]){ + $acknowledgedRevision=[UInt64]$event.Revision;$key=[string]$acknowledgedRevision + if($Context.PublishedPresentations.Contains($key)){ + Add-CcodTrayHostBoundedPresentation $Context.AcknowledgedPresentations $key $Context.PublishedPresentations[$key] + if($acknowledgedRevision-gt[UInt64]$Context.LastAcknowledgedRevision){$Context.LastAcknowledgedRevision=$acknowledgedRevision} + foreach($publishedKey in @($Context.PublishedPresentations.Keys)){if([UInt64]$publishedKey-le$acknowledgedRevision){[void]$Context.PublishedPresentations.Remove($publishedKey)}} + } + } + } 'Action' { $command=switch($event.Command){CheckAndRepair{'CheckAndRepair'}SetLanguageSystem{'SetLanguageSystem'}SetLanguageChinese{'SetLanguageChinese'}SetLanguageEnglish{'SetLanguageEnglish'}OpenLogs{'OpenLogs'}ShowAbout{'ShowAbout'}Exit{'Exit'}default{$null}} if($null-eq$command-or$event.ActionId-eq[guid]::Empty-or$event.Revision-isnot[UInt64]-and$event.Revision-isnot[long]-and$event.Revision-isnot[int]){continue} diff --git a/src/persistence/modules/WorkerRuntime.psm1 b/src/persistence/modules/WorkerRuntime.psm1 index a8f937b..65626bf 100644 --- a/src/persistence/modules/WorkerRuntime.psm1 +++ b/src/persistence/modules/WorkerRuntime.psm1 @@ -258,6 +258,7 @@ function Start-CcodWorkerProcess { if($null-eq$native-or$native.ProcessId-lt1-or$null-eq$native.ProcessHandle-or$null-eq$native.ThreadHandle){throw 'native process'} $process=[Diagnostics.Process]::GetProcessById([int]$native.ProcessId) $created=$process.StartTime.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) + $process.EnableRaisingEvents=$true $assigned=[bool](&$adapter.AssignProcessToJob $job $native);if(-not$assigned){throw 'job'} $resumed=[bool](&$adapter.ResumeProcess $native);if(-not$resumed){throw 'resume'} &$adapter.DisposeNativeProcess $native|Out-Null @@ -281,20 +282,14 @@ function Get-CcodWorkerPoll { } $completed = $false $exitCode = $null - $probe = Get-Process -Id $Slot.ProcessId -ErrorAction SilentlyContinue - if ($null -ne $probe) { - try { - $probe.Refresh() - if ($probe.HasExited) { - $completed = $true - $exitCode = [int]$probe.ExitCode - } - } finally { - $probe.Dispose() - } - } else { + $retainedHandle = $Slot.PSObject.Properties['Handle'] + if ($null -eq $retainedHandle -or $retainedHandle.Value -isnot [Diagnostics.Process]) { + Throw-CcodWorkerRuntimeError 'CCOD_WORKER_SLOT_INVALID' 'Worker slot has no retained exact process handle' $Slot + } + $retainedHandle.Value.Refresh() + if ($retainedHandle.Value.HasExited) { $completed = $true - $exitCode = [int]0 + $exitCode = [int]$retainedHandle.Value.ExitCode } $stdoutText = '' if ($completed -and [IO.File]::Exists($Slot.ResultPath)) { diff --git a/src/portable/AssemblyInfo.cs b/src/portable/AssemblyInfo.cs new file mode 100644 index 0000000..79463e3 --- /dev/null +++ b/src/portable/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("CodexRemote-fix Portable")] +[assembly: AssemblyDescription("EXE launcher for the verified CodexRemote-fix portable bundle")] +[assembly: AssemblyCompany("CodexRemote-fix contributors")] +[assembly: AssemblyProduct("CodexRemote-fix")] +[assembly: AssemblyVersion("2.5.21.0")] +[assembly: AssemblyFileVersion("2.5.21.0")] +[assembly: ComVisible(false)] diff --git a/src/portable/CodexRemote.Portable.manifest b/src/portable/CodexRemote.Portable.manifest index 441ffce..f97d0fb 100644 --- a/src/portable/CodexRemote.Portable.manifest +++ b/src/portable/CodexRemote.Portable.manifest @@ -1,6 +1,6 @@ - + diff --git a/src/trayhost/AssemblyInfo.cs b/src/trayhost/AssemblyInfo.cs index f4b8320..9055236 100644 --- a/src/trayhost/AssemblyInfo.cs +++ b/src/trayhost/AssemblyInfo.cs @@ -5,6 +5,6 @@ [assembly: AssemblyDescription("Persistent native tray host for CodexRemote-fix")] [assembly: AssemblyCompany("CodexRemote-fix contributors")] [assembly: AssemblyProduct("CodexRemote-fix")] -[assembly: AssemblyVersion("2.5.9.0")] -[assembly: AssemblyFileVersion("2.5.9.0")] +[assembly: AssemblyVersion("2.5.21.0")] +[assembly: AssemblyFileVersion("2.5.21.0")] [assembly: ComVisible(false)] diff --git a/src/trayhost/CodexRemote.TrayHost.manifest b/src/trayhost/CodexRemote.TrayHost.manifest index 1b5e22d..34dea1e 100644 --- a/src/trayhost/CodexRemote.TrayHost.manifest +++ b/src/trayhost/CodexRemote.TrayHost.manifest @@ -1,6 +1,6 @@ - + diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs index 9d52416..f71a77b 100644 --- a/src/trayhost/HostTransport.cs +++ b/src/trayhost/HostTransport.cs @@ -10,8 +10,9 @@ private sealed class PendingAction } private readonly object _gate = new object(); - private readonly Queue _controls = new Queue(); + private readonly Action _presentationReady; private readonly Queue _completedAbout = new Queue(); + private readonly Queue _failedActions = new Queue(); private readonly Dictionary _pendingActions = new Dictionary(); private readonly Queue _recentActionOrder = new Queue(); private readonly HashSet _recentActions = new HashSet(); @@ -19,9 +20,29 @@ private sealed class PendingAction private bool _menuOpen; private bool _disposed; + internal HostTransport() : this(null) + { + } + + internal HostTransport(Action presentationReady) + { + _presentationReady = presentationReady; + } + internal void SetMenuOpen(bool value) { - lock (_gate) { if (!_disposed) { _menuOpen = value; } } + bool notify = false; + lock (_gate) + { + if (!_disposed) + { + bool wasOpen = _menuOpen; + _menuOpen = value; + notify = wasOpen && !value && _pendingPresentation != null; + } + } + Action presentationReady = _presentationReady; + if (notify && presentationReady != null) { presentationReady(); } } internal bool TryAcceptPresentation(PresentationSnapshot snapshot) @@ -42,8 +63,6 @@ internal bool TryTakePresentation(out PresentationSnapshot snapshot) if (_menuOpen || _pendingPresentation == null) { snapshot = null; return false; } snapshot = _pendingPresentation; _pendingPresentation = null; - if (_controls.Count >= 16) { throw new InvalidOperationException("host control queue is exhausted"); } - _controls.Enqueue(new TrayHostControl(TrayHostControlKind.PresentationAck, ShutdownReason.SupervisorExit, snapshot.Revision)); return true; } } @@ -78,8 +97,10 @@ internal bool TryAcknowledgeAction(TrayActionResult result) } if (result.Status != TrayActionResultStatus.Completed && result.Status != TrayActionResultStatus.Rejected && result.Status != TrayActionResultStatus.Failed) { return false; } if (result.Status == TrayActionResultStatus.Completed && TrayCommandPolicy.RequiresAcceptedBeforeCompleted(pending.Action.Command) && !pending.Accepted) { return false; } + if ((result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) && _failedActions.Count >= 8) { return false; } _pendingActions.Remove(result.ActionId); if (result.Status == TrayActionResultStatus.Completed && pending.Action.Command == TrayCommand.ShowAbout) { _completedAbout.Enqueue(result); } + if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) { _failedActions.Enqueue(result); } return true; } } @@ -93,13 +114,12 @@ internal bool TryTakeCompletedAbout(out TrayActionResult result) } } - internal bool TryDequeueControl(out TrayHostControl control) + internal bool TryTakeFailedAction(out TrayActionResult result) { lock (_gate) { - if (_controls.Count == 0) { control = null; return false; } - control = _controls.Dequeue(); - return true; + if (_failedActions.Count == 0) { result = null; return false; } + result = _failedActions.Dequeue(); return true; } } @@ -109,8 +129,8 @@ public void Dispose() { if (_disposed) { return; } _disposed = true; - _controls.Clear(); _completedAbout.Clear(); + _failedActions.Clear(); _pendingActions.Clear(); _recentActions.Clear(); _recentActionOrder.Clear(); diff --git a/src/trayhost/NativeMethods.cs b/src/trayhost/NativeMethods.cs index 0d2ed0c..9a27055 100644 --- a/src/trayhost/NativeMethods.cs +++ b/src/trayhost/NativeMethods.cs @@ -208,12 +208,12 @@ internal static int GetMessageLoop(out Message value) { MSG message; int result = GetMessageW(out message, IntPtr.Zero, 0U, 0U); - value = new Message(message.Message, message.WParam, message.LParam); + value = new Message(message.HWnd, message.Message, message.WParam, message.LParam); return result; } internal static void TranslateAndDispatch(Message message) { - MSG native = new MSG { Message = message.MessageId, WParam = message.WParam, LParam = message.LParam }; + MSG native = new MSG { HWnd = message.Window, Message = message.MessageId, WParam = message.WParam, LParam = message.LParam }; TranslateMessage(ref native); DispatchMessageW(ref native); } internal static void PostQuit(int exitCode) { PostQuitMessage(exitCode); } @@ -221,8 +221,8 @@ internal static void TranslateAndDispatch(Message message) internal struct Message { - internal uint MessageId; internal IntPtr WParam; internal IntPtr LParam; - internal Message(uint messageId, IntPtr wParam, IntPtr lParam) { MessageId = messageId; WParam = wParam; LParam = lParam; } + internal IntPtr Window; internal uint MessageId; internal IntPtr WParam; internal IntPtr LParam; + internal Message(IntPtr window, uint messageId, IntPtr wParam, IntPtr lParam) { Window = window; MessageId = messageId; WParam = wParam; LParam = lParam; } } [StructLayout(LayoutKind.Sequential)] private struct POINT { internal int X; internal int Y; } diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs index efa55a2..106b3e6 100644 --- a/src/trayhost/Program.cs +++ b/src/trayhost/Program.cs @@ -38,10 +38,10 @@ private static int RunChild(string[] args) if (initialFrame.MessageType != TrayHostMessageType.Presentation) { return 2; } PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload); Win32TrayPlatform platform = new Win32TrayPlatform(); - TrayWindow window = new TrayWindow(platform); - HostTransport transport = new HostTransport(); - bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); TrayHostApplication application = null; + HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }); + TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen); + bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object(); Action command = delegate(TrayCommand selected, ulong revision) { TrayHostAction action; @@ -55,11 +55,15 @@ private static int RunChild(string[] args) PresentationSnapshot next; if (transport.TryTakePresentation(out next)) { - window.Apply(next); - lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.PresentationAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(next.Revision)), keys.HostToParent); } + if (window.Apply(next)) + { + lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.PresentationAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(next.Revision)), keys.HostToParent); } + } } TrayActionResult about; while (transport.TryTakeCompletedAbout(out about)) { window.ShowAbout(); } + TrayActionResult failed; + while (transport.TryTakeFailedAction(out failed)) { window.ShowActionFailed(); } bool shouldShutdown; lock (stateGate) { shouldShutdown = shutdownRequested && !shutdownSent; if (shouldShutdown) { shutdownSent = true; } } if (shouldShutdown) diff --git a/src/trayhost/TrayWindow.cs b/src/trayhost/TrayWindow.cs index fb2e57f..e97a550 100644 --- a/src/trayhost/TrayWindow.cs +++ b/src/trayhost/TrayWindow.cs @@ -3,6 +3,7 @@ internal sealed class TrayWindow : IDisposable { private readonly INativeTrayPlatform _native; + private readonly Action _menuOpenChanged; private InputModeGuard _inputGuard; private TrayIconData _icon; private IntPtr _owner; @@ -18,10 +19,14 @@ internal sealed class TrayWindow : IDisposable internal IntPtr OwnerHandle { get { return _owner; } } internal LanguageMode CurrentLanguage { get { return _current == null ? LanguageMode.System : _current.Language; } } - internal TrayWindow(INativeTrayPlatform native) + internal TrayWindow(INativeTrayPlatform native) : this(native, null) + { + } + + internal TrayWindow(INativeTrayPlatform native, Action menuOpenChanged) { if (native == null) { throw new ArgumentNullException("native"); } - _native = native; + _native = native; _menuOpenChanged = menuOpenChanged; } internal void Create(PresentationSnapshot initial) @@ -48,12 +53,13 @@ internal void Create(PresentationSnapshot initial) } } - internal void Apply(PresentationSnapshot snapshot) + internal bool Apply(PresentationSnapshot snapshot) { if (snapshot == null) { throw new ArgumentNullException("snapshot"); } if (!_created || _disposed) { throw new InvalidOperationException("tray window is not active"); } - if (_menuOpen) { _pending = snapshot; return; } - if (snapshot.Revision >= CurrentRevision) { _current = snapshot; } + if (_menuOpen) { _pending = snapshot; return false; } + if (snapshot.Revision < CurrentRevision) { return false; } + _current = snapshot; return true; } internal uint? HandleContextMenu(TrayPoint point) @@ -63,6 +69,8 @@ internal void Apply(PresentationSnapshot snapshot) uint result = 0U; try { + Action menuOpenChanged = _menuOpenChanged; + if (menuOpenChanged != null) { menuOpenChanged(true); } _inputGuard.VerifyNoOwnerInputContext(_owner); using (NativeMenu menu = new NativeMenu(_native, _owner, _current)) { result = menu.Show(point); } if (result != 0U && Enum.IsDefined(typeof(TrayCommand), (ushort)result) && (TrayCommand)result != TrayCommand.None) @@ -83,6 +91,8 @@ internal void Apply(PresentationSnapshot snapshot) _pending = null; if (pending.Revision >= CurrentRevision) { _current = pending; } } + Action menuOpenChanged = _menuOpenChanged; + if (menuOpenChanged != null) { menuOpenChanged(false); } } } @@ -104,6 +114,12 @@ internal void ShowAbout() _native.ShowMessageBox(_owner, _current.Strings[12], _current.Strings[11]); } + internal void ShowActionFailed() + { + if (!_created || _disposed || _current == null) { throw new InvalidOperationException("tray window is not active"); } + _native.ShowMessageBox(_owner, _current.Strings[15], _current.Strings[0]); + } + private TrayIconData CreateIconData(IntPtr owner) { TrayIconData icon = new TrayIconData(); diff --git a/tests/installed/Invoke-InstalledLifecycleIntegration.ps1 b/tests/installed/Invoke-InstalledLifecycleIntegration.ps1 index aaeba78..6856431 100644 --- a/tests/installed/Invoke-InstalledLifecycleIntegration.ps1 +++ b/tests/installed/Invoke-InstalledLifecycleIntegration.ps1 @@ -17,6 +17,10 @@ $script:CcodInstalledLifecycleRestartScenarios = @( 'FreshRestart','Upgrade','SlowLaunch','ManualLaunchResume','LanguageStress','RepairStates','SafeExit','SettingsUninstall','DirectUninstall' ) $script:CcodInstalledLifecycleTaskName = 'Codex Control Other Devices Supervisor' +$script:CcodInstalledLifecycleRepositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$script:CcodInstalledLifecyclePersistenceIoModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\PersistenceIO.psm1') -Force -PassThru +$script:CcodInstalledLifecycleStateStoreModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\StateStore.psm1') -Force -PassThru +$script:CcodInstalledLifecycleTransactionModule = Import-Module (Join-Path $script:CcodInstalledLifecycleRepositoryRoot 'src\persistence\modules\LifecycleTransaction.psm1') -Force -PassThru function Throw-CcodInstalledLifecycleError { param( @@ -57,6 +61,33 @@ function Test-CcodInstalledLifecycleCanonicalUtc { return $parsed.ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) -ceq $Value } +function ConvertTo-CcodInstalledLifecycleCreationTimeUtc { + param($Value) + $created = $null + if ($Value -is [datetime]) { + if ($Value.Kind -ne [DateTimeKind]::Local -and $Value.Kind -ne [DateTimeKind]::Utc) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process has invalid creation-time evidence' $null + } + $created = $Value + } elseif ($Value -is [string]) { + if ($Value -cnotmatch '^\d{14}\.\d{6}[+-]\d{3}$') { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process has invalid creation-time evidence' $null + } + try { + $created = [Management.ManagementDateTimeConverter]::ToDateTime($Value) + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process has invalid creation-time evidence' $null + } + } else { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process has invalid creation-time evidence' $null + } + $canonical = $created.ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) + if (-not (Test-CcodInstalledLifecycleCanonicalUtc -Value $canonical)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process creation time is not canonical' $null + } + return $canonical +} + function Assert-CcodInstalledLifecycleRegularFile { param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Kind) if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) { @@ -187,9 +218,52 @@ function ConvertTo-CcodInstalledLifecycleIdentities { return @($result | Sort-Object pid, creationTimeUtc) } +function Test-CcodInstalledLifecycleExactProperties { + param($Value, [Parameter(Mandatory)][string[]]$Expected) + if ($null -eq $Value -or ($Value -isnot [pscustomobject] -and $Value -isnot [Collections.IDictionary])) { return $false } + $actual = if ($Value -is [Collections.IDictionary]) { @($Value.Keys | ForEach-Object { [string]$_ }) } else { @($Value.PSObject.Properties.Name) } + return $actual.Count -eq $Expected.Count -and ($actual -join "`0") -ceq ($Expected -join "`0") +} + +function Test-CcodInstalledLifecyclePositiveInteger { + param($Value, [switch]$AllowZero) + if ($Value -isnot [byte] -and $Value -isnot [uint16] -and $Value -isnot [uint32] -and $Value -isnot [uint64] -and + $Value -isnot [int16] -and $Value -isnot [int32] -and $Value -isnot [int64]) { return $false } + return $Value -ge $(if ($AllowZero) { 0 } else { 1 }) +} + +function ConvertTo-CcodInstalledLifecycleNullableIdentity { + param($Identity, [Parameter(Mandatory)][string]$Kind) + if ($null -eq $Identity) { return $null } + $converted = @(ConvertTo-CcodInstalledLifecycleIdentities -Records @($Identity) -Kind $Kind) + if ($converted.Count -ne 1) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' "$Kind identity is malformed" $Identity } + return $converted[0] +} + +function ConvertTo-CcodInstalledLifecycleReceiptFact { + param($Receipt) + if ($null -eq $Receipt) { return $null } + $fields = @('kind','origin','runtimeId','runtimeGeneration','phase') + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $Receipt -Expected $fields) -or + $Receipt.kind -isnot [string] -or @('RestartAndRepair','CheckAndRepair','SafeExit') -cnotcontains $Receipt.kind -or + $Receipt.origin -isnot [string] -or @('Installer','Tray','ExplicitStart','Guardian') -cnotcontains $Receipt.origin -or + $Receipt.runtimeId -isnot [string] -or $Receipt.runtimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or + -not (Test-CcodInstalledLifecyclePositiveInteger -Value $Receipt.runtimeGeneration) -or + $Receipt.phase -isnot [string] -or @('Completed','CloseFailed','OrdinaryLaunchFailed','OrdinaryObservationTimedOut','LaunchWindowExpired','RepairFailed','VerificationFailed','CancelledBeforeClose','SupersededByUpgrade') -cnotcontains $Receipt.phase) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt fact is malformed' $Receipt + } + return [pscustomobject][ordered]@{ + kind = [string]$Receipt.kind + origin = [string]$Receipt.origin + runtimeId = [string]$Receipt.runtimeId + runtimeGeneration = [UInt64]$Receipt.runtimeGeneration + phase = [string]$Receipt.phase + } +} + function ConvertTo-CcodInstalledLifecycleFacts { param($Facts) - $expected = @('appPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisor','trayHost','codex','taskState','statusPhase','transitionStage','aboutVersion','deviceKeyPresent','deviceKeySha256','shortcuts') + $expected = @('appPresent','activeRuntimeId','activeGeneration','runtimeManifestSha256','supervisor','trayHost','codex','taskState','statusPhase','statusRuntimeId','statusCodex','transitionStage','lifecycleReceipt','aboutVersion','deviceKeyPresent','deviceKeySha256','shortcuts') if ($null -eq $Facts -or ($Facts -isnot [pscustomobject] -and $Facts -isnot [Collections.IDictionary])) { Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Machine facts are unavailable' $Facts } @@ -202,6 +276,7 @@ function ConvertTo-CcodInstalledLifecycleFacts { ($null -ne $Facts.activeGeneration -and $Facts.activeGeneration -isnot [UInt64]) -or ($null -ne $Facts.runtimeManifestSha256 -and ($Facts.runtimeManifestSha256 -isnot [string] -or $Facts.runtimeManifestSha256 -cnotmatch '^[0-9a-f]{64}$')) -or ($null -ne $Facts.aboutVersion -and ($Facts.aboutVersion -isnot [string] -or $Facts.aboutVersion -cnotmatch '^\d+\.\d+\.\d+$')) -or + ($null -ne $Facts.statusRuntimeId -and ($Facts.statusRuntimeId -isnot [string] -or $Facts.statusRuntimeId -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$')) -or ($null -ne $Facts.deviceKeySha256 -and ($Facts.deviceKeySha256 -isnot [string] -or $Facts.deviceKeySha256 -cnotmatch '^[0-9a-f]{64}$')) -or ($Facts.deviceKeyPresent -and $null -eq $Facts.deviceKeySha256) -or (-not $Facts.deviceKeyPresent -and $null -ne $Facts.deviceKeySha256) -or @@ -219,7 +294,10 @@ function ConvertTo-CcodInstalledLifecycleFacts { codex = @(ConvertTo-CcodInstalledLifecycleIdentities -Records $Facts.codex -Kind 'Codex') taskState = [string]$Facts.taskState statusPhase = [string]$Facts.statusPhase + statusRuntimeId = $Facts.statusRuntimeId + statusCodex = ConvertTo-CcodInstalledLifecycleNullableIdentity -Identity $Facts.statusCodex -Kind 'Status Codex' transitionStage = [string]$Facts.transitionStage + lifecycleReceipt = ConvertTo-CcodInstalledLifecycleReceiptFact -Receipt $Facts.lifecycleReceipt aboutVersion = $Facts.aboutVersion deviceKeyPresent = [bool]$Facts.deviceKeyPresent deviceKeySha256 = $Facts.deviceKeySha256 @@ -232,6 +310,210 @@ function New-CcodInstalledLifecyclePhase { return [pscustomobject][ordered]@{ name = $Name; outcome = $Outcome } } +function Read-CcodInstalledLifecycleStrictJson { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][int]$ExpectedSchema, + [Parameter(Mandatory)][string]$Kind + ) + return & $script:CcodInstalledLifecyclePersistenceIoModule { + param($DocumentPath, $Schema, $DocumentKind) + Read-CcodStrictJson -Path $DocumentPath -ExpectedSchema $Schema -Kind $DocumentKind + } $Path $ExpectedSchema $Kind +} + +function Read-CcodInstalledLifecycleActiveFact { + param([Parameter(Mandatory)][string]$InstallRoot) + $path = Join-Path $InstallRoot 'active.json' + if (-not [IO.File]::Exists($path)) { return $null } + $active = Read-CcodInstalledLifecycleStrictJson -Path $path -ExpectedSchema 2 -Kind 'installed active runtime' + if (-not (Test-CcodInstalledLifecycleExactProperties -Value $active -Expected @('schemaVersion','activeRuntime','previousRuntime','generation','updatedAtUtc')) -or + $active.schemaVersion -isnot [int] -or $active.schemaVersion -ne 2 -or + $active.activeRuntime -isnot [string] -or $active.activeRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$' -or + ($null -ne $active.previousRuntime -and ($active.previousRuntime -isnot [string] -or $active.previousRuntime -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$')) -or + -not (Test-CcodInstalledLifecyclePositiveInteger -Value $active.generation) -or + -not (Test-CcodInstalledLifecycleCanonicalUtc -Value $active.updatedAtUtc)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Active runtime schema 2 is malformed' $path + } + return $active +} + +function Read-CcodInstalledLifecycleStatusFact { + param([Parameter(Mandatory)][string]$StateRoot) + $path = Join-Path $StateRoot 'status.json' + if (-not [IO.File]::Exists($path)) { return $null } + $status = Read-CcodInstalledLifecycleStrictJson -Path $path -ExpectedSchema 1 -Kind 'installed status' + try { + & $script:CcodInstalledLifecycleStateStoreModule { param($Value) Assert-CcodStatusShape -Status $Value } $status + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Status schema 1 is malformed' $path + } + return $status +} + +function Read-CcodInstalledLifecycleTransitionFact { + param([Parameter(Mandatory)][string]$StateRoot) + $path = Join-Path $StateRoot 'transition.json' + if (-not [IO.File]::Exists($path)) { return $null } + $transition = Read-CcodInstalledLifecycleStrictJson -Path $path -ExpectedSchema 1 -Kind 'installed transition' + try { + & $script:CcodInstalledLifecycleStateStoreModule { param($Value) Assert-CcodTransitionShape -Transition $Value } $transition + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Transition schema 1 is malformed' $path + } + return $transition +} + +function Get-CcodInstalledLifecycleReceiptFact { + param( + [Parameter(Mandatory)][string]$StateRoot, + [AllowNull()][string]$ActiveRuntimeId, + [UInt64]$ActiveGeneration + ) + $directory = Join-Path $StateRoot 'lifecycle\receipts' + if (-not [IO.Directory]::Exists($directory)) { return $null } + $directoryItem = Get-Item -LiteralPath $directory -Force -ErrorAction Stop + if (-not $directoryItem.PSIsContainer -or (($directoryItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt root is malformed' $directory + } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $candidates = [Collections.Generic.List[object]]::new() + foreach ($item in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop | Where-Object { $_.Name -like '*.json' })) { + if ($item.PSIsContainer -or $item -isnot [IO.FileInfo] -or (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt entry is malformed' $item.FullName + } + $receipt = Read-CcodInstalledLifecycleStrictJson -Path $item.FullName -ExpectedSchema 1 -Kind 'installed lifecycle receipt' + try { + & $script:CcodInstalledLifecycleTransactionModule { param($Value) Assert-CcodLifecycleRequest -Request $Value } $receipt + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt schema 1 is malformed' $item.FullName + } + if ($item.Name -cne ($receipt.transactionId + '.json') -or -not $seen.Add([string]$receipt.transactionId)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipts have a mismatched or duplicate identity' $item.FullName + } + if ($receipt.phase -cnotin @('Completed','CloseFailed','OrdinaryLaunchFailed','OrdinaryObservationTimedOut','LaunchWindowExpired','RepairFailed','VerificationFailed','CancelledBeforeClose','SupersededByUpgrade')) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Lifecycle receipt is not terminal' $item.FullName + } + if ($null -ne $ActiveRuntimeId -and $receipt.runtimeId -ceq $ActiveRuntimeId -and + [UInt64]$receipt.runtimeGeneration -eq $ActiveGeneration -and + $receipt.origin -ceq 'Installer' -and $receipt.kind -ceq 'RestartAndRepair') { + $candidates.Add($receipt) + } + } + if ($candidates.Count -eq 0) { return $null } + $ordered = @($candidates | Sort-Object updatedAtUtc -Descending) + if ($ordered.Count -gt 1 -and $ordered[0].updatedAtUtc -ceq $ordered[1].updatedAtUtc) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'Latest current-runtime installer lifecycle receipt is ambiguous' $directory + } + $latest = $ordered[0] + return [pscustomobject][ordered]@{ + kind = [string]$latest.kind + origin = [string]$latest.origin + runtimeId = [string]$latest.runtimeId + runtimeGeneration = [UInt64]$latest.runtimeGeneration + phase = [string]$latest.phase + } +} + +function Initialize-CcodInstalledLifecycleCommandLineParser { + if ($null -ne ('CcodInstalledLifecycleCommandLine' -as [type])) { return } + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class CcodInstalledLifecycleCommandLine +{ + [DllImport("shell32.dll", SetLastError = true)] + private static extern IntPtr CommandLineToArgvW( + [MarshalAs(UnmanagedType.LPWStr)] string commandLine, + out int argumentCount); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); + + public static string[] Parse(string commandLine) + { + int argumentCount; + IntPtr arguments = CommandLineToArgvW(commandLine, out argumentCount); + if (arguments == IntPtr.Zero || argumentCount < 1) return null; + try + { + string[] result = new string[argumentCount]; + for (int index = 0; index < argumentCount; index++) + { + IntPtr value = Marshal.ReadIntPtr(arguments, index * IntPtr.Size); + result[index] = Marshal.PtrToStringUni(value); + } + return result; + } + finally + { + LocalFree(arguments); + } + } +} +'@ +} + +function Test-CcodInstalledLifecycleOrdinaryChatGptRoot { + param($Process) + if ($null -eq $Process -or $Process.CommandLine -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$Process.CommandLine)) { return $false } + try { + Initialize-CcodInstalledLifecycleCommandLineParser + $arguments = @([CcodInstalledLifecycleCommandLine]::Parse([string]$Process.CommandLine)) + } catch { + return $false + } + if ($arguments.Count -eq 0 -or @($arguments | Where-Object { $null -eq $_ -or $_ -isnot [string] }).Count -ne 0) { return $false } + foreach ($argument in $arguments) { + if ($argument.StartsWith('--type=', [StringComparison]::OrdinalIgnoreCase)) { return $false } + } + return $true +} + +function Get-CcodInstalledLifecycleChatGptClassification { + param($Process) + if ($null -eq $Process -or $Process.PSObject.Properties['Name'] -eq $null -or + $Process.Name -isnot [string] -or $Process.Name -cne 'ChatGPT.exe' -or + $Process.PSObject.Properties['CommandLine'] -eq $null -or $Process.CommandLine -isnot [string] -or + [string]::IsNullOrWhiteSpace([string]$Process.CommandLine)) { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process has unreadable command-line evidence' $null + } + try { + Initialize-CcodInstalledLifecycleCommandLineParser + $arguments=@([CcodInstalledLifecycleCommandLine]::Parse([string]$Process.CommandLine)) + } catch { + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process command line could not be parsed' $null + } + if($arguments.Count -lt 1 -or @($arguments|Where-Object{$_ -isnot [string] -or [string]::IsNullOrWhiteSpace($_)}).Count -ne 0 -or + [IO.Path]::GetFileName([string]$arguments[0]) -ine 'ChatGPT.exe'){ + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process command line is not a complete executable argv' $null + } + $typeArguments=@($arguments|Select-Object -Skip 1|Where-Object{$_.StartsWith('--type=',[StringComparison]::OrdinalIgnoreCase)}) + if($typeArguments.Count -gt 1 -or ($typeArguments.Count -eq 1 -and $typeArguments[0].Length -le 7)){ + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process has an invalid Electron type argv' $null + } + return $(if($typeArguments.Count -eq 1){'Child'}else{'Root'}) +} + +function Get-CcodInstalledLifecycleChatGptIdentities { + $records=[Collections.Generic.List[object]]::new() + try{$processes=@(Get-CimInstance -ClassName Win32_Process -Filter "Name = 'ChatGPT.exe'" -ErrorAction Stop)}catch{ + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'ChatGPT process enumeration failed' $null + } + foreach($process in $processes){ + if($null -eq $process -or $process.PSObject.Properties['ProcessId'] -eq $null -or + -not (Test-CcodInstalledLifecyclePositiveInteger -Value $process.ProcessId) -or [decimal]$process.ProcessId -gt [int]::MaxValue -or + $process.PSObject.Properties['CreationDate'] -eq $null){ + Throw-CcodInstalledLifecycleError 'CCOD_INTEGRATION_FACTS_INVALID' 'An enumerated ChatGPT process has invalid identity evidence' $null + } + $created = ConvertTo-CcodInstalledLifecycleCreationTimeUtc -Value $process.CreationDate + $classification=Get-CcodInstalledLifecycleChatGptClassification -Process $process + if($classification -ceq 'Root'){$records.Add([pscustomobject][ordered]@{Pid=[int]$process.ProcessId;CreationTimeUtc=$created})} + } + return @($records) +} + function Get-CcodInstalledLifecycleFacts { param([Parameter(Mandatory)][string]$InstallRoot) $root = [IO.Path]::GetFullPath($InstallRoot) @@ -240,45 +522,33 @@ function Get-CcodInstalledLifecycleFacts { [UInt64]$activeGeneration = 0 $runtimeManifestSha256 = $null $statusPhase = 'Unavailable' + $statusRuntimeId = $null + $statusCodex = $null $transitionStage = 'Unavailable' - $activePath = Join-Path $root 'active.json' - if ([IO.File]::Exists($activePath)) { - try { - $active = [IO.File]::ReadAllText($activePath) | ConvertFrom-Json -ErrorAction Stop - $generationProperty = $active.PSObject.Properties['generation'] - $generation = [UInt64]0 - $generationValid = $false - if ($null -ne $generationProperty -and $generationProperty.Value -is [ValueType]) { - try { - $generation = [UInt64]$generationProperty.Value - $generationValid = $generation -ge 1 - } catch { $generationValid = $false } + $active = Read-CcodInstalledLifecycleActiveFact -InstallRoot $root + if ($null -ne $active) { + $activeRuntimeId = [string]$active.activeRuntime + $activeGeneration = [UInt64]$active.generation + $manifest = Join-Path (Join-Path (Join-Path $root 'runtime') $activeRuntimeId) 'manifest.json' + if ([IO.File]::Exists($manifest)) { $runtimeManifestSha256 = Get-CcodInstalledLifecycleHash -Path $manifest } + } + $stateRoot = Join-Path $root 'state' + $status = Read-CcodInstalledLifecycleStatusFact -StateRoot $stateRoot + if ($null -ne $status -and $null -ne $status.session) { + $statusPhase = [string]$status.session.sessionState + $statusRuntimeId = [string]$status.session.runtimeId + if ($null -ne $status.session.codex) { + $statusCodex = [pscustomobject][ordered]@{ + pid = [int]$status.session.codex.pid + creationTimeUtc = [string]$status.session.codex.creationTimeUtc } - if ([int]$active.schemaVersion -eq 2 -and $active.activeRuntime -is [string] -and $active.activeRuntime -cmatch '^[A-Za-z0-9._-]{1,96}$' -and $generationValid) { - $activeRuntimeId = [string]$active.activeRuntime - $activeGeneration = $generation - $manifest = Join-Path (Join-Path (Join-Path $root 'runtime') $activeRuntimeId) 'manifest.json' - if ([IO.File]::Exists($manifest)) { $runtimeManifestSha256 = Get-CcodInstalledLifecycleHash -Path $manifest } - } else { $statusPhase = 'InvalidActivePointer' } - } catch { $statusPhase = 'InvalidActivePointer' } - } - $statusPath = Join-Path (Join-Path $root 'state') 'status.json' - if ([IO.File]::Exists($statusPath)) { - try { - $status = [IO.File]::ReadAllText($statusPath) | ConvertFrom-Json -ErrorAction Stop - if ($null -ne $status.PSObject.Properties['phase'] -and $status.phase -is [string] -and $status.phase.Length -le 64) { $statusPhase = [string]$status.phase } - elseif ($statusPhase -ceq 'Unavailable') { $statusPhase = 'Present' } - } catch { $statusPhase = 'InvalidStatus' } + } } - $transitionPath = Join-Path (Join-Path $root 'state') 'transition.json' - if ([IO.File]::Exists($transitionPath)) { - try { - $transition = [IO.File]::ReadAllText($transitionPath) | ConvertFrom-Json -ErrorAction Stop - if ($null -eq $transition.PSObject.Properties['activeTransaction'] -or $null -eq $transition.activeTransaction) { $transitionStage = 'Idle' } - elseif ($null -ne $transition.activeTransaction.PSObject.Properties['stage'] -and $transition.activeTransaction.stage -is [string] -and $transition.activeTransaction.stage.Length -le 64) { $transitionStage = [string]$transition.activeTransaction.stage } - else { $transitionStage = 'InvalidTransition' } - } catch { $transitionStage = 'InvalidTransition' } + $transition = Read-CcodInstalledLifecycleTransitionFact -StateRoot $stateRoot + if ($null -ne $transition) { + $transitionStage = if ($null -eq $transition.activeTransaction) { 'Idle' } else { [string]$transition.activeTransaction.stage } } + $lifecycleReceipt = Get-CcodInstalledLifecycleReceiptFact -StateRoot $stateRoot -ActiveRuntimeId $activeRuntimeId -ActiveGeneration $activeGeneration $taskState = 'Unknown' try { $matches = @(Get-ScheduledTask -ErrorAction Stop | Where-Object { $_.TaskName -ceq $script:CcodInstalledLifecycleTaskName }) @@ -309,7 +579,7 @@ function Get-CcodInstalledLifecycleFacts { param($Process) [string]$Process.ExecutablePath -ieq $trayHostPath } - $codex = & $makeIdentities @('Codex.exe') { param($Process) $true } + $codex = @(Get-CcodInstalledLifecycleChatGptIdentities) $aboutVersion = $null $packagePath = Join-Path $appRoot 'package.json' if ([IO.File]::Exists($packagePath)) { @@ -335,7 +605,10 @@ function Get-CcodInstalledLifecycleFacts { codex = @($codex) taskState = $taskState statusPhase = $statusPhase + statusRuntimeId = $statusRuntimeId + statusCodex = $statusCodex transitionStage = $transitionStage + lifecycleReceipt = $lifecycleReceipt aboutVersion = $aboutVersion deviceKeyPresent = [bool]$deviceKeyPresent deviceKeySha256 = $deviceKeySha256 @@ -416,8 +689,28 @@ function Test-CcodInstalledLifecycleScenario { if ($Context.scenario -eq 'FreshLater' -and $BeforeFacts.codex.Count -gt 0) { $verified = $verified -and (Test-CcodInstalledLifecycleSameIdentitySet -Expected $BeforeFacts.codex -Actual $afterFacts.codex) } - if ($Context.scenario -eq 'FreshRestart' -and $BeforeFacts.codex.Count -gt 0) { - $verified = $verified -and $afterFacts.codex.Count -gt 0 -and -not (Test-CcodInstalledLifecycleSameIdentitySet -Expected $BeforeFacts.codex -Actual $afterFacts.codex) + if ($Context.scenario -eq 'FreshRestart') { + $rootIdentityReplaced = $true + if ($BeforeFacts.codex.Count -gt 0) { + $rootIdentityReplaced = $afterFacts.codex.Count -eq 1 -and @($BeforeFacts.codex | Where-Object { + $_.pid -eq $afterFacts.codex[0].pid -and $_.creationTimeUtc -ceq $afterFacts.codex[0].creationTimeUtc + }).Count -eq 0 + } + $verified = $verified -and + $afterFacts.codex.Count -eq 1 -and + $rootIdentityReplaced -and + $afterFacts.statusPhase -ceq 'Active' -and + $afterFacts.statusRuntimeId -ceq $afterFacts.activeRuntimeId -and + $null -ne $afterFacts.statusCodex -and + $afterFacts.statusCodex.pid -eq $afterFacts.codex[0].pid -and + $afterFacts.statusCodex.creationTimeUtc -ceq $afterFacts.codex[0].creationTimeUtc -and + $afterFacts.transitionStage -ceq 'Idle' -and + $null -ne $afterFacts.lifecycleReceipt -and + $afterFacts.lifecycleReceipt.kind -ceq 'RestartAndRepair' -and + $afterFacts.lifecycleReceipt.origin -ceq 'Installer' -and + $afterFacts.lifecycleReceipt.runtimeId -ceq $afterFacts.activeRuntimeId -and + [UInt64]$afterFacts.lifecycleReceipt.runtimeGeneration -eq [UInt64]$afterFacts.activeGeneration -and + $afterFacts.lifecycleReceipt.phase -ceq 'Completed' } } if (-not $verified) { $code = 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' } diff --git a/tests/persistence/Bootstrap.SelfTest.ps1 b/tests/persistence/Bootstrap.SelfTest.ps1 index 8de98cd..d8473b4 100644 --- a/tests/persistence/Bootstrap.SelfTest.ps1 +++ b/tests/persistence/Bootstrap.SelfTest.ps1 @@ -370,6 +370,28 @@ $results += Invoke-CcodTest 'keeps a ready active runtime and does not rewrite t } } +$results += Invoke-CcodTest 'returns success when a ready Supervisor remains alive for installer verification' { + $root = Join-Path ([IO.Path]::GetTempPath()) ("ccod-bootstrap-" + [guid]::NewGuid().ToString('N')) + $supervisorPid = $null + try { + New-CcodBootstrapFixture -Root $root | Out-Null + $pidPath = Join-Path $root 'ready-long-lived.pid' + $activeId = Add-CcodTestRuntime -Root $root -SupervisorScript (New-CcodTestSupervisorScript -Kind 'ReadyLongLived' -MarkerPath $pidPath) + Set-CcodTestActivePointer -Root $root -ActiveRuntime $activeId -PreviousRuntime $null + + $run = Invoke-CcodBootstrapTimed -Root $root -ReadyToken (New-CcodBootstrapToken) -TimeoutMilliseconds 10000 + if ([IO.File]::Exists($pidPath)) { $supervisorPid = [int][IO.File]::ReadAllText($pidPath) } + Assert-CcodExactEqual $false $run.TimedOut 'ready long-lived Supervisor does not hold bootstrap open' + Assert-CcodExactEqual 0 $run.ExitCode 'ready long-lived Supervisor is a successful bootstrap outcome' + } finally { + if ($null -ne $supervisorPid) { + $leftover = Get-Process -Id $supervisorPid -ErrorAction SilentlyContinue + if ($null -ne $leftover) { try { $leftover.Kill(); $leftover.WaitForExit(2000) | Out-Null } finally { $leftover.Dispose() } } + } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + $results += Invoke-CcodTest 'launches a schema-two active pointer without downgrading its generation' { $root = Join-Path ([IO.Path]::GetTempPath()) ("ccod-bootstrap-" + [guid]::NewGuid().ToString('N')) try { @@ -602,8 +624,8 @@ $results += Invoke-CcodTest 'bootstrap contains no Codex process, package, or ta $results += Invoke-CcodTest 'launches the supervisor child with an explicit STA apartment' { $source = Get-Content -LiteralPath $bootstrapScript -Raw - Assert-CcodTrue ($source.Contains('-STA -File')) 'bootstrap supervisor launch arguments include -STA before -File' - Assert-CcodTrue ($source.Contains('-NoProfile -ExecutionPolicy Bypass -STA -File')) 'bootstrap uses the exact STA launch argument prefix' + Assert-CcodTrue (($source.Contains('-STA -File') -or $source.Contains("'-STA', '-File'"))) 'bootstrap supervisor launch arguments include -STA before -File' + Assert-CcodTrue (($source.Contains('-NoProfile -ExecutionPolicy Bypass -STA -File') -or $source.Contains("'-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File'"))) 'bootstrap uses the exact STA launch argument prefix' Assert-CcodTrue (-not $source.Contains('-NoProfile -ExecutionPolicy Bypass -File "')) 'bootstrap no longer launches the supervisor without -STA' } diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1 index d818e71..d84bd9b 100644 --- a/tests/persistence/InstallLifecycle.SelfTest.ps1 +++ b/tests/persistence/InstallLifecycle.SelfTest.ps1 @@ -387,6 +387,200 @@ $results += Invoke-CcodTest 'default installer validation uses the structural ru } } +# Production mutation caught: recognizing JSON members only when the colon has zero or one following space. +$results += Invoke-CcodTest 'Inno activation progress parser accepts every legal JSON whitespace form' { + $installerPath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss' + $installerScript = Get-Content -LiteralPath $installerPath -Raw -Encoding UTF8 + $valueFunctionOffset = $installerScript.IndexOf('function HasJsonStringValue', [StringComparison]::Ordinal) + $helperFunctionOffset = $installerScript.IndexOf('function IsJsonWhitespace', [StringComparison]::Ordinal) + $parserOffset = if ($helperFunctionOffset -ge 0 -and $helperFunctionOffset -lt $valueFunctionOffset) { $helperFunctionOffset } else { $valueFunctionOffset } + $parserEnd = $installerScript.IndexOf('function DetectActivationPhase', [StringComparison]::Ordinal) + Assert-CcodTrue ($parserOffset -ge 0 -and $parserEnd -gt $parserOffset) 'the production Inno JSON progress parser can be isolated for execution' + + $isccCandidates = @( + (Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), + (Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe') + ) + $iscc = @($isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and [IO.File]::Exists($_) }) | Select-Object -First 1 + if ($null -eq $iscc) { + Write-Output 'Inno activation parser execution skipped because ISCC.exe is unavailable.' + return + } + + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-parser-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory($root) | Out-Null + $parserSource = $installerScript.Substring($parserOffset,$parserEnd-$parserOffset) + $harnessPath = Join-Path $root 'ActivationParser.iss' + $resultPath = Join-Path $root 'parser-result.txt' + $outputRoot = Join-Path $root 'out' + [IO.Directory]::CreateDirectory($outputRoot) | Out-Null + $harness = @" +[Setup] +AppName=CodexRemote-fix activation parser self-test +AppVersion=1.0 +DefaultDirName={tmp}\CcodActivationParserSelfTest +CreateAppDir=no +Uninstallable=no +PrivilegesRequired=lowest +OutputDir=$outputRoot +OutputBaseFilename=ActivationParserSelfTest +Compression=none + +[Code] +$parserSource +function InitializeSetup(): Boolean; +begin + if not HasJsonStringValue('{"phase":"StartingProtection"}', 'phase', 'StartingProtection') then + begin SaveStringToFile('$resultPath', 'compact JSON was rejected', False); Result := False; Exit; end; + if not HasJsonStringValue('{"phase": "StartingProtection"}', 'phase', 'StartingProtection') then + begin SaveStringToFile('$resultPath', 'two spaces were rejected', False); Result := False; Exit; end; + if not HasJsonStringValue('{"phase"' + #9 + ':' + #13 + #10 + ' "StartingProtection"}', 'phase', 'StartingProtection') then + begin SaveStringToFile('$resultPath', 'tab or newline JSON whitespace was rejected', False); Result := False; Exit; end; + if not HasJsonStringValue('{"activationId" : "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}', 'activationId', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') then + begin SaveStringToFile('$resultPath', 'correlation JSON whitespace was rejected', False); Result := False; Exit; end; + if HasJsonStringValue('{"phase": "StartingProtection-extra"}', 'phase', 'StartingProtection') then + begin SaveStringToFile('$resultPath', 'a non-exact JSON string value was accepted', False); Result := False; Exit; end; + SaveStringToFile('$resultPath', 'pass', False); + Result := False; +end; +"@ + [IO.File]::WriteAllText($harnessPath,$harness,[Text.UTF8Encoding]::new($false)) + $compileOutput = @(& $iscc $harnessPath 2>&1) + Assert-CcodEqual 0 $LASTEXITCODE "production Inno parser compiles in an executable harness: $($compileOutput -join ' ')" + $executable = Join-Path $outputRoot 'ActivationParserSelfTest.exe' + Assert-CcodTrue (Test-Path -LiteralPath $executable -PathType Leaf) 'compiled parser harness exists' + $process = Start-Process -FilePath $executable -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-') -WindowStyle Hidden -Wait -PassThru + try { $runExitCode = [int]$process.ExitCode } finally { $process.Dispose() } + Assert-CcodTrue (Test-Path -LiteralPath $resultPath -PathType Leaf) "production Inno parser harness executed with exit code $runExitCode" + Assert-CcodEqual 'pass' ([IO.File]::ReadAllText($resultPath,[Text.UTF8Encoding]::new($false))) 'production Inno parser accepts legal JSON whitespace and exact string values at runtime' + } finally { + if (Test-Path -LiteralPath $root) { + $cleanupDeadline = [DateTime]::UtcNow.AddSeconds(5) + while (Test-Path -LiteralPath $root) { + try { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction Stop } + catch { + if ([DateTime]::UtcNow -ge $cleanupDeadline) { throw } + Start-Sleep -Milliseconds 50 + } + } + } + } +} + +# Production mutation caught: launching activation without a process owner or returning from Setup before that owner exits. +$results += Invoke-CcodTest 'Inno visibly starts and synchronously owns the bounded activation worker' { + $installerScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 + $activationWorker = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8 + $firstReceiptTimeout = [regex]::Match($installerScript,'(?m)^\s*FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS\s*=\s*(?[0-9]+);\r?$') + Assert-CcodTrue $firstReceiptTimeout.Success 'installer declares a dedicated first-receipt timeout' + Assert-CcodEqual 90000 ([int]$firstReceiptTimeout.Groups['milliseconds'].Value) 'a worker that emits no receipt cannot hold Setup for five minutes' + + $postInstall = [regex]::Match($installerScript,'(?s)procedure CurStepChanged\(CurStep: TSetupStep\);.*?\nbegin(?.*?)\nend;') + Assert-CcodTrue $postInstall.Success 'post-install activation route exists' + $body = $postInstall.Groups['body'].Value + $startingOffset = $body.IndexOf('UpdateActivationStartingPresentation();',[StringComparison]::Ordinal) + $launchOffset = $body.IndexOf('ewWaitUntilTerminated, ActivationResultCode',[StringComparison]::Ordinal) + Assert-CcodTrue ($startingOffset -ge 0 -and $launchOffset -gt $startingOffset) 'visible startup is rendered before synchronously waiting for the activation owner' + Assert-CcodTrue ($body -cmatch '(?s)-FirstReceiptTimeoutMilliseconds\s+.*?FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS' -and + $body -cmatch '(?s)-ActivationTimeoutMilliseconds\s+.*?ACTIVATION_TIMEOUT_MILLISECONDS') 'Inno passes both fixed deadlines to the process owner' + Assert-CcodTrue ($body -cnotmatch 'ewNoWait') 'Setup never releases an unowned activation worker' + Assert-CcodTrue ($activationWorker -cmatch '(?s)function Stop-CcodOwnedInstallProcess.*?\.Kill\(\).*?WaitForExit\(5000\).*?\.HasExited' -and + $activationWorker -cmatch '(?s)function Invoke-CcodOwnedInstallWorker.*?finally\s*\{.*?if \(\$null -ne \$process\).*?if \(-not \$process\.HasExited\)\s*\{\s*Stop-CcodOwnedInstallProcess.*?\$process\.Dispose\(\)') 'every abnormal owner path kills and proves the exact child exit before disposal' + Assert-CcodTrue ($activationWorker -cmatch '(?s)function Stop-CcodOwnedInstallProcess.*?WaitForExit\(5000\).*?while \(\$true\).*?\.Kill\(\).*?\.WaitForExit\(\).*?\.HasExited.*?return') 'an unproven bounded stop escalates to waiting until exact child exit instead of returning to Setup' +} + +$results += Invoke-CcodTest 'activation owner kills and proves exit before a timed-out child can change the receipt' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-owned-timeout-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null + $lateMarker = Join-Path $root 'late-write.txt' + $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' + $installSource = @" +param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) +Start-Sleep -Milliseconds 1200 +`$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Ready';runtimeId='late-runtime';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$true;errorCode=`$null} +[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +[IO.File]::WriteAllText('$lateMarker','late',[Text.UTF8Encoding]::new(`$false)) +exit 0 +"@ + [IO.File]::WriteAllText($installScript,$installSource,[Text.UTF8Encoding]::new($false)) + $activationId = '77777777-6666-5555-4444-333333333333' + $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -ActivationId $activationId -FirstReceiptTimeoutMilliseconds 200 -ActivationTimeoutMilliseconds 3000 2>&1) + Assert-CcodEqual 1 $LASTEXITCODE 'missing first receipt makes the owned activation fail' + Start-Sleep -Milliseconds 1400 + Assert-CcodTrue (-not (Test-Path -LiteralPath $lateMarker)) 'the exact install child cannot write after the activation owner returns' + Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $root 'state\post-install-activation.json'))) 'the killed child cannot later publish Ready' + $log = Get-Content -LiteralPath (Join-Path $root 'logs\post-install-activation.log') -Raw -Encoding UTF8 + Assert-CcodTrue ($log -cmatch 'CCOD_ACTIVATION_FIRST_RECEIPT_TIMEOUT') 'the owner records the stable first-receipt timeout code' + Assert-CcodTrue (($output -join "`n") -cmatch 'CCOD_ACTIVATION_FIRST_RECEIPT_TIMEOUT') 'the owner reports the bounded timeout without a safe terminal receipt' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +$results += Invoke-CcodTest 'activation owner freezes a correlated progress receipt at the total deadline' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-owned-total-timeout-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null + $lateMarker = Join-Path $root 'late-total-write.txt' + $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' + $installSource = @" +param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) +`$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='StartingProtection';runtimeId='pending-runtime';previousRuntimeId=`$null;startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$false;errorCode=`$null} +[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +Start-Sleep -Milliseconds 3000 +`$receipt.phase='Ready';`$receipt.ready=`$true;`$receipt.updatedAtUtc='2030-02-03T04:05:08.0000000Z' +[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +[IO.File]::WriteAllText('$lateMarker','late',[Text.UTF8Encoding]::new(`$false)) +exit 0 +"@ + [IO.File]::WriteAllText($installScript,$installSource,[Text.UTF8Encoding]::new($false)) + $activationId = '77777777-6666-5555-4444-333333333333' + $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -ActivationId $activationId -FirstReceiptTimeoutMilliseconds 1000 -ActivationTimeoutMilliseconds 1200 2>&1) + Assert-CcodEqual 1 $LASTEXITCODE 'total deadline makes the owned activation fail after correlated progress' + Start-Sleep -Milliseconds 3200 + Assert-CcodTrue (-not (Test-Path -LiteralPath $lateMarker)) 'the exact install child cannot write after the total deadline owner return' + $receipt = Get-Content -LiteralPath (Join-Path $root 'state\post-install-activation.json') -Raw -Encoding UTF8 | ConvertFrom-Json + Assert-CcodEqual 'StartingProtection' $receipt.phase 'the killed child cannot change progress into Ready after Setup regains control' + $log = Get-Content -LiteralPath (Join-Path $root 'logs\post-install-activation.log') -Raw -Encoding UTF8 + Assert-CcodTrue ($log -cmatch 'CCOD_ACTIVATION_TIMEOUT') 'the owner records the stable total timeout code' + Assert-CcodTrue (($output -join "`n") -cmatch 'CCOD_ACTIVATION_TIMEOUT') 'the owner reports the bounded total timeout without a safe Ready result' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + +# Production mutation caught: reducing a strict correlated Failed receipt to the generic activation-worker exit code. +$results += Invoke-CcodTest 'activation worker reports the strict Failed receipt support code without prompting' { + $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-failed-report-' + [guid]::NewGuid().ToString('N')) + try { + [IO.Directory]::CreateDirectory((Join-Path $root 'state')) | Out-Null + $promptMarker = Join-Path $root 'prompt-marker.txt' + $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1' + $promptScript = Join-Path $root 'Prompt-CcodRestart.ps1' + $installSource = @" +param([string]`$InstallRoot,[string]`$ActivationId,[switch]`$EnableCandidateCompatibleUpdates) +`$receipt=[ordered]@{schemaVersion=1;activationId=`$ActivationId;phase='Failed';runtimeId='runtime-new';previousRuntimeId='runtime-old';startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=`$false;errorCode='CCOD_INSTALL_NEW_RUNTIME_NOT_READY'} +[IO.File]::WriteAllText((Join-Path `$InstallRoot 'state\post-install-activation.json'),(`$receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new(`$false)) +exit 1 +"@ + [IO.File]::WriteAllText($installScript,$installSource,[Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($promptScript,"[IO.File]::WriteAllText('$promptMarker','prompted');exit 0",[Text.UTF8Encoding]::new($false)) + $activationId = '77777777-6666-5555-4444-333333333333' + $output = @(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $root -InstallRoot $root -ActivationId $activationId -Prompt -NoUi 2>&1) + + Assert-CcodEqual 1 $LASTEXITCODE 'strict Failed receipt keeps the activation worker unsuccessful' + Assert-CcodTrue (-not (Test-Path -LiteralPath $promptMarker)) 'failed activation never reaches the restart prompt' + $log = Get-Content -LiteralPath (Join-Path $root 'logs\post-install-activation.log') -Raw -Encoding UTF8 + Assert-CcodTrue ($log -cmatch '"code":"CCOD_INSTALL_NEW_RUNTIME_NOT_READY"') 'activation log retains the strict bounded installer support code' + Assert-CcodTrue ($log -cnotmatch '"code":"CCOD_ACTIVATION_RUNTIME_FAILED"') 'strict Failed receipt is not hidden behind the generic child exit code' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue } + } +} + # Production mutation caught: accepting a caller-controlled activation id and persisting it into receipts or logs before canonical validation. $results += Invoke-CcodTest 'install rejects a noncanonical caller ActivationId before receipt log or task mutation' { $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot @@ -445,6 +639,34 @@ $results += Invoke-CcodTest 'first install stages verifies activates task and pe } } +# Production mutation caught: routing the activation progress receipt through the generic pretty-printed JSON writer. +$results += Invoke-CcodTest 'activation progress receipt uses one compact JSON line for PowerShell 5.1 and Inno' { + $install = New-CcodLifecycleTempRoot + try { + $activationId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + $receipt = [pscustomobject][ordered]@{ + schemaVersion = 1 + activationId = $activationId + phase = 'StartingProtection' + runtimeId = 'runtime-new' + previousRuntimeId = 'runtime-old' + startedAtUtc = '2030-02-03T03:04:05.0000000Z' + updatedAtUtc = '2030-02-03T03:04:06.0000000Z' + ready = $false + errorCode = $null + } + $module = Get-Module InstallLifecycle + & $module { param($Root,$Value) Write-CcodActivationReceiptFile -InstallRoot $Root -Receipt $Value } $install $receipt + + $raw = [IO.File]::ReadAllText((Join-Path $install 'state\post-install-activation.json'),[Text.UTF8Encoding]::new($false)) + $expected = '{"schemaVersion":1,"activationId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","phase":"StartingProtection","runtimeId":"runtime-new","previousRuntimeId":"runtime-old","startedAtUtc":"2030-02-03T03:04:05.0000000Z","updatedAtUtc":"2030-02-03T03:04:06.0000000Z","ready":false,"errorCode":null}' + "`n" + Assert-CcodEqual $expected $raw 'activation progress is a compact JSON object with only one trailing newline' + Assert-CcodEqual 'StartingProtection' (($raw | ConvertFrom-Json).phase) 'compact progress remains valid JSON for the strict terminal reader' + } finally { + if (Test-Path -LiteralPath $install) { Remove-Item -LiteralPath $install -Recurse -Force } + } +} + $results += Invoke-CcodTest 'hidden persistence module is staged and manifest-hashed' { $source = New-CcodLifecycleTempRoot $install = New-CcodLifecycleTempRoot @@ -596,6 +818,66 @@ $results += Invoke-CcodTest 'new-runtime readiness accepts only the exact post-t } } +# Production mutation caught: treating a just-started Supervisor's not-yet-created Ready event as a terminal readiness failure. +$results += Invoke-CcodTest 'new-runtime readiness retries a temporary Ready event open failure until the same Supervisor signals' { + $source = New-CcodLifecycleTempRoot + $install = New-CcodLifecycleTempRoot + $nodeRoot = New-CcodLifecycleTempRoot + try { + New-CcodLifecycleSourceFixture -Root $source -Version '2.5.20-ready-open-retry' | Out-Null + $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot + $installed = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters + $identity = New-CcodLifecycleIdentity + $started = [DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime() + $bootstrap = [IO.Path]::GetFullPath((Join-Path $install 'bootstrap.ps1')) + $supervisor = [IO.Path]::GetFullPath((Join-Path $install "runtime\$($installed.RuntimeId)\src\persistence\Supervisor.ps1")) + $token = 'e' * 64 + $items = @( + [pscustomobject]@{ProcessId=800;ParentProcessId=1;SessionId=1;CreationDate=[DateTime]::Parse('2030-02-03T03:04:05.1Z').ToUniversalTime();Name='powershell.exe';CommandLine="powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File `"$bootstrap`" -InstallRoot `"$install`" -EntryMode Task"}, + [pscustomobject]@{ProcessId=801;ParentProcessId=800;SessionId=1;CreationDate=[DateTime]::Parse('2030-02-03T03:04:06Z').ToUniversalTime();Name='powershell.exe';CommandLine="powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -File `"$supervisor`" -ReadyToken $token"} + ) + $world = [pscustomobject]@{Elapsed=0L;OpenAttempts=0;Closed=0} + $adapters = @{ + EnumerateProcesses = { $items }.GetNewClosure() + GetProcessOwnerSid = { param($Process) $identity.UserSid }.GetNewClosure() + OpenReadyEvent = { param($Sid,$Session,$ReadyToken) $world.OpenAttempts++; if($world.OpenAttempts -eq 1){throw 'event not created yet'};[pscustomobject]@{Handle='event'} }.GetNewClosure() + WaitReadyEvent = { param($Event,$Milliseconds) $true } + IsSupervisorIdentityCurrent = { param($Candidate) $true } + CloseReadyEvent = { param($Event) $world.Closed++ }.GetNewClosure() + StartClock = { $world }.GetNewClosure() + GetElapsedMilliseconds = { param($Clock) [long]$Clock.Elapsed } + Sleep = { param($Milliseconds) $world.Elapsed += $Milliseconds }.GetNewClosure() + } + $module = Get-Module InstallLifecycle + $proof = & $module {param($Root,$Runtime,$Current,$Started,$A) Wait-CcodLifecycleNewRuntimeReady -InstallRoot $Root -RuntimeId $Runtime -RuntimeGeneration 1 -Identity $Current -TaskStartedAtUtc $Started -TimeoutMilliseconds 200 -Adapters $A} $install $installed.RuntimeId $identity $started $adapters + + Assert-CcodEqual $true $proof.SupervisorReady 'temporary Ready event absence does not discard the live exact Supervisor' + Assert-CcodEqual $true $proof.TrayReady 'a later signal from the same token proves the TrayHost handshake' + Assert-CcodEqual 2 $world.OpenAttempts 'Ready event is reopened after one startup race' + Assert-CcodEqual 1 $world.Closed 'the successfully opened Ready event is closed exactly once' + } finally { + foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}} + } +} + +# Production mutation caught: comparing CIM's microsecond process timestamp to Get-Process's 100ns timestamp as exact text. +$results += Invoke-CcodTest 'readiness identity accepts only the same CIM-truncated process creation instant' { + $module = Get-Module InstallLifecycle -ErrorAction Stop + $actual = [DateTime]::ParseExact('2030-02-03T03:04:05.1234569Z','o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + $exact = '2030-02-03T03:04:05.1234569Z' + $cimTruncated = '2030-02-03T03:04:05.1234560Z' + $differentMicrosecond = '2030-02-03T03:04:05.1234550Z' + $nextMicrosecond = [DateTime]::ParseExact('2030-02-03T03:04:05.1234570Z','o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind) + $futureTick = '2030-02-03T03:04:05.1234570Z' + + Assert-CcodEqual $true (& $module {param($Expected,$Observed) Test-CcodLifecycleCimCreationTime -ExpectedUtc $Expected -ActualUtc $Observed} $exact $actual) 'exact creation time remains current' + Assert-CcodEqual $true (& $module {param($Expected,$Observed) Test-CcodLifecycleCimCreationTime -ExpectedUtc $Expected -ActualUtc $Observed} $cimTruncated $actual) 'CIM precision loss of at most nine 100ns ticks remains the same process' + Assert-CcodEqual $false (& $module {param($Expected,$Observed) Test-CcodLifecycleCimCreationTime -ExpectedUtc $Expected -ActualUtc $Observed} $cimTruncated $nextMicrosecond) 'ten 100ns ticks cross the microsecond identity boundary' + Assert-CcodEqual $false (& $module {param($Expected,$Observed) Test-CcodLifecycleCimCreationTime -ExpectedUtc $Expected -ActualUtc $Observed} $differentMicrosecond $actual) 'a different microsecond remains a different process identity' + Assert-CcodEqual $false (& $module {param($Expected,$Observed) Test-CcodLifecycleCimCreationTime -ExpectedUtc $Expected -ActualUtc $Observed} $futureTick $actual) 'a future expected timestamp is never normalized backward' + Assert-CcodEqual $false (& $module {param($Expected,$Observed) Test-CcodLifecycleCimCreationTime -ExpectedUtc $Expected -ActualUtc $Observed} 'not-a-time' $actual) 'malformed identity timestamps fail closed' +} + # Production mutation caught: accepting a wrong generation/path/SID/session/start time/parent/command or more than one matching Supervisor candidate. $results += Invoke-CcodTest 'new-runtime readiness rejects every mismatched or ambiguous Supervisor candidate before opening its token event' { $source = New-CcodLifecycleTempRoot;$install = New-CcodLifecycleTempRoot;$nodeRoot = New-CcodLifecycleTempRoot @@ -855,8 +1137,8 @@ $results += Invoke-CcodTest 'post-Ready completion-log failure stays Ready retai } finally {foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} } -# Production mutation caught: waiting for the task-created Supervisor while retaining install/lifecycle mutex ownership that blocks bootstrap launch. -$results += Invoke-CcodTest 'upgrade releases generation ownership before waiting for task-created readiness' { +# Production mutation caught: starting the task or waiting for its Supervisor while retaining install/lifecycle mutex ownership that blocks bootstrap launch. +$results += Invoke-CcodTest 'upgrade releases generation ownership before starting and waiting for task-created readiness' { $source=New-CcodLifecycleTempRoot;$install=New-CcodLifecycleTempRoot;$nodeRoot=New-CcodLifecycleTempRoot try{ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.0-handoff-a'|Out-Null;$nodePath=New-CcodLifecycleFakeNode -Root $nodeRoot @@ -868,7 +1150,7 @@ $results += Invoke-CcodTest 'upgrade releases generation ownership before waitin Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters|Out-Null [string[]]$calls=@($fake.World.Calls) $startIndex=[Array]::IndexOf($calls,'StartTask');$ownershipExit=[Array]::IndexOf($calls,'ExitLifecycleOwnership');$installExit=[Array]::IndexOf($calls,'ExitInstallLease');$readyIndex=[Array]::IndexOf($calls,'WaitNewRuntimeReady') - Assert-CcodTrue ($startIndex-ge0-and$ownershipExit-gt$startIndex-and$installExit-gt$ownershipExit-and$readyIndex-gt$installExit) 'task starts, ownership fully releases, then readiness polling begins' + Assert-CcodTrue ($ownershipExit-ge0-and$installExit-gt$ownershipExit-and$startIndex-gt$installExit-and$readyIndex-gt$startIndex) 'ownership fully releases before task starts, then readiness polling begins' Assert-CcodEqual 1 $fake.World.InstallLeaseReleased 'install lease releases exactly once before readiness' }finally{foreach($path in @($source,$install,$nodeRoot)){if(Test-Path -LiteralPath $path){Remove-Item -LiteralPath $path -Recurse -Force}}} } @@ -1979,9 +2261,9 @@ $results += Invoke-CcodTest 'installer exposes CodexRemote-fix as the searchable Assert-CcodTrue ($buildScript -cmatch '\$bundle\.sha256\.txt') 'build script writes a hash beside the public portable ZIP filename' } -$results += Invoke-CcodTest 'portable builder publishes the exact CodexRemote-fix 2.5.9 release artifact contract' { +$results += Invoke-CcodTest 'portable builder publishes the exact CodexRemote-fix 2.5.21 release artifact contract' { $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json - Assert-CcodEqual '2.5.9' ([string]$package.version) 'package version is exactly 2.5.9' + Assert-CcodEqual '2.5.21' ([string]$package.version) 'package version is exactly 2.5.21' $buildScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw Assert-CcodTrue ($buildScript -cmatch 'CodexRemote-fix-\$Version-windows-x64\.zip') 'portable build resolves the exact public ZIP filename' @@ -1990,8 +2272,8 @@ $results += Invoke-CcodTest 'portable builder publishes the exact CodexRemote-fi Assert-CcodTrue ($buildScript -cmatch "distribution = 'portable-zip'") 'portable build labels the release distribution explicitly' } -# Production mutation caught: allowing raw activation JSON to authorize Ready/Failed, checking the deadline after terminal processing, prompting without a strict validator child exit, or synchronously waiting on an unbounded validator. -$results += Invoke-CcodTest 'installer treats raw activation receipts as progress and prompts only from a bounded strict validator child exit' { +# Production mutation caught: trusting the activation owner exit without a separate strict receipt validation. +$results += Invoke-CcodTest 'installer accepts success only from the exited owner plus a bounded strict validator child' { $installerScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 $activationWorker = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8 $activationScript = Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1' @@ -2000,25 +2282,12 @@ $results += Invoke-CcodTest 'installer treats raw activation receipts as progres Assert-CcodTrue (Test-Path -LiteralPath $promptScript -PathType Leaf) 'post-install Codex restart prompt exists' Assert-CcodTrue ($installerScript -cmatch '(?m)^CloseApplications=no\r?$') 'installer never lets Restart Manager close Codex' Assert-CcodTrue ($installerScript -cmatch 'Activate-CcodRemoteFix\.ps1') 'installer bundles the activation worker' - Assert-CcodTrue ($installerScript -cmatch 'GetTickCount64@kernel32\.dll stdcall' -and $installerScript -cmatch 'ACTIVATION_TIMEOUT_MILLISECONDS\s*=\s*[1-9][0-9]*') 'installer uses a fixed monotonic activation deadline' - Assert-CcodTrue ($installerScript -cmatch '(?s)RefuseStaleActivationReceipt\(ReceiptPath\).*DeadlineTick\s*:=\s*GetTickCount64\(\)\s*\+\s*ACTIVATION_TIMEOUT_MILLISECONDS.*Activate-CcodRemoteFix\.ps1.*ewNoWait') 'installer clears the stale receipt and fixes the deadline before activation launch' - $pollLoop = [regex]::Match($installerScript, '(?s)while True do\s*begin(.*?)Sleep\(ACTIVATION_POLL_MILLISECONDS\);\s*end;') - Assert-CcodTrue $pollLoop.Success 'installer has one bounded activation polling loop' - $deadlineOffset = $pollLoop.Groups[1].Value.IndexOf('if GetTickCount64() >= DeadlineTick') - $progressOffset = $pollLoop.Groups[1].Value.IndexOf('ReadActivationProgressPhase(ReceiptPath, ActivationId)') - $validationOffset = $pollLoop.Groups[1].Value.IndexOf('-ValidateReceiptWithTimeout') - Assert-CcodTrue ($deadlineOffset -ge 0 -and $progressOffset -gt $deadlineOffset -and $validationOffset -gt $deadlineOffset) 'deadline is checked before raw or validated terminal state can be processed' - Assert-CcodTrue ($installerScript -cmatch '(?s)if \(Phase in \[apReady, apFailed\]\).*?-ValidateReceiptWithTimeout.*-ValidationTimeoutMilliseconds.*ewWaitUntilTerminated, ValidationResultCode.*case ValidationResultCode of') 'a possible raw terminal invokes one bounded strict validator and interprets only its direct exit code' - Assert-CcodTrue ($installerScript -cmatch 'UpdateActivationPresentation' -and $installerScript -cmatch 'ProgressGauge\.Position') 'installer maps durable phases to visible status and progress' - $progressReader=[regex]::Match($installerScript,'(?s)function ReadActivationProgressPhase\(.*?\nend;') - Assert-CcodTrue ($progressReader.Success -and $progressReader.Groups[0].Value-cnotmatch 'ProgressGauge\.Position\s*:=\s*100|Prompt-CcodRestart|ewWaitUntilTerminated') 'raw activation receipt parsing is progress-only and has no terminal authority' - $readyBranch=[regex]::Match($installerScript,'(?s)\s+0:\s*begin(.*?)\s+end;\s+2:') - $failedBranch=[regex]::Match($installerScript,'(?s)\s+2:\s*begin(.*?)\s+end;\s+else') - $retryBranch=[regex]::Match($installerScript,'(?s)\s+else\s+begin(.*?)\s+end;\s+end;\s+end;\s+WizardForm\.Update') + Assert-CcodTrue ($installerScript -cmatch '(?s)RefuseStaleActivationReceipt\(ReceiptPath\).*UpdateActivationStartingPresentation\(\).*Activate-CcodRemoteFix\.ps1.*ewWaitUntilTerminated, ActivationResultCode') 'installer clears stale state, shows startup, and waits for the exact owner to exit' + Assert-CcodTrue ($installerScript -cmatch '(?s)if ActivationResultCode <> 0 then.*RaiseException.*?-ValidateReceiptWithTimeout.*ewWaitUntilTerminated, ValidationResultCode') 'only a normal owner exit reaches the separate bounded validator' + $readyBranch=[regex]::Match($installerScript,'(?s)if ValidationResultCode = 0 then\s*begin(.*?)\s*end;') Assert-CcodTrue ($readyBranch.Success -and $readyBranch.Groups[1].Value-cmatch'ProgressGauge\.Position\s*:=\s*100' -and $readyBranch.Groups[1].Value-cmatch'Prompt-CcodRestart\.ps1') 'only strict validator exit zero reaches completion and prompting' - Assert-CcodTrue ($failedBranch.Success -and $failedBranch.Groups[1].Value-cmatch'RaiseException' -and $installerScript-cmatch'CodexRemote-fix activation timed out') 'strict Failed and timeout states fail closed' + Assert-CcodTrue ($installerScript -cmatch 'if ValidationResultCode <> 0 then' -and $installerScript -cmatch 'RaiseException') 'every nonzero validator result fails closed' Assert-CcodTrue ($installerScript -cnotmatch '(?s)Activate-CcodRemoteFix\.ps1[^;]*-Prompt') 'background activation worker never owns the prompt' - Assert-CcodTrue ($failedBranch.Groups[1].Value-cnotmatch'ProgressGauge\.Position\s*:=\s*100|Prompt-CcodRestart' -and $retryBranch.Success -and $retryBranch.Groups[1].Value-cmatch'NextValidationAttemptTick') 'no non-Ready validator result displays completed progress or prompts and invalid reads retry' Assert-CcodTrue ($activationWorker -cmatch '(?s)if \(\$ValidateReceiptOnly\).*Read-CcodTerminalActivationReceipt.*phase -ceq .Ready.*exit 0.*phase -ceq .Failed.*exit 2' -and $activationWorker -cnotmatch 'ValidationResultPath|ValidationId|Start-Sleep') 'inner validator is a one-shot strict receipt reader with only direct terminal exit codes' Assert-CcodTrue ($activationWorker -cmatch '(?s)function Invoke-CcodBoundedReceiptValidator.*WaitForExit\(\$TimeoutMilliseconds\).*\.Kill\(\).*return 3' -and $activationWorker -cmatch '(?s)if \(\$ValidateReceiptWithTimeout\).*Invoke-CcodBoundedReceiptValidator.*exit \$validationResult') 'outer validator owns a finite child wait and reduces timeout to a fail-closed direct exit' Assert-CcodTrue ($installerScript -cnotmatch 'ValidationResultPath|ReadValidationResultState|post-install-activation\.validation') 'Inno never accepts a file sidecar as validator authority' @@ -2026,20 +2295,19 @@ $results += Invoke-CcodTest 'installer treats raw activation receipts as progres Assert-CcodTrue ($installerScript -cnotmatch '(?ms)^\[Run\]\s*\r?\nFilename: "powershell\.exe"; Parameters: ".*Install-CodexControlOtherDevices\.ps1') 'installer does not silently ignore its runtime installer exit code through a Run entry' } -# Production mutation caught: treating ewNoWait ResultCode as a PID, reopening an unrelated process, accepting a raw receipt or unauthenticated sidecar as a terminal result, or blocking indefinitely on validator completion. -$results += Invoke-CcodTest 'installer uses launch-only ewNoWait and accepts terminal status only from its bounded strict validator child' { +# Production mutation caught: releasing an unowned worker, reopening a PID, or accepting an unauthenticated sidecar. +$results += Invoke-CcodTest 'installer waits for its process owner and accepts terminal status only from its strict validator child' { $installerScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8 Assert-CcodTrue ($installerScript -cmatch 'CoCreateGuid@ole32\.dll' -and $installerScript -cmatch 'StringFromGUID2@ole32\.dll') 'installer generates a canonical activation GUID before worker launch' Assert-CcodTrue ($installerScript -cmatch '(?s)ActivationId\s*:=\s*NewActivationId\(\).*ReceiptPath\s*:=.*RefuseStaleActivationReceipt\(ReceiptPath\).*Activate-CcodRemoteFix\.ps1.*-ActivationId\s+"' ) 'installer removes or refuses the stale receipt and passes its own activation id to the worker' - Assert-CcodTrue ($installerScript -cmatch 'ewNoWait, LaunchResultCode' -and $installerScript -cmatch 'SysErrorMessage\(LaunchResultCode\)' -and $installerScript -cmatch 'ewWaitUntilTerminated, ValidationResultCode' -and $installerScript -cmatch '-ValidateReceiptWithTimeout' -and $installerScript -cmatch 'VALIDATION_TIMEOUT_MILLISECONDS') 'ewNoWait ResultCode is used only as a launch diagnostic while the bounded validator exit code is read only after wait' + Assert-CcodTrue ($installerScript -cmatch 'ewWaitUntilTerminated, ActivationResultCode' -and $installerScript -cmatch 'ewWaitUntilTerminated, ValidationResultCode' -and $installerScript -cmatch '-ValidateReceiptWithTimeout' -and $installerScript -cmatch 'VALIDATION_TIMEOUT_MILLISECONDS') 'Inno waits for both the activation owner and bounded validator exit codes' + Assert-CcodTrue ($installerScript -cnotmatch 'ewNoWait') 'Inno never releases an unowned activation worker' foreach($forbidden in @('OpenProcess@kernel32.dll','WaitForSingleObject@kernel32.dll','GetExitCodeProcess@kernel32.dll','PROCESS_QUERY_INFORMATION','ProcessHandle := OpenProcess')){Assert-CcodTrue ($installerScript -cnotmatch [regex]::Escape($forbidden)) "installer never uses $forbidden to infer worker identity"} - Assert-CcodTrue ($installerScript -cmatch '(?s)if \(Phase in \[apReady, apFailed\]\).*?VALIDATION_LAUNCH_BUDGET_MILLISECONDS.*?-ValidateReceiptWithTimeout.*-ValidationTimeoutMilliseconds.*ewWaitUntilTerminated, ValidationResultCode.*if GetTickCount64\(\) >= DeadlineTick.*case ValidationResultCode of') 'Inno reserves a finite validator budget, checks the deadline again, and branches on the bounded direct exit code' - Assert-CcodTrue ($installerScript -cmatch 'VALIDATION_RETRY_MILLISECONDS' -and $installerScript -cmatch 'NextValidationAttemptTick\s*:=\s*GetTickCount64\(\)\s*\+\s*VALIDATION_RETRY_MILLISECONDS') 'nonterminal validator outcomes yield to the UI before a bounded retry' + Assert-CcodTrue ($installerScript -cmatch '(?s)if ActivationResultCode <> 0 then.*RaiseException.*?-ValidateReceiptWithTimeout.*ewWaitUntilTerminated, ValidationResultCode.*if ValidationResultCode <> 0 then.*RaiseException') 'owner failure cannot reach validation and every nonzero validator result fails closed' Assert-CcodTrue ($installerScript -cmatch '(?s)function IsSafeActivationFile.*ExtractFileDir.*DirectoryAttributes.*RootAttributes.*FILE_ATTRIBUTE_REPARSE_POINT') 'Inno rejects reparse-point state ancestors even for raw presentation reads' Assert-CcodTrue ($installerScript -cnotmatch 'ValidationResultPath|ReadValidationResultState|post-install-activation\.validation') 'no writable sidecar can impersonate the strict validator' - $readyBranch=[regex]::Match($installerScript,'(?s)\s+0:\s*begin(.*?)\s+end;\s+2:') - $failedBranch=[regex]::Match($installerScript,'(?s)\s+2:\s*begin(.*?)\s+end;\s+else') - Assert-CcodTrue ($readyBranch.Groups[1].Value-cmatch'Prompt-CcodRestart\.ps1' -and $failedBranch.Groups[1].Value-cnotmatch'Prompt-CcodRestart\.ps1') 'only strict Ready exit zero reaches the restart prompt' + $readyBranch=[regex]::Match($installerScript,'(?s)if ValidationResultCode = 0 then\s*begin(.*?)\s+end;') + Assert-CcodTrue ($readyBranch.Groups[1].Value-cmatch'Prompt-CcodRestart\.ps1') 'only strict Ready exit zero reaches the restart prompt' } # Production mutation caught: compiling a script with an unrecognized built-in identifier, or leaving the bounded validator route uncompiled. @@ -2080,7 +2348,16 @@ $results += Invoke-CcodTest 'activation terminal validator enforces the complete $reparseTarget=Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-validator-target-'+[guid]::NewGuid().ToString('N')) try{ [IO.Directory]::CreateDirectory((Join-Path $root 'state'))|Out-Null - $activationId='77777777-6666-5555-4444-333333333333';$receiptPath=Join-Path $root 'state\post-install-activation.json';$stderrPath=Join-Path $root 'validator.err' + $activationId='77777777-6666-5555-4444-333333333333';$receiptPath=Join-Path $root 'state\post-install-activation.json' + $activationScript=Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1';$powershellExecutable=Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $invokeValidator={param([string]$ErrorPath) + $outputPath="$ErrorPath.stdout" + $argumentLine='-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -AppRoot "{1}" -InstallRoot "{2}" -ActivationId "{3}" -ValidateReceiptOnly' -f $activationScript,$repositoryRoot,$root,$activationId + $process=Start-Process -FilePath $powershellExecutable -ArgumentList $argumentLine -WindowStyle Hidden -RedirectStandardOutput $outputPath -RedirectStandardError $ErrorPath -Wait -PassThru + try{$exitCode=[int]$process.ExitCode}finally{$process.Dispose()} + $output=@();if((Test-Path -LiteralPath $outputPath)-and (Get-Item -LiteralPath $outputPath).Length-gt0){$output=@(Get-Content -LiteralPath $outputPath)} + [pscustomobject]@{ExitCode=$exitCode;Stdout=$output} + } $valid=[ordered]@{schemaVersion=1;activationId=$activationId;phase='Ready';runtimeId='runtime-new';previousRuntimeId='runtime-old';startedAtUtc='2030-02-03T04:05:06.0000000Z';updatedAtUtc='2030-02-03T04:05:07.0000000Z';ready=$true;errorCode=$null} $cases=@( [pscustomobject]@{Name='Ready';Exit=0;Mutate={param($r)}}, @@ -2096,27 +2373,27 @@ $results += Invoke-CcodTest 'activation terminal validator enforces the complete foreach($case in $cases){ $receipt=[ordered]@{};foreach($key in $valid.Keys){$receipt[$key]=$valid[$key]};&$case.Mutate $receipt [IO.File]::WriteAllText($receiptPath,($receipt|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) - if(Test-Path -LiteralPath $stderrPath){Remove-Item -LiteralPath $stderrPath -Force} - $stdout=@(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $repositoryRoot -InstallRoot $root -ActivationId $activationId -ValidateReceiptOnly 2>$stderrPath) - Assert-CcodEqual $case.Exit $LASTEXITCODE "$($case.Name) maps to the strict validator exit contract" - Assert-CcodEqual 0 $stdout.Count "$($case.Name) emits no receipt data to stdout" + $stderrPath=Join-Path $root (('validator-{0}.err' -f ($case.Name-replace'[^A-Za-z0-9]','-'))) + $invocation=&$invokeValidator $stderrPath + Assert-CcodEqual $case.Exit $invocation.ExitCode "$($case.Name) maps to the strict validator exit contract" + Assert-CcodEqual 0 $invocation.Stdout.Count "$($case.Name) emits no receipt data to stdout" if($case.Exit-ne0-and$case.Exit-ne2){Assert-CcodTrue ((Get-Content -LiteralPath $stderrPath -Raw)-cmatch'CCOD_ACTIVATION_RECEIPT_') "$($case.Name) retains a bounded support code on stderr"} } [IO.File]::WriteAllText($receiptPath,'{"schemaVersion":1,"activationId":"77777777-6666-5555-4444-333333333333","phase":"Ready","runtimeId":"runtime-new"',[Text.UTF8Encoding]::new($false)) - $stdout=@(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $repositoryRoot -InstallRoot $root -ActivationId $activationId -ValidateReceiptOnly 2>$stderrPath) - Assert-CcodEqual 3 $LASTEXITCODE 'truncated Ready JSON is rejected by the executable validator' - Assert-CcodEqual 0 $stdout.Count 'truncated Ready JSON emits no receipt data to stdout' + $stderrPath=Join-Path $root 'validator-truncated.err';$invocation=&$invokeValidator $stderrPath + Assert-CcodEqual 3 $invocation.ExitCode 'truncated Ready JSON is rejected by the executable validator' + Assert-CcodEqual 0 $invocation.Stdout.Count 'truncated Ready JSON emits no receipt data to stdout' [IO.File]::WriteAllText($receiptPath,('{'+'"padding":"'+('x'*17000)+'"}'),[Text.UTF8Encoding]::new($false)) - $stdout=@(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $repositoryRoot -InstallRoot $root -ActivationId $activationId -ValidateReceiptOnly 2>$stderrPath) - Assert-CcodEqual 3 $LASTEXITCODE 'oversized receipt is refused before JSON parsing' - Assert-CcodEqual 0 $stdout.Count 'oversized receipt emits no data to stdout' + $stderrPath=Join-Path $root 'validator-oversized.err';$invocation=&$invokeValidator $stderrPath + Assert-CcodEqual 3 $invocation.ExitCode 'oversized receipt is refused before JSON parsing' + Assert-CcodEqual 0 $invocation.Stdout.Count 'oversized receipt emits no data to stdout' Remove-Item -LiteralPath (Join-Path $root 'state') -Recurse -Force [IO.Directory]::CreateDirectory($reparseTarget)|Out-Null [IO.File]::WriteAllText((Join-Path $reparseTarget 'post-install-activation.json'),($valid|ConvertTo-Json -Compress),[Text.UTF8Encoding]::new($false)) New-Item -ItemType Junction -Path (Join-Path $root 'state') -Target $reparseTarget|Out-Null - $stdout=@(& (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -AppRoot $repositoryRoot -InstallRoot $root -ActivationId $activationId -ValidateReceiptOnly 2>$stderrPath) - Assert-CcodEqual 3 $LASTEXITCODE 'receipt beneath a reparse-point parent is refused before parsing' - Assert-CcodEqual 0 $stdout.Count 'reparse-parent refusal emits no receipt data to stdout' + $stderrPath=Join-Path $root 'validator-reparse.err';$invocation=&$invokeValidator $stderrPath + Assert-CcodEqual 3 $invocation.ExitCode 'receipt beneath a reparse-point parent is refused before parsing' + Assert-CcodEqual 0 $invocation.Stdout.Count 'reparse-parent refusal emits no receipt data to stdout' Assert-CcodTrue ((Get-Content -LiteralPath $stderrPath -Raw)-cmatch'CCOD_ACTIVATION_RECEIPT_') 'reparse-parent refusal retains a bounded receipt support code' }finally{ $statePath=Join-Path $root 'state' @@ -2345,16 +2622,16 @@ $results += Invoke-CcodTest 'README and release workflow publish current portabl Assert-CcodTrue ($readmeChinese -cmatch '\A(?s:
.*?

CodexRemote-fix

)') 'Chinese README uses the centered public product heading' $quickStart = [regex]::Match($readme, '(?ms)^## Quick start[^\r\n]*\r?\n(.*?)(?=^## )').Groups[1].Value - Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.9-setup\.exe') 'English Quick Start names the setup installer' - Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.9-windows-x64\.zip') 'English Quick Start names the exact portable artifact' + Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.21-setup\.exe') 'English Quick Start names the setup installer' + Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.21-windows-x64\.zip') 'English Quick Start names the exact portable artifact' Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix\.exe') 'English Quick Start names the portable double-click entrypoint' Assert-CcodTrue ($quickStart -cmatch '\.sha256\.txt') 'English Quick Start names the checksum artifact' $quickStartChineseMatch = [regex]::Match($readmeChinese, '(?ms)^## [^\r\n]+\r?\n(?:\r?\n)?(?=1\.[^\r\n]*\[Releases\])(.*?)(?=^## |\z)') Assert-CcodTrue $quickStartChineseMatch.Success 'Chinese README exposes a Quick Start section' $quickStartChinese = $quickStartChineseMatch.Groups[1].Value - Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.9-setup\.exe') 'Chinese Quick Start names the setup installer' - Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.9-windows-x64\.zip') 'Chinese Quick Start names the exact portable artifact' + Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.21-setup\.exe') 'Chinese Quick Start names the setup installer' + Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.21-windows-x64\.zip') 'Chinese Quick Start names the exact portable artifact' Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix\.exe') 'Chinese Quick Start names the portable double-click entrypoint' Assert-CcodTrue ($quickStartChinese -cmatch '\.sha256\.txt') 'Chinese Quick Start names the checksum artifact' diff --git a/tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 b/tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 index b3ae0dc..ef0e4ed 100644 --- a/tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 +++ b/tests/persistence/InstalledLifecycleHarness.SelfTest.ps1 @@ -30,18 +30,36 @@ function New-CcodHarnessCandidate { } function New-CcodHarnessFacts { - param([string]$Version = '2.5.0') + param( + [string]$Version = '2.5.0', + [string]$RuntimeId = 'runtime-1', + [int]$CodexPid = 102, + [string]$CodexCreationTimeUtc = '2026-08-24T00:00:02.0000000Z', + [string]$StatusRuntimeId = $RuntimeId, + [int]$StatusCodexPid = $CodexPid, + [string]$StatusCodexCreationTimeUtc = $CodexCreationTimeUtc, + [string]$ReceiptPhase = 'Completed' + ) return [pscustomobject][ordered]@{ appPresent = $true - activeRuntimeId = 'runtime-1' + activeRuntimeId = $RuntimeId activeGeneration = [UInt64]1 runtimeManifestSha256 = ('a' * 64) supervisor = @([pscustomobject]@{ pid = 100; creationTimeUtc = '2026-08-24T00:00:00.0000000Z' }) trayHost = @([pscustomobject]@{ pid = 101; creationTimeUtc = '2026-08-24T00:00:01.0000000Z' }) - codex = @([pscustomobject]@{ pid = 102; creationTimeUtc = '2026-08-24T00:00:02.0000000Z' }) + codex = @([pscustomobject]@{ pid = $CodexPid; creationTimeUtc = $CodexCreationTimeUtc }) taskState = 'Ready' statusPhase = 'Active' - transitionStage = 'Completed' + statusRuntimeId = $StatusRuntimeId + statusCodex = [pscustomobject][ordered]@{ pid = $StatusCodexPid; creationTimeUtc = $StatusCodexCreationTimeUtc } + transitionStage = 'Idle' + lifecycleReceipt = [pscustomobject][ordered]@{ + kind = 'RestartAndRepair' + origin = 'Installer' + runtimeId = $RuntimeId + runtimeGeneration = [UInt64]1 + phase = $ReceiptPhase + } aboutVersion = $Version deviceKeyPresent = $true deviceKeySha256 = ('b' * 64) @@ -53,6 +71,139 @@ function New-CcodHarnessFacts { } } +function Write-CcodHarnessJson { + param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)]$Value) + $null = [IO.Directory]::CreateDirectory((Split-Path $Path -Parent)) + [IO.File]::WriteAllText($Path, ($Value | ConvertTo-Json -Depth 16 -Compress), [Text.UTF8Encoding]::new($false)) +} + +function New-CcodHarnessLifecycleReceipt { + param( + [Parameter(Mandatory)][string]$RuntimeId, + [Parameter(Mandatory)][UInt64]$RuntimeGeneration, + [Parameter(Mandatory)][string]$Phase, + [string]$TransactionId = '11111111-2222-3333-4444-555555555555', + [string]$UpdatedAtUtc = '2026-08-24T00:00:05.0000000Z' + ) + return [ordered]@{ + schemaVersion = 1 + transactionId = $TransactionId + kind = 'RestartAndRepair' + origin = 'Installer' + runtimeId = $RuntimeId + runtimeGeneration = $RuntimeGeneration + leaseEpoch = [UInt64]9 + ownerIdentity = [ordered]@{ pid = 700; creationTimeUtc = '2026-08-24T00:00:00.0000000Z' } + logonIdentity = [ordered]@{ authenticationId = '00000000:000003E7'; userSid = 'S-1-5-21-1-2-3-1001'; sessionId = 2 } + phase = $Phase + createdAtUtc = '2026-08-24T00:00:01.0000000Z' + updatedAtUtc = $UpdatedAtUtc + launchRequestedAtUtc = '2026-08-24T00:00:02.0000000Z' + manualLaunchExpiresAtUtc = $null + automaticLaunchAttempts = 1 + error = if ($Phase -ceq 'Completed') { $null } else { 'CCOD_CLOSE_FAILED' } + } +} + +function New-CcodHarnessInstalledStateFixture { + param( + [Parameter(Mandatory)][string]$Root, + [Parameter(Mandatory)][string]$ActiveRuntimeId, + [Parameter(Mandatory)][UInt64]$ActiveGeneration, + [Parameter(Mandatory)][string]$StatusRuntimeId, + [Parameter(Mandatory)][int]$StatusCodexPid, + [Parameter(Mandatory)][string]$StatusCodexCreationTimeUtc, + [Parameter(Mandatory)][string]$ReceiptPhase + ) + $active = [ordered]@{ + schemaVersion = 2 + activeRuntime = $ActiveRuntimeId + previousRuntime = '2.5.19-old' + generation = $ActiveGeneration + updatedAtUtc = '2026-08-24T00:00:03.0000000Z' + } + $status = [ordered]@{ + schemaVersion = 1 + session = [ordered]@{ + supervisorPid = 700 + supervisorCreationTimeUtc = '2026-08-24T00:00:00.0000000Z' + sessionId = 'session-1' + runtimeId = $StatusRuntimeId + sessionState = 'Active' + codex = [ordered]@{ + pid = $StatusCodexPid + creationTimeUtc = $StatusCodexCreationTimeUtc + packageFullName = 'OpenAI.Codex_1.0.0.0_x64__test' + packageVersion = '1.0.0.0' + appAsarSha256 = ('c' * 64) + mainPort = 41001 + rendererPort = 41002 + mainProbe = 'Closed' + rendererProbe = 'BridgeValid' + } + } + } + $transition = [ordered]@{ schemaVersion = 1; activeTransaction = $null } + $receipt = New-CcodHarnessLifecycleReceipt -RuntimeId $ActiveRuntimeId -RuntimeGeneration $ActiveGeneration -Phase $ReceiptPhase + Write-CcodHarnessJson -Path (Join-Path $Root 'active.json') -Value $active + $manifestPath = Join-Path (Join-Path (Join-Path $Root 'runtime') $ActiveRuntimeId) 'manifest.json' + Write-CcodHarnessJson -Path $manifestPath -Value ([ordered]@{ schemaVersion = 1; runtimeId = $ActiveRuntimeId }) + Write-CcodHarnessJson -Path (Join-Path $Root 'state\status.json') -Value $status + Write-CcodHarnessJson -Path (Join-Path $Root 'state\transition.json') -Value $transition + Write-CcodHarnessJson -Path (Join-Path $Root ("state\lifecycle\receipts\{0}.json" -f $receipt.transactionId)) -Value $receipt +} + +function Set-CcodHarnessProcessFixture { + param([object[]]$ChatGPT, [object[]]$Codex = @()) + $script:CcodHarnessProcessFixture = [pscustomobject]@{ ChatGPT = @($ChatGPT); Codex = @($Codex) } + function global:Get-CimInstance { + param($ClassName, $Filter, $ErrorAction) + if ([string]$Filter -cmatch "Name = 'ChatGPT.exe'") { return @($script:CcodHarnessProcessFixture.ChatGPT) } + if ([string]$Filter -cmatch "Name = 'Codex.exe'") { return @($script:CcodHarnessProcessFixture.Codex) } + return @() + } + function global:Get-ScheduledTask { + param($ErrorAction) + return @([pscustomobject]@{ TaskName = 'Codex Control Other Devices Supervisor'; State = 'Ready' }) + } +} + +function Clear-CcodHarnessProcessFixture { + Remove-Item -LiteralPath Function:\Get-CimInstance -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath Function:\Get-ScheduledTask -Force -ErrorAction SilentlyContinue + $script:CcodHarnessProcessFixture = $null +} + +function New-CcodHarnessCimProcess { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][int]$ProcessId, + [Parameter(Mandatory)][string]$CreationTimeUtc, + [Parameter(Mandatory)][AllowNull()][AllowEmptyString()][string]$CommandLine, + [int]$ParentProcessId = 0 + ) + $created = [datetime]::ParseExact($CreationTimeUtc, 'o', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind) + return [pscustomobject][ordered]@{ + Name = $Name + ProcessId = $ProcessId + ParentProcessId = $ParentProcessId + CreationDate = [Management.ManagementDateTimeConverter]::ToDmtfDateTime($created) + CommandLine = $CommandLine + ExecutablePath = "C:\Program Files\WindowsApps\OpenAI.Codex\$Name" + } +} + +function Invoke-CcodHarnessWithCapturedFacts { + param([Parameter(Mandatory)]$Context, [Parameter(Mandatory)]$BeforeFacts, [Parameter(Mandatory)]$AfterFacts) + $original = ${function:Get-CcodInstalledLifecycleFacts} + try { + Set-Item -LiteralPath Function:\Get-CcodInstalledLifecycleFacts -Value ({ param($InstallRoot) return $AfterFacts }.GetNewClosure()) + return Test-CcodInstalledLifecycleScenario -Context $Context -BeforeFacts $BeforeFacts -RunResult ([pscustomobject]@{ code = 'CCOD_INTEGRATION_OPERATOR_COMPLETED' }) + } finally { + Set-Item -LiteralPath Function:\Get-CcodInstalledLifecycleFacts -Value $original + } +} + function New-CcodHarnessAdapters { param( [Parameter(Mandatory)]$Fixture, @@ -137,6 +288,349 @@ Invoke-CcodTest 'installed lifecycle harness exposes the guarded library interfa Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodInstalledLifecycleIntegration -ErrorAction SilentlyContinue)) 'harness exports its invocation function when loaded as a library' } +# Production mutation caught: FreshRestart ignores stale status and a current-runtime CloseFailed receipt. +Invoke-CcodTest 'rejects FreshRestart when current active runtime has stale status and a CloseFailed installer receipt' { + . $harnessPath -Library + $before = New-CcodHarnessFacts -RuntimeId '2.5.19-old' -CodexPid 10664 + $after = New-CcodHarnessFacts -RuntimeId '2.5.21-new' -CodexPid 13948 ` + -StatusRuntimeId '2.5.19-old' -StatusCodexPid 10664 -ReceiptPhase 'CloseFailed' + $context = [pscustomobject]@{ scenario = 'FreshRestart'; expectedVersion = '2.5.0'; installRoot = 'C:\fixture' } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'stale status and CloseFailed terminal state leave FreshRestart unverified' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'rejection uses the stable observation code' +} + +# Production mutation caught: querying Codex.exe instead of the Windows app root ChatGPT.exe. +Invoke-CcodTest 'captures exactly the top-level ChatGPT root and excludes Electron type children' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-capture-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + $created = '2026-08-24T00:00:04.0000000Z' + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc $created -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"'), + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13949 -CreationTimeUtc '2026-08-24T00:00:04.1000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" --type=renderer' -ParentProcessId 13948), + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13950 -CreationTimeUtc '2026-08-24T00:00:04.2000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" --TYPE=utility' -ParentProcessId 13948), + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13953 -CreationTimeUtc '2026-08-24T00:00:04.5000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" "--type=renderer"' -ParentProcessId 13948) + ) + $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 1 $facts.codex.Count 'only one ordinary ChatGPT root is retained' + Assert-CcodEqual 13948 $facts.codex[0].pid 'the ordinary ChatGPT root identity is captured' + Assert-CcodEqual $created $facts.codex[0].creationTimeUtc 'the root creation time is canonical and retained' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'normalizes only supported CIM creation-time evidence for complete ChatGPT roots' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-cim-creation-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + $commandLine = '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"' + $local = [DateTime]::SpecifyKind([DateTime]'2030-02-03T12:05:06', [DateTimeKind]::Local) + $utc = [DateTime]::SpecifyKind([DateTime]'2030-02-03T12:05:06', [DateTimeKind]::Utc) + $dmtf = [Management.ManagementDateTimeConverter]::ToDmtfDateTime($utc) + $successCases = @( + [pscustomobject]@{ Name = 'Local DateTime'; Value = $local; Expected = $local.ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) }, + [pscustomobject]@{ Name = 'Utc DateTime'; Value = $utc; Expected = $utc.ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) }, + [pscustomobject]@{ Name = 'complete DMTF string'; Value = $dmtf; Expected = $utc.ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture) } + ) + foreach ($case in $successCases) { + $process = New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine $commandLine + $process.CreationDate = $case.Value + Set-CcodHarnessProcessFixture -ChatGPT @($process) + $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 1 $facts.codex.Count ("{0} keeps one root" -f $case.Name) + Assert-CcodEqual 13948 $facts.codex[0].pid ("{0} retains the root PID" -f $case.Name) + Assert-CcodEqual $case.Expected $facts.codex[0].creationTimeUtc ("{0} becomes canonical UTC" -f $case.Name) + } + foreach ($case in @( + [pscustomobject]@{ Name = 'null'; Value = $null }, + [pscustomobject]@{ Name = 'Unspecified DateTime'; Value = [DateTime]::SpecifyKind([DateTime]'2030-02-03T12:05:06', [DateTimeKind]::Unspecified) }, + [pscustomobject]@{ Name = 'malformed DMTF'; Value = '20300203120506.000000+08' }, + [pscustomobject]@{ Name = 'lexically complete invalid DMTF'; Value = '20301303120506.000000+000' }, + [pscustomobject]@{ Name = 'integer'; Value = 1 }, + [pscustomobject]@{ Name = 'arbitrary string'; Value = 'not-a-dmtf-time' } + )) { + $process = New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine $commandLine + $process.CreationDate = $case.Value + Set-CcodHarnessProcessFixture -ChatGPT @($process) + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_INTEGRATION_FACTS_INVALID' + } + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'fails closed when any enumerated ChatGPT process cannot be proven root or Electron child' { + . $harnessPath -Library + foreach($case in @('null command line','empty command line','unparsable command line','invalid creation time','zero PID','string PID','nonstring command line','missing command line')){ + $root=Join-Path $env:TEMP ("ccod-installed-indeterminate-$($case.Replace(' ','-'))-"+[guid]::NewGuid().ToString('N')) + try{ + $null=[IO.Directory]::CreateDirectory($root) + $valid=New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"' + $indeterminate=New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13949 -CreationTimeUtc '2026-08-24T00:00:04.1000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" --type=renderer' -ParentProcessId 13948 + switch($case){ + 'null command line'{$indeterminate.CommandLine=$null} + 'empty command line'{$indeterminate.CommandLine=''} + 'unparsable command line'{$indeterminate.CommandLine=' '} + 'invalid creation time'{$indeterminate.CreationDate='not-a-dmtf-time'} + 'zero PID'{$indeterminate.ProcessId=0} + 'string PID'{$indeterminate.ProcessId='13949'} + 'nonstring command line'{$indeterminate.CommandLine=42} + 'missing command line'{$indeterminate.PSObject.Properties.Remove('CommandLine')} + } + Set-CcodHarnessProcessFixture -ChatGPT @($valid,$indeterminate) + Assert-CcodThrows {Get-CcodInstalledLifecycleFacts -InstallRoot $root} 'CCOD_INTEGRATION_FACTS_INVALID' + }finally{ + Clear-CcodHarnessProcessFixture + if(Test-Path -LiteralPath $root){Remove-Item -LiteralPath $root -Recurse -Force} + } + } +} + +Invoke-CcodTest 'captures bounded current-runtime status transition and latest installer receipt facts from complete schemas' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-state-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.19-old' -StatusCodexPid 10664 -StatusCodexCreationTimeUtc '2026-08-24T00:00:02.0000000Z' -ReceiptPhase 'CloseFailed' + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"'), + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13949 -CreationTimeUtc '2026-08-24T00:00:04.1000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" --type=renderer' -ParentProcessId 13948) + ) + $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual '2.5.21-new' $facts.activeRuntimeId 'active schema 2 supplies the current runtime' + Assert-CcodEqual 8 ([UInt64]$facts.activeGeneration) 'active schema 2 supplies the current generation' + Assert-CcodEqual 'Active' $facts.statusPhase 'complete status schema 1 supplies the session state' + Assert-CcodEqual '2.5.19-old' $facts.statusRuntimeId 'stale status runtime remains visible for correlation rejection' + Assert-CcodEqual 10664 $facts.statusCodex.pid 'stale status Codex identity remains visible for correlation rejection' + Assert-CcodEqual 'Idle' $facts.transitionStage 'null active transaction is captured as idle' + Assert-CcodEqual 'RestartAndRepair' $facts.lifecycleReceipt.kind 'receipt kind is bounded and retained' + Assert-CcodEqual 'Installer' $facts.lifecycleReceipt.origin 'receipt origin is bounded and retained' + Assert-CcodEqual '2.5.21-new' $facts.lifecycleReceipt.runtimeId 'latest receipt is bound to the active runtime' + Assert-CcodEqual 'CloseFailed' $facts.lifecycleReceipt.phase 'terminal receipt failure remains visible' + $serialized = $facts | ConvertTo-Json -Depth 16 -Compress + foreach ($forbidden in @('00000000:000003E7','S-1-5-21-1-2-3-1001','OpenAI.Codex_1.0.0.0_x64__test','41001','--type=renderer','C:\Program Files\WindowsApps')) { + Assert-CcodTrue (-not $serialized.Contains($forbidden)) "captured facts omit private receipt status and command-line data: $forbidden" + } + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'selects only an unambiguous latest current-runtime installer restart receipt' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-receipts-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'CloseFailed' + $completed = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` + -TransactionId '22222222-3333-4444-5555-666666666666' -UpdatedAtUtc '2026-08-24T00:00:06.0000000Z' + Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $completed.transactionId)) -Value $completed + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"') + ) + $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 'Completed' $facts.lifecycleReceipt.phase 'strict receipt selection uses the unique latest updatedAtUtc' + + $ambiguous = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]8) -Phase 'CloseFailed' ` + -TransactionId '33333333-4444-5555-6666-777777777777' -UpdatedAtUtc '2026-08-24T00:00:06.0000000Z' + Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $ambiguous.transactionId)) -Value $ambiguous + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_INTEGRATION_FACTS_INVALID' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +# Production mutation caught: choosing latest by runtime before filtering the active generation. +Invoke-CcodTest 'ignores a newer wrong-generation receipt when selecting the current-generation terminal result' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-receipt-generation-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + $wrongGeneration = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]7) -Phase 'CloseFailed' ` + -TransactionId '44444444-5555-6666-7777-888888888888' -UpdatedAtUtc '2026-08-24T00:00:07.0000000Z' + Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $wrongGeneration.transactionId)) -Value $wrongGeneration + Set-CcodHarnessProcessFixture -ChatGPT @() + $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 8 ([UInt64]$facts.lifecycleReceipt.runtimeGeneration) 'receipt selection stays bound to the active generation' + Assert-CcodEqual 'Completed' $facts.lifecycleReceipt.phase 'newer stale-generation failure cannot mask current-generation completion' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'does not treat tied wrong-generation receipts as current-generation ambiguity' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-receipt-generation-tie-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + foreach ($record in @( + (New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]7) -Phase 'CloseFailed' -TransactionId '55555555-6666-7777-8888-999999999999' -UpdatedAtUtc '2026-08-24T00:00:07.0000000Z'), + (New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]7) -Phase 'RepairFailed' -TransactionId '66666666-7777-8888-9999-aaaaaaaaaaaa' -UpdatedAtUtc '2026-08-24T00:00:07.0000000Z') + )) { + Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $record.transactionId)) -Value $record + } + Set-CcodHarnessProcessFixture -ChatGPT @() + $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 'Completed' $facts.lifecycleReceipt.phase 'only current-generation timestamps participate in ambiguity detection' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'fails closed on receipt filename duplicate-property and nonterminal boundaries' { + . $harnessPath -Library + foreach ($case in @('filename','duplicate-json','nonterminal')) { + $root = Join-Path $env:TEMP ("ccod-installed-receipt-$case-" + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + switch ($case) { + 'filename' { + $receipt = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` + -TransactionId '77777777-8888-9999-aaaa-bbbbbbbbbbbb' + Write-CcodHarnessJson -Path (Join-Path $root 'state\lifecycle\receipts\88888888-9999-aaaa-bbbb-cccccccccccc.json') -Value $receipt + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_INTEGRATION_FACTS_INVALID' + } + 'duplicate-json' { + $path = Join-Path $root 'state\lifecycle\receipts\11111111-2222-3333-4444-555555555555.json' + $json = [IO.File]::ReadAllText($path).Replace('"schemaVersion":1', '"schemaVersion":1,"schemaVersion":1') + [IO.File]::WriteAllText($path, $json, [Text.UTF8Encoding]::new($false)) + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_STATE_MALFORMED' + } + 'nonterminal' { + $receipt = New-CcodHarnessLifecycleReceipt -RuntimeId '2.5.21-new' -RuntimeGeneration ([UInt64]8) -Phase 'Completed' ` + -TransactionId '99999999-aaaa-bbbb-cccc-dddddddddddd' + $receipt.phase = 'Requested' + Write-CcodHarnessJson -Path (Join-Path $root ("state\lifecycle\receipts\{0}.json" -f $receipt.transactionId)) -Value $receipt + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_INTEGRATION_FACTS_INVALID' + } + } + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } +} + +Invoke-CcodTest 'fails closed on malformed active status and transition schemas' { + . $harnessPath -Library + foreach ($case in @('active','status','transition')) { + $root = Join-Path $env:TEMP ("ccod-installed-state-$case-" + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + switch ($case) { + 'active' { + $path = Join-Path $root 'active.json' + $value = [IO.File]::ReadAllText($path) | ConvertFrom-Json + $value.generation = 'eight' + Write-CcodHarnessJson -Path $path -Value $value + } + 'status' { + $path = Join-Path $root 'state\status.json' + $value = [IO.File]::ReadAllText($path) | ConvertFrom-Json + $value.session.codex.rendererProbe = 'Open' + Write-CcodHarnessJson -Path $path -Value $value + } + 'transition' { + $path = Join-Path $root 'state\transition.json' + $value = [IO.File]::ReadAllText($path) | ConvertFrom-Json + $value.activeTransaction = [pscustomobject][ordered]@{} + Write-CcodHarnessJson -Path $path -Value $value + } + } + Assert-CcodThrows { Get-CcodInstalledLifecycleFacts -InstallRoot $root } 'CCOD_INTEGRATION_FACTS_INVALID' + } finally { + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } + } +} + +# Production mutation caught: treating a Codex.exe-only observation as installed app process evidence. +Invoke-CcodTest 'does not accept a Codex.exe-only fixture as Codex process evidence' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-capture-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + Set-CcodHarnessProcessFixture -ChatGPT @() -Codex @( + (New-CcodHarnessCimProcess -Name 'Codex.exe' -ProcessId 10664 -CreationTimeUtc '2026-08-24T00:00:02.0000000Z' -CommandLine '"C:\Program Files\Codex.exe"') + ) + $facts = Get-CcodInstalledLifecycleFacts -InstallRoot $root + Assert-CcodEqual 0 $facts.codex.Count 'Codex.exe is not the Windows app root and cannot satisfy process evidence' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'verifies FreshRestart only with one correlated ChatGPT root and Completed current-runtime installer receipt' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-success-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-new' -StatusCodexPid 13948 -StatusCodexCreationTimeUtc '2026-08-24T00:00:04.0000000Z' -ReceiptPhase 'Completed' + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13948 -CreationTimeUtc '2026-08-24T00:00:04.0000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"'), + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId 13949 -CreationTimeUtc '2026-08-24T00:00:04.1000000Z' -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe" --type=renderer' -ParentProcessId 13948) + ) + $after = Get-CcodInstalledLifecycleFacts -InstallRoot $root + $after.appPresent = $true + $after.aboutVersion = '2.5.0' + $after.deviceKeyPresent = $true + $after.deviceKeySha256 = ('b' * 64) + $before = New-CcodHarnessFacts -RuntimeId '2.5.19-old' -CodexPid 10664 + $context = [pscustomobject]@{ scenario = 'FreshRestart'; expectedVersion = '2.5.0'; installRoot = $root } + $verification = Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $true $verification.verified 'fully correlated current runtime verifies FreshRestart' + Assert-CcodEqual 'CCOD_INTEGRATION_VERIFIED' $verification.code 'success retains the stable verification code' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + +Invoke-CcodTest 'rejects FreshRestart when the fully correlated after root is unchanged from before' { + . $harnessPath -Library + $root = Join-Path $env:TEMP ('ccod-installed-unchanged-root-' + [guid]::NewGuid().ToString('N')) + try { + $null = [IO.Directory]::CreateDirectory($root) + $unchangedPid=13948;$unchangedCreation='2026-08-24T00:00:04.0000000Z' + New-CcodHarnessInstalledStateFixture -Root $root -ActiveRuntimeId '2.5.21-new' -ActiveGeneration ([UInt64]8) ` + -StatusRuntimeId '2.5.21-new' -StatusCodexPid $unchangedPid -StatusCodexCreationTimeUtc $unchangedCreation -ReceiptPhase 'Completed' + Set-CcodHarnessProcessFixture -ChatGPT @( + (New-CcodHarnessCimProcess -Name 'ChatGPT.exe' -ProcessId $unchangedPid -CreationTimeUtc $unchangedCreation -CommandLine '"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"') + ) + $after=Get-CcodInstalledLifecycleFacts -InstallRoot $root + $after.appPresent=$true;$after.aboutVersion='2.5.0';$after.deviceKeyPresent=$true;$after.deviceKeySha256=('b'*64) + $before=New-CcodHarnessFacts -RuntimeId '2.5.19-old' -CodexPid $unchangedPid -CodexCreationTimeUtc $unchangedCreation + $context=[pscustomobject]@{scenario='FreshRestart';expectedVersion='2.5.0';installRoot=$root} + $verification=Invoke-CcodHarnessWithCapturedFacts -Context $context -BeforeFacts $before -AfterFacts $after + Assert-CcodEqual $false $verification.verified 'FreshRestart requires the root identity to change even when status and receipt fully correlate' + Assert-CcodEqual 'CCOD_INTEGRATION_OBSERVATION_UNPROVEN' $verification.code 'unchanged root rejection uses the stable observation code' + } finally { + Clear-CcodHarnessProcessFixture + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } + } +} + Invoke-CcodTest 'requires mutation and Codex-restart consent before any adapter or filesystem action' { . $harnessPath -Library $fixture = New-CcodHarnessFixture diff --git a/tests/persistence/LifecycleCoordinator.SelfTest.ps1 b/tests/persistence/LifecycleCoordinator.SelfTest.ps1 index 17a4f16..afc6dd7 100644 --- a/tests/persistence/LifecycleCoordinator.SelfTest.ps1 +++ b/tests/persistence/LifecycleCoordinator.SelfTest.ps1 @@ -59,9 +59,21 @@ $results += Invoke-CcodTest 'phase table selects one exact lifecycle operation' } } +$results += Invoke-CcodTest 'a recovered close request cannot skip a still-running ordinary root' { + $request = New-CcodCoordinatorFixture -Phase CloseRequested -Kind RestartAndRepair + $step = Get-CcodLifecycleStep -Request $request -Observation Ordinary -NowUtc '2030-02-03T04:06:00.0000000Z' + Assert-CcodCoordinatorStep $step Operation $null Close $null $null 'recovered close request with ordinary root' +} + $results += Invoke-CcodTest 'repair is idempotent and safe exit never selects Apply' { $connected = Get-CcodLifecycleStep -Request (New-CcodCoordinatorFixture -Kind CheckAndRepair) -Observation RemoteVerified -NowUtc '2030-02-03T04:06:00.0000000Z' Assert-CcodCoordinatorStep $connected Completed CancelledBeforeClose None $null $null 'already connected repair' + $inspectRequest=New-CcodCoordinatorFixture -Kind CheckAndRepair + $inspect=Get-CcodLifecycleStep -Request $inspectRequest -Observation Special -NowUtc '2030-02-03T04:06:00.0000000Z' + Assert-CcodCoordinatorStep $inspect Operation $null Inspect $null $null 'unproven persisted special inspection' + $inspectResult=[pscustomobject][ordered]@{schemaVersion=1;transactionId=$inspectRequest.transactionId;action='Inspect';ok=$true;outcome='Inspected';observation='Special';error=$null} + $repair=Reduce-CcodLifecycleWorkerResult -Request $inspectRequest -Result $inspectResult -NowUtc '2030-02-03T04:06:01.0000000Z' + Assert-CcodEqual CloseRequested $repair.phase 'renderer-invalid strong Inspect advances to the normal repair close boundary' foreach ($observation in @('Ordinary','NoCodex')) { $safe = Get-CcodLifecycleStep -Request (New-CcodCoordinatorFixture -Kind SafeExit) -Observation $observation -NowUtc '2030-02-03T04:06:00.0000000Z' Assert-CcodCoordinatorStep $safe Completed CancelledBeforeClose None $null $null "safe exit $observation" diff --git a/tests/persistence/LifecycleWorker.SelfTest.ps1 b/tests/persistence/LifecycleWorker.SelfTest.ps1 index 9ac5bca..39166ee 100644 --- a/tests/persistence/LifecycleWorker.SelfTest.ps1 +++ b/tests/persistence/LifecycleWorker.SelfTest.ps1 @@ -55,6 +55,71 @@ function Get-CcodWorkerErrorCode($Run){ if($null -eq $Run.Result.error){return $ $results=@();$roots=[Collections.Generic.List[string]]::new() try { + $results += Invoke-CcodTest 'default worker adapter reads a strict JSON request in the production script scope' { + $h=New-CcodLifecycleWorkerHarness -Name production-adapter + $roots.Add($h.Install) + $adapters=Get-CcodLifecycleWorkerAdapters $null + $parsed=& $adapters.ReadRequest $h.RequestPath + Assert-CcodEqual $h.Request.transactionId $parsed.transactionId 'production ReadRequest preserves request correlation' + Assert-CcodEqual $h.Request.action $parsed.action 'production ReadRequest preserves the worker action' + } + + $results += Invoke-CcodTest 'default ordinary observation supplies an explicit empty status instead of null' { + $binding=$script:CcodLifecycleWorkerModuleBindings['ProcessControl'];$original=$binding['Wait-CcodVerifiedOrdinaryRoot'] + $capture=[pscustomobject]@{Status=$null} + try{ + $binding['Wait-CcodVerifiedOrdinaryRoot']={param($NotBefore,$Sid,$SessionId,$Status,$Timeout)$capture.Status=$Status;$null}.GetNewClosure() + $adapter=Get-CcodLifecycleWorkerAdapters $null + $request=New-CcodLifecycleWorkerFixtureRequest -Action ObserveOrdinary + $context=[pscustomobject]@{InstallRoot='C:\install';RuntimeRoot='C:\runtime'} + $null=& $adapter.ObserveOrdinary $request $context + Assert-CcodTrue ($null-ne$capture.Status) 'ordinary observation passes non-null status evidence' + Assert-CcodEqual 'schemaVersion,session' (($capture.Status.PSObject.Properties.Name)-join ',') 'ordinary observation status shape is exact' + Assert-CcodEqual 1 $capture.Status.schemaVersion 'ordinary observation status schema is one' + Assert-CcodEqual $null $capture.Status.session 'ordinary observation has no persisted special session' + }finally{$binding['Wait-CcodVerifiedOrdinaryRoot']=$original} + } + + $results += Invoke-CcodTest 'controller facade binds delegation before a dot-sourced Action parameter changes scope metadata' { + $root=[IO.Path]::GetFullPath((Join-Path ([IO.Path]::GetTempPath()) ('ccod-lifecycle-facade-'+[guid]::NewGuid().ToString('N'))));$roots.Add($root) + $runtime=Join-Path $root 'runtime\2.5.0-a';$controllerDirectory=Join-Path $runtime 'src\persistence';[IO.Directory]::CreateDirectory($controllerDirectory)|Out-Null + $controllerPath=Join-Path $controllerDirectory 'SessionController.ps1' + $fixture=@' +[CmdletBinding(DefaultParameterSetName='Manual')] +param( + [Parameter(ParameterSetName='Manual')] + [ValidateSet('Inspect','Close','Apply','RepairRenderer')] + [string]$Action +) +function Invoke-CcodSessionController { + param($Request,$Paths,[string]$ResultPath,[hashtable]$Adapters) + $delegated=& $Adapters.GetDelegatedOwnership + if($null-eq$delegated-or$delegated.marker-cne'delegated'){throw 'delegation was not bound'} + $expectedRestart=$Request.action-cne'Close' + if($Request.restartOrdinary-ne$expectedRestart){throw "restartOrdinary mismatch for $($Request.action)"} + $outcome=switch($Request.action){'Close'{'Closed'}'Apply'{'Activated'}'Inspect'{'Inspected'}default{throw 'unexpected action'}} + $safeState=switch($Request.action){'Close'{'Closed'}default{'SpecialValidated'}} + $result=[pscustomobject][ordered]@{ + schemaVersion=1;action=$Request.action;ok=$true;outcome=$outcome;safeState=$safeState;stage='Completed';transactionId=$Request.transactionId + package=$null;source=$null;special=$null;probes=$null;recovery=$null;error=$null;logFile=$null + } + [pscustomobject][ordered]@{Result=$result;ExitCode=0} +} +'@ + [IO.File]::WriteAllText($controllerPath,$fixture,[Text.UTF8Encoding]::new($false)) + $request=New-CcodLifecycleWorkerFixtureRequest -Action Close + $context=[pscustomobject][ordered]@{InstallRoot=$root;RuntimeRoot=$runtime} + $delegation=[pscustomobject][ordered]@{marker='delegated'} + $controller=Invoke-CcodLifecycleControllerFacade -Action Close -Request $request -Context $context -Delegation $delegation + Assert-CcodEqual Close $controller.action 'dot-sourced controller receives the Close action' + Assert-CcodEqual $true $controller.ok 'dot-sourced controller returns one successful result' + Assert-CcodEqual $request.transactionId $controller.transactionId 'dot-sourced controller result remains correlated' + $apply=Invoke-CcodLifecycleControllerFacade -Action Apply -Request $request -Context $context -Delegation $delegation + Assert-CcodEqual Apply $apply.action 'Apply uses the restart-enabled controller contract' + $verify=Invoke-CcodLifecycleControllerFacade -Action VerifyRemote -Request $request -Context $context -Delegation $delegation + Assert-CcodEqual Inspect $verify.action 'VerifyRemote maps to the restart-enabled Inspect contract' + } + $results += Invoke-CcodTest 'worker rejects runtime generation and exact request-shape mismatches before dispatch' { foreach ($case in @('Runtime','Generation','Extra','UnknownAction')) { $h=New-CcodLifecycleWorkerHarness -Name $case;$roots.Add($h.Install) diff --git a/tests/persistence/ProcessControl.SelfTest.ps1 b/tests/persistence/ProcessControl.SelfTest.ps1 index db8c199..20564a9 100644 --- a/tests/persistence/ProcessControl.SelfTest.ps1 +++ b/tests/persistence/ProcessControl.SelfTest.ps1 @@ -139,6 +139,14 @@ function New-CcodSpecialStatus { } } +function Assert-CcodIdentityObservationThrows { + param([Parameter(Mandatory)][scriptblock]$Action, [Parameter(Mandatory)][string]$Message) + + $threw = $false + try { & $Action | Out-Null } catch { $threw = $true } + Assert-CcodTrue $threw $Message +} + try { Invoke-CcodTest 'default process adapter recognizes exactly one Codex renderer target' { $module = Get-Module ProcessControl @@ -157,6 +165,96 @@ try { $probe = & $adapters.ProbeSpecial 100 9335 52359 Assert-CcodTrue (-not $probe.Valid) 'duplicate renderer targets fail closed' } + + Invoke-CcodTest 'normalizes native error 31 only when an independent PID probe proves exit' { + $module = Get-Module ProcessControl | Select-Object -First 1 + $failure31 = [Management.Automation.ErrorRecord]::new( + [ComponentModel.Win32Exception]::new(31), 'Win32Exception', [Management.Automation.ErrorCategory]::ReadError, 700) + $failure5 = [Management.Automation.ErrorRecord]::new( + [ComponentModel.Win32Exception]::new(5), 'Win32Exception', [Management.Automation.ErrorCategory]::PermissionDenied, 700) + $nonTerminatingFailure = & $module { + param($Failure) + Test-CcodNativeProcessQueryProvesExit -Failure $Failure -ProcessId 700 -GetProcess { param($Id) Write-Error 'probe failed' -ErrorAction Continue } + } $failure31 + Assert-CcodEqual $false $nonTerminatingFailure 'a nonterminating PID probe error is never absence proof' + $gone = & $module { + param($Failure) + Test-CcodNativeProcessQueryProvesExit -Failure $Failure -ProcessId 700 -GetProcess { param($Id) [pscustomobject][ordered]@{Outcome='Absent';Process=$null} } + } $failure31 + Assert-CcodEqual $true $gone 'error 31 plus an absent PID is a proven exit race' + $live = & $module { + param($Failure,$ProcessId) + Test-CcodNativeProcessQueryProvesExit -Failure $Failure -ProcessId $ProcessId -GetProcess { param($Id) [pscustomobject][ordered]@{Outcome='Found';Process=(Get-Process -Id $Id -ErrorAction Stop)} } + } $failure31 ([int]$PID) + Assert-CcodEqual $false $live 'error 31 never hides a live or PID-reused process' + $terminatingFailure = & $module { + param($Failure) + Test-CcodNativeProcessQueryProvesExit -Failure $Failure -ProcessId 700 -GetProcess { param($Id) throw 'probe failed' } + } $failure31 + Assert-CcodEqual $false $terminatingFailure 'a terminating PID probe error is never absence proof' + $malformed = & $module { + param($Failure) + Test-CcodNativeProcessQueryProvesExit -Failure $Failure -ProcessId 700 -GetProcess { param($Id) $null } + } $failure31 + Assert-CcodEqual $false $malformed 'an empty untyped PID probe result is not absence proof' + $wrongError = & $module { + param($Failure) + Test-CcodNativeProcessQueryProvesExit -Failure $Failure -ProcessId 700 -GetProcess { param($Id) [pscustomobject][ordered]@{Outcome='Absent';Process=$null} } + } $failure5 + Assert-CcodEqual $false $wrongError 'other native failures stay fail-closed even when the PID is absent' + } + + Invoke-CcodTest 'observes absence same identity and PID reuse through one minimal native PID and creation query' { + $cases = @( + @{ Name='absent at handle-open boundary'; Native=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null}; Want='Absent'; Creation=$null }, + @{ Name='same'; Native=[pscustomobject][ordered]@{Outcome='Found';Pid=10664;CreationTimeUtc='2026-08-26T04:49:22.1551350Z'}; Want='SameIdentity'; Creation='2026-08-26T04:49:22.1551350Z' }, + @{ Name='reused'; Native=[pscustomobject][ordered]@{Outcome='Found';Pid=10664;CreationTimeUtc='2026-08-27T04:49:22.1551350Z'}; Want='IdentityChanged'; Creation='2026-08-27T04:49:22.1551350Z' } + ) + foreach ($case in $cases) { + $fixture = [pscustomobject]@{ Value=$case.Native } + $calls = [pscustomobject]@{ Count=0 } + try { + $result = Get-CcodProcessIdentityObservation -ProcessId 10664 -ExpectedCreationTimeUtc '2026-08-26T04:49:22.1551350Z' -Adapters @{ + GetMinimalNativeProcess = { param($ProcessId) [void]($calls.Count++); $fixture.Value }.GetNewClosure() + GetNativeProcess = { throw 'rich path SID package query must not run for PID identity observation' } + } + } catch { throw "$($case.Name) observation failed: $($_.Exception.Message)" } + Assert-CcodEqual 'Outcome,Pid,CreationTimeUtc' (($result.PSObject.Properties.Name) -join ',') "$($case.Name) observation exposes only the ordered identity contract" + Assert-CcodEqual $case.Want $result.Outcome "$($case.Name) outcome is hand-derived" + Assert-CcodEqual 10664 $result.Pid "$($case.Name) preserves the requested PID" + Assert-CcodEqual $case.Creation $result.CreationTimeUtc "$($case.Name) preserves only a proven native creation time" + Assert-CcodEqual 1 $calls.Count "$($case.Name) invokes exactly one native identity query" + } + } + + Invoke-CcodTest 'rejects malformed failed or incidental minimal native identity observations' { + $same = [pscustomobject][ordered]@{Outcome='Found';Pid=10664;CreationTimeUtc='2026-08-26T04:49:22.1551350Z'} + $wrongPid = $same | ConvertTo-Json -Depth 4 | ConvertFrom-Json + $wrongPid.Pid = 10665 + $noncanonical = $same | ConvertTo-Json -Depth 4 | ConvertFrom-Json + $noncanonical.CreationTimeUtc = '2026-08-26T04:49:22Z' + $extra = $same | ConvertTo-Json -Depth 4 | ConvertFrom-Json + $extra | Add-Member Extra 'unexpected' + $missing = $same | ConvertTo-Json -Depth 4 | ConvertFrom-Json + $missing.PSObject.Properties.Remove('CreationTimeUtc') + foreach ($case in @( + @{ Name='access failure'; Query={ param($ProcessId) throw [UnauthorizedAccessException]::new('denied') } }, + @{ Name='post-handle GetProcessTimes error 87'; Query={ param($ProcessId) throw [ComponentModel.Win32Exception]::new(87) } }, + @{ Name='wrong PID'; Value=$wrongPid }, + @{ Name='noncanonical creation time'; Value=$noncanonical }, + @{ Name='extra native field'; Value=$extra }, + @{ Name='missing native field'; Value=$missing }, + @{ Name='success-stream diagnostic'; Query={ param($ProcessId) 'diagnostic'; $null } } + )) { + $query = if ($null -ne $case.Query) { $case.Query } else { $value=$case.Value; { param($ProcessId) $value }.GetNewClosure() } + Assert-CcodIdentityObservationThrows { + Get-CcodProcessIdentityObservation -ProcessId 10664 -ExpectedCreationTimeUtc '2026-08-26T04:49:22.1551350Z' -Adapters @{GetMinimalNativeProcess=$query;GetNativeProcess={throw 'rich query must not run'}} + } "$($case.Name) cannot be reported as native absence" + } + Assert-CcodIdentityObservationThrows { + Get-CcodProcessIdentityObservation -ProcessId 10664 -ExpectedCreationTimeUtc '2026-08-26T04:49:22Z' -Adapters @{GetMinimalNativeProcess={param($ProcessId)[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null}}} + } 'a noncanonical expected identity is rejected before absence can be reported' + } } catch { throw } @@ -255,6 +353,15 @@ try { Assert-CcodEqual 4596 $result.Snapshot.Pid 'the exact old root is retained' Assert-CcodEqual 6 $calls.Reads 'candidate identity is bracketed and reread before it can be closed' + $indeterminateAdapters = $oldAdapters.Clone() + $indeterminateAdapters.GetNativeProcess = { + param($ProcessId) + if ([int]$ProcessId -eq 4597) { return $null } + [pscustomobject]@{Pid=[int]$ProcessId;CreationTimeUtc='2026-08-02T00:00:00.0000000Z';SessionId=1;UserSid='S-1-5-21-test';Path=$oldPath;PackageFamilyName='OpenAI.Codex_2p2nqsd0c76g0'} + }.GetNewClosure() + $incompleteRoots = Get-CcodStalePackageRootResult -Package $current -ProcessIds @(4596,4597) -Adapters $indeterminateAdapters + Assert-CcodEqual 'Incomplete' $incompleteRoots.Outcome 'one visible root plus one unclassified enumerated PID cannot authorize stale-root adoption' + $gracefulCalls=[pscustomobject]@{Close=0;Dispose=0};$fakeStaleProcess=[pscustomobject]@{Id=4596} $oldAdapters.GetGracefulCloseProcess={param($ProcessId)$fakeStaleProcess}.GetNewClosure() $oldAdapters.GetGracefulCloseCreationTimeUtc={param($Process)'2026-08-02T00:00:01.0000000Z'} @@ -269,6 +376,35 @@ try { $staleTree=Get-CcodVerifiedStaleProcessTree -Root $result.Snapshot -Package $current -Adapters $oldAdapters Assert-CcodEqual '4596' (($staleTree|ForEach-Object Pid)-join ',') 'default stale-tree composition retains the exact raw root before any close' + $staleChildCommand='"' + $oldPath + '" --type=renderer' + $staleTreeReads=@{4596=0;4597=0} + $strictStaleTreeAdapters=$oldAdapters.Clone() + $strictStaleTreeAdapters.ListProcessIds={@(4596,4597)} + $strictStaleTreeAdapters.GetNativeProcess={ + param($ProcessId) + $pidValue=[int]$ProcessId;$staleTreeReads[$pidValue]++ + if($pidValue -eq 4597 -and $staleTreeReads[$pidValue] -gt 2){return $null} + [pscustomobject]@{Pid=$pidValue;CreationTimeUtc=if($pidValue -eq 4596){'2026-08-02T00:00:00.0000000Z'}else{'2026-08-02T00:00:01.0000000Z'};SessionId=1;UserSid='S-1-5-21-test';Path=$oldPath;PackageFamilyName='OpenAI.Codex_2p2nqsd0c76g0'} + }.GetNewClosure() + $strictStaleTreeAdapters.GetCimProcess={ + param($ProcessId) + [pscustomobject]@{ProcessId=[uint32]$ProcessId;ParentProcessId=if([int]$ProcessId -eq 4596){[uint32]0}else{[uint32]4596};CommandLine=if([int]$ProcessId -eq 4596){$oldCommand}else{$staleChildCommand}} + }.GetNewClosure() + $strictStaleTreeAdapters.ParseCommandLine={param($CommandLine)if($CommandLine -ceq $oldCommand){$oldArgs}else{@($oldPath,'--type=renderer')}}.GetNewClosure() + $indeterminateStaleTree=@(Get-CcodVerifiedStaleProcessTree -Root $result.Snapshot -Package $current -Adapters $strictStaleTreeAdapters) + Assert-CcodEqual 0 $indeterminateStaleTree.Count 'one stale root plus one indeterminate reachable child cannot authorize a complete close tree' + + $initialStaleTreeAdapters=$oldAdapters.Clone() + $initialStaleTreeAdapters.ListProcessIds={@(4596,4598)} + $initialStaleTreeAdapters.GetNativeProcess={ + param($ProcessId) + if([int]$ProcessId -eq 4598){return $null} + [pscustomobject]@{Pid=4596;CreationTimeUtc='2026-08-02T00:00:00.0000000Z';SessionId=1;UserSid='S-1-5-21-test';Path=$oldPath;PackageFamilyName='OpenAI.Codex_2p2nqsd0c76g0'} + }.GetNewClosure() + $initialStaleTreeAdapters.GetMinimalNativeProcess={param($ProcessId)[pscustomobject][ordered]@{Outcome='Found';Pid=[int]$ProcessId;CreationTimeUtc=if([int]$ProcessId -eq 4596){'2026-08-02T00:00:00.0000000Z'}else{'2026-08-02T00:00:01.0000000Z'}}} + $initialIndeterminateStaleTree=@(Get-CcodVerifiedStaleProcessTree -Root $result.Snapshot -Package $current -Adapters $initialStaleTreeAdapters) + Assert-CcodEqual 0 $initialIndeterminateStaleTree.Count 'an initially indeterminate live stale-scope PID rejects the complete stale close tree' + $currentRoot = New-CcodSnapshot -ProcessId 26700 -Path $current.ExecutablePath -Mode Unrelated -RendererPort 41003 -MainPort 41004 -CommandLine ('"' + $current.ExecutablePath + '" --remote-debugging-address=127.0.0.1 --remote-debugging-port=41003 --inspect=127.0.0.1:41004') $currentRoot.IsTopLevel = $true $currentResult = Get-CcodStalePackageRootResult -Package $current -Snapshots @($currentRoot) -Adapters @{ GetCurrentSessionId={1};GetCurrentUserSid={'S-1-5-21-test'};GetProcess = { param($ProcessId, $StatusEvidence) $currentRoot } } @@ -513,6 +649,7 @@ try { $calls = [pscustomobject]@{ Stop = 0 } $exited = Stop-CcodProcessIfMatch -Expected $expected -Adapters @{ GetProcess = { param($ProcessId) $null } + ObserveProcessIdentity = { param($ProcessId,$ExpectedCreationTimeUtc) [pscustomobject][ordered]@{Outcome='Absent';Pid=[int]$ProcessId;CreationTimeUtc=$null} } StopProcess = { $calls.Stop++; throw 'must not run' }.GetNewClosure() } Assert-CcodStopResultContract -Result $exited -Outcome SourceExited -Stopped $false -Snapshot $null -Message 'natural exit' @@ -524,6 +661,20 @@ try { } Assert-CcodStopResultContract -Result $changed -Outcome IdentityChanged -Stopped $false -Snapshot $reused -Message 'PID reuse' Assert-CcodEqual 0 $calls.Stop 'dangerous boundary is unreachable without an exact reread' + + foreach ($case in @( + @{Name='same identity after rich null';Observe={param($ProcessId,$ExpectedCreationTimeUtc)[pscustomobject][ordered]@{Outcome='SameIdentity';Pid=[int]$ProcessId;CreationTimeUtc=[string]$ExpectedCreationTimeUtc}}}, + @{Name='indeterminate observer';Observe={param($ProcessId,$ExpectedCreationTimeUtc)throw 'native creation query failed'}} + )) { + $observer=$case.Observe + $unproven = Stop-CcodProcessIfMatch -Expected $expected -Adapters @{ + GetProcess = { param($ProcessId,$StatusEvidence) $null } + ObserveProcessIdentity = $observer + StopProcess = { $calls.Stop++; throw 'must not run' }.GetNewClosure() + } + Assert-CcodStopResultContract -Result $unproven -Outcome StopUnconfirmed -Stopped $false -Snapshot $null -Message $case.Name + } + Assert-CcodEqual 0 $calls.Stop 'rich null never reaches the stop boundary without strict absence or reuse proof' } Invoke-CcodTest 'default graceful-close adapter rejects PID reuse on the same process object before signaling it' { @@ -598,8 +749,16 @@ try { $exitedReceipt = Stop-CcodProcessIfMatch -Expected $expected -Adapters @{ GetProcess = { param($ProcessId, $StatusEvidence) $expected }.GetNewClosure() StopProcess = { param($Snapshot, $TimeoutMilliseconds) [pscustomobject]@{ Outcome='ExitedBeforeStop'; StoppedByController=$false } } + ObserveProcessIdentity = { param($ProcessId,$ExpectedCreationTimeUtc) [pscustomobject][ordered]@{Outcome='Absent';Pid=[int]$ProcessId;CreationTimeUtc=$null} } } Assert-CcodStopResultContract -Result $exitedReceipt -Outcome SourceExited -Stopped $false -Snapshot $null -Message 'internal pre-stop exit' + + $sameAfterReceipt = Stop-CcodProcessIfMatch -Expected $expected -Adapters @{ + GetProcess = { param($ProcessId, $StatusEvidence) $expected }.GetNewClosure() + StopProcess = { param($Snapshot, $TimeoutMilliseconds) [pscustomobject]@{ Outcome='ExitedBeforeStop'; StoppedByController=$false } } + ObserveProcessIdentity = { param($ProcessId,$ExpectedCreationTimeUtc) [pscustomobject][ordered]@{Outcome='SameIdentity';Pid=[int]$ProcessId;CreationTimeUtc=[string]$ExpectedCreationTimeUtc} } + } + Assert-CcodStopResultContract -Result $sameAfterReceipt -Outcome StopUnconfirmed -Stopped $false -Snapshot $expected -Message 'post-handle exit receipt without strict exit proof' } Invoke-CcodTest 'distinguishes access denial and delayed exit' { @@ -657,7 +816,24 @@ try { Assert-CcodTrue ($reads.Count -ge 10) 'included tree identities are reread before return' } - Invoke-CcodTest 'rejects a child older than its exact current parent and drops mutated rereads' { + Invoke-CcodTest 'rejects an initially indeterminate live current-scope tree member but ignores proven foreign scope' { + $root=New-CcodSnapshot + foreach($case in @( + @{Name='current scope';UserSid='S-1-5-21-test';Want=0}, + @{Name='foreign scope';UserSid='S-1-5-21-other';Want=1} + )){ + $sid=[string]$case.UserSid + $tree=@(Get-CcodVerifiedProcessTree -Root $root -Adapters @{ + ListProcessIds={@(100,101)} + GetProcess={param($ProcessId,$StatusEvidence)if([int]$ProcessId -eq 100){$root}else{$null}}.GetNewClosure() + GetMinimalNativeProcess={param($ProcessId)[pscustomobject][ordered]@{Outcome='Found';Pid=[int]$ProcessId;CreationTimeUtc='2026-08-02T00:00:01.0000000Z'}} + GetNativeProcess={param($ProcessId)[pscustomobject][ordered]@{Pid=[int]$ProcessId;CreationTimeUtc='2026-08-02T00:00:01.0000000Z';SessionId=1;UserSid=$sid;Path='C:\Codex\ChatGPT.exe';PackageFamilyName='OpenAI.Codex_2p2nqsd0c76g0'}}.GetNewClosure() + }) + Assert-CcodEqual $case.Want $tree.Count "$($case.Name) indeterminate PID is gated only by exact current scope" + } + } + + Invoke-CcodTest 'rejects a child older than its exact current parent and rejects an indeterminate reachable reread' { $root = New-CcodSnapshot $reusedParent = New-CcodSnapshot -ProcessId 101 -CreationTimeUtc '2026-08-02T00:00:10.0000000Z' -ParentPid 100 -IsTopLevel $false -Mode Unrelated $staleChild = New-CcodSnapshot -ProcessId 102 -CreationTimeUtc '2026-08-02T00:00:05.0000000Z' -ParentPid 101 -IsTopLevel $false -Mode Unrelated @@ -681,8 +857,21 @@ try { $root }.GetNewClosure() }) - Assert-CcodEqual '100' (($mutationTree.Pid | Sort-Object) -join ',') 'identity mutation on final reread removes the node' + Assert-CcodEqual '' (($mutationTree.Pid | Sort-Object) -join ',') 'identity mutation on a reachable final reread rejects the complete tree' Assert-CcodTrue ($counts[103] -ge 2) 'included child is actually reread' + + $nullCounts = @{100=0;103=0} + $indeterminateTree = @(Get-CcodVerifiedProcessTree -Root $root -Adapters @{ + ListProcessIds = { @(100,103) } + GetProcess = { + param($ProcessId,$StatusEvidence) + $nullCounts[[int]$ProcessId]++ + if ([int]$ProcessId -eq 103 -and $nullCounts[103] -gt 1) { return $null } + if ([int]$ProcessId -eq 103) { return $stableChild } + $root + }.GetNewClosure() + }) + Assert-CcodEqual 0 $indeterminateTree.Count 'one verified root plus one indeterminate reachable child cannot authorize a complete close tree' } Invoke-CcodTest 'passes status evidence while rereading a Special tree root' { diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 index 3d4ec8f..2fc3b87 100644 --- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 +++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1 @@ -229,22 +229,30 @@ Invoke-CcodTest 'package scripts build provenance and workflows retain the relea Assert-CcodTrue ($release -cmatch '(?ms)^ publish:\r?\n needs: build\r?\n runs-on: windows-latest\r?\n permissions:\r?\n contents: write\s*$') 'only the publish job receives release-write permission' } -Invoke-CcodTest '2.5.9 documentation matches the portable release, Defender gate, and protected uninstall contract' { +$iss = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw; Assert-CcodTrue ($iss -match 'PortableArtifactDirectory' -and $iss -match 'CodexRemote.Portable.exe' -and $iss -match 'portable-launcher-provenance.json') 'Inno installer packages the portable launcher into the verified bin set'; +Invoke-CcodTest '2.5.21 documentation matches the stable portable release, Defender gate, and protected uninstall contract' { $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json - Assert-CcodEqual '2.5.9' ([string]$package.version) 'package metadata is the 2.5.9 release' + Assert-CcodEqual '2.5.21' ([string]$package.version) 'package metadata is the 2.5.21 release' $changelog = Get-Content -LiteralPath (Join-Path $repositoryRoot 'CHANGELOG.md') -Raw - $releaseSection = [regex]::Match($changelog, '(?ms)^## v2\.5\.9\s*\r?\n(?.*?)(?=^## |\z)') - Assert-CcodTrue $releaseSection.Success 'v2.5.9 release section exists' - Assert-CcodTrue ($releaseSection.Groups['body'].Value.Contains('Inno Setup installer executable')) 'v2.5.9 changelog records the upload release fix' + $releaseSection = [regex]::Match($changelog, '(?ms)^## v2\.5\.21\s*\r?\n(?.*?)(?=^## |\z)') + Assert-CcodTrue $releaseSection.Success 'v2.5.21 release section exists' + Assert-CcodTrue ($releaseSection.Groups['body'].Value -match '(?m)^### English\s*$') 'v2.5.21 changelog has concise English release notes' + Assert-CcodTrue ($releaseSection.Groups['body'].Value.Contains('stale-status recovery')) 'v2.5.21 changelog records stale-status repair' + Assert-CcodTrue ($releaseSection.Groups['body'].Value.Contains('safe lifecycle diagnostics')) 'v2.5.21 changelog records exact safe diagnostics' + Assert-CcodTrue (-not $releaseSection.Groups['body'].Value.Contains('installed Supervisor and TrayHost lifecycle evidence')) 'v2.5.21 changelog omits the unsupported Supervisor and TrayHost validation claim' $readme = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.md') -Raw $quickStart = [regex]::Match($readme, '(?ms)^## Quick start\s*\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value - Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.9-setup.exe')) 'English Quick Start names the setup installer' - Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.9-windows-x64.zip')) 'English Quick Start names the portable ZIP' + Assert-CcodTrue ($readme.Contains('v2.5.21 is the current stable release')) 'English README marks v2.5.21 stable without a version-by-version What''s new block' + Assert-CcodTrue (-not $readme.Contains("## What's new")) 'English README keeps release details off the home page' + Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.21-setup.exe')) 'English Quick Start names the setup installer' + Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.21-windows-x64.zip')) 'English Quick Start names the portable ZIP' Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix.exe')) 'English Quick Start names the portable double-click entrypoint' Assert-CcodTrue ($quickStart.Contains('Microsoft Defender')) 'English Quick Start documents the local Defender gate' $readmeZh = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.zh-CN.md') -Raw -Encoding UTF8 - Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.9-setup.exe')) 'Chinese Quick Start names the setup installer' - Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.9-windows-x64.zip')) 'Chinese Quick Start names the portable ZIP' + Assert-CcodTrue ($readmeZh -match 'v2\.5\.21 \u662F\u5F53\u524D\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.21 stable' + Assert-CcodTrue (-not $readmeZh.Contains("## What's new")) 'Chinese README keeps release details off the home page' + Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.21-setup.exe')) 'Chinese Quick Start names the setup installer' + Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.21-windows-x64.zip')) 'Chinese Quick Start names the portable ZIP' Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix.exe')) 'Chinese Quick Start names the portable double-click entrypoint' Assert-CcodTrue ($readmeZh.Contains('Microsoft Defender')) 'Chinese Quick Start documents the local Defender gate' $technical = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\TECHNICAL.md') -Raw diff --git a/tests/persistence/SessionEngine.SelfTest.ps1 b/tests/persistence/SessionEngine.SelfTest.ps1 index 2e840d7..ff0c1d6 100644 --- a/tests/persistence/SessionEngine.SelfTest.ps1 +++ b/tests/persistence/SessionEngine.SelfTest.ps1 @@ -274,6 +274,7 @@ function New-CcodEngineAdapters { StaticProbe={ param($NodeCandidates,$CheckerPath) $eventsValue.Add('StaticProbe'); $probeValue }.GetNewClosure() ListProcesses={ param($StatusEvidence) @($processValues) }.GetNewClosure() GetProcess={ param($Pid,$StatusEvidence) @($processValues | Where-Object { $_.Pid -eq $Pid } | Select-Object -First 1) }.GetNewClosure() + ObserveProcessIdentity={ param($ProcessId,$ExpectedCreationTimeUtc) [pscustomobject][ordered]@{Outcome='SameIdentity';Pid=[int]$ProcessId;CreationTimeUtc=[string]$ExpectedCreationTimeUtc} } ProcessMatch={ param($Expected,$Actual) $null -ne $Actual -and $Expected.Pid -eq $Actual.Pid -and $Expected.CreationTimeUtc -ceq $Actual.CreationTimeUtc -and $Expected.Mode -ceq $Actual.Mode } NewTransition={ param($Path,$Source,$Package,$RuntimeId,$RendererPort,$MainPort,$TransactionId) @@ -321,6 +322,17 @@ function New-CcodEngineAdapters { } } +function Set-CcodEngineAliveIdentityObserver { + param([Parameter(Mandatory)][hashtable]$Adapters,[Parameter(Mandatory)][hashtable]$Alive) + $aliveMap=$Alive + $Adapters.ObserveProcessIdentity={ + param($ProcessId,$ExpectedCreationTimeUtc) + if(-not $aliveMap.ContainsKey([int]$ProcessId)){return [pscustomobject][ordered]@{Outcome='Absent';Pid=[int]$ProcessId;CreationTimeUtc=$null}} + $creation=[string]$aliveMap[[int]$ProcessId].CreationTimeUtc + [pscustomobject][ordered]@{Outcome=if($creation -ceq $ExpectedCreationTimeUtc){'SameIdentity'}else{'IdentityChanged'};Pid=[int]$ProcessId;CreationTimeUtc=$creation} + }.GetNewClosure() +} + function Assert-CcodEngineResultContract($Result,[string]$TransactionId,[string]$Message) { Assert-CcodEqual 'schemaVersion,action,ok,outcome,safeState,stage,transactionId,package,source,special,probes,recovery,error,logFile' (($Result.PSObject.Properties.Name) -join ',') "$Message exact 14 fields" Assert-CcodEqual $TransactionId $Result.transactionId "$Message request correlation" @@ -460,7 +472,8 @@ try { Assert-CcodEqual 1 $messages.Count 'one core failure writes one diagnostic record' Assert-CcodTrue ($messages[0] -cnotmatch 'secret|hunter2|swordfish|command\.exe|[\r\n]') 'diagnostic record excludes raw path command secret and multiline text' $record=$messages[0]|ConvertFrom-Json - Assert-CcodEqual 'schemaVersion,timestampUtc,action,transactionId,stage,code' (($record.PSObject.Properties.Name)-join ',') 'diagnostic log uses the fixed allowlist' + Assert-CcodEqual 'schemaVersion,timestampUtc,action,transactionId,stage,code,reason' (($record.PSObject.Properties.Name)-join ',') 'diagnostic log uses the fixed allowlist' + Assert-CcodEqual 'Unclassified' $record.reason 'unknown adapter failure records only the allow-listed fallback reason' [IO.Directory]::CreateDirectory((Split-Path $paths.SessionLogPath -Parent))|Out-Null [IO.File]::WriteAllText($paths.SessionLogPath,('x'*(2MB+1)),[Text.UTF8Encoding]::new($false)) @@ -473,6 +486,34 @@ try { Assert-CcodEqual $false (Test-Path -LiteralPath ($paths.SessionLogPath+'.11')) 'rotation never creates an eleventh history generation' } + Invoke-CcodTest 'records only exact safe close failure reasons without exception text' { + $cases=@( + [pscustomobject]@{Message='Recorded Active root is missing, changed, or accompanied by another root';Reason='RecordedActiveMismatch'}, + [pscustomobject]@{Message='Recorded Active root cannot be safely replaced';Reason='RecordedActiveMismatch'}, + [pscustomobject]@{Message='Multiple current-package close roots are ambiguous';Reason='MultipleRoots'}, + [pscustomobject]@{Message='A status-less debug root lacks one valid distinct port pair';Reason='MissingPortPair'}, + [pscustomobject]@{Message='A replacement debug root lacks one valid distinct port pair';Reason='MissingPortPair'}, + [pscustomobject]@{Message='Requested close source is not the one verified current root';Reason='RequestedSourceMismatch'}, + [pscustomobject]@{Message='Current close target tree is not exact and verified';Reason='EmptyVerifiedTree'}, + [pscustomobject]@{Message="unknown C:\\private\\target`n--token hunter2";Reason='Unclassified'} + ) + foreach($case in $cases){ + $messages=[Collections.Generic.List[string]]::new() + $record=[Management.Automation.ErrorRecord]::new([InvalidOperationException]::new($case.Message),'CCOD_CLOSE_UNPROVEN',[Management.Automation.ErrorCategory]::InvalidData,$null) + $result=Invoke-CcodApplySession -Request (New-CcodEngineRequest -Action Apply -Source (New-CcodEngineSnapshot) -TransactionId ([guid]::NewGuid().ToString('D'))) -Paths $paths -Adapters @{ + ReadState={throw $record}.GetNewClosure() + WriteLog={param($Path,$Message)$messages.Add($Message)}.GetNewClosure() + } + Assert-CcodEqual 'CCOD_CLOSE_UNPROVEN' $result.error.code "$($case.Reason) retains the stable failure code" + Assert-CcodEqual 1 $messages.Count "$($case.Reason) writes one diagnostic record" + $diagnostic=$messages[0]|ConvertFrom-Json + Assert-CcodEqual 'schemaVersion,timestampUtc,action,transactionId,stage,code,reason' (($diagnostic.PSObject.Properties.Name)-join ',') "$($case.Reason) uses only the diagnostic allowlist" + Assert-CcodEqual 2 $diagnostic.schemaVersion "$($case.Reason) uses diagnostic schema 2" + Assert-CcodEqual $case.Reason $diagnostic.reason "$($case.Reason) maps only the exact stable predicate" + Assert-CcodTrue ($messages[0] -cnotmatch 'private|target|hunter2|[\r\n]') "$($case.Reason) never logs raw exception text" + } + } + Invoke-CcodTest 'maps every unrecognized prefixed error ID to the stable generic code' { foreach($case in @( [pscustomobject]@{Name='token';Id='CCOD_LEAK_PRIVATE_TOKEN';Marker='LEAK_PRIVATE_TOKEN'}, @@ -1145,6 +1186,7 @@ try { $adapters=New-CcodEngineAdapters -State (New-CcodEngineState -Status $status) -Processes @($special) -Events $events -Counters $counters $adapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure() $adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure() + $adapters.ObserveProcessIdentity={param($ProcessId,$ExpectedCreationTimeUtc)if($alive.ContainsKey([int]$ProcessId)){[pscustomobject][ordered]@{Outcome='SameIdentity';Pid=[int]$ProcessId;CreationTimeUtc=[string]$ExpectedCreationTimeUtc}}else{[pscustomobject][ordered]@{Outcome='Absent';Pid=[int]$ProcessId;CreationTimeUtc=$null}}}.GetNewClosure() $adapters.GetTree={param($Root,$StatusEvidence)@($special,$child)}.GetNewClosure() $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$events.Add("Stop:$($Expected.Pid)");$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() $request=New-CcodEngineRequest -SchemaVersion 2 -Action Close -Source $special -RestartOrdinary $false @@ -1154,6 +1196,148 @@ try { Assert-CcodEqual 0 $counters.OrdinaryStart 'Close never requests ordinary activation' } + Invoke-CcodTest 'proven stale status adopts and closes the unique current ordinary root' { + $oldStatus = New-CcodEngineActiveStatus -RuntimeId '2.5.19-old' + $oldStatus.session.codex.pid = 10664 + $oldStatus.session.codex.creationTimeUtc = '2026-08-26T04:49:22.1551350Z' + $oldStatus.session.codex.packageFullName = 'OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0' + $oldStatus.session.codex.packageVersion = '26.820.7780.0' + $replacement = New-CcodEngineSnapshot -Pid 13948 -CreationTimeUtc '2026-08-27T07:00:17.0000000Z' -Mode Ordinary + $probe = New-CcodEngineProbe + $probe.PackageFullName = 'OpenAI.Codex_26.820.9563.0_x64__2p2nqsd0c76g0' + $probe.PackageVersion = '26.820.9563.0' + $events = [Collections.Generic.List[string]]::new() + $alive = @{13948=$replacement} + $adapters = New-CcodEngineAdapters -State (New-CcodEngineState -Status $oldStatus) -Probe $probe -Processes @($replacement) -Events $events + $adapters.StaticProbe = { param($NodeCandidates,$CheckerPath) $probe }.GetNewClosure() + $adapters.ListProcesses = { param($StatusEvidence) @($alive.Values) }.GetNewClosure() + $adapters.GetProcess = { param($ProcessId,$StatusEvidence) if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null} }.GetNewClosure() + $adapters.ObserveProcessIdentity = { param($ProcessId,$ExpectedCreationTimeUtc) [pscustomobject][ordered]@{Outcome='Absent';Pid=[int]$ProcessId;CreationTimeUtc=$null} } + $adapters.GetTree = { param($Root,$StatusEvidence) @($Root) } + $adapters.StopProcess = { param($Expected,$StatusEvidence,$TimeoutMilliseconds) $events.Add("Stop:$($Expected.Pid)");$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected} }.GetNewClosure() + + $result = Invoke-CcodCloseSession -Request (New-CcodEngineRequest -SchemaVersion 2 -Action Close -RestartOrdinary $false) -Paths $paths -Adapters $adapters + Assert-CcodEqual 'Closed' $result.outcome "proven stale status adopts and closes the unique current ordinary root error=$($result.error.code):$($result.error.message)" + Assert-CcodEqual 'IntentWritten,CloseRequested,Stop:13948,Closed,WriteStatus,Complete:Closed' ($events -join ',') 'journal precedes stop and stale status is cleared only at durable completion' + } + + Invoke-CcodTest 'stale status replacement remains fail-closed without one strict replacement root and tree' { + $oldStatus = New-CcodEngineActiveStatus -RuntimeId '2.5.19-old' + $oldStatus.session.codex.pid = 10664 + $oldStatus.session.codex.creationTimeUtc = '2026-08-26T04:49:22.1551350Z' + $oldStatus.session.codex.packageFullName = 'OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0' + $oldStatus.session.codex.packageVersion = '26.820.7780.0' + $probe = New-CcodEngineProbe + $probe.PackageFullName = 'OpenAI.Codex_26.820.9563.0_x64__2p2nqsd0c76g0' + $probe.PackageVersion = '26.820.9563.0' + $ordinary = New-CcodEngineSnapshot -Pid 13948 -CreationTimeUtc '2026-08-27T07:00:17.0000000Z' -Mode Ordinary + $second = New-CcodEngineSnapshot -Pid 13949 -CreationTimeUtc '2026-08-27T07:00:18.0000000Z' -Mode Ordinary + $missingPort = New-CcodEngineSnapshot -Pid 13950 -CreationTimeUtc '2026-08-27T07:00:19.0000000Z' -Mode Unrelated -RendererPort 42001 + $equalPorts = New-CcodEngineSnapshot -Pid 13951 -CreationTimeUtc '2026-08-27T07:00:20.0000000Z' -Mode Unrelated -RendererPort 42001 -MainPort 42001 + $cases = @( + @{Name='same native identity';Processes=@($ordinary);Observation=[pscustomobject][ordered]@{Outcome='SameIdentity';Pid=10664;CreationTimeUtc='2026-08-26T04:49:22.1551350Z'}}, + @{Name='native query exception';Processes=@($ordinary);Throw=$true}, + @{Name='invalid native receipt';Processes=@($ordinary);Observation=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null;Extra=$true}}, + @{Name='zero roots';Processes=@();Observation=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null}}, + @{Name='two top-level roots';Processes=@($ordinary,$second);Observation=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null}}, + @{Name='one visible root plus one indeterminate root';Processes=@($ordinary);Observation=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null};EnumerationIncomplete=$true}, + @{Name='debug root missing one port';Processes=@($missingPort);Observation=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null}}, + @{Name='debug root equal ports';Processes=@($equalPorts);Observation=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null}}, + @{Name='empty verified tree';Processes=@($ordinary);Observation=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null};EmptyTree=$true} + ) + foreach($case in $cases){ + $events=[Collections.Generic.List[string]]::new();$processes=@($case.Processes);$alive=@{};foreach($process in $processes){$alive[[int]$process.Pid]=$process} + $adapters=New-CcodEngineAdapters -State (New-CcodEngineState -Status $oldStatus) -Probe $probe -Processes $processes -Events $events + $adapters.StaticProbe={param($NodeCandidates,$CheckerPath)$probe}.GetNewClosure() + $adapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure() + if($case.ContainsKey('EnumerationIncomplete')){ + $visible=@($alive.Values) + $adapters.ListProcessResult={param($StatusEvidence)[pscustomobject][ordered]@{Outcome='Incomplete';Snapshots=@($visible)}}.GetNewClosure() + } + $adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure() + if($case.Throw){$adapters.ObserveProcessIdentity={param($ProcessId,$ExpectedCreationTimeUtc)throw 'native query failed'}}else{$observation=$case.Observation;$adapters.ObserveProcessIdentity={param($ProcessId,$ExpectedCreationTimeUtc)$observation}.GetNewClosure()} + if($case.EmptyTree){$adapters.GetTree={param($Root,$StatusEvidence)@()}} + $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$events.Add("Stop:$($Expected.Pid)");throw 'must not stop'}.GetNewClosure() + $result=Invoke-CcodCloseSession -Request (New-CcodEngineRequest -SchemaVersion 2 -Action Close -RestartOrdinary $false -TransactionId ([guid]::NewGuid().ToString('D'))) -Paths $paths -Adapters $adapters + Assert-CcodEqual 'Error' $result.outcome "$($case.Name) fails closed" + Assert-CcodEqual 'CCOD_CLOSE_UNPROVEN' $result.error.code "$($case.Name) returns the exact stable close rejection" + Assert-CcodEqual '' ($events -join ',') "$($case.Name) performs zero journal status or process mutations" + } + } + + Invoke-CcodTest 'rich null never proves tree exit before stop after stop or during final verification' { + $probe=New-CcodEngineProbe + $rootProcess=New-CcodEngineSnapshot -Pid 13948 -CreationTimeUtc '2026-08-27T07:00:17.0000000Z' -Mode Ordinary + foreach($case in @( + @{Name='pre-stop rich null plus SameIdentity';Stage='Pre';Indeterminate=$false}, + @{Name='post-stop rich null plus SameIdentity';Stage='Post';Indeterminate=$false}, + @{Name='observer indeterminate';Stage='Pre';Indeterminate=$true}, + @{Name='final rich null plus SameIdentity';Stage='Final';Indeterminate=$false} + )){ + $events=[Collections.Generic.List[string]]::new();$reads=[pscustomobject]@{Count=0};$observations=[pscustomobject]@{Count=0} + $adapters=New-CcodEngineAdapters -State (New-CcodEngineState) -Probe $probe -Processes @($rootProcess) -Events $events + $adapters.StaticProbe={param($NodeCandidates,$CheckerPath)$probe}.GetNewClosure() + $adapters.ListProcesses={param($StatusEvidence)@($rootProcess)}.GetNewClosure() + $stage=[string]$case.Stage + $adapters.GetProcess={ + param($ProcessId,$StatusEvidence) + $reads.Count++ + if($stage -ceq 'Pre'){return $null} + if($reads.Count -eq 1){return $rootProcess} + return $null + }.GetNewClosure() + $indeterminate=[bool]$case.Indeterminate + $adapters.ObserveProcessIdentity={ + param($ProcessId,$ExpectedCreationTimeUtc) + $observations.Count++ + if($indeterminate){throw 'native creation observation failed'} + if($stage -ceq 'Final' -and $observations.Count -eq 1){return [pscustomobject][ordered]@{Outcome='Absent';Pid=[int]$ProcessId;CreationTimeUtc=$null}} + [pscustomobject][ordered]@{Outcome='SameIdentity';Pid=[int]$ProcessId;CreationTimeUtc=[string]$ExpectedCreationTimeUtc} + }.GetNewClosure() + $adapters.GetTree={param($Root,$StatusEvidence)@($Root)} + $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$events.Add("Stop:$($Expected.Pid)");[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() + $result=Invoke-CcodCloseSession -Request (New-CcodEngineRequest -SchemaVersion 2 -Action Close -RestartOrdinary $false -TransactionId ([guid]::NewGuid().ToString('D'))) -Paths $paths -Adapters $adapters + Assert-CcodEqual 'Error' $result.outcome "$($case.Name) fails closed" + Assert-CcodEqual 'CCOD_CLOSE_UNPROVEN' $result.error.code "$($case.Name) uses the stable close proof error" + Assert-CcodTrue (($events -join ',') -cmatch 'IntentWritten,CloseRequested') "$($case.Name) retains the durable CloseRequested checkpoint" + Assert-CcodTrue (($events -join ',') -cnotmatch 'Closed|WriteStatus|Complete:') "$($case.Name) never writes Closed clears status or archives completion" + Assert-CcodTrue ($observations.Count -ge 1) "$($case.Name) invokes the strict PID and creation observer" + } + } + + Invoke-CcodTest 'stale status accepts PID reuse and one debug replacement only with its new distinct ports' { + $oldStatus = New-CcodEngineActiveStatus -RuntimeId '2.5.19-old' + $oldStatus.session.codex.pid = 10664 + $oldStatus.session.codex.creationTimeUtc = '2026-08-26T04:49:22.1551350Z' + $oldStatus.session.codex.packageFullName = 'OpenAI.Codex_26.820.7780.0_x64__2p2nqsd0c76g0' + $oldStatus.session.codex.packageVersion = '26.820.7780.0' + $probe = New-CcodEngineProbe + $probe.PackageFullName = 'OpenAI.Codex_26.820.9563.0_x64__2p2nqsd0c76g0' + $probe.PackageVersion = '26.820.9563.0' + $reuse = New-CcodEngineSnapshot -Pid 10664 -CreationTimeUtc '2026-08-27T07:00:17.0000000Z' -Mode Ordinary + $debug = New-CcodEngineSnapshot -Pid 13948 -CreationTimeUtc '2026-08-27T07:00:18.0000000Z' -Mode Unrelated -RendererPort 42001 -MainPort 42002 + $debug.CommandLine='"C:\Codex\ChatGPT.exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=42001 --inspect=127.0.0.1:42002' + foreach($case in @( + @{Name='PID reuse';Root=$reuse;Observation=[pscustomobject][ordered]@{Outcome='IdentityChanged';Pid=10664;CreationTimeUtc='2026-08-27T07:00:17.0000000Z'};Ports=''}, + @{Name='debug replacement';Root=$debug;Observation=[pscustomobject][ordered]@{Outcome='Absent';Pid=10664;CreationTimeUtc=$null};Ports='42001,42002'} + )){ + $events=[Collections.Generic.List[string]]::new();$ports=[Collections.Generic.List[int]]::new();$rootValue=$case.Root;$alive=@{([int]$rootValue.Pid)=$rootValue} + $adapters=New-CcodEngineAdapters -State (New-CcodEngineState -Status $oldStatus) -Probe $probe -Processes @($rootValue) -Events $events + $adapters.StaticProbe={param($NodeCandidates,$CheckerPath)$probe}.GetNewClosure();$adapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure();$adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure() + $adapters.ObserveProcessIdentity={ + param($ProcessId,$ExpectedCreationTimeUtc) + if(-not $alive.ContainsKey([int]$ProcessId)){return [pscustomobject][ordered]@{Outcome='Absent';Pid=[int]$ProcessId;CreationTimeUtc=$null}} + $creation=[string]$alive[[int]$ProcessId].CreationTimeUtc + [pscustomobject][ordered]@{Outcome=if($creation -ceq $ExpectedCreationTimeUtc){'SameIdentity'}else{'IdentityChanged'};Pid=[int]$ProcessId;CreationTimeUtc=$creation} + }.GetNewClosure();$adapters.GetTree={param($Root,$StatusEvidence)@($Root)} + $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$events.Add("Stop:$($Expected.Pid)");$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() + $adapters.WaitPortClosed={param($Port,$TimeoutMilliseconds)$ports.Add([int]$Port);$true}.GetNewClosure() + $result=Invoke-CcodCloseSession -Request (New-CcodEngineRequest -SchemaVersion 2 -Action Close -RestartOrdinary $false -TransactionId ([guid]::NewGuid().ToString('D'))) -Paths $paths -Adapters $adapters + Assert-CcodEqual 'Closed' $result.outcome "$($case.Name) closes the one proven replacement" + Assert-CcodEqual $case.Ports ($ports -join ',') "$($case.Name) uses only the replacement command-line ports" + } + } + Invoke-CcodTest 'schema-v2 mutations revalidate the lifecycle fence immediately before stop launch bridge status and terminal commit' { $targets=@( @{Name='stop';Prior='StopRequested';Mutation='StopProcess'}, @@ -1199,6 +1383,7 @@ try { $adapters=New-CcodEngineAdapters -State (New-CcodEngineState -Status $status) -Processes @($rootProcess) -Events $events -Counters $counters $adapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure() $adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure() + $adapters.ObserveProcessIdentity={param($ProcessId,$ExpectedCreationTimeUtc)if($alive.ContainsKey([int]$ProcessId)){[pscustomobject][ordered]@{Outcome='SameIdentity';Pid=[int]$ProcessId;CreationTimeUtc=[string]$ExpectedCreationTimeUtc}}else{[pscustomobject][ordered]@{Outcome='Absent';Pid=[int]$ProcessId;CreationTimeUtc=$null}}}.GetNewClosure() $adapters.GetTree={param($Root,$StatusEvidence)@($rootProcess,$child)}.GetNewClosure() $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$events.Add("Stop:$($Expected.Pid)");$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() $result=Invoke-CcodRecoverSession -Request (New-CcodEngineRequest -Action Recover -RestartOrdinary $false) -Paths $paths -Adapters $adapters @@ -1232,6 +1417,7 @@ try { $debug=New-CcodEngineSnapshot -Mode Unrelated -RendererPort 41001 -MainPort 41002;$alive=@{100=$debug};$events=[Collections.Generic.List[string]]::new();$stops=[Collections.Generic.List[int]]::new() $counts=[pscustomobject]@{SpecialStart=0;OrdinaryStart=0;Recover=0;Node=0};$adapters=New-CcodEngineAdapters -Processes @($debug) -Events $events -Counters $counts $adapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure();$adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure();$adapters.GetTree={param($Root,$StatusEvidence)@($Root)} + Set-CcodEngineAliveIdentityObserver -Adapters $adapters -Alive $alive $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$stops.Add([int]$Expected.Pid);$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure();$adapters.WaitPortClosed={param($Port,$TimeoutMilliseconds)$true} $closed=Invoke-CcodRecoverSession -Request (New-CcodEngineRequest -Action Recover -RestartOrdinary $false) -Paths $paths -Adapters $adapters Assert-CcodEqual 'Closed' $closed.outcome 'one exact status-less debug root is durably close-owned' @@ -1240,6 +1426,7 @@ try { $openDebug=New-CcodEngineSnapshot -Mode Unrelated -RendererPort 41001 -MainPort 41002;$openAlive=@{100=$openDebug};$openAdapters=New-CcodEngineAdapters -Processes @($openDebug) -Counters $counts $openAdapters.ListProcesses={param($StatusEvidence)@($openAlive.Values)}.GetNewClosure();$openAdapters.GetProcess={param($ProcessId,$StatusEvidence)if($openAlive.ContainsKey([int]$ProcessId)){$openAlive[[int]$ProcessId]}else{$null}}.GetNewClosure();$openAdapters.GetTree={param($Root,$StatusEvidence)@($Root)} + Set-CcodEngineAliveIdentityObserver -Adapters $openAdapters -Alive $openAlive $openAdapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$openAlive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure();$openAdapters.WaitPortClosed={param($Port,$TimeoutMilliseconds)$false} $open=Invoke-CcodRecoverSession -Request (New-CcodEngineRequest -Action Recover -RestartOrdinary $false -TransactionId '9c2324b9-07a4-4ad3-9de5-c48dde73c713') -Paths $paths -Adapters $openAdapters Assert-CcodEqual 'CCOD_CLOSE_UNPROVEN' $open.error.code 'open debug ports prevent a false Closed result' @@ -1275,6 +1462,7 @@ try { $special=New-CcodEngineSnapshot -Pid 201 -CreationTimeUtc '2030-02-03T04:05:07.0000000Z' -Mode Special -RendererPort 41001 -MainPort 41002 $alive=@{201=$special};$events=[Collections.Generic.List[string]]::new();$liveAdapters=New-CcodEngineAdapters -Processes @($special) -Events $events -Counters $counters $liveAdapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure();$liveAdapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure() + Set-CcodEngineAliveIdentityObserver -Adapters $liveAdapters -Alive $alive $liveAdapters.GetTree={param($Root,$StatusEvidence)@($Root)};$liveAdapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure();$liveAdapters.WaitPortClosed={param($Port,$TimeoutMilliseconds)$true} $live=Invoke-CcodReplayTransition -Request $request -Paths $paths -Transition $cold -Adapters $liveAdapters Assert-CcodEqual 'Closed' $live.outcome 'same-execution retained exact tree can prove absence and complete close' @@ -1350,6 +1538,7 @@ try { $adapters=New-CcodEngineAdapters -State $withOld -Processes @($ordinary) -Events $events -Counters $counters $adapters.ReadState={param($StateRoot,$SuppressionKey)$reads.Count++;if($reads.Count -le 2){$withOld}else{$withoutOld}}.GetNewClosure() $adapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure();$adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure();$adapters.GetTree={param($Root,$StatusEvidence)@($Root)} + Set-CcodEngineAliveIdentityObserver -Adapters $adapters -Alive $alive $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() $request=New-CcodEngineRequest -Action Recover -RestartOrdinary $false -TransactionId '16df4637-47f5-4758-89a4-f04c7e7375cf' $result=Invoke-CcodRecoverSession -Request $request -Paths $paths -Adapters $adapters @@ -1390,6 +1579,7 @@ try { $adapters.WaitPortClosed={param($Port,$TimeoutMilliseconds)$counts.Wait++;return ($counts.Wait -gt 1)}.GetNewClosure() $adapters.InvokeNode={param($NodePath,$Arguments)$counts.Node++;New-CcodFullBridgeInvocation}.GetNewClosure() $adapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure();$adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure();$adapters.GetTree={param($Root,$StatusEvidence)@($Root)} + Set-CcodEngineAliveIdentityObserver -Adapters $adapters -Alive $alive $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() $baseSet=$adapters.SetTransition;$adapters.SetTransition={param($Path,$TransactionId,$ExpectedStage,$NewStage,$SpecialIdentity,$RecoveryIdentity,$RendererPort,$MainPort)if($NewStage -ceq 'RecoveryLaunchRequested'){$counts.RecoveryStages++};& $baseSet $Path $TransactionId $ExpectedStage $NewStage $SpecialIdentity $RecoveryIdentity $RendererPort $MainPort}.GetNewClosure() $adapters.WriteStatus={param($StateRoot,$Status,$LiveProbe)if($null -ne $LiveProbe){$counts.ActiveWrites++}} @@ -1456,6 +1646,7 @@ try { $writtenVerified=[pscustomobject]@{Value=$null} $adapters.InvokeNode={param($NodePath,$Arguments)[pscustomobject][ordered]@{ExitCode=0;Stdout='{}';Stderr=''}} $adapters.ListProcesses={param($StatusEvidence)@($alive.Values)}.GetNewClosure();$adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure();$adapters.GetTree={param($Root,$StatusEvidence)@($Root)} + Set-CcodEngineAliveIdentityObserver -Adapters $adapters -Alive $alive $adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$alive.Remove([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure() $adapters.ReadVerified={param($StateRoot)$existingVerified}.GetNewClosure() $adapters.WriteVerified={param($StateRoot,$Verified)$writtenVerified.Value=$Verified}.GetNewClosure() diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1 index 43e79b4..fae9fd7 100644 --- a/tests/persistence/Supervisor.SelfTest.ps1 +++ b/tests/persistence/Supervisor.SelfTest.ps1 @@ -178,7 +178,15 @@ function New-CcodSupervisorFake { $adapters.GetSupervisorDecision={param($Context)$world.Calls.Add('Decision');$world.Decision}.GetNewClosure() $adapters.AddObservedEvent={param($Observed,$Pid,$Created)$world.Calls.Add("Observed:$Pid");$true}.GetNewClosure() $adapters.CompleteControllerRun={param($Result,$TransactionId,$Action,$RuntimeId)$world.Calls.Add("Reduce:$Action");[pscustomobject][ordered]@{SessionState='Idle';BlockAutomaticActions=$false;AttemptKey=$null;RecoveryIgnoreKey=$null;SuppressionKey=$null;ErrorCode=$null;Reason='Reduced'}}.GetNewClosure() - $adapters.GetTrayPresentation={param($Arguments)$world.PresentationInputs.Add($Arguments);[pscustomobject][ordered]@{Color='Gray';ConnectionState=$Arguments.ConnectionState;ProtectionState=$Arguments.ProtectionState;RepairEnabled=$false;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true;Busy=[bool]$Arguments.Busy}}.GetNewClosure() + $adapters.GetTrayPresentation={ + param($Arguments) + $world.PresentationInputs.Add($Arguments);$blocked=[bool]($Arguments.Busy-or$Arguments.StateDamageBlocksActions) + [pscustomobject][ordered]@{ + Color='Gray';ConnectionState=$Arguments.ConnectionState;ProtectionState=$Arguments.ProtectionState + RepairEnabled=[bool]($Arguments.ConnectionState-ceq'RepairNeeded'-and-not$blocked);LanguageEnabled=[bool](-not$blocked);OpenLogsEnabled=$true;AboutEnabled=$true + ExitEnabled=[bool]($Arguments.ConnectionState-ceq'Connected'-and$Arguments.ProtectionState-ceq'Running'-and-not$blocked);Busy=[bool]$Arguments.Busy + } + }.GetNewClosure() $adapters.NewQueue={param($Kind)$world.Calls.Add("Queue:$Kind");if($Kind -ceq 'Command'){Write-Output -NoEnumerate $world.CommandQueue}else{Write-Output -NoEnumerate $world.EventQueue}}.GetNewClosure() $adapters.GetQueueCount={param($Queue)[int]$Queue.Count}.GetNewClosure() $adapters.TryDequeue={param($Queue)$world.TryDequeueSawRealQueue=[object]::ReferenceEquals($Queue,$world.CommandQueue);if($Queue.Count){[pscustomobject][ordered]@{Succeeded=$true;Value=$Queue.Dequeue()}}else{[pscustomobject][ordered]@{Succeeded=$false;Value=$null}}}.GetNewClosure() @@ -199,7 +207,17 @@ function New-CcodSupervisorFake { $world.Calls.Add("Poll:$($Slot.Kind)") if($Slot.Kind -ceq 'Lifecycle' -and $world.AutoCompleteLifecycleWorkers){ $world.WorkerResult=switch($Slot.Action){ - 'Apply' {[pscustomobject][ordered]@{schemaVersion=1;transactionId=$Slot.Request.transactionId;action='Apply';ok=$true;outcome='Activated';observation='Special';error=$null}} + 'Apply' { + foreach($processId in @($world.ProcessIds)){ + if(-not$world.Snapshots.ContainsKey([int]$processId)){continue} + $snapshot=$world.Snapshots[[int]$processId] + if($snapshot.Mode-cne'Ordinary'){continue} + $special=$snapshot.PSObject.Copy();$special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $world.Snapshots[[int]$processId]=$special + } + [pscustomobject][ordered]@{schemaVersion=1;transactionId=$Slot.Request.transactionId;action='Apply';ok=$true;outcome='Activated';observation='Special';error=$null} + } + 'Inspect' {[pscustomobject][ordered]@{schemaVersion=1;transactionId=$Slot.Request.transactionId;action='Inspect';ok=$true;outcome='Inspected';observation='RemoteVerified';error=$null}} 'VerifyRemote' {[pscustomobject][ordered]@{schemaVersion=1;transactionId=$Slot.Request.transactionId;action='VerifyRemote';ok=$true;outcome='Inspected';observation='RemoteVerified';error=$null}} default {throw "UNEXPECTED_LIFECYCLE_ACTION_$($Slot.Action)"} } @@ -230,7 +248,9 @@ function New-CcodTickFixture { [void](& $fake.Adapters.EnterLifecycleOwnership $fake.Layout.InstallRoot $fake.Layout.RuntimeId ([UInt64]7) ([pscustomobject][ordered]@{pid=$fake.Identity.Pid;creationTimeUtc=$fake.Identity.CreationTimeUtc}) $fake.Identity.UserSid $fake.Identity.SessionId 5000) $wake=& $fake.Adapters.OpenLifecycleWakeEvent $fake.Identity.UserSid $fake.Identity.SessionId $hostState=New-CcodSupervisorHostState -Identity $fake.Identity -Layout $fake.Layout -Clock ([pscustomobject]@{Kind='Clock'}) -ShutdownEvent $shutdown -LifecycleWakeEvent $wake -CommandQueue $fake.World.CommandQueue -EventQueue $fake.World.EventQueue -State $state -Journal $null -LifecycleOwnership $fake.World.LifecycleOwnership -LifecycleRequest $fake.World.ActiveLifecycleRequest -LogonIdentity $fake.World.LogonIdentity - $hostState.Tray=[pscustomobject]@{Kind='Tray';CurrentRevision=[UInt64]1;RenderedLanguageMode='System';RenderedCatalog=$script:TestSystemCatalog;RenderedText='old-stable'} + $initialPresentation=[pscustomobject][ordered]@{Color='Green';ConnectionState='Connected';ProtectionState='Running';RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true;Busy=$false} + $hostState.Tray=[pscustomobject]@{Kind='Tray';CurrentRevision=[UInt64]1;AcknowledgedPresentations=[ordered]@{'1'=$initialPresentation};RenderedLanguageMode='System';RenderedCatalog=$script:TestSystemCatalog;RenderedText='old-stable'} + $hostState.LastAcknowledgedPresentation=$initialPresentation $hostState.UiLanguageMode='System';$hostState.UiCatalog=$script:TestSystemCatalog [pscustomobject]@{Fake=$fake;Host=$hostState} } @@ -359,7 +379,7 @@ Invoke-CcodTest 'acquires both lifetime leases and signals Ready only after all Assert-CcodTrue $fake.World.NewTraySawRealQueue 'tray receives the command queue itself' Assert-CcodTrue $fake.World.NewWatcherSawRealQueue 'watcher receives the event queue itself' $calls=@($fake.World.Calls) - foreach($before in @('Enter:AccountSupervisor:5000','Enter:Supervisor:5000','Read:ActiveRuntime','Read:LogonIdentity','Enter:LifecycleOwnership:7','Open:Ready','Open:Shutdown','Open:LifecycleWake','Read:State','Read:Journal','Read:LifecycleRequest','Read:UiPreference','Get:SystemCulture','Get:UiCatalog:System','Queue:Command','Queue:Event','New:Tray','New:Watcher')){ + foreach($before in @('Enter:AccountSupervisor:30000','Enter:Supervisor:30000','Read:ActiveRuntime','Read:LogonIdentity','Enter:LifecycleOwnership:7','Open:Ready','Open:Shutdown','Open:LifecycleWake','Read:State','Read:Journal','Read:LifecycleRequest','Read:UiPreference','Get:SystemCulture','Get:UiCatalog:System','Queue:Command','Queue:Event','New:Tray','New:Watcher')){ Assert-CcodTrue ([Array]::IndexOf($calls,$before) -ge 0) "$before occurs" Assert-CcodTrue ([Array]::IndexOf($calls,$before) -lt [Array]::IndexOf($calls,'Signal:Ready')) "$before precedes Ready" } @@ -373,19 +393,19 @@ Invoke-CcodTest 'acquires both lifetime leases and signals Ready only after all Assert-CcodTrue ([Array]::IndexOf($calls,'Exit:Supervisor') -lt [Array]::IndexOf($calls,'Exit:AccountSupervisor')) 'leases release in reverse order' } -Invoke-CcodTest 'uses one monotonic 5000ms acquisition budget' { +Invoke-CcodTest 'uses one monotonic 30000ms acquisition budget' { $fake=New-CcodSupervisorFake $fake.World.Elapsed.Enqueue([long]0);$fake.World.Elapsed.Enqueue([long]4200) $receipt=Invoke-CcodSupervisorHost -ReadyToken $readyToken -Adapters $fake.Adapters Assert-CcodReceipt $receipt 'Stopped' 0 - Assert-CcodTrue ($fake.World.Calls.Contains('Enter:AccountSupervisor:5000')) 'first lease receives full budget' - Assert-CcodTrue ($fake.World.Calls.Contains('Enter:Supervisor:800')) 'second lease receives only remainder' + Assert-CcodTrue ($fake.World.Calls.Contains('Enter:AccountSupervisor:30000')) 'first lease receives full budget' + Assert-CcodTrue ($fake.World.Calls.Contains('Enter:Supervisor:25800')) 'second lease receives only remainder' } Invoke-CcodTest 'does not take over or signal Ready when either lifetime lease times out' { foreach($case in @( - [pscustomobject]@{First='TimedOut';Second='Acquired';Expected='Enter:AccountSupervisor:5000';Forbidden='Enter:Supervisor:5000'}, - [pscustomobject]@{First='Acquired';Second='TimedOut';Expected='Enter:Supervisor:5000';Forbidden='Open:Ready'} + [pscustomobject]@{First='TimedOut';Second='Acquired';Expected='Enter:AccountSupervisor:30000';Forbidden='Enter:Supervisor:30000'}, + [pscustomobject]@{First='Acquired';Second='TimedOut';Expected='Enter:Supervisor:30000';Forbidden='Open:Ready'} )){ $fake=New-CcodSupervisorFake -FirstLeaseOutcome $case.First -SecondLeaseOutcome $case.Second $receipt=Invoke-CcodSupervisorHost -ReadyToken $readyToken -Adapters $fake.Adapters @@ -639,6 +659,26 @@ Invoke-CcodTest 'Exit is rejected unless the acknowledged presentation enabled i Assert-CcodEqual 0 @($world.Calls|Where-Object { $_ -like 'New:Lifecycle:SafeExit' }).Count 'disabled presentation creates no SafeExit request' } +Invoke-CcodTest 'initial acknowledged presentation authorizes About before the first current projection' { + # Production mutation caught: requiring LastAcknowledgedPresentation even though the native rev1 handshake is already authoritative. + $fixture=New-CcodTickFixture;$hostState=$fixture.Host;$world=$fixture.Fake.World + $hostState.LastAcknowledgedPresentation=$null + $action=[pscustomobject][ordered]@{ActionId=[guid]::NewGuid();Command='ShowAbout';Revision=[UInt64]1} + $result=Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action + Assert-CcodEqual 'Completed' $result.Status 'manifest-verified About remains available from the acknowledged initial menu' + Assert-CcodEqual 1 @($world.Calls|Where-Object{$_-ceq'Verify:About'}).Count 'initial About reaches manifest verification instead of current-capability rejection' +} + +Invoke-CcodTest 'initial acknowledged presentation authorizes language before the first current projection' { + # Production mutation caught: rejecting initial language solely because the first reconciliation tick has not projected again. + $fixture=New-CcodTickFixture;$hostState=$fixture.Host;$world=$fixture.Fake.World + $hostState.LastAcknowledgedPresentation=$null + $action=[pscustomobject][ordered]@{ActionId=[guid]::NewGuid();Command='SetLanguageEnglish';Revision=[UInt64]1} + $result=Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action + Assert-CcodEqual 'Completed' $result.Status 'language remains available from the acknowledged initial menu' + Assert-CcodEqual 1 @($world.SetUiLanguageModes|Where-Object{$_-ceq'en-US'}).Count 'initial language reaches preference persistence instead of current-capability rejection' +} + Invoke-CcodTest 'adds the exact localization adapters and host fields at the frozen boundaries' { $names=Get-CcodSupervisorAdapterNames $first=[Array]::IndexOf($names,'ReadUiPreference') @@ -712,6 +752,171 @@ Invoke-CcodTest 'resumes WaitingForManualLaunch after Supervisor restart and com Assert-CcodEqual 0 @($fake.World.Calls|Where-Object{$_ -like 'Start:Controller:*'}).Count 'resume performs no mutation outside LifecycleWorker slot' } +Invoke-CcodTest 'supervisor restart performs one strong Inspect before adopting a persisted special session' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $world.ProcessIds=@(201);$world.Snapshots[201]=$special;$world.AutoCompleteLifecycleWorkers=$true + $hostState.SpecialProof=$null;$hostState.ForceReconcile=$true + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $fixture.Fake.Adapters.GetSupervisorDecision={param($Context)$world.Calls.Add('Decision');Get-CcodSupervisorDecision -Context $Context}.GetNewClosure() + + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodEqual $null $hostState.SpecialProof 'weak ProcessControl observation never becomes a strong proof by itself' + Assert-CcodTrue ($null-ne$hostState.LifecycleRequest-and$hostState.LifecycleRequest.kind-ceq'CheckAndRepair') 'restart creates one durable guardian inspection' + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodEqual 'Inspect' $hostState.LifecycleWorkerSlot.Action 'healthy persisted special runs read-only strong Inspect instead of Close' + Assert-CcodEqual 201 $hostState.LifecycleWorkerSlot.ProofCandidate.Pid 'strong Inspect is correlated to the exact pre-probe root' + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodEqual 1 $world.CompletedLifecycleRequests.Count 'one strong Inspect produces one terminal lifecycle receipt' + Assert-CcodEqual 'CancelledBeforeClose' $world.CompletedLifecycleRequests[0].phase 'healthy special inspection completes without process mutation' + Assert-CcodTrue ($null-ne$hostState.SpecialProof) 'strong Inspect binds the exact post-probe special identity' + Assert-CcodEqual 201 $hostState.SpecialProof.Pid 'strong Inspect proof names the exact special process' + + $world.Calls.Clear() + foreach($iteration in 1..5){$hostState.ForceReconcile=$true;Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters} + Assert-CcodEqual 1 $world.CompletedLifecycleRequests.Count 'later guardian intervals create no additional terminal receipts' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -like 'New:Lifecycle:*'}).Count 'later guardian intervals create no lifecycle submissions' + Assert-CcodEqual 'AdoptSpecial' $hostState.LastDecision.Action 'later guardian intervals adopt the strong proof' +} + +Invoke-CcodTest 'successful lifecycle verification binds a fresh special proof before later guardian decisions' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + foreach($phase in @('CloseRequested','CloseConfirmed','RepairRequested')){ + $request=& $transactionModule {param($Value,$NextPhase)Move-CcodLifecyclePhase -Request $Value -NextPhase $NextPhase -NowUtc '2030-02-03T03:01:05.0000000Z'} $request $phase + } + $request.ownerIdentity=$hostState.LifecycleOwnership.ownerIdentity;$request.leaseEpoch=[UInt64]$hostState.LifecycleOwnership.epoch + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $world.ProcessIds=@(201);$world.Snapshots[201]=$special;$world.AutoCompleteLifecycleWorkers=$true + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $fixture.Fake.Adapters.GetSupervisorDecision={param($Context)$world.Calls.Add('Decision');Get-CcodSupervisorDecision -Context $Context}.GetNewClosure() + + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters VerifyRemote $null|Out-Null + Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters + + Assert-CcodTrue ($null-ne$hostState.SpecialProof) 'successful verification binds one freshly revalidated special identity' + Assert-CcodEqual 201 $hostState.SpecialProof.Pid 'bound proof names the exact revalidated special process' + Assert-CcodEqual $true $hostState.Special[0].ProbeValid 'freshly bound proof is visible to the decision engine' + Assert-CcodEqual $false $hostState.SpecialNeedsInspect 'freshly bound proof suppresses redundant inspection' + [void](Invoke-CcodSupervisorDriveLifecycle $hostState $fixture.Fake.Adapters) + Assert-CcodEqual $null $hostState.LifecycleRequest 'verified lifecycle reaches one terminal completion' + + $world.Calls.Clear();$hostState.ForceReconcile=$true + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodEqual 'AdoptSpecial' $hostState.LastDecision.Action 'later guardian decision adopts the bound special proof' + Assert-CcodEqual $null $hostState.LifecycleRequest 'later guardian decision creates no redundant repair lifecycle' +} + +Invoke-CcodTest 'post-verify identity drift becomes one terminal failure and suppresses same-root guardian retries' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + foreach($phase in @('CloseRequested','CloseConfirmed','RepairRequested')){ + $request=& $transactionModule {param($Value,$NextPhase)Move-CcodLifecyclePhase -Request $Value -NextPhase $NextPhase -NowUtc '2030-02-03T03:01:05.0000000Z'} $request $phase + } + $request.ownerIdentity=$hostState.LifecycleOwnership.ownerIdentity;$request.leaseEpoch=[UInt64]$hostState.LifecycleOwnership.epoch + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $world.ProcessIds=@(201);$world.Snapshots[201]=$special;$world.AutoCompleteLifecycleWorkers=$true + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters VerifyRemote $null|Out-Null + $drift=$special.PSObject.Copy();$drift.RendererPort=[int]42001;$drift.MainPort=[int]42002;$world.Snapshots[201]=$drift + + Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters + + Assert-CcodEqual 1 $world.CompletedLifecycleRequests.Count 'rebind drift produces one terminal lifecycle receipt' + Assert-CcodEqual 'VerificationFailed' $world.CompletedLifecycleRequests[0].phase 'rebind drift fails verification closed' + Assert-CcodEqual 'CCOD_REMOTE_PROOF_REBIND_FAILED' $world.CompletedLifecycleRequests[0].error 'rebind drift uses one stable terminal code' + Assert-CcodEqual $null $hostState.SpecialProof 'rebind drift never publishes a strong proof' + Assert-CcodEqual ('201|'+$special.CreationTimeUtc) $hostState.FailedSpecialProofKey 'failed proof is bounded to the exact root identity' + Assert-CcodEqual 'RepairNeeded' $hostState.ConnectionState 'bounded strong-proof failure remains manually repairable' + $world.Calls.Clear();$hostState.ForceReconcile=$true + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodEqual ('201|'+$special.CreationTimeUtc) $hostState.FailedSpecialProofKey 'same-root failure key survives reconciliation' + Assert-CcodEqual $hostState.FailedSpecialProofKey (Get-CcodSupervisorSpecialProofKey $hostState.Special[0].Snapshot) 'same-root reconciled snapshot has the identical suppression key' + Assert-CcodEqual $false $hostState.SpecialNeedsInspect 'same-root failure remains suppressed after reconciliation' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -like 'New:Lifecycle:*'}).Count 'same-root failure creates no automatic retry loop' + $manual=[pscustomobject][ordered]@{ActionId=[guid]'22222222-3333-4444-5555-666666666666';Command='CheckAndRepair';Revision=[UInt64]1} + $manualResult=Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $manual + Assert-CcodEqual Accepted $manualResult.Status 'manual repair remains available after automatic suppression' + Assert-CcodEqual $null $hostState.FailedSpecialProofKey 'manual repair clears the same-root automatic suppression' +} + +Invoke-CcodTest 'post-verify state reread failure becomes a terminal receipt instead of escaping the supervisor loop' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + foreach($phase in @('CloseRequested','CloseConfirmed','RepairRequested')){ + $request=& $transactionModule {param($Value,$NextPhase)Move-CcodLifecyclePhase -Request $Value -NextPhase $NextPhase -NowUtc '2030-02-03T03:01:05.0000000Z'} $request $phase + } + $request.ownerIdentity=$hostState.LifecycleOwnership.ownerIdentity;$request.leaseEpoch=[UInt64]$hostState.LifecycleOwnership.epoch + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $world.ProcessIds=@(201);$world.Snapshots[201]=$special;$world.AutoCompleteLifecycleWorkers=$true + $reads=[pscustomobject]@{Count=0} + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$reads.Count++;if($reads.Count-eq1){return $hostState.State};throw 'injected post-worker reread failure'}.GetNewClosure() + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters VerifyRemote $null|Out-Null + + Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters + + Assert-CcodEqual 1 $world.CompletedLifecycleRequests.Count 'reread failure produces one terminal lifecycle receipt' + Assert-CcodEqual 'VerificationFailed' $world.CompletedLifecycleRequests[0].phase 'reread failure is a verification failure' + Assert-CcodEqual 'CCOD_REMOTE_PROOF_REBIND_FAILED' $world.CompletedLifecycleRequests[0].error 'reread failure exposes only the stable rebind code' + Assert-CcodEqual $null $hostState.LifecycleRequest 'reread failure leaves no nonterminal request to restart-loop' +} + +Invoke-CcodTest 'pre-verify candidate reread failure remains contained and cannot publish an unbound worker success' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + foreach($phase in @('CloseRequested','CloseConfirmed','RepairRequested')){ + $request=& $transactionModule {param($Value,$NextPhase)Move-CcodLifecyclePhase -Request $Value -NextPhase $NextPhase -NowUtc '2030-02-03T03:01:05.0000000Z'} $request $phase + } + $request.ownerIdentity=$hostState.LifecycleOwnership.ownerIdentity;$request.leaseEpoch=[UInt64]$hostState.LifecycleOwnership.epoch + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request;$world.AutoCompleteLifecycleWorkers=$true + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $world.ProcessIds=@(201);$world.Snapshots[201]=$special + $hostState.Special=[object[]]@([pscustomobject][ordered]@{Snapshot=$special;IdentityValid=$true;ProbeValid=$false});$hostState.SpecialNeedsInspect=$true + $reads=[pscustomobject]@{Count=0} + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$reads.Count++;if($reads.Count-le2){throw 'injected proof reread failure'};return $hostState.State}.GetNewClosure() + + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters VerifyRemote $null|Out-Null + Assert-CcodEqual 201 $hostState.LifecycleWorkerSlot.ProofCandidate.Pid 'failed pre-read retains only the already-observed identity candidate' + Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters + + Assert-CcodEqual 1 $world.CompletedLifecycleRequests.Count 'unbound worker success produces one terminal receipt' + Assert-CcodEqual 'VerificationFailed' $world.CompletedLifecycleRequests[0].phase 'unbound worker success fails verification closed' + Assert-CcodEqual 'CCOD_REMOTE_PROOF_REBIND_FAILED' $world.CompletedLifecycleRequests[0].error 'unbound worker success uses the stable rebind code' + Assert-CcodEqual 'RepairNeeded' $hostState.ConnectionState 'pre-read failure remains manually repairable' + $world.Calls.Clear();$hostState.ForceReconcile=$true + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -like 'New:Lifecycle:*'}).Count 'pre-read failure creates no later guardian receipt loop' +} + +Invoke-CcodTest 'ordinary observation uses the durable transaction start as its launch-race lower bound' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $request=New-CcodPersistedLifecycleRequest -Kind RestartAndRepair + $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru + foreach($phase in @('CloseRequested','CloseConfirmed','OrdinaryLaunchRequested')){ + $request=& $transactionModule {param($Value,$Next)Move-CcodLifecyclePhase -Request $Value -NextPhase $Next -NowUtc '2030-02-03T03:02:00.0000000Z'} $request $phase + } + $request.ownerIdentity=$hostState.LifecycleOwnership.ownerIdentity + $request.leaseEpoch=[UInt64]$hostState.LifecycleOwnership.epoch + $world.ActiveLifecycleRequest=$request;$hostState.LifecycleRequest=$request + Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters ObserveOrdinary $null|Out-Null + Assert-CcodEqual 1 $world.StartedLifecycleRequests.Count 'one ordinary observation worker request is persisted' + $workerRequest=$world.StartedLifecycleRequests[0] + Assert-CcodEqual $request.createdAtUtc $workerRequest.notBeforeUtc 'observation accepts an ordinary root created after the verified lifecycle began' + Assert-CcodTrue ($workerRequest.notBeforeUtc -cne '2030-02-03T03:04:05.0000000Z') 'observation does not move the lower bound to the current worker tick' +} + Invoke-CcodTest 'rejects a nonzero lifecycle worker exit even when its result frame claims success' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru @@ -988,6 +1193,24 @@ Invoke-CcodTest 'converts persisted-special inspection into one durable lifecycl Assert-CcodEqual 1 $world.CommandQueue.Count 'durable lifecycle leaves command queued' } +Invoke-CcodTest 'matching special proof is projected into the decision and does not schedule redundant repair' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $special.Mode='Special';$special.RendererPort=[int]41001;$special.MainPort=[int]41002 + $world.ProcessIds=@(201);$world.Snapshots[201]=$special + $hostState.SpecialProof=$special;$hostState.SessionState='Active';$hostState.Reason='SpecialValidated';$hostState.ForceReconcile=$true + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $fixture.Fake.Adapters.GetSupervisorDecision={param($Context)$world.Calls.Add('Decision');Get-CcodSupervisorDecision -Context $Context}.GetNewClosure() + + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + + Assert-CcodEqual 1 @($hostState.Special).Count 'one matching special process remains observed' + Assert-CcodEqual $true $hostState.Special[0].ProbeValid 'matching durable proof is visible to the decision engine' + Assert-CcodEqual 'AdoptSpecial' $hostState.LastDecision.Action 'verified special process is adopted without repair' + Assert-CcodEqual $null $hostState.LifecycleRequest 'verified special process creates no redundant lifecycle' + Assert-CcodEqual 0 $world.LifecycleWrites.Count 'verified special process writes no cancellation receipt precursor' +} + Invoke-CcodTest 'bounds stale-package special reconciliation to one live process identity without clearing state or keys' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $special=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' @@ -1181,6 +1404,7 @@ Invoke-CcodTest 'authorizes a correlated CheckAndRepair action through the durab $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $hostState.ConnectionState='RepairNeeded' $hostState.Tray.CurrentRevision=[UInt64]12 + $hostState.Tray.AcknowledgedPresentations['12']=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';Command='CheckAndRepair';Revision=[UInt64]12} $results=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action) Assert-CcodEqual 1 $results.Count 'authorized repair returns one action result' @@ -1197,6 +1421,7 @@ Invoke-CcodTest 'authorizes a correlated CheckAndRepair action through the durab Invoke-CcodTest 'completes a correlated CheckAndRepair action when its lifecycle reaches a terminal phase' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $hostState.ConnectionState='RepairNeeded';$hostState.Tray.CurrentRevision=[UInt64]13 + $hostState.Tray.AcknowledgedPresentations['13']=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeef';Command='CheckAndRepair';Revision=[UInt64]13} $accepted=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] $hostState.LifecycleRequest.phase='Completed' @@ -1208,6 +1433,71 @@ Invoke-CcodTest 'completes a correlated CheckAndRepair action when its lifecycle Assert-CcodEqual 'Completed' $terminal.Status 'successful lifecycle sends completed action result' } +Invoke-CcodTest 'propagates a validated CloseFailed lifecycle error to its correlated terminal tray action' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $hostState.ConnectionState='RepairNeeded';$hostState.Tray.CurrentRevision=[UInt64]13 + $hostState.Tray.AcknowledgedPresentations['13']=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} + $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeece';Command='CheckAndRepair';Revision=[UInt64]13} + [void]@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action) + $hostState.LifecycleRequest.phase='CloseFailed';$hostState.LifecycleRequest.error='CCOD_CLOSE_UNPROVEN' + Complete-CcodSupervisorLifecycleTerminal $hostState $fixture.Fake.Adapters + Assert-CcodEqual 2 $world.TrayActionResults.Count 'failed repair lifecycle emits accepted and terminal correlated results' + $terminal=$world.TrayActionResults[1] + Assert-CcodEqual 'Failed' $terminal.Status 'CloseFailed lifecycle sends a failed action result' + Assert-CcodEqual 'CCOD_CLOSE_UNPROVEN' $terminal.ErrorCode 'validated terminal lifecycle error is preserved exactly' +} + +Invoke-CcodTest 'falls back for malformed terminal lifecycle errors' { + foreach($case in @( + [pscustomobject]@{Name='CRLF';Error="CCOD_CLOSE_UNPROVEN`r`n"}, + [pscustomobject]@{Name='lowercase';Error='ccod_close_unproven'}, + [pscustomobject]@{Name='overlength';Error=('CCOD_'+('A'*92))}, + [pscustomobject]@{Name='terminal LF';Error="CCOD_CLOSE_UNPROVEN`n"} + )){ + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $hostState.ConnectionState='RepairNeeded';$hostState.Tray.CurrentRevision=[UInt64]13 + $hostState.Tray.AcknowledgedPresentations['13']=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} + $action=[pscustomobject][ordered]@{ActionId=[guid]::NewGuid();Command='CheckAndRepair';Revision=[UInt64]13} + [void]@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action) + $hostState.LifecycleRequest.phase='CloseFailed';$hostState.LifecycleRequest.error=$case.Error + Complete-CcodSupervisorLifecycleTerminal $hostState $fixture.Fake.Adapters + Assert-CcodEqual 2 $world.TrayActionResults.Count "$($case.Name) failure emits accepted and terminal correlated results" + Assert-CcodEqual 'CCOD_LIFECYCLE_ACTION_FAILED' $world.TrayActionResults[1].ErrorCode "$($case.Name) malformed terminal error retains the generic fallback" + } +} + +Invoke-CcodTest 'authorizes an acknowledged displayed revision after a newer capable presentation is published' { + $fixture=New-CcodTickFixture;$hostState=$fixture.Host + $hostState.ConnectionState='RepairNeeded';$hostState.Tray.CurrentRevision=[UInt64]8 + $hostState.Tray.AcknowledgedPresentations['7']=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} + $hostState.LastAcknowledgedPresentation=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} + $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee07';Command='CheckAndRepair';Revision=[UInt64]7} + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + Assert-CcodEqual 'Accepted' $result.Status 'the displayed and acknowledged revision remains action authority after a newer capable projection' +} + +Invoke-CcodTest 'rejects an action revision that was published but never acknowledged as displayed' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $hostState.Tray.CurrentRevision=[UInt64]9 + $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee09';Command='OpenLogs';Revision=[UInt64]9} + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + Assert-CcodEqual 'Rejected' $result.Status 'unacknowledged presentation revision is rejected' + Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'unacknowledged presentation revision has the stable stale error' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'unacknowledged action performs no side effect' +} + +Invoke-CcodTest 'rejects an old displayed action after the current presentation revokes its capability' { + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $hostState.Tray.CurrentRevision=[UInt64]8 + $hostState.Tray.AcknowledgedPresentations['7']=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true} + $hostState.LastAcknowledgedPresentation=[pscustomobject][ordered]@{RepairEnabled=$false;LanguageEnabled=$false;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$false} + $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee17';Command='SetLanguageEnglish';Revision=[UInt64]7} + $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0] + Assert-CcodEqual 'Rejected' $result.Status 'current capability revocation fails closed' + Assert-CcodEqual 'CCOD_TRAY_ACTION_UNAVAILABLE' $result.ErrorCode 'current capability revocation is unavailable rather than stale' + Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -like 'Persist:UiLanguage:*'}).Count 'revoked action performs no preference mutation' +} + Invoke-CcodTest 'completes About only after validating the active runtime manifest' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeea';Command='ShowAbout';Revision=[UInt64]1} @@ -1322,6 +1612,118 @@ Invoke-CcodTest 'worker request ids are canonical D-format GUIDs for probe frami Assert-CcodTrue ($slot.RequestPath.EndsWith("static-probe-$($slot.RequestId).request.json")) 'request path matches StaticProbeWorker framing' } +Invoke-CcodTest 'transient static target failure remains retryable for a new ordinary root' { + # Production mutation caught: poisoning the Supervisor lifetime as UnknownOrIncompatible after one startup PID race. + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $first=New-CcodSupervisorTestSnapshot -ProcessId 71 -CreationTimeUtc '2030-02-03T03:01:00.0000000Z' + $slot=Start-CcodSupervisorWorkerSlot $hostState $fixture.Fake.Adapters 'StaticProbe' 'StaticProbe' $first + $world.WorkerResult=[pscustomobject][ordered]@{ + schemaVersion=1;action='StaticProbe';ok=$false;requestId=$slot.Request.requestId;runtimeId=$slot.Request.runtimeId + targetIdentity=[pscustomobject][ordered]@{pid=$slot.Request.targetIdentity.pid;creationTimeUtc=$slot.Request.targetIdentity.creationTimeUtc} + probe=$null;error=[pscustomobject][ordered]@{code='CCOD_STATIC_TARGET_CHANGED';stage='TargetPostflight';message='The static probe worker failed safely.'} + } + $stdout=$world.WorkerResult|ConvertTo-Json -Depth 20 -Compress + $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]1;StdoutText=$stdout;StdoutByteCount=[int]$stdout.Length;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} + Invoke-CcodSupervisorPollSlot $hostState $fixture.Fake.Adapters + Assert-CcodEqual $null $hostState.Classification 'a correlated transient probe failure does not become a permanent compatibility verdict' + + $second=New-CcodSupervisorTestSnapshot -ProcessId 72 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z' + $world.ProcessIds=@([int]$second.Pid);$world.Snapshots[[int]$second.Pid]=$second + $hostState.ForceReconcile=$true;$hostState.ObservationDirty=$true + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $fixture.Fake.Adapters.GetSupervisorDecision={param($Context)$world.Calls.Add('Decision');Get-CcodSupervisorDecision -Context $Context}.GetNewClosure() + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodTrue ($null-ne$hostState.WorkerSlot -and $hostState.WorkerSlot.Kind-ceq'StaticProbe') 'a new stable ordinary root is probed again' + Assert-CcodEqual 72 $hostState.WorkerSlot.Request.targetIdentity.pid 'retry is bound to the replacement ordinary root' +} + +Invoke-CcodTest 'valid nonretryable static failure is bound to one ordinary root without breaking the next tick' { + # Production mutation caught: setting global UnknownOrIncompatible without the package tuple required by the decision context. + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $target=New-CcodSupervisorTestSnapshot -ProcessId 73 -CreationTimeUtc '2030-02-03T03:03:00.0000000Z' + $slot=Start-CcodSupervisorWorkerSlot $hostState $fixture.Fake.Adapters 'StaticProbe' 'StaticProbe' $target + $world.WorkerResult=[pscustomobject][ordered]@{ + schemaVersion=1;action='StaticProbe';ok=$false;requestId=$slot.Request.requestId;runtimeId=$slot.Request.runtimeId + targetIdentity=[pscustomobject][ordered]@{pid=$slot.Request.targetIdentity.pid;creationTimeUtc=$slot.Request.targetIdentity.creationTimeUtc} + probe=$null;error=[pscustomobject][ordered]@{code='CCOD_STATIC_RUNTIME_UNAUTHORIZED';stage='RuntimePostflight';message='The static probe worker failed safely.'} + } + $stdout=$world.WorkerResult|ConvertTo-Json -Depth 20 -Compress + $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]1;StdoutText=$stdout;StdoutByteCount=[int]$stdout.Length;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} + Invoke-CcodSupervisorPollSlot $hostState $fixture.Fake.Adapters + $attemptKey='73|2030-02-03T03:03:00.0000000Z' + Assert-CcodEqual $null $hostState.Classification 'failure without a trusted package tuple cannot set a global compatibility verdict' + Assert-CcodTrue $hostState.AttemptKeys.Contains($attemptKey) 'nonretryable failure is bound to the exact ordinary identity' + Assert-CcodEqual 'StaticProbeFailed' $hostState.Reason 'nonretryable failure exposes one fixed reason' + Assert-CcodEqual $true $hostState.BlockAutomaticActions 'nonretryable failure blocks automatic mutation for that root' + + $world.ProcessIds=@([int]$target.Pid);$world.Snapshots[[int]$target.Pid]=$target + $hostState.ForceReconcile=$true;$hostState.ObservationDirty=$true + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $fixture.Fake.Adapters.GetSupervisorDecision={param($Context)$world.Calls.Add('Decision');Get-CcodSupervisorDecision -Context $Context}.GetNewClosure() + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodEqual 'KeepOrdinary' $hostState.LastDecision.Action 'same failed ordinary root remains fail-closed without terminating Supervisor' + Assert-CcodEqual 'AlreadyAttempted' $hostState.LastDecision.Reason 'same failed ordinary root is not probed in an unbounded loop' + Assert-CcodEqual $null $hostState.WorkerSlot 'same failed ordinary root starts no second worker' +} + +Invoke-CcodTest 'malformed static failure frames remain fail-closed' { + $cases=@( + [pscustomobject]@{Name='wrong request';ExitCode=1;Mutate={param($Result)$Result.requestId=[guid]::NewGuid().ToString('D')}}, + [pscustomobject]@{Name='invalid stage';ExitCode=1;Mutate={param($Result)$Result.error.stage='PostProbe'}}, + [pscustomobject]@{Name='unbounded message';ExitCode=1;Mutate={param($Result)$Result.error.message='private target path'}}, + [pscustomobject]@{Name='contradictory exit';ExitCode=0;Mutate={param($Result)}} + ) + foreach($case in $cases){ + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $target=New-CcodSupervisorTestSnapshot + $slot=Start-CcodSupervisorWorkerSlot $hostState $fixture.Fake.Adapters 'StaticProbe' 'StaticProbe' $target + $result=[pscustomobject][ordered]@{ + schemaVersion=1;action='StaticProbe';ok=$false;requestId=$slot.Request.requestId;runtimeId=$slot.Request.runtimeId + targetIdentity=[pscustomobject][ordered]@{pid=$slot.Request.targetIdentity.pid;creationTimeUtc=$slot.Request.targetIdentity.creationTimeUtc} + probe=$null;error=[pscustomobject][ordered]@{code='CCOD_STATIC_TARGET_CHANGED';stage='TargetPostflight';message='The static probe worker failed safely.'} + } + & $case.Mutate $result + $world.WorkerResult=$result;$stdout=$result|ConvertTo-Json -Depth 20 -Compress + $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]$case.ExitCode;StdoutText=$stdout;StdoutByteCount=[int]$stdout.Length;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} + Invoke-CcodSupervisorPollSlot $hostState $fixture.Fake.Adapters + Assert-CcodEqual $null $hostState.Classification "$($case.Name) cannot create a package verdict without trusted evidence" + Assert-CcodTrue $hostState.AttemptKeys.Contains('71|2030-02-03T03:01:00.0000000Z') "$($case.Name) is bound to the exact failed ordinary root" + Assert-CcodEqual 'WorkerFramingFailed' $hostState.Reason "$($case.Name) is reduced to the fixed framing failure" + Assert-CcodEqual $true $hostState.BlockAutomaticActions "$($case.Name) blocks automatic mutation" + } +} + +Invoke-CcodTest 'invalid or empty completed static poll remains fail-closed for the same root' { + $cases=@( + [pscustomobject]@{Name='invalid poll';Poll=[pscustomobject][ordered]@{Completed=$true;StdoutText='';StdoutByteCount=[int]0;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false}}, + [pscustomobject]@{Name='empty completed poll';Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]1;StdoutText='';StdoutByteCount=[int]0;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false}} + ) + $processId=73 + foreach($case in $cases){ + $processId++ + $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host + $created=('2030-02-03T03:{0}:00.0000000Z'-f($processId-70).ToString('00')) + $target=New-CcodSupervisorTestSnapshot -ProcessId $processId -CreationTimeUtc $created + Start-CcodSupervisorWorkerSlot $hostState $fixture.Fake.Adapters 'StaticProbe' 'StaticProbe' $target|Out-Null + $world.Poll=$case.Poll + Invoke-CcodSupervisorPollSlot $hostState $fixture.Fake.Adapters + $attemptKey=('{0}|{1}'-f$processId,$created) + Assert-CcodEqual $null $hostState.Classification "$($case.Name) creates no untrusted package verdict" + Assert-CcodTrue $hostState.AttemptKeys.Contains($attemptKey) "$($case.Name) is bound to the exact failed ordinary root" + Assert-CcodEqual 'WorkerFramingFailed' $hostState.Reason "$($case.Name) has one fixed failure reason" + Assert-CcodEqual $true $hostState.BlockAutomaticActions "$($case.Name) blocks automatic mutation" + Assert-CcodEqual $null $hostState.WorkerSlot "$($case.Name) clears the completed worker slot" + + $world.ProcessIds=@([int]$target.Pid);$world.Snapshots[[int]$target.Pid]=$target + $hostState.ForceReconcile=$true;$hostState.ObservationDirty=$true + $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure() + $fixture.Fake.Adapters.GetSupervisorDecision={param($Context)$world.Calls.Add('Decision');Get-CcodSupervisorDecision -Context $Context}.GetNewClosure() + Invoke-CcodSupervisorTick $hostState $fixture.Fake.Adapters + Assert-CcodEqual 'KeepOrdinary' $hostState.LastDecision.Action "$($case.Name) keeps the same root fail-closed" + Assert-CcodEqual 'AlreadyAttempted' $hostState.LastDecision.Reason "$($case.Name) starts no unbounded retry loop" + } +} + Invoke-CcodTest 'fails closed when a static worker fabricates a ready candidate without sentinel proof' { $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host $target=New-CcodSupervisorTestSnapshot @@ -1338,7 +1740,8 @@ Invoke-CcodTest 'fails closed when a static worker fabricates a ready candidate $stdout=$world.WorkerResult|ConvertTo-Json -Depth 20 -Compress $world.Poll=[pscustomobject][ordered]@{Completed=$true;ExitCode=[int]0;StdoutText=$stdout;StdoutByteCount=[int]$stdout.Length;StdoutOverflow=$false;StderrByteCount=[int]0;StderrOverflow=$false} Invoke-CcodSupervisorPollSlot $hostState $fixture.Fake.Adapters - Assert-CcodEqual 'UnknownOrIncompatible' $hostState.Classification 'incomplete public proof cannot update the candidate classification' + Assert-CcodEqual $null $hostState.Classification 'incomplete public proof cannot update the candidate classification' + Assert-CcodTrue $hostState.AttemptKeys.Contains('71|2030-02-03T03:01:00.0000000Z') 'malformed success is bound to the exact failed ordinary root' Assert-CcodEqual 'WorkerFramingFailed' $hostState.Reason 'malformed public proof is recorded as a framing failure' Assert-CcodEqual $true $hostState.BlockAutomaticActions 'malformed public proof blocks automatic actions' } diff --git a/tests/persistence/TrayHostBuild.SelfTest.ps1 b/tests/persistence/TrayHostBuild.SelfTest.ps1 index fe95a6f..2e9153e 100644 --- a/tests/persistence/TrayHostBuild.SelfTest.ps1 +++ b/tests/persistence/TrayHostBuild.SelfTest.ps1 @@ -4,14 +4,19 @@ $ErrorActionPreference='Stop' $repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $lockPath=Join-Path $repositoryRoot 'build\trayhost-packages.lock.json' -Invoke-CcodTest '2.5.9 source metadata binds the package and TrayHost assembly identities' { +Invoke-CcodTest '2.5.21 source metadata binds the package and native assembly identities' { $package=Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw|ConvertFrom-Json - Assert-CcodEqual '2.5.9' ([string]$package.version) 'package version is the 2.5.9 release version' + Assert-CcodEqual '2.5.21' ([string]$package.version) 'package version is the 2.5.21 release version' $assemblyInfo=Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\trayhost\AssemblyInfo.cs') -Raw - Assert-CcodTrue ($assemblyInfo -match 'AssemblyVersion\("2\.5\.9\.0"\)') 'TrayHost assembly version is 2.5.9.0' - Assert-CcodTrue ($assemblyInfo -match 'AssemblyFileVersion\("2\.5\.9\.0"\)') 'TrayHost file version is 2.5.9.0' + Assert-CcodTrue ($assemblyInfo -match 'AssemblyVersion\("2\.5\.21\.0"\)') 'TrayHost assembly version is 2.5.21.0' + Assert-CcodTrue ($assemblyInfo -match 'AssemblyFileVersion\("2\.5\.21\.0"\)') 'TrayHost file version is 2.5.21.0' $manifest=Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\trayhost\CodexRemote.TrayHost.manifest') -Raw - Assert-CcodTrue ($manifest -match ' 0, "real GetMessage receives the posted owner work"); + Win32TrayPlatform.TranslateAndDispatch(message); + AssertTrue(observed == workMessage, "dispatch preserves the target HWND so posted presentation work reaches the owner WndProc"); + } + finally + { + if (owner != IntPtr.Zero) { platform.DestroyOwner(owner); } + } + } + public static int Main(string[] args) { try @@ -218,14 +281,18 @@ public static int Main(string[] args) TestForegroundFailureFallsBackToNativeMenu(); TestOwnerShowFailureNeverTracks(); TestReentryAndPendingSnapshot(); + TestStalePresentationIsNotReportedAsDisplayed(); + TestMenuRevisionGateHoldsNewPresentationUntilTheDisplayedActionIsSelected(); TestSelectedCommandAndTaskbarRestore(); TestAboutCommandDefersProofToSupervisor(); TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion(); + TestActionFailureUsesTheAcknowledgedSnapshotStrings(); TestSimplifiedMenuAndExitConfirmation(); TestNoHimcFailureIsSafe(); TestShellRightClickNotificationMapping(); TestRealNativePInvokeSurface(); - Console.WriteLine("TrayHost native self-tests passed: 5"); + TestPostedWorkMessageDispatchesToItsOwnerWindow(); + Console.WriteLine("TrayHost native self-tests passed: 15"); return 0; } catch (Exception error) diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs index ccd2531..63d0c7d 100644 --- a/tests/trayhost/TrayHostTransportSelfTest.cs +++ b/tests/trayhost/TrayHostTransportSelfTest.cs @@ -51,17 +51,17 @@ private static void TestBrokenPipeAndStderrCap() private static void TestHostPendingAndReplayBound() { - HostTransport transport = new HostTransport(); + int releasedPresentationSignals = 0; + HostTransport transport = new HostTransport(delegate { releasedPresentationSignals++; }); transport.SetMenuOpen(true); AssertTrue(transport.TryAcceptPresentation(Snapshot(1)), "host accepts first presentation"); AssertTrue(transport.TryAcceptPresentation(Snapshot(2)), "host coalesces a newer presentation while menu is open"); PresentationSnapshot ignored; AssertTrue(!transport.TryTakePresentation(out ignored), "menu-open presentation is not applied early"); transport.SetMenuOpen(false); + AssertTrue(releasedPresentationSignals == 1, "menu close re-signals pending presentation work consumed by the nested menu loop"); PresentationSnapshot newest; AssertTrue(transport.TryTakePresentation(out newest) && newest.Revision == 2UL, "menu close applies newest snapshot only"); - TrayHostControl control; - AssertTrue(transport.TryDequeueControl(out control) && control.Kind == TrayHostControlKind.PresentationAck && control.Revision == 2UL, "one ack is emitted for the applied revision"); Guid actionId = Guid.NewGuid(); AssertTrue(transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 2UL)), "first action accepted"); AssertTrue(!transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 2UL)), "replayed action id is rejected"); @@ -76,6 +76,18 @@ private static void TestHostPendingAndReplayBound() transport.Dispose(); } + private static void TestDirectPresentationAcksDoNotAccumulateAnUnusedControlQueue() + { + HostTransport transport = new HostTransport(); + for (ulong revision = 1UL; revision <= 40UL; revision++) + { + AssertTrue(transport.TryAcceptPresentation(Snapshot(revision)), "production presentation is accepted without a side-channel ACK queue"); + PresentationSnapshot applied; + AssertTrue(transport.TryTakePresentation(out applied) && applied.Revision == revision, "production presentation remains available for the direct wire ACK path"); + } + transport.Dispose(); + } + private static void TestActionResultCorrelationAndControlPriority() { Guid actionId = Guid.NewGuid(); Guid transactionId = Guid.Parse("11111111-2222-3333-4444-555555555555"); @@ -121,6 +133,34 @@ private static void TestAcknowledgedAboutQueuesOneUiWorkItem() host.Dispose(); } + private static void TestRejectedAndFailedActionsQueueUserFeedback() + { + HostTransport host = new HostTransport(); + Guid rejectedId = Guid.NewGuid(); Guid failedId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(rejectedId, TrayCommand.OpenLogs, 20UL)), "rejected action registers"); + AssertTrue(host.TryRegisterAction(new TrayHostAction(failedId, TrayCommand.SetLanguageEnglish, 20UL)), "failed action registers"); + AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(rejectedId, 20UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_UNAVAILABLE", null)), "correlated rejected result is accepted"); + AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(failedId, 20UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "correlated failed result is accepted"); + TrayActionResult first; TrayActionResult second; TrayActionResult none; + AssertTrue(host.TryTakeFailedAction(out first) && first.ActionId == rejectedId && first.Status == TrayActionResultStatus.Rejected, "rejected result queues user feedback"); + AssertTrue(host.TryTakeFailedAction(out second) && second.ActionId == failedId && second.Status == TrayActionResultStatus.Failed, "failed result queues user feedback"); + AssertTrue(!host.TryTakeFailedAction(out none), "each terminal failure queues feedback exactly once"); + host.Dispose(); + } + + private static void TestUndisplayedActionFailureFeedbackIsBounded() + { + HostTransport host = new HostTransport(); + for (int index = 0; index < 9; index++) + { + Guid actionId = Guid.NewGuid(); + AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 21UL)), "terminal failure releases pending action capacity"); + bool accepted = host.TryAcknowledgeAction(new TrayActionResult(actionId, 21UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)); + AssertTrue(index < 8 ? accepted : !accepted, "undisplayed action failure feedback is bounded to the pending-action capacity"); + } + host.Dispose(); + } + public static int Main(string[] args) { try @@ -128,9 +168,12 @@ public static int Main(string[] args) TestParentLatestAndReservedControl(); TestBrokenPipeAndStderrCap(); TestHostPendingAndReplayBound(); + TestDirectPresentationAcksDoNotAccumulateAnUnusedControlQueue(); TestActionResultCorrelationAndControlPriority(); TestAcknowledgedAboutQueuesOneUiWorkItem(); - Console.WriteLine("TrayHost transport self-tests passed: 5"); + TestRejectedAndFailedActionsQueueUserFeedback(); + TestUndisplayedActionFailureFeedbackIsBounded(); + Console.WriteLine("TrayHost transport self-tests passed: 8"); return 0; } catch (Exception error)