From cf27ee5e34c27063541d782d6f0a8e8968bd1164 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 01:10:30 +0800
Subject: [PATCH 001/187] docs: plan v2.5.22 runtime reliability
---
...08-28-v2522-install-runtime-reliability.md | 255 ++++++++++++++++++
...2522-install-runtime-reliability-design.md | 100 +++++++
2 files changed, 355 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
create mode 100644 docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
diff --git a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
new file mode 100644
index 0000000..dc9fb82
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
@@ -0,0 +1,255 @@
+# v2.5.22 Install and Runtime Reliability 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:** Ship v2.5.22 so a fresh or legacy upgrade can only activate the
+payload supplied by that setup, while stable Codex roots survive transient
+Electron child churn and tray action failures remain diagnosable.
+
+**Architecture:** Setup uses a versioned immutable payload directory and a
+hash manifest as the lifecycle source allowlist. Lifecycle close/recovery
+retries only indeterminate child-tree reads while retaining the exact top-level
+root identity boundary. Tray actions retain the authenticated protocol but
+produce a sanitized local diagnostic result and receive end-to-end coverage.
+
+**Tech Stack:** Windows PowerShell 5.1, Inno Setup, C# TrayHost, Node.js 22,
+existing PowerShell persistence self-tests.
+
+**Spec:** `docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md`
+
+## Global Constraints
+
+- Release version is exactly `2.5.22`; setup, portable ZIP, runtime manifests,
+ README, and release contracts must agree.
+- Never modify Codex binaries or `WindowsApps`; preserve the current-user DPAPI
+ device-key file.
+- A changed PID, creation time, session, user SID, package family, executable
+ path, mode, or debug-port identity is fail-closed and must not be retried as
+ success.
+- Every implementation change begins with a failing behavioral test, then the
+ smallest production change, then focused and full validation.
+- Keep the primary checkout untouched; all work occurs in this worktree.
+
+---
+
+### Task 1: Bind setup activation to an immutable payload
+
+**Files:**
+- Modify: `build/build.ps1`
+- Modify: `build/CodexControlOtherDevices.iss`
+- Modify: `Activate-CcodRemoteFix.ps1`
+- Modify: `Install-CodexControlOtherDevices.ps1`
+- Modify: `src/persistence/modules/InstallLifecycle.psm1`
+- Test: `tests/persistence/InstallLifecycle.SelfTest.ps1`
+- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+**Interfaces:**
+- Build writes `installer-payload.manifest.json` with ordered
+ `{schemaVersion,projectVersion,files}` records in the setup payload root.
+- `Activate-CcodRemoteFix.ps1` receives `-ExpectedVersion` and validates the
+ payload before activation and the active runtime after `Ready`.
+- `Invoke-CcodInstall` receives `-ExpectedVersion` and `-PayloadManifestPath`.
+
+- [ ] **Step 1: Write failing lifecycle tests**
+
+```powershell
+$oldPayload = New-CcodLifecycleSourceFixture -Root $source -Version '2.5.13'
+Assert-CcodThrows {
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install `
+ -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters
+} 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
+Assert-CcodEqual $oldRuntime $pointer.activeRuntime 'old payload cannot replace the active runtime'
+```
+
+Add a second test whose matching manifest has an extra stale source file. It
+must prove the staged runtime contains only manifest-listed records.
+
+- [ ] **Step 2: Run the focused test and confirm RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstallLifecycle.SelfTest.ps1`
+
+Expected: the new test fails because `Invoke-CcodInstall` does not accept or
+enforce the expected version/manifest yet.
+
+- [ ] **Step 3: Implement the smallest payload-binding path**
+
+Generate the ordered manifest during build, copy the release payload into
+`{app}\payload\{#ProjectVersion}`, and pass that directory plus
+`-ExpectedVersion {#ProjectVersion}` to activation. Validate each source file
+against the manifest before staging, reject an unexpected project version, and
+reread `active.json` plus the selected runtime manifest after readiness before
+writing `Ready`.
+
+- [ ] **Step 4: Run focused tests and prove GREEN**
+
+Run the lifecycle test, release workflow test, and Inno compile contract test.
+Expected: matching new/upgrade fixtures are Ready; mismatched and stale payload
+fixtures fail before active-pointer mutation.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: bind setup activation to immutable payload
+```
+
+### Task 2: Retry only transient Electron tree reads
+
+**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:**
+- Add `Get-CcodStableVerifiedProcessTree` that accepts a root snapshot,
+ `StatusEvidence`, a fixed retry budget, and adapters.
+- It returns a verified tree only if every successful attempt preserves the
+ original root identity; otherwise it returns no tree.
+
+- [ ] **Step 1: Write failing process-tree tests**
+
+```powershell
+$attempt = 0
+$tree = Get-CcodStableVerifiedProcessTree -Root $root -Adapters @{
+ GetVerifiedTree = { $attempt++; if ($attempt -eq 1) { @() } else { @($root,$child) } }
+ GetProcess = { param($pid,$status) if ($pid -eq $root.Pid) { $root } else { $child } }
+ Delay = { param($milliseconds) }
+}
+Assert-CcodEqual 2 $tree.Count 'one transient empty tree is retried without relaxing root identity'
+```
+
+Add a companion test where the second root snapshot has a different creation
+time; it must return no tree and perform no close mutation.
+
+- [ ] **Step 2: Run focused tests and confirm RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1`
+
+Expected: the new helper is unavailable and the tests fail for that missing
+behavior.
+
+- [ ] **Step 3: Implement bounded stable acquisition**
+
+Use a fixed small retry count and condition-based delay only around a failed
+tree acquisition. On each retry reread the root and require
+`Test-CcodProcessMatch` against the original root. Replace pre-mutation
+`GetTree` calls in close and recovery paths with this helper; leave per-member
+pre-stop/post-stop/final proofs unchanged.
+
+- [ ] **Step 4: Run focused tests and prove GREEN**
+
+Run ProcessControl and SessionEngine self-tests. Confirm child churn retries
+successfully, root drift remains fail-closed, and ordinary close tests still
+prove every stopped member.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: stabilize verified Electron process trees
+```
+
+### Task 3: Make tray action correlation testable and diagnosable
+
+**Files:**
+- Modify: `src/persistence/modules/TrayHostClient.psm1`
+- Modify: `src/persistence/Supervisor.ps1`
+- Modify: `src/trayhost/HostTransport.cs`
+- Modify: `src/trayhost/TrayWindow.cs`
+- Test: `tests/persistence/TrayHostClient.SelfTest.ps1`
+- Test: `tests/persistence/Supervisor.SelfTest.ps1`
+- Test: `tests/trayhost/TrayHostTransportSelfTest.cs`
+
+**Interfaces:**
+- A terminal tray action record carries `command`, `revision`, `status`, and a
+ stable `CCOD_*` code to the local diagnostic log.
+- A current acknowledged presentation revision reaches its command handler;
+ an unacknowledged revision is rejected as `CCOD_TRAY_ACTION_STALE`.
+
+- [ ] **Step 1: Write failing end-to-end action tests**
+
+```powershell
+$action = [pscustomobject]@{ ActionId=[guid]::NewGuid(); Command='OpenLogs'; Revision=[UInt64]7 }
+$hostState.Tray.AcknowledgedPresentations['7'] = $enabledPresentation
+$result = Invoke-CcodSupervisorCommand $hostState $adapters $action
+Assert-CcodEqual 'Completed' $result.Status 'acknowledged revision reaches OpenLogs'
+```
+
+Add the same command with revision `8` and no ACK map entry; assert
+`Rejected` plus `CCOD_TRAY_ACTION_STALE` and one sanitized diagnostic record.
+
+- [ ] **Step 2: Run focused tests and confirm RED**
+
+Run the TrayHost client, Supervisor, and transport tests. Expected failure:
+the diagnostic record and full correlation behavior are absent.
+
+- [ ] **Step 3: Implement only the action correlation record and tests**
+
+Record terminal action outcomes after Supervisor authorization and before the
+host collapses them into the generic dialog. Keep the generic dialog wording
+and authenticated pipe protocol unchanged. Do not add external control or a
+new unauthenticated command channel.
+
+- [ ] **Step 4: Run focused tests and prove GREEN**
+
+Run all three focused suites and the native TrayHost self-test. Confirm a
+stale revision cannot perform any action and a current revision carries the
+exact terminal code into local diagnostics.
+
+- [ ] **Step 5: Commit**
+
+```text
+test: cover tray action revision correlation
+```
+
+### Task 4: Version, documentation, and release acceptance
+
+**Files:**
+- Modify: `package.json`
+- Modify: `src/trayhost/AssemblyInfo.cs`
+- Modify: `src/portable/AssemblyInfo.cs`
+- Modify: `README.md`
+- Modify: `README.zh-CN.md`
+- Modify: `CHANGELOG.md`
+- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+- [ ] **Step 1: Write failing version-contract assertions**
+
+Add release contract coverage requiring version `2.5.22`, the versioned setup
+payload route, and both setup/portable manifests to expose the same version.
+
+- [ ] **Step 2: Run the release contract test and confirm RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+Expected: it fails while the product metadata is still `2.5.21`.
+
+- [ ] **Step 3: Update metadata and concise user documentation**
+
+Set every product version to `2.5.22`. Keep the README stable-release section
+concise; put technical repair detail in `CHANGELOG.md` and the GitHub release
+body.
+
+- [ ] **Step 4: Run focused and full validation**
+
+Run `npm.cmd test`, release contract tests, native TrayHost tests, and a clean
+release build. Review setup and portable manifests byte-for-byte against the
+generated assets.
+
+- [ ] **Step 5: Commit**
+
+```text
+release: prepare CodexRemote-fix v2.5.22
+```
+
+## Plan self-review
+
+- Task 1 produces the immutable source/expected-version contract consumed by
+ the new setup flow and release contract.
+- Task 2 changes only pre-mutation tree acquisition and explicitly leaves all
+ identity and post-stop checks strict.
+- Task 3 has no dependency on Task 2 and can be reviewed independently; its
+ diagnostics make future live failures actionable without weakening protocol
+ authentication.
+- Task 4 consumes the completed behavior and performs no release publication;
+ publishing, installation, and reboot remain a separately authorized final
+ acceptance step.
diff --git a/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
new file mode 100644
index 0000000..cfdb7bc
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
@@ -0,0 +1,100 @@
+# v2.5.22 Install and Runtime Reliability Design
+
+## Goal
+
+Make a fresh installation and an upgrade from any supported historical
+CodexRemote-fix installation converge on one verified current-user runtime,
+then keep remote recovery and tray actions reliable while preserving the
+existing strict process-identity security boundary.
+
+## Evidence driving this design
+
+The reported v2.5.21 machine contained a current versioned runtime while its
+installer shell and active pointer could still describe an older payload. The
+current Inno route treats a correlated `Ready` receipt as sufficient evidence;
+it does not bind the receipt to the setup version or verify the final active
+runtime manifest. Separately, normal Electron child-process churn can make
+the one-pass verified-tree read fail during a close operation even though the
+top-level Codex root has not changed. Native tray commands reach TrayHost but
+the production result path reduces every rejected or failed action to the same
+generic dialog.
+
+## Non-negotiable constraints
+
+- Remain a current-user Windows installation. Do not modify `ChatGPT.exe`,
+ `app.asar`, `WindowsApps`, account state, or the DPAPI device-key store.
+- Retain exact PID, creation time, user SID, package family, executable path,
+ session, and debug-port checks. A retry must never convert an identity
+ mismatch into success.
+- The setup and portable paths must reject an incomplete or stale payload
+ instead of reporting successful activation.
+- Preserve a working old runtime until the new active pointer, Supervisor, and
+ TrayHost have all been proven ready.
+- Keep end-user tray text concise; write sanitized support codes to the local
+ log rather than exposing raw implementation details in a dialog.
+
+## Architecture
+
+### 1. Immutable, version-bound installer payload
+
+The setup package installs its executable payload into
+`{app}\payload\` and launches activation from that exact
+directory. Build generates an `installer-payload-manifest.json` in the same
+directory with a schema version, project version, and ordered SHA-256 records
+for every file the lifecycle installer may copy. The activation worker accepts
+the compile-time expected version, validates the versioned payload manifest and
+the local `package.json`, and passes the expected version and manifest path to
+the lifecycle installer.
+
+`InstallLifecycle` consumes the validated manifest records as its source-file
+allowlist. It no longer discovers arbitrary leftover files under `{app}`.
+After the scheduled task signals readiness it rereads `active.json`, validates
+the selected runtime manifest, and requires that the manifest project version
+equals the expected setup version before it writes a terminal `Ready` receipt.
+This works for new users because the versioned payload directory is created
+from scratch, and for upgrades because an old app root cannot substitute its
+payload for the requested version.
+
+### 2. Bounded stable-tree acquisition
+
+Add a retry wrapper around verified Electron process-tree acquisition for
+close/recovery work. It makes a small, bounded number of fresh snapshots with
+a short condition-based delay only when a read is indeterminate or the child
+set is transient. Every successful attempt must independently prove the same
+top-level root identity and a self-consistent tree. If the root PID, creation
+time, session, SID, package family, executable path, command line mode, or
+recorded debug ports change, the operation remains fail-closed immediately.
+
+The wrapper is used only before a mutation starts. Once a close transition is
+written, the existing per-member pre-stop, post-stop, and final identity checks
+remain strict. Diagnostics classify the last stable failure boundary so a
+future support log can distinguish tree churn from an unsafe process identity.
+
+### 3. Tray action correlation and diagnostics
+
+Keep the authenticated TrayHost protocol and revision/capability gates. Add a
+small sanitized action-result record that includes the command, presentation
+revision, terminal status, and stable error code. Add an end-to-end test that
+drives a native menu command through the real host transport, presentation ACK
+map, Supervisor authorization, and result ACK. A missing acknowledged revision
+must be reported as `CCOD_TRAY_ACTION_STALE`; a current acknowledged revision
+must reach the specific handler.
+
+## Acceptance criteria
+
+1. A test simulating an old installer payload with a newer requested setup
+ version fails before changing the active pointer and returns a stable
+ activation error.
+2. A matching versioned payload activates exactly the expected runtime and the
+ final receipt is `Ready` only after pointer and manifest revalidation.
+3. Fresh and upgrade fixtures copy only manifest-listed payload files; stale
+ app-root leftovers cannot enter a new runtime.
+4. A stable root plus one transient child-tree read succeeds within the fixed
+ retry budget; a changed root identity still fails without a retry-based
+ bypass.
+5. Tray end-to-end action tests prove an acknowledged current revision succeeds
+ and a stale revision is rejected with a recorded support code.
+6. Existing persistence, package, release, native TrayHost, and Defender gates
+ remain green. A real current-user setup install, reboot, remote connection,
+ About, language switching, open logs, and repair are all checked before
+ release completion.
From 30cf6dcde037a8719c60f85ae4c07ee17d44f4cb Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 01:29:29 +0800
Subject: [PATCH 002/187] fix: bind setup activation to immutable payload
---
Activate-CcodRemoteFix.ps1 | 84 +++++++++++-
Install-CodexControlOtherDevices.ps1 | 8 +-
build/CodexControlOtherDevices.iss | 8 ++
build/build.ps1 | 23 +++-
src/persistence/modules/InstallLifecycle.psm1 | 125 +++++++++++++++++-
.../persistence/InstallLifecycle.SelfTest.ps1 | 107 +++++++++++++++
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 13 ++
7 files changed, 361 insertions(+), 7 deletions(-)
diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1
index 9837167..3fa3cf5 100644
--- a/Activate-CcodRemoteFix.ps1
+++ b/Activate-CcodRemoteFix.ps1
@@ -2,6 +2,8 @@
param(
[Parameter(Mandatory)][string]$AppRoot,
[Parameter(Mandatory)][string]$InstallRoot,
+ [string]$PayloadRoot,
+ [string]$ExpectedVersion,
[string]$ActivationId,
[switch]$ValidateReceiptOnly,
[switch]$ValidateReceiptWithTimeout,
@@ -51,6 +53,59 @@ function Write-CcodActivationRecord {
} catch { }
}
+function Assert-CcodActivationPayload {
+ param(
+ [Parameter(Mandatory)][string]$Root,
+ [Parameter(Mandatory)][string]$Version
+ )
+
+ if ($Version -cnotmatch '^\d+\.\d+\.\d+$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ $payload = [IO.Path]::GetFullPath($Root)
+ $packagePath = Join-Path $payload 'package.json'
+ $manifestPath = Join-Path $payload 'installer-payload.manifest.json'
+ $modulePath = Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1'
+ if (-not [IO.File]::Exists($packagePath) -or -not [IO.File]::Exists($manifestPath) -or -not [IO.File]::Exists($modulePath)) {
+ throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
+ }
+ try { $package = Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json -ErrorAction Stop }
+ catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ if ($package.version -isnot [string] -or [string]$package.version -cne $Version) {
+ throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
+ }
+ $module = Import-Module $modulePath -Force -PassThru
+ try {
+ & $module {
+ param($SourceRoot,$ExpectedVersion,$ManifestPath)
+ Get-CcodLifecyclePayloadManifestFiles -SourceRoot $SourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $ManifestPath | Out-Null
+ } $payload $Version $manifestPath
+ } finally {
+ Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue
+ }
+ return $manifestPath
+}
+
+function Assert-CcodActivatedRuntimeVersion {
+ param(
+ [Parameter(Mandatory)][string]$Root,
+ [Parameter(Mandatory)][string]$RuntimeId,
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$RuntimeModulePath
+ )
+
+ $module = Import-Module $RuntimeModulePath -Force -PassThru
+ try {
+ $pointer = Read-CcodActiveRuntime -InstallRoot $Root
+ if ($pointer.activeRuntime -cne $RuntimeId) { throw 'CCOD_ACTIVATION_RUNTIME_VERSION_MISMATCH' }
+ $runtimeRoot = Join-Path (Join-Path $Root 'runtime') $RuntimeId
+ $validation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $RuntimeId
+ if (-not $validation.Valid -or [string]$validation.Manifest.projectVersion -cne $Version) {
+ throw 'CCOD_ACTIVATION_RUNTIME_VERSION_MISMATCH'
+ }
+ } finally {
+ Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue
+ }
+}
+
function Assert-CcodActivationReceiptPathSafe {
param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path)
$canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd([IO.Path]::DirectorySeparatorChar)
@@ -179,6 +234,9 @@ function Invoke-CcodOwnedInstallWorker {
'-ActivationId',
$ActivationId
)
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
+ $arguments += @('-ExpectedVersion',$ExpectedVersion,'-PayloadManifestPath',(Join-Path $PayloadRoot 'installer-payload.manifest.json'))
+ }
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $PowerShellPath
$startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ')
@@ -235,12 +293,15 @@ function Invoke-CcodBoundedReceiptValidator {
$PSCommandPath,
'-AppRoot',
$AppRoot,
+ '-PayloadRoot',
+ $PayloadRoot,
'-InstallRoot',
$InstallRoot,
'-ValidateReceiptOnly',
'-ActivationId',
$ActivationId
)
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { $arguments += @('-ExpectedVersion',$ExpectedVersion) }
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $hostExecutable
$startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ')
@@ -286,6 +347,17 @@ function Show-CcodActivationFailure {
}
if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_VALIDATOR_MODE_INVALID' -ErrorAction Continue; exit 3 }
+if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot }
+$PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot)
+if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
+ try { $null = Assert-CcodActivationPayload -Root $PayloadRoot -Version $ExpectedVersion }
+ catch {
+ $candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0]
+ $code = if ($candidate -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $candidate } elseif ($_.Exception.Message -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $Matches[0] } else { 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ Write-Error $code -ErrorAction Continue
+ exit 3
+ }
+}
if ([string]::IsNullOrWhiteSpace($ActivationId)) {
if ($ValidateReceiptOnly -or $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_ID_INVALID' -ErrorAction Continue; exit 3 }
$ActivationId = [guid]::NewGuid().ToString('D')
@@ -299,7 +371,12 @@ if ($ValidateReceiptWithTimeout) {
if ($ValidateReceiptOnly) {
try {
$terminalReceipt = Read-CcodTerminalActivationReceipt -Root ([IO.Path]::GetFullPath($InstallRoot)) -ExpectedActivationId $ActivationId
- if ($terminalReceipt.phase -ceq 'Ready') { exit 0 }
+ if ($terminalReceipt.phase -ceq 'Ready') {
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
+ Assert-CcodActivatedRuntimeVersion -Root ([IO.Path]::GetFullPath($InstallRoot)) -RuntimeId ([string]$terminalReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $PayloadRoot 'src\persistence\modules\RuntimeManifest.psm1')
+ }
+ exit 0
+ }
if ($terminalReceipt.phase -ceq 'Failed') { exit 2 }
throw 'CCOD_ACTIVATION_RECEIPT_NOT_READY'
} catch {
@@ -313,7 +390,7 @@ $clock = [Diagnostics.Stopwatch]::StartNew()
try {
$root = [IO.Path]::GetFullPath($AppRoot)
$stateRoot = [IO.Path]::GetFullPath($InstallRoot)
- $installScript = Join-Path $root 'Install-CodexControlOtherDevices.ps1'
+ $installScript = Join-Path $PayloadRoot 'Install-CodexControlOtherDevices.ps1'
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
@@ -328,6 +405,9 @@ try {
throw 'CCOD_ACTIVATION_RUNTIME_FAILED'
}
$activationReceipt = Read-CcodFinalActivationReceipt -Root $stateRoot -ExpectedActivationId $ActivationId
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
+ Assert-CcodActivatedRuntimeVersion -Root $stateRoot -RuntimeId ([string]$activationReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $PayloadRoot 'src\persistence\modules\RuntimeManifest.psm1')
+ }
Write-CcodActivationRecord -Code 'RUNTIME_ACTIVATED' -DurationMilliseconds $clock.ElapsedMilliseconds
} catch {
$candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0]
diff --git a/Install-CodexControlOtherDevices.ps1 b/Install-CodexControlOtherDevices.ps1
index 3b5d71f..4960ea0 100644
--- a/Install-CodexControlOtherDevices.ps1
+++ b/Install-CodexControlOtherDevices.ps1
@@ -4,7 +4,9 @@ param(
[switch]$EnableCandidateCompatibleUpdates,
[switch]$RepairState,
[switch]$DoNotStart,
- [string]$ActivationId
+ [string]$ActivationId,
+ [string]$ExpectedVersion,
+ [string]$PayloadManifestPath
)
$ErrorActionPreference = 'Stop'
@@ -37,7 +39,9 @@ $receipt = Invoke-CcodInstall `
-EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) `
-RepairState:([bool]$RepairState) `
-DoNotStart:([bool]$DoNotStart) `
- -ActivationId $ActivationId
+ -ActivationId $ActivationId `
+ -ExpectedVersion $ExpectedVersion `
+ -PayloadManifestPath $PayloadManifestPath
Write-Host ''
Write-Host 'CodexRemote-fix - install result' -ForegroundColor Cyan
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index 49445df..c8c6e29 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -5,6 +5,9 @@
#ifndef PortableArtifactDirectory
#define PortableArtifactDirectory SourcePath + "\generated\portable"
#endif
+#ifndef InstallerPayloadDirectory
+#define InstallerPayloadDirectory SourcePath + "\.."
+#endif
AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}
AppName=CodexRemote-fix
AppVersion={#ProjectVersion}
@@ -68,6 +71,7 @@ Source: "..\Reset-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignore
Source: "..\Test-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\Activate-CcodRemoteFix.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\Prompt-CcodRestart.ps1"; DestDir: "{app}"; Flags: ignoreversion
+Source: "{#InstallerPayloadDirectory}\*"; DestDir: "{app}\payload\{#ProjectVersion}"; Flags: ignoreversion recursesubdirs createallsubdirs
[InstallDelete]
Type: files; Name: "{userprograms}\Codex Control other devices\Codex Control other devices for Windows.lnk"
@@ -294,6 +298,8 @@ begin
UpdateActivationStartingPresentation();
Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' +
ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') +
+ '" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') +
+ '" -ExpectedVersion "{#ProjectVersion}' +
'" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') +
'" -ActivationId "' + ActivationId + '" -FirstReceiptTimeoutMilliseconds ' +
IntToStr(FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS) + ' -ActivationTimeoutMilliseconds ' +
@@ -312,6 +318,8 @@ begin
end;
Parameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' +
ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') +
+ '" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') +
+ '" -ExpectedVersion "{#ProjectVersion}' +
'" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') +
'" -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds ' + IntToStr(VALIDATION_TIMEOUT_MILLISECONDS) +
' -ActivationId "' + ActivationId + '"';
diff --git a/build/build.ps1 b/build/build.ps1
index 3fc8236..af3ba71 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -138,6 +138,7 @@ foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$release
}
$stageRoot = Join-Path $PSScriptRoot ('.portable-stage-' + [guid]::NewGuid().ToString('N'))
+$installerPayloadDirectory = Join-Path $PSScriptRoot ('.installer-payload-stage-' + [guid]::NewGuid().ToString('N'))
try {
[IO.Directory]::CreateDirectory($stageRoot) | Out-Null
$payloadRoot = Join-Path $stageRoot 'payload'
@@ -186,6 +187,16 @@ try {
Remove-Module -Name $lifecycleModule.Name -Force -ErrorAction SilentlyContinue
}
$payloadRecords = Get-CcodBuildPayloadRecords -PayloadRoot $payloadRoot
+ [IO.Directory]::CreateDirectory($installerPayloadDirectory) | Out-Null
+ foreach ($record in $payloadRecords) {
+ Copy-CcodBuildPayloadFile -Source (Join-Path $payloadRoot ([string]$record.path).Replace('/','\')) -PayloadRoot $installerPayloadDirectory -Relative ([string]$record.path)
+ }
+ $installerPayloadManifest = [ordered]@{
+ schemaVersion = 1
+ projectVersion = $Version
+ files = $payloadRecords
+ }
+ Write-CcodBuildUtf8 -Path (Join-Path $installerPayloadDirectory 'installer-payload.manifest.json') -Text (($installerPayloadManifest | ConvertTo-Json -Depth 8) + [Environment]::NewLine)
$payloadManifestPath = Join-Path $stageRoot 'payload-manifest.json'
$payloadManifest = [ordered]@{
schemaVersion = 1
@@ -257,7 +268,7 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -
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'
$portableArtifact = Join-Path $PSScriptRoot 'generated\portable'
-& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/O$dist\." $issPath
+& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/O$dist\." $issPath
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) {
throw "Inno Setup compilation failed with exit code $LASTEXITCODE"
}
@@ -285,3 +296,13 @@ Write-Host (" Setup: {0}" -f $setupExe)
Write-Host (" SHA-256: {0}" -f $setupChecksum)
Write-Host (" Manifest: {0}" -f $setupReleaseManifest)
Write-Host ''
+
+if ([IO.Directory]::Exists($installerPayloadDirectory)) {
+ $installerPayloadFull = [IO.Path]::GetFullPath($installerPayloadDirectory)
+ $buildFull = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\'
+ if ($installerPayloadFull.StartsWith($buildFull,[StringComparison]::OrdinalIgnoreCase) -and
+ [IO.Path]::GetFileName($installerPayloadFull).StartsWith('.installer-payload-stage-',[StringComparison]::Ordinal) -and
+ -not ((Get-Item -LiteralPath $installerPayloadFull -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ Remove-Item -LiteralPath $installerPayloadFull -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1
index ffc7656..ed5c4c3 100644
--- a/src/persistence/modules/InstallLifecycle.psm1
+++ b/src/persistence/modules/InstallLifecycle.psm1
@@ -363,6 +363,108 @@ function Get-CcodLifecycleSourceFiles {
return @($files)
}
+function Get-CcodLifecyclePayloadManifestFiles {
+ param(
+ [Parameter(Mandatory)][string]$SourceRoot,
+ [Parameter(Mandatory)][string]$ExpectedVersion,
+ [Parameter(Mandatory)][string]$PayloadManifestPath
+ )
+
+ if ($ExpectedVersion -cnotmatch '^\d+\.\d+\.\d+$') {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_INPUT_INVALID' 'ExpectedVersion must be a canonical three-part project version' $ExpectedVersion
+ }
+ $root = Get-CcodLifecycleCanonicalRoot -Path $SourceRoot -Kind 'Source root'
+ try { $manifestPath = [IO.Path]::GetFullPath($PayloadManifestPath) }
+ catch { Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest path could not be canonicalized' $PayloadManifestPath }
+ $prefix = $root.TrimEnd('\') + '\'
+ if (-not $manifestPath.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or -not [IO.File]::Exists($manifestPath)) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest must be a file inside the source root' $manifestPath
+ }
+ $cursor = $manifestPath
+ while ($true) {
+ if (Test-CcodLifecycleReparse -Path $cursor) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_REPARSE' 'Payload manifest path contains a reparse point' $cursor
+ }
+ if ($cursor -ceq $root) { break }
+ $cursor = Split-Path $cursor -Parent
+ if ([string]::IsNullOrWhiteSpace($cursor)) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest escaped the source root' $manifestPath
+ }
+ }
+ try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -ErrorAction Stop }
+ catch { Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest is not valid JSON' $manifestPath }
+ if ($manifest -isnot [pscustomobject]) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest must be one JSON object' $manifestPath
+ }
+ $properties = @($manifest.PSObject.Properties.Name)
+ $expectedProperties = @('schemaVersion','projectVersion','files')
+ if ($properties.Count -ne $expectedProperties.Count) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest fields are invalid' $manifestPath
+ }
+ for ($index = 0; $index -lt $expectedProperties.Count; $index++) {
+ if ($properties[$index] -cne $expectedProperties[$index]) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest fields are not canonical' $manifestPath
+ }
+ }
+ if ($manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or $manifest.projectVersion -isnot [string]) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest header is invalid' $manifestPath
+ }
+ if ($manifest.projectVersion -cne $ExpectedVersion) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' 'Payload manifest does not match the expected setup version' $manifestPath
+ }
+ $records = @($manifest.files)
+ if ($records.Count -eq 0) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest contains no source records' $manifestPath
+ }
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ $files = [Collections.Generic.List[object]]::new()
+ $previousPath = $null
+ foreach ($record in $records) {
+ if ($record -isnot [pscustomobject] -or
+ @($record.PSObject.Properties.Name).Count -ne 3 -or
+ @($record.PSObject.Properties.Name)[0] -cne 'path' -or
+ @($record.PSObject.Properties.Name)[1] -cne 'length' -or
+ @($record.PSObject.Properties.Name)[2] -cne 'sha256' -or
+ $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or
+ $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or
+ $record.length -isnot [ValueType] -or [int64]$record.length -lt 0 -or
+ $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$') {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest file record is invalid' $manifestPath
+ }
+ if (($null -ne $previousPath -and [StringComparer]::Ordinal.Compare($previousPath,[string]$record.path) -ge 0) -or -not $seen.Add([string]$record.path)) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest file records are not strictly ordered and unique' $manifestPath
+ }
+ $previousPath = [string]$record.path
+ $source = [IO.Path]::GetFullPath((Join-Path $root ($record.path.Replace('/','\'))))
+ if (-not $source.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or -not [IO.File]::Exists($source)) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'A manifest-listed source file is missing' $source
+ }
+ $cursor = $source
+ while ($true) {
+ if (Test-CcodLifecycleReparse -Path $cursor) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_REPARSE' 'A manifest-listed source path contains a reparse point' $cursor
+ }
+ if ($cursor -ceq $root) { break }
+ $cursor = Split-Path $cursor -Parent
+ }
+ $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or (Test-CcodLifecycleAlternateDataStreams -Path $source)) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'A manifest-listed source file is not a plain data-stream file' $source
+ }
+ if ([int64]$item.Length -ne [int64]$record.length -or
+ (Get-CcodLifecycleFileSha256 -Path $source) -cne [string]$record.sha256) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'A manifest-listed source file failed immutable payload validation' $source
+ }
+ $files.Add([pscustomobject][ordered]@{
+ Relative = ([string]$record.path).Replace('/','\')
+ Source = $source
+ ExpectedLength = [int64]$record.length
+ ExpectedSha256 = [string]$record.sha256
+ })
+ }
+ return @($files)
+}
+
function Copy-CcodLifecycleStaging {
param(
[Parameter(Mandatory)][string]$SourceRoot,
@@ -407,7 +509,7 @@ function Copy-CcodLifecycleStaging {
}
foreach ($file in $Files) {
$destination = [IO.Path]::GetFullPath((Join-Path $stagingDirectory $file.Relative))
- $expected = Get-CcodLifecycleFileSha256 -Path $file.Source
+ $expected = if ($null -ne $file.PSObject.Properties['ExpectedSha256']) { [string]$file.ExpectedSha256 } else { Get-CcodLifecycleFileSha256 -Path $file.Source }
$actual = Get-CcodLifecycleFileSha256 -Path $destination
if ($actual -cne $expected) {
Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'A staged runtime file failed its source hash comparison' $destination
@@ -1562,6 +1664,8 @@ function Invoke-CcodInstall {
[switch]$RepairState,
[switch]$DoNotStart,
[string]$ActivationId,
+ [string]$ExpectedVersion,
+ [string]$PayloadManifestPath,
[hashtable]$Adapters
)
@@ -1586,6 +1690,13 @@ function Invoke-CcodInstall {
if (-not [IO.Directory]::Exists($sourceRoot)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_MISSING' 'Source checkout does not exist' $sourceRoot
}
+ $payloadBound = -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -or -not [string]::IsNullOrWhiteSpace($PayloadManifestPath)
+ if ($payloadBound -and ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($PayloadManifestPath))) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_INPUT_INVALID' 'ExpectedVersion and PayloadManifestPath must be supplied together' $null
+ }
+ $files = if ($payloadBound) {
+ @(Get-CcodLifecyclePayloadManifestFiles -SourceRoot $sourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $PayloadManifestPath)
+ } else { $null }
if (-not (& $adapters.ValidateSource $sourceRoot)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INVALID' 'Source checkout failed hermetic validation' $sourceRoot
}
@@ -1594,8 +1705,11 @@ function Invoke-CcodInstall {
Throw-CcodLifecycleError 'CCOD_INSTALL_IDENTITY_INVALID' 'Current user identity is unavailable' $null
}
$projectVersion = & $adapters.GetProjectVersion $sourceRoot
+ if ($payloadBound -and [string]$projectVersion -cne $ExpectedVersion) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' 'Source package version does not match the expected setup version' $sourceRoot
+ }
$nodeCandidates = @(Get-CcodLifecycleNodeCandidates -Adapters $adapters)
- $files = @(Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot -RequireTrayHost)
+ if (-not $payloadBound) { $files = @(Get-CcodLifecycleSourceFiles -SourceRoot $sourceRoot -RequireTrayHost) }
$existingPointer = $null
$activePath = Join-Path $root 'active.json'
@@ -1730,6 +1844,13 @@ function Invoke-CcodInstall {
-not $readyProof.SupervisorReady -or -not $readyProof.TrayReady) {
Throw-CcodLifecycleError 'CCOD_INSTALL_NEW_RUNTIME_NOT_READY' 'The new Supervisor and authenticated TrayHost readiness were not proven' $null
}
+ $finalPointer = Read-CcodActiveRuntime -InstallRoot $root
+ $finalValidation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $runtimeId
+ $finalExpectedVersion = if ($payloadBound) { $ExpectedVersion } else { [string]$projectVersion }
+ if ($finalPointer.activeRuntime -cne $runtimeId -or [UInt64]$finalPointer.generation -ne [UInt64]$pointer.generation -or
+ -not $finalValidation.Valid -or [string]$finalValidation.Manifest.projectVersion -cne $finalExpectedVersion) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN' 'The final active pointer and runtime version could not be revalidated' $runtimeRoot
+ }
Write-CcodInstallActivationPhase -Activation $activation -Phase 'Ready' -RuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime -ErrorCode $null -Adapters $adapters -InstallRoot $root | Out-Null
if ($upgrade) {
try { Remove-CcodLifecycleOldRuntimes -InstallRoot $root -ActiveRuntimeId $runtimeId -PreviousRuntimeId $pointer.previousRuntime }
diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1
index d84bd9b..e8710b9 100644
--- a/tests/persistence/InstallLifecycle.SelfTest.ps1
+++ b/tests/persistence/InstallLifecycle.SelfTest.ps1
@@ -60,6 +60,34 @@ function New-CcodLifecycleSourceFixture {
return $Root
}
+function New-CcodLifecyclePayloadManifest {
+ param(
+ [Parameter(Mandatory)][string]$Root,
+ [Parameter(Mandatory)][string]$Version
+ )
+
+ $module = Get-Module InstallLifecycle
+ $sourceFiles = @(& $module { param($SourceRoot) Get-CcodLifecycleSourceFiles -SourceRoot $SourceRoot -RequireTrayHost } $Root)
+ $recordList = [Collections.Generic.List[object]]::new()
+ foreach ($sourceFile in $sourceFiles) {
+ $recordList.Add([pscustomobject][ordered]@{
+ path = ([string]$sourceFile.Relative).Replace('\','/')
+ length = [int64](Get-Item -LiteralPath $sourceFile.Source -Force).Length
+ sha256 = (Get-FileHash -LiteralPath $sourceFile.Source -Algorithm SHA256).Hash.ToLowerInvariant()
+ })
+ }
+ $comparison = [System.Comparison[object]]{ param($left,$right) [StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path) }
+ $recordList.Sort($comparison)
+ $records = @($recordList)
+ $manifestPath = Join-Path $Root 'installer-payload.manifest.json'
+ [IO.File]::WriteAllText(
+ $manifestPath,
+ ([ordered]@{ schemaVersion = 1; projectVersion = $Version; files = $records } | ConvertTo-Json -Depth 8),
+ [Text.UTF8Encoding]::new($false)
+ )
+ return $manifestPath
+}
+
function New-CcodLifecycleFakeNode {
param([Parameter(Mandatory)][string]$Root)
New-Item -ItemType Directory -Path (Join-Path $Root 'node') -Force | Out-Null
@@ -639,6 +667,85 @@ $results += Invoke-CcodTest 'first install stages verifies activates task and pe
}
}
+# Production mutation caught: accepting an old setup payload for a newer expected setup version and stopping the active runtime before rejecting it.
+$results += Invoke-CcodTest 'upgrade rejects a payload version mismatch before active-pointer or protection mutation' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.13' | Out-Null
+ $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.13'
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ $first = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters
+ $oldRuntime = [string]$first.RuntimeId
+ $before = Read-CcodActiveRuntime -InstallRoot $install
+ $fake = New-CcodLifecycleFake -NodePath $nodePath
+
+ Assert-CcodThrows {
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null
+ } 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
+
+ $after = Read-CcodActiveRuntime -InstallRoot $install
+ Assert-CcodEqual $oldRuntime $after.activeRuntime 'old payload cannot replace the active runtime'
+ Assert-CcodEqual $before.generation $after.generation 'version mismatch cannot advance the active generation'
+ Assert-CcodEqual 0 $fake.World.ShutdownSignaled 'version mismatch cannot stop the existing protection'
+ Assert-CcodEqual 0 $fake.World.TaskInstalled 'version mismatch performs no task mutation'
+ } finally {
+ foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+# Production mutation caught: rediscovering the payload directory after manifest validation and copying an unlisted leftover into the runtime.
+$results += Invoke-CcodTest 'matching payload stages only immutable manifest-listed records' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null
+ $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22'
+ $stalePath = Join-Path $source 'src\runtime\stale-old-payload.js'
+ [IO.File]::WriteAllText($stalePath,"module.exports = 'stale';`n",[Text.UTF8Encoding]::new($false))
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+
+ $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters
+
+ $runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)"
+ Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $runtimeRoot 'src\runtime\stale-old-payload.js'))) 'unlisted stale source file is absent from the runtime'
+ $runtimeManifest = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $receipt.RuntimeId
+ Assert-CcodEqual $true $runtimeManifest.Valid 'manifest-filtered runtime remains valid'
+ Assert-CcodEqual 0 @($runtimeManifest.Manifest.files | Where-Object { $_.path -ceq 'src/runtime/stale-old-payload.js' }).Count 'runtime manifest contains no unlisted stale record'
+ } finally {
+ foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+# Production mutation caught: trusting readiness without rereading the active pointer and selected runtime manifest before the terminal Ready receipt.
+$results += Invoke-CcodTest 'payload-bound install rereads the active pointer after readiness before reporting Ready' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null
+ $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22'
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ $first = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters
+ $oldPointer = Read-CcodActiveRuntime -InstallRoot $install
+ $fake = New-CcodLifecycleFake -NodePath $nodePath
+ $fake.Adapters.WaitNewRuntimeReady = {
+ param($InstallRoot,$RuntimeId,$RuntimeGeneration,$Identity,$TaskStartedAtUtc,$TimeoutMilliseconds)
+ [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),($oldPointer | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ [pscustomobject][ordered]@{ SupervisorReady = $true; TrayReady = $true }
+ }.GetNewClosure()
+
+ Assert-CcodThrows {
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null
+ } 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN'
+ Assert-CcodTrue ($fake.World.Phases -notcontains 'Ready') 'pointer drift after readiness cannot produce a Ready receipt'
+ } finally {
+ foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
# 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
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 2fc3b87..e8b881a 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -80,6 +80,19 @@ Invoke-CcodTest 'release defender tool exposes manifest and scan functions witho
Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodReleaseDefenderCheck -ErrorAction SilentlyContinue)) 'Defender invocation is available'
}
+Invoke-CcodTest 'setup build and activation bind one immutable versioned payload end to end' {
+ $build = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw -Encoding UTF8
+ $inno = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8
+ $activation = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8
+ $installer = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Install-CodexControlOtherDevices.ps1') -Raw -Encoding UTF8
+
+ Assert-CcodTrue ($build -cmatch 'installer-payload\.manifest\.json' -and $build -cmatch 'schemaVersion\s*=\s*1\s+projectVersion\s*=\s*\$Version\s+files\s*=\s*\$payloadRecords') 'build emits the canonical ordered setup payload manifest'
+ Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory'
+ Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version'
+ Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract'
+ Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '-ExpectedVersion\s+\$ExpectedVersion' -and $installer -cmatch '-PayloadManifestPath\s+\$PayloadManifestPath') 'installer forwards the immutable payload contract to lifecycle activation'
+}
+
Invoke-CcodTest 'release manifest binds the final asset names hashes version commit and timestamp' {
. $defenderPath -Library
$fixture = New-CcodReleaseFixture
From aec0eeb06b55ada520a517b76f3928363df532cf Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 02:06:21 +0800
Subject: [PATCH 003/187] fix: harden immutable setup payload verification
---
Activate-CcodRemoteFix.ps1 | 123 +++++++-
build/CodexControlOtherDevices.iss | 32 +-
build/build.ps1 | 19 +-
src/persistence/modules/InstallLifecycle.psm1 | 79 ++++-
.../persistence/InstallLifecycle.SelfTest.ps1 | 135 +++++++++
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 277 +++++++++++++++++-
tools/New-InstallerPayloadManifest.ps1 | 54 ++++
7 files changed, 683 insertions(+), 36 deletions(-)
create mode 100644 tools/New-InstallerPayloadManifest.ps1
diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1
index 3fa3cf5..bebd91a 100644
--- a/Activate-CcodRemoteFix.ps1
+++ b/Activate-CcodRemoteFix.ps1
@@ -4,6 +4,7 @@ param(
[Parameter(Mandatory)][string]$InstallRoot,
[string]$PayloadRoot,
[string]$ExpectedVersion,
+ [string]$ExpectedPayloadManifestSha256,
[string]$ActivationId,
[switch]$ValidateReceiptOnly,
[switch]$ValidateReceiptWithTimeout,
@@ -53,34 +54,126 @@ function Write-CcodActivationRecord {
} catch { }
}
+function Get-CcodActivationFileSha256 {
+ param([Parameter(Mandatory)][string]$Path)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ $stream = $null
+ try {
+ $stream = [IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()
+ } finally {
+ if ($null -ne $stream) { $stream.Dispose() }
+ $sha.Dispose()
+ }
+}
+
+function Assert-CcodActivationNonReparsePath {
+ param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path)
+ $canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+ $canonicalPath = [IO.Path]::GetFullPath($Path)
+ $prefix = $canonicalRoot + '\'
+ if (-not ($canonicalPath.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase) -or $canonicalPath.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) {
+ throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID'
+ }
+ $cursor = $canonicalPath
+ while ($true) {
+ $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($cursor.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase)) { break }
+ $parent = Split-Path $cursor -Parent
+ if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $cursor) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
+ $cursor = $parent
+ }
+ return $canonicalPath
+}
+
+function Test-CcodActivationAlternateDataStreams {
+ param([Parameter(Mandatory)][string]$Path)
+ try {
+ foreach ($stream in @(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop)) {
+ if ([string]$stream.Stream -cnotin @(':$DATA','::$DATA','$DATA')) { return $true }
+ }
+ return $false
+ } catch {
+ throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
+ }
+}
+
function Assert-CcodActivationPayload {
param(
+ [Parameter(Mandatory)][string]$AppRoot,
[Parameter(Mandatory)][string]$Root,
- [Parameter(Mandatory)][string]$Version
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$ExpectedManifestSha256
)
- if ($Version -cnotmatch '^\d+\.\d+\.\d+$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
- $payload = [IO.Path]::GetFullPath($Root)
+ if ($Version -cnotmatch '^\d+\.\d+\.\d+$' -or $ExpectedManifestSha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ $app = [IO.Path]::GetFullPath($AppRoot).TrimEnd('\')
+ $payload = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+ $expectedPayload = [IO.Path]::GetFullPath((Join-Path (Join-Path $app 'payload') $Version)).TrimEnd('\')
+ if (-not $payload.Equals($expectedPayload,[StringComparison]::OrdinalIgnoreCase)) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
+ [void](Assert-CcodActivationNonReparsePath -Root $app -Path $app)
+ [void](Assert-CcodActivationNonReparsePath -Root $app -Path (Join-Path $app 'payload'))
+ [void](Assert-CcodActivationNonReparsePath -Root $app -Path $payload)
$packagePath = Join-Path $payload 'package.json'
$manifestPath = Join-Path $payload 'installer-payload.manifest.json'
$modulePath = Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1'
if (-not [IO.File]::Exists($packagePath) -or -not [IO.File]::Exists($manifestPath) -or -not [IO.File]::Exists($modulePath)) {
throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
}
- try { $package = Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json -ErrorAction Stop }
+ [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $manifestPath)
+ $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop
+ if ($manifestItem.PSIsContainer -or $manifestItem.Length -le 0 -or $manifestItem.Length -gt 4194304 -or
+ (Test-CcodActivationAlternateDataStreams -Path $manifestPath) -or
+ (Get-CcodActivationFileSha256 -Path $manifestPath) -cne $ExpectedManifestSha256) {
+ throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
+ }
+ try {
+ $manifest = [IO.File]::ReadAllText($manifestPath,[Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop
+ } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $properties = @($manifest.PSObject.Properties.Name)
+ $expectedProperties = @('schemaVersion','projectVersion','files')
+ if ($manifest -isnot [pscustomobject] -or $properties.Count -ne $expectedProperties.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ for ($index=0;$index-lt$expectedProperties.Count;$index++) {
+ if ($properties[$index] -cne $expectedProperties[$index]) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ }
+ if ($manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or $manifest.projectVersion -isnot [string] -or $manifest.projectVersion -cne $Version) {
+ throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
+ }
+ $records = @($manifest.files)
+ if ($records.Count -eq 0) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ $previous = $null
+ foreach ($record in $records) {
+ $recordProperties = @($record.PSObject.Properties.Name)
+ if ($record -isnot [pscustomobject] -or $recordProperties.Count -ne 3 -or $recordProperties[0] -cne 'path' -or $recordProperties[1] -cne 'length' -or $recordProperties[2] -cne 'sha256' -or
+ $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or
+ $record.length -isnot [ValueType] -or [decimal]$record.length -ne [decimal][int64]$record.length -or [int64]$record.length -lt 0 -or
+ $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ if (($null-ne$previous -and [StringComparer]::Ordinal.Compare([string]$previous,[string]$record.path)-ge 0) -or -not $seen.Add([string]$record.path)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $previous = [string]$record.path
+ $source = [IO.Path]::GetFullPath((Join-Path $payload ($record.path.Replace('/','\'))))
+ [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $source)
+ $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or (Test-CcodActivationAlternateDataStreams -Path $source) -or [int64]$item.Length -ne [int64]$record.length -or
+ (Get-CcodActivationFileSha256 -Path $source) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
+ }
+ $actualFileList = [Collections.Generic.List[string]]::new()
+ foreach ($actualItem in @(Get-ChildItem -LiteralPath $payload -Force -Recurse -ErrorAction Stop)) {
+ if (($actualItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($actualItem.PSIsContainer -or $actualItem.FullName.Equals($manifestPath,[StringComparison]::OrdinalIgnoreCase)) { continue }
+ $actualFileList.Add($actualItem.FullName.Substring($payload.Length + 1).Replace('\','/'))
+ }
+ $actualFiles = @($actualFileList)
+ if ($actualFiles.Count -ne $records.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ foreach ($actual in $actualFiles) {
+ if (-not $seen.Contains([string]$actual)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ }
+ try { $package = [IO.File]::ReadAllText($packagePath,[Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop }
catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
if ($package.version -isnot [string] -or [string]$package.version -cne $Version) {
throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
}
- $module = Import-Module $modulePath -Force -PassThru
- try {
- & $module {
- param($SourceRoot,$ExpectedVersion,$ManifestPath)
- Get-CcodLifecyclePayloadManifestFiles -SourceRoot $SourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $ManifestPath | Out-Null
- } $payload $Version $manifestPath
- } finally {
- Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue
- }
return $manifestPath
}
@@ -301,7 +394,7 @@ function Invoke-CcodBoundedReceiptValidator {
'-ActivationId',
$ActivationId
)
- if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { $arguments += @('-ExpectedVersion',$ExpectedVersion) }
+ if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) { $arguments += @('-ExpectedVersion',$ExpectedVersion,'-ExpectedPayloadManifestSha256',$ExpectedPayloadManifestSha256) }
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $hostExecutable
$startInfo.Arguments = (($arguments | ForEach-Object { ConvertTo-CcodNativeProcessArgument ([string]$_) }) -join ' ')
@@ -350,7 +443,7 @@ if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_A
if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot }
$PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot)
if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
- try { $null = Assert-CcodActivationPayload -Root $PayloadRoot -Version $ExpectedVersion }
+ try { $null = Assert-CcodActivationPayload -AppRoot $AppRoot -Root $PayloadRoot -Version $ExpectedVersion -ExpectedManifestSha256 $ExpectedPayloadManifestSha256 }
catch {
$candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0]
$code = if ($candidate -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $candidate } elseif ($_.Exception.Message -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $Matches[0] } else { 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index c8c6e29..1a0eade 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -6,7 +6,10 @@
#define PortableArtifactDirectory SourcePath + "\generated\portable"
#endif
#ifndef InstallerPayloadDirectory
-#define InstallerPayloadDirectory SourcePath + "\.."
+#error InstallerPayloadDirectory must be supplied by the release builder
+#endif
+#ifndef InstallerPayloadManifestSha256
+#error InstallerPayloadManifestSha256 must be supplied by the release builder
#endif
AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}
AppName=CodexRemote-fix
@@ -71,6 +74,7 @@ Source: "..\Reset-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignore
Source: "..\Test-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\Activate-CcodRemoteFix.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\Prompt-CcodRestart.ps1"; DestDir: "{app}"; Flags: ignoreversion
+Source: "..\tools\New-InstallerPayloadManifest.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "{#InstallerPayloadDirectory}\*"; DestDir: "{app}\payload\{#ProjectVersion}"; Flags: ignoreversion recursesubdirs createallsubdirs
[InstallDelete]
@@ -115,6 +119,30 @@ function StringFromGUID2(var Guid: TGUID; GuidString: String; MaxCharacters: Int
function GetFileAttributesW(const FileName: String): Cardinal;
external 'GetFileAttributesW@kernel32.dll stdcall';
+function IsSafeExistingPayloadDirectory(const DirectoryName: String): Boolean;
+var
+ Attributes: Cardinal;
+begin
+ Attributes := GetFileAttributesW(DirectoryName);
+ Result := (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or
+ (((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and
+ ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0));
+end;
+
+function PrepareToInstall(var NeedsRestart: Boolean): String;
+var
+ AppDirectory, PayloadDirectory, VersionDirectory: String;
+begin
+ Result := '';
+ AppDirectory := ExpandConstant('{app}');
+ PayloadDirectory := ExpandConstant('{app}\payload');
+ VersionDirectory := ExpandConstant('{app}\payload\{#ProjectVersion}');
+ if not IsSafeExistingPayloadDirectory(AppDirectory) or
+ not IsSafeExistingPayloadDirectory(PayloadDirectory) or
+ not IsSafeExistingPayloadDirectory(VersionDirectory) then
+ Result := 'CodexRemote-fix refused an unsafe existing payload directory.';
+end;
+
function NewActivationId(): String;
var
Guid: TGUID;
@@ -300,6 +328,7 @@ begin
ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') +
'" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') +
'" -ExpectedVersion "{#ProjectVersion}' +
+ '" -ExpectedPayloadManifestSha256 "{#InstallerPayloadManifestSha256}' +
'" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') +
'" -ActivationId "' + ActivationId + '" -FirstReceiptTimeoutMilliseconds ' +
IntToStr(FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS) + ' -ActivationTimeoutMilliseconds ' +
@@ -320,6 +349,7 @@ begin
ExpandConstant('{app}\Activate-CcodRemoteFix.ps1') + '" -AppRoot "' + ExpandConstant('{app}') +
'" -PayloadRoot "' + ExpandConstant('{app}\payload\{#ProjectVersion}') +
'" -ExpectedVersion "{#ProjectVersion}' +
+ '" -ExpectedPayloadManifestSha256 "{#InstallerPayloadManifestSha256}' +
'" -InstallRoot "' + ExpandConstant('{localappdata}\CodexControlOtherDevices') +
'" -ValidateReceiptWithTimeout -ValidationTimeoutMilliseconds ' + IntToStr(VALIDATION_TIMEOUT_MILLISECONDS) +
' -ActivationId "' + ActivationId + '"';
diff --git a/build/build.ps1 b/build/build.ps1
index af3ba71..27b0b29 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -191,12 +191,19 @@ try {
foreach ($record in $payloadRecords) {
Copy-CcodBuildPayloadFile -Source (Join-Path $payloadRoot ([string]$record.path).Replace('/','\')) -PayloadRoot $installerPayloadDirectory -Relative ([string]$record.path)
}
- $installerPayloadManifest = [ordered]@{
- schemaVersion = 1
- projectVersion = $Version
- files = $payloadRecords
+ $installerPayloadManifestPath = Join-Path $installerPayloadDirectory 'installer-payload.manifest.json'
+ $installerPayloadGenerator = Join-Path $repoRoot 'tools\New-InstallerPayloadManifest.ps1'
+ if (-not [IO.File]::Exists($installerPayloadGenerator)) { throw "Installer payload manifest generator is missing: $installerPayloadGenerator" }
+ $installerPayloadManifest = & $installerPayloadGenerator -PayloadRoot $installerPayloadDirectory -ProjectVersion $Version -OutputPath $installerPayloadManifestPath
+ if (@($installerPayloadManifest.files).Count -ne $payloadRecords.Count) { throw 'Installer payload generator record count differs from the verified portable payload.' }
+ for ($recordIndex = 0; $recordIndex -lt $payloadRecords.Count; $recordIndex++) {
+ if ([string]$installerPayloadManifest.files[$recordIndex].path -cne [string]$payloadRecords[$recordIndex].path -or
+ [int64]$installerPayloadManifest.files[$recordIndex].length -ne [int64]$payloadRecords[$recordIndex].length -or
+ [string]$installerPayloadManifest.files[$recordIndex].sha256 -cne [string]$payloadRecords[$recordIndex].sha256) {
+ throw 'Installer payload generator records differ from the verified portable payload.'
+ }
}
- Write-CcodBuildUtf8 -Path (Join-Path $installerPayloadDirectory 'installer-payload.manifest.json') -Text (($installerPayloadManifest | ConvertTo-Json -Depth 8) + [Environment]::NewLine)
+ $installerPayloadManifestSha256 = Get-CcodBuildFileSha256 -Path $installerPayloadManifestPath
$payloadManifestPath = Join-Path $stageRoot 'payload-manifest.json'
$payloadManifest = [ordered]@{
schemaVersion = 1
@@ -268,7 +275,7 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -
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'
$portableArtifact = Join-Path $PSScriptRoot 'generated\portable'
-& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/O$dist\." $issPath
+& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256" "/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/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1
index ed5c4c3..ecbf18e 100644
--- a/src/persistence/modules/InstallLifecycle.psm1
+++ b/src/persistence/modules/InstallLifecycle.psm1
@@ -142,6 +142,38 @@ function Get-CcodLifecycleCanonicalRoot {
return $root.TrimEnd('\')
}
+function Assert-CcodLifecycleInstallPathSafe {
+ param(
+ [Parameter(Mandatory)][string]$InstallRoot,
+ [Parameter(Mandatory)][string]$Path
+ )
+
+ $root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root'
+ $candidate = [IO.Path]::GetFullPath($Path)
+ $prefix = $root.TrimEnd('\') + '\'
+ if (-not ($candidate.Equals($root,[StringComparison]::OrdinalIgnoreCase) -or $candidate.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PATH_OUTSIDE_ROOT' 'Install path escaped the install root' $candidate
+ }
+ $cursor = $candidate
+ while (-not (Test-Path -LiteralPath $cursor)) {
+ $parent = Split-Path $cursor -Parent
+ if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $cursor) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install path has no safe existing ancestor' $candidate
+ }
+ $cursor = $parent
+ }
+ while ($true) {
+ $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install path contains a reparse point' $cursor
+ }
+ $parent = Split-Path $cursor -Parent
+ if ([string]::IsNullOrWhiteSpace($parent) -or $parent -ceq $cursor) { break }
+ $cursor = $parent
+ }
+ return $candidate
+}
+
function Test-CcodLifecycleReparse {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $false }
@@ -385,7 +417,7 @@ function Get-CcodLifecyclePayloadManifestFiles {
if (Test-CcodLifecycleReparse -Path $cursor) {
Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_REPARSE' 'Payload manifest path contains a reparse point' $cursor
}
- if ($cursor -ceq $root) { break }
+ if ($cursor.Equals($root,[StringComparison]::OrdinalIgnoreCase)) { break }
$cursor = Split-Path $cursor -Parent
if ([string]::IsNullOrWhiteSpace($cursor)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest escaped the source root' $manifestPath
@@ -444,7 +476,7 @@ function Get-CcodLifecyclePayloadManifestFiles {
if (Test-CcodLifecycleReparse -Path $cursor) {
Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_REPARSE' 'A manifest-listed source path contains a reparse point' $cursor
}
- if ($cursor -ceq $root) { break }
+ if ($cursor.Equals($root,[StringComparison]::OrdinalIgnoreCase)) { break }
$cursor = Split-Path $cursor -Parent
}
$item = Get-Item -LiteralPath $source -Force -ErrorAction Stop
@@ -473,10 +505,14 @@ function Copy-CcodLifecycleStaging {
[Parameter(Mandatory)][object[]]$Files
)
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $InstallRoot)
$stagingRoot = [IO.Path]::GetFullPath((Join-Path $InstallRoot '.staging'))
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingRoot)
[IO.Directory]::CreateDirectory($stagingRoot) | Out-Null
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingRoot)
$stagingDirectory = Join-Path $stagingRoot ([guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($stagingDirectory) | Out-Null
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingDirectory)
$bootstrapPath = Join-Path $InstallRoot 'bootstrap.ps1'
$uninstallerPath = Join-Path $InstallRoot 'Uninstall-CodexControlOtherDevices.ps1'
$bootstrapExistedBefore = [IO.File]::Exists($bootstrapPath)
@@ -505,7 +541,9 @@ function Copy-CcodLifecycleStaging {
if (-not $destination.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_STAGING_PATH_INVALID' 'Staging path escaped the staging root' $destination
}
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination)
& $Adapters.CopyFile $file.Source $destination
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination)
}
foreach ($file in $Files) {
$destination = [IO.Path]::GetFullPath((Join-Path $stagingDirectory $file.Relative))
@@ -515,21 +553,29 @@ function Copy-CcodLifecycleStaging {
Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'A staged runtime file failed its source hash comparison' $destination
}
}
- $bootstrapSource = [IO.Path]::GetFullPath((Join-Path (Split-Path $PSScriptRoot -Parent) 'bootstrap.ps1'))
+ $bootstrapSource = [IO.Path]::GetFullPath((Join-Path $stagingDirectory 'src\persistence\bootstrap.ps1'))
if (-not [IO.File]::Exists($bootstrapSource)) {
- Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'The bootstrap script is missing from the persistence source' $bootstrapSource
+ Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'The verified staging bootstrap script is missing' $bootstrapSource
}
& $Adapters.CopyFile $bootstrapSource $bootstrapPath
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath)
+ if ((Get-CcodLifecycleFileSha256 -Path $bootstrapPath) -cne (Get-CcodLifecycleFileSha256 -Path $bootstrapSource)) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'The stable bootstrap copy differs from verified staging bytes' $bootstrapPath
+ }
$copiedStableBootstrap = $true
- $uninstallerSource = Join-Path $SourceRoot 'Uninstall-CodexControlOtherDevices.ps1'
+ $uninstallerSource = Join-Path $stagingDirectory 'Uninstall-CodexControlOtherDevices.ps1'
if ([IO.File]::Exists($uninstallerSource)) {
& $Adapters.CopyFile $uninstallerSource $uninstallerPath
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath)
+ if ((Get-CcodLifecycleFileSha256 -Path $uninstallerPath) -cne (Get-CcodLifecycleFileSha256 -Path $uninstallerSource)) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'The stable uninstaller copy differs from verified staging bytes' $uninstallerPath
+ }
$copiedStableUninstaller = $true
}
return $stagingDirectory
} catch {
if ([IO.Directory]::Exists($stagingDirectory)) {
- try { Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
}
if ($copiedStableBootstrap -and -not $bootstrapExistedBefore -and [IO.File]::Exists($bootstrapPath)) {
try { Remove-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop } catch { }
@@ -1613,8 +1659,10 @@ function Remove-CcodLifecycleOldRuntimes {
$runtimeRoot = Join-Path $InstallRoot 'runtime'
if (-not [IO.Directory]::Exists($runtimeRoot)) { return }
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $runtimeRoot)
foreach ($directory in Get-ChildItem -LiteralPath $runtimeRoot -Directory -Force -ErrorAction Stop) {
if ($directory.Name -ceq $ActiveRuntimeId -or $directory.Name -ceq $PreviousRuntimeId) { continue }
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $directory.FullName)
[void](Test-CcodLifecycleRemovePath -Root $InstallRoot -Path $directory.FullName)
Remove-Item -LiteralPath $directory.FullName -Recurse -Force -ErrorAction Stop
}
@@ -1673,6 +1721,7 @@ function Invoke-CcodInstall {
if ([string]::IsNullOrWhiteSpace($InstallRoot)) { $InstallRoot = $script:CcodLifecycleDefaultInstallRoot }
$root = Get-CcodLifecycleCanonicalRoot -Path $InstallRoot -Kind 'Install root'
$sourceRoot = Get-CcodLifecycleCanonicalRoot -Path $SourceRoot -Kind 'Source root'
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $root)
if ($RepairState) {
return Invoke-CcodRepairState -InstallRoot $root -Adapters $adapters
@@ -1782,18 +1831,24 @@ function Invoke-CcodInstall {
if (-not $validation.Valid) {
Throw-CcodLifecycleError 'CCOD_INSTALL_MANIFEST_INVALID' ("Staged runtime failed manifest validation: {0}" -f $validation.Code) $stagingDirectory
}
- $runtimeRoot = [IO.Path]::GetFullPath((Join-Path (Join-Path $root 'runtime') $runtimeId))
+ $runtimeContainer = [IO.Path]::GetFullPath((Join-Path $root 'runtime'))
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeContainer)
+ $runtimeRoot = [IO.Path]::GetFullPath((Join-Path $runtimeContainer $runtimeId))
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot)
if ([IO.Directory]::Exists($runtimeRoot)) {
$existingValidation = Test-CcodRuntimeManifest -RuntimeDirectory $runtimeRoot -ExpectedRuntimeId $runtimeId
if (-not $existingValidation.Valid) {
Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_CONFLICT' 'An invalid runtime already occupies the target runtime id' $runtimeRoot
}
if ([IO.Directory]::Exists($stagingDirectory)) {
- try { Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
}
} else {
- [IO.Directory]::CreateDirectory((Split-Path $runtimeRoot -Parent)) | Out-Null
+ [IO.Directory]::CreateDirectory($runtimeContainer) | Out-Null
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeContainer)
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory)
[IO.Directory]::Move($stagingDirectory, $runtimeRoot)
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot)
$runtimeCreated = $true
}
$stagingDirectory = $null
@@ -1921,7 +1976,7 @@ function Invoke-CcodInstall {
}
}
if (-not $pointerCommitted -and $runtimeCreated -and $null -ne $runtimeRoot -and [IO.Directory]::Exists($runtimeRoot)) {
- try { Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot); Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { }
}
if ($errorCode -cne 'CCOD_INSTALL_ACTIVATION_RECEIPT_FAILED') {
try {
@@ -1941,11 +1996,11 @@ function Invoke-CcodInstall {
try { & $adapters.ExitLifecycleOwnership $lifecycleOwnership | Out-Null } catch { }
}
if ($null -ne $stagingDirectory -and [IO.Directory]::Exists($stagingDirectory)) {
- try { Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
}
$stagingRoot = [IO.Path]::GetFullPath((Join-Path $root '.staging'))
if ([IO.Directory]::Exists($stagingRoot) -and -not (Test-CcodLifecycleReparse -Path $stagingRoot)) {
- try { Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingRoot); Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { }
}
}
diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1
index e8710b9..92cb918 100644
--- a/tests/persistence/InstallLifecycle.SelfTest.ps1
+++ b/tests/persistence/InstallLifecycle.SelfTest.ps1
@@ -746,6 +746,141 @@ $results += Invoke-CcodTest 'payload-bound install rereads the active pointer af
}
}
+# Production mutation caught: copying stable bootstrap/uninstaller from mutable source paths after their manifest-verified staging copies already exist.
+$results += Invoke-CcodTest 'stable bootstrap and uninstaller come from manifest-verified staging bytes' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null
+ $uninstallerSource = Join-Path $source 'Uninstall-CodexControlOtherDevices.ps1'
+ [IO.File]::WriteAllText($uninstallerSource,"# verified uninstaller`r`n",[Text.UTF8Encoding]::new($false))
+ $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22'
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ $fake = New-CcodLifecycleFake -NodePath $nodePath
+ $fake.World.CopyOverride = [pscustomobject]@{
+ Match = '*Uninstall-CodexControlOtherDevices.ps1'
+ Action = {
+ param($SourcePath,$DestinationPath)
+ [IO.Directory]::CreateDirectory((Split-Path $DestinationPath -Parent)) | Out-Null
+ [IO.File]::Copy($SourcePath,$DestinationPath,$true)
+ if ([IO.Path]::GetFullPath($SourcePath).Equals([IO.Path]::GetFullPath($uninstallerSource),[StringComparison]::OrdinalIgnoreCase)) {
+ [IO.File]::WriteAllText($SourcePath,"# mutated after staging`r`n",[Text.UTF8Encoding]::new($false))
+ }
+ }.GetNewClosure()
+ }
+
+ $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters
+
+ $runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)"
+ Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path $runtimeRoot 'src\persistence\bootstrap.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $install 'bootstrap.ps1')) 'stable bootstrap equals its verified runtime byte copy'
+ Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path $runtimeRoot 'Uninstall-CodexControlOtherDevices.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $install 'Uninstall-CodexControlOtherDevices.ps1')) 'stable uninstaller ignores post-staging source mutation'
+ } finally {
+ foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+$results += Invoke-CcodTest 'install root junction is rejected before staging writes' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $target = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source | Out-Null
+ [IO.Directory]::CreateDirectory($target) | Out-Null
+ New-Item -ItemType Junction -Path $install -Target $target | Out-Null
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters | Out-Null } 'CCOD_INSTALL_REPARSE_PATH'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $target 'active.json'))) 'install-root junction target receives no active pointer'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $target '.staging'))) 'install-root junction target receives no staging directory'
+ } finally {
+ if (Test-Path -LiteralPath $install) { [IO.Directory]::Delete($install) }
+ foreach ($path in @($source,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+$results += Invoke-CcodTest 'staging junction is rejected before payload writes' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $target = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source | Out-Null
+ [IO.Directory]::CreateDirectory($install) | Out-Null
+ [IO.Directory]::CreateDirectory($target) | Out-Null
+ New-Item -ItemType Junction -Path (Join-Path $install '.staging') -Target $target | Out-Null
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters | Out-Null } 'CCOD_INSTALL_REPARSE_PATH'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'staging junction target remains empty'
+ } finally {
+ $junction = Join-Path $install '.staging'
+ if (Test-Path -LiteralPath $junction) { [IO.Directory]::Delete($junction) }
+ foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+$results += Invoke-CcodTest 'runtime junction is rejected before staging promotion' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $target = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source | Out-Null
+ [IO.Directory]::CreateDirectory($install) | Out-Null
+ [IO.Directory]::CreateDirectory($target) | Out-Null
+ New-Item -ItemType Junction -Path (Join-Path $install 'runtime') -Target $target | Out-Null
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters | Out-Null } 'CCOD_INSTALL_REPARSE_PATH'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'runtime junction target receives no promoted runtime'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $install 'active.json'))) 'runtime junction cannot reach active pointer mutation'
+ } finally {
+ $junction = Join-Path $install 'runtime'
+ if (Test-Path -LiteralPath $junction) { [IO.Directory]::Delete($junction) }
+ foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+$results += Invoke-CcodTest 'payload manifest ancestry treats Windows root equality case-insensitively' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null
+ $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22'
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ $receipt = Invoke-CcodInstall -SourceRoot $source.ToUpperInvariant() -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters
+ Assert-CcodEqual 'Installed' $receipt.Outcome 'case-only source-root difference preserves valid Windows payload ancestry'
+ } finally {
+ foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+$results += Invoke-CcodTest 'payload-bound install rejects a changed runtime manifest after readiness' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null
+ $manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22'
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ $fake = New-CcodLifecycleFake -NodePath $nodePath
+ $fake.Adapters.WaitNewRuntimeReady = {
+ param($InstallRoot,$RuntimeId,$RuntimeGeneration,$Identity,$TaskStartedAtUtc,$TimeoutMilliseconds)
+ $runtimeManifestPath = Join-Path $InstallRoot "runtime\$RuntimeId\manifest.json"
+ $runtimeManifest = Get-Content -LiteralPath $runtimeManifestPath -Raw | ConvertFrom-Json
+ $runtimeManifest.projectVersion = '2.5.13'
+ [IO.File]::WriteAllText($runtimeManifestPath,($runtimeManifest|ConvertTo-Json -Depth 16),[Text.UTF8Encoding]::new($false))
+ [pscustomobject][ordered]@{SupervisorReady=$true;TrayReady=$true}
+ }.GetNewClosure()
+ Assert-CcodThrows {
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null
+ } 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN'
+ Assert-CcodTrue ($fake.World.Phases -notcontains 'Ready') 'changed selected runtime manifest cannot produce Ready'
+ } finally {
+ foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
# 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
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index e8b881a..be03513 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -80,19 +80,292 @@ Invoke-CcodTest 'release defender tool exposes manifest and scan functions witho
Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodReleaseDefenderCheck -ErrorAction SilentlyContinue)) 'Defender invocation is available'
}
+Invoke-CcodTest 'production installer payload generator writes ordered version-bound file records' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-manifest-generator-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $payload = Join-Path $root 'payload'
+ [IO.Directory]::CreateDirectory((Join-Path $payload 'nested')) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $payload 'z-last.txt'),'z',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $payload 'nested\a-first.txt'),'alpha',[Text.UTF8Encoding]::new($false))
+ $manifestPath = Join-Path $root 'installer-payload.manifest.json'
+ & (Join-Path $repositoryRoot 'tools\New-InstallerPayloadManifest.ps1') -PayloadRoot $payload -ProjectVersion '2.5.22' -OutputPath $manifestPath | Out-Null
+ Assert-CcodTrue (Test-Path -LiteralPath $manifestPath -PathType Leaf) 'production manifest generator writes its requested output'
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
+ Assert-CcodEqual 'schemaVersion,projectVersion,files' (@($manifest.PSObject.Properties.Name) -join ',') 'generator writes the canonical manifest field order'
+ Assert-CcodEqual '2.5.22' $manifest.projectVersion 'generator binds the requested project version'
+ Assert-CcodEqual 'nested/a-first.txt,z-last.txt' (@($manifest.files.path) -join ',') 'generator sorts file records ordinally'
+ foreach ($record in $manifest.files) {
+ $file = Join-Path $payload ([string]$record.path).Replace('/','\')
+ Assert-CcodEqual ([int64](Get-Item -LiteralPath $file).Length) ([int64]$record.length) 'record length binds the file bytes'
+ Assert-CcodEqual (Get-CcodTestFileSha256 -Path $file) ([string]$record.sha256) 'record hash binds the file bytes'
+ }
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
+function New-CcodActivationPayloadFixture {
+ param([string]$Version = '2.5.22')
+
+ $appRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-payload-' + [guid]::NewGuid().ToString('N'))
+ $payloadRoot = Join-Path $appRoot "payload\$Version"
+ [IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'src\persistence\modules')) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $payloadRoot 'package.json'),([ordered]@{name='fixture';version=$Version}|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $payloadRoot 'Install-CodexControlOtherDevices.ps1'),'exit 0',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $payloadRoot 'src\persistence\modules\InstallLifecycle.psm1'),'function Get-CcodLifecyclePayloadManifestFiles { @() }',[Text.UTF8Encoding]::new($false))
+ $records = [Collections.Generic.List[object]]::new()
+ foreach ($file in @(Get-ChildItem -LiteralPath $payloadRoot -File -Force -Recurse)) {
+ $relative = $file.FullName.Substring($payloadRoot.TrimEnd('\').Length + 1).Replace('\','/')
+ $records.Add([pscustomobject][ordered]@{path=$relative;length=[int64]$file.Length;sha256=Get-CcodTestFileSha256 -Path $file.FullName})
+ }
+ $comparison = [System.Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)}
+ $records.Sort($comparison)
+ $manifestPath = Join-Path $payloadRoot 'installer-payload.manifest.json'
+ [IO.File]::WriteAllText($manifestPath,([ordered]@{schemaVersion=1;projectVersion=$Version;files=@($records)}|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ return [pscustomobject]@{AppRoot=$appRoot;PayloadRoot=$payloadRoot;ManifestPath=$manifestPath;ManifestSha256=Get-CcodTestFileSha256 -Path $manifestPath}
+}
+
+function Invoke-CcodActivationVerifierFixture {
+ param([Parameter(Mandatory)]$Fixture,[Parameter(Mandatory)][string]$ExpectedManifestSha256,[string]$PayloadRoot)
+
+ $stdoutPath = Join-Path $Fixture.AppRoot ('stdout-' + [guid]::NewGuid().ToString('N') + '.txt')
+ $stderrPath = Join-Path $Fixture.AppRoot ('stderr-' + [guid]::NewGuid().ToString('N') + '.txt')
+ if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $Fixture.PayloadRoot }
+ $argumentLine = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -AppRoot "{1}" -InstallRoot "{1}" -PayloadRoot "{2}" -ExpectedVersion "2.5.22" -ExpectedPayloadManifestSha256 "{3}" -ActivationId "77777777-6666-5555-4444-333333333333" -ValidateReceiptOnly' -f (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'),$Fixture.AppRoot,$PayloadRoot,$ExpectedManifestSha256
+ $process = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $argumentLine -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru
+ try { $exitCode = [int]$process.ExitCode } finally { $process.Dispose() }
+ return [pscustomobject]@{ExitCode=$exitCode;Output=([IO.File]::ReadAllText($stdoutPath)+[IO.File]::ReadAllText($stderrPath))}
+}
+
+function Invoke-CcodInnoPayloadCompileFixture {
+ param([switch]$IncludePayloadDefines)
+
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-payload-contract-' + [guid]::NewGuid().ToString('N'))
+ $tray = Join-Path $root 'tray'
+ $portable = Join-Path $root 'portable'
+ $payload = Join-Path $root 'payload'
+ $output = Join-Path $root 'output'
+ foreach ($directory in @($tray,$portable,$payload,$output)) { [IO.Directory]::CreateDirectory($directory) | Out-Null }
+ foreach ($leaf in @('CodexRemote.TrayHost.exe','CodexRemote.TrayHost.exe.config','trayhost-build-provenance.json')) {
+ [IO.File]::WriteAllText((Join-Path $tray $leaf),"fixture $leaf",[Text.UTF8Encoding]::new($false))
+ }
+ foreach ($leaf in @('CodexRemote.Portable.exe','CodexRemote.Portable.exe.config','portable-launcher-provenance.json')) {
+ [IO.File]::WriteAllText((Join-Path $portable $leaf),"fixture $leaf",[Text.UTF8Encoding]::new($false))
+ }
+ [IO.File]::WriteAllText((Join-Path $payload 'package.json'),'{"version":"2.5.21"}',[Text.UTF8Encoding]::new($false))
+ $packageHash = Get-CcodTestFileSha256 -Path (Join-Path $payload 'package.json')
+ $manifest = [ordered]@{schemaVersion=1;projectVersion='2.5.21';files=@([ordered]@{path='package.json';length=[int64](Get-Item -LiteralPath (Join-Path $payload 'package.json')).Length;sha256=$packageHash})}
+ $manifestPath = Join-Path $payload 'installer-payload.manifest.json'
+ [IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ $iscc = @(
+ (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')
+ ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
+ if (-not $iscc) { throw 'Inno Setup 6 is required for the setup payload contract' }
+ $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'))
+ if ($IncludePayloadDefines) {
+ $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'))
+ }
+ $previousPreference = $ErrorActionPreference
+ try {
+ $ErrorActionPreference = 'Continue'
+ $compileOutput = @(& $iscc @arguments 2>&1)
+ $exitCode = $LASTEXITCODE
+ } finally { $ErrorActionPreference = $previousPreference }
+ return [pscustomobject]@{Root=$root;ExitCode=$exitCode;Output=($compileOutput -join "`n");SetupPath=(Join-Path $output 'CodexRemote-fix-2.5.21-setup.exe')}
+}
+
Invoke-CcodTest 'setup build and activation bind one immutable versioned payload end to end' {
$build = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw -Encoding UTF8
$inno = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8
$activation = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8
$installer = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Install-CodexControlOtherDevices.ps1') -Raw -Encoding UTF8
- Assert-CcodTrue ($build -cmatch 'installer-payload\.manifest\.json' -and $build -cmatch 'schemaVersion\s*=\s*1\s+projectVersion\s*=\s*\$Version\s+files\s*=\s*\$payloadRecords') 'build emits the canonical ordered setup payload manifest'
+ Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256') 'build invokes the tested generator and binds its manifest hash into setup compilation'
Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory'
Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version'
- Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract'
+ Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract'
Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '-ExpectedVersion\s+\$ExpectedVersion' -and $installer -cmatch '-PayloadManifestPath\s+\$PayloadManifestPath') 'installer forwards the immutable payload contract to lifecycle activation'
}
+Invoke-CcodTest 'activation accepts a compile-bound installer manifest hash before payload verification' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-hash-interface-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $activationId = '77777777-6666-5555-4444-333333333333'
+ $stdoutPath = Join-Path $root 'stdout.txt'
+ $stderrPath = Join-Path $root 'stderr.txt'
+ $argumentLine = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -AppRoot "{1}" -InstallRoot "{1}" -PayloadRoot "{1}\payload\2.5.22" -ExpectedVersion "2.5.22" -ExpectedPayloadManifestSha256 "{2}" -ActivationId "{3}" -ValidateReceiptOnly' -f (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'),$root,('0' * 64),$activationId
+ $process = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList $argumentLine -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath -Wait -PassThru
+ try { $exitCode = [int]$process.ExitCode } finally { $process.Dispose() }
+ $output = [IO.File]::ReadAllText($stdoutPath) + [IO.File]::ReadAllText($stderrPath)
+ Assert-CcodEqual 3 $exitCode 'missing payload reaches the bounded verifier contract after accepting the manifest hash parameter'
+ Assert-CcodTrue ($output -cnotmatch 'parameter name .ExpectedPayloadManifestSha256') 'compile-bound hash is a real activation parameter'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'activation rejects a changed payload module before any payload code executes' {
+ $fixture = New-CcodActivationPayloadFixture
+ try {
+ $marker = Join-Path $fixture.AppRoot 'payload-module-executed.txt'
+ $modulePath = Join-Path $fixture.PayloadRoot 'src\persistence\modules\InstallLifecycle.psm1'
+ $markerLiteral = $marker.Replace("'","''")
+ [IO.File]::WriteAllText($modulePath,"[IO.File]::WriteAllText('$markerLiteral','executed'); function Get-CcodLifecyclePayloadManifestFiles { @() }",[Text.UTF8Encoding]::new($false))
+
+ $result = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256
+
+ Assert-CcodEqual 3 $result.ExitCode 'payload hash mismatch is a bounded verification failure'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath $marker)) 'unverified payload module code never executes'
+ Assert-CcodTrue ($result.Output -cmatch 'CCOD_INSTALL_FILE_HASH_MISMATCH|CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID') 'failure retains a stable payload verification code'
+ } finally {
+ if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'activation distinguishes a valid payload from a bad compile-bound manifest hash' {
+ $fixture = New-CcodActivationPayloadFixture
+ try {
+ $valid = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256
+ Assert-CcodEqual 3 $valid.ExitCode 'valid payload advances to bounded receipt verification'
+ Assert-CcodTrue ($valid.Output -cmatch 'CCOD_ACTIVATION_RECEIPT_MISSING') 'valid payload reaches the receipt boundary after independent verification'
+ $invalid = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 ('0' * 64)
+ Assert-CcodEqual 3 $invalid.ExitCode 'bad compile-bound manifest hash fails verification'
+ Assert-CcodTrue ($invalid.Output -cmatch 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID') 'bad manifest hash retains the stable payload code'
+ } finally {
+ if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'activation requires the exact versioned payload root' {
+ $fixture = New-CcodActivationPayloadFixture
+ try {
+ $wrongRoot = Join-Path $fixture.AppRoot 'payload\other'
+ [IO.Directory]::CreateDirectory($wrongRoot) | Out-Null
+ $result = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256 -PayloadRoot $wrongRoot
+ Assert-CcodEqual 3 $result.ExitCode 'wrong version directory fails before receipt processing'
+ Assert-CcodTrue ($result.Output -cmatch 'CCOD_INSTALL_PAYLOAD_PATH_INVALID') 'wrong payload root retains a stable path code'
+ } finally {
+ if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'activation rejects a reparse payload ancestor' {
+ $fixture = New-CcodActivationPayloadFixture
+ $targetRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-payload-target-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $sourceVersion = $fixture.PayloadRoot
+ [IO.Directory]::CreateDirectory($targetRoot) | Out-Null
+ Move-Item -LiteralPath $sourceVersion -Destination (Join-Path $targetRoot '2.5.22')
+ Remove-Item -LiteralPath (Join-Path $fixture.AppRoot 'payload') -Force
+ New-Item -ItemType Junction -Path (Join-Path $fixture.AppRoot 'payload') -Target $targetRoot | Out-Null
+ $result = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256
+ Assert-CcodEqual 3 $result.ExitCode 'payload junction fails before receipt processing'
+ Assert-CcodTrue ($result.Output -cmatch 'CCOD_INSTALL_SOURCE_REPARSE') 'payload junction retains the reparse support code'
+ } finally {
+ $junction = Join-Path $fixture.AppRoot 'payload'
+ if (Test-Path -LiteralPath $junction) { [IO.Directory]::Delete($junction) }
+ if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force }
+ if (Test-Path -LiteralPath $targetRoot) { Remove-Item -LiteralPath $targetRoot -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'activation rejects an unlisted nested payload junction' {
+ $fixture = New-CcodActivationPayloadFixture
+ $targetRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-nested-target-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($targetRoot) | Out-Null
+ $junction = Join-Path $fixture.PayloadRoot 'unlisted-junction'
+ New-Item -ItemType Junction -Path $junction -Target $targetRoot | Out-Null
+ $result = Invoke-CcodActivationVerifierFixture -Fixture $fixture -ExpectedManifestSha256 $fixture.ManifestSha256
+ Assert-CcodEqual 3 $result.ExitCode 'nested payload junction fails before receipt processing'
+ Assert-CcodTrue ($result.Output -cmatch 'CCOD_INSTALL_SOURCE_REPARSE') 'nested payload junction retains the reparse support code'
+ } finally {
+ $junction = Join-Path $fixture.PayloadRoot 'unlisted-junction'
+ if (Test-Path -LiteralPath $junction) { [IO.Directory]::Delete($junction) }
+ if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force }
+ if (Test-Path -LiteralPath $targetRoot) { Remove-Item -LiteralPath $targetRoot -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'Inno compile refuses an implicit installer payload source' {
+ $compile = Invoke-CcodInnoPayloadCompileFixture
+ try {
+ Assert-CcodTrue ($compile.ExitCode -ne 0) 'setup compilation fails without an explicit installer payload directory and manifest hash'
+ Assert-CcodTrue ($compile.Output -cmatch 'InstallerPayloadDirectory') 'compiler identifies the missing immutable payload define'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath $compile.SetupPath)) 'missing payload define produces no setup artifact'
+ } finally {
+ if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer payload' {
+ $compile = Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines
+ try {
+ Assert-CcodEqual 0 $compile.ExitCode "explicit setup payload contract compiles: $($compile.Output)"
+ Assert-CcodTrue (Test-Path -LiteralPath $compile.SetupPath -PathType Leaf) 'explicit payload compile produces the setup artifact'
+ Assert-CcodTrue ($compile.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the exact payload manifest and manifest-listed file'
+ } finally {
+ if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
+ $inno = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8
+ $helper = [regex]::Match($inno,'(?ms)^function IsSafeExistingPayloadDirectory\(.*?^end;')
+ Assert-CcodTrue $helper.Success 'production Inno script exposes the directory predicate used before payload writes'
+ Assert-CcodTrue ($inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?IsSafeExistingPayloadDirectory') 'PrepareToInstall rejects unsafe app and payload directories before file copy'
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-reparse-harness-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $normal = Join-Path $root 'normal'
+ $target = Join-Path $root 'target'
+ $junction = Join-Path $root 'junction'
+ $missing = Join-Path $root 'missing'
+ $resultPath = Join-Path $root 'result.txt'
+ [IO.Directory]::CreateDirectory($normal) | Out-Null
+ [IO.Directory]::CreateDirectory($target) | Out-Null
+ New-Item -ItemType Junction -Path $junction -Target $target | Out-Null
+ $harnessPath = Join-Path $root 'ReparseGate.iss'
+ $harness = @"
+[Setup]
+AppName=ReparseGate
+AppVersion=1.0.0
+DefaultDirName={tmp}\ReparseGate
+PrivilegesRequired=lowest
+OutputDir=$($root.Replace('\','\\'))
+OutputBaseFilename=ReparseGate
+Uninstallable=no
+[Code]
+const
+ CCOD_FILE_ATTRIBUTE_DIRECTORY = `$00000010;
+ CCOD_FILE_ATTRIBUTE_REPARSE_POINT = `$00000400;
+ CCOD_INVALID_FILE_ATTRIBUTES = `$FFFFFFFF;
+function GetFileAttributesW(const FileName: String): Cardinal;
+ external 'GetFileAttributesW@kernel32.dll stdcall';
+$($helper.Value)
+function InitializeSetup(): Boolean;
+begin
+ if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and
+ IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and
+ (not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) then
+ SaveStringToFile('$($resultPath.Replace("'","''"))','pass',False);
+ Result := False;
+end;
+"@
+ [IO.File]::WriteAllText($harnessPath,$harness,[Text.UTF8Encoding]::new($false))
+ $iscc = Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'
+ $compileOutput = @(& $iscc $harnessPath 2>&1)
+ Assert-CcodEqual 0 $LASTEXITCODE "reparse predicate harness compiles: $($compileOutput -join ' ')"
+ $process = Start-Process -FilePath (Join-Path $root 'ReparseGate.exe') -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-') -WindowStyle Hidden -Wait -PassThru
+ try { $null = $process.ExitCode } finally { $process.Dispose() }
+ Assert-CcodEqual 'pass' ([IO.File]::ReadAllText($resultPath,[Text.UTF8Encoding]::new($false))) 'production predicate accepts absent/normal directories and rejects a junction'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
Invoke-CcodTest 'release manifest binds the final asset names hashes version commit and timestamp' {
. $defenderPath -Library
$fixture = New-CcodReleaseFixture
diff --git a/tools/New-InstallerPayloadManifest.ps1 b/tools/New-InstallerPayloadManifest.ps1
new file mode 100644
index 0000000..ce22b3a
--- /dev/null
+++ b/tools/New-InstallerPayloadManifest.ps1
@@ -0,0 +1,54 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string]$PayloadRoot,
+ [Parameter(Mandatory)][string]$ProjectVersion,
+ [Parameter(Mandatory)][string]$OutputPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Get-CcodInstallerPayloadSha256 {
+ param([Parameter(Mandatory)][string]$Path)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ $stream = $null
+ try {
+ $stream = [IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()
+ } finally {
+ if ($null -ne $stream) { $stream.Dispose() }
+ $sha.Dispose()
+ }
+}
+
+function Test-CcodInstallerPayloadRelativePath {
+ param([Parameter(Mandatory)][string]$Path)
+ return $Path -cmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -and -not $Path.Contains('//') -and -not $Path.Contains('..') -and -not $Path.Contains(':')
+}
+
+if ($ProjectVersion -cnotmatch '^\d+\.\d+\.\d+$') { throw "Invalid installer payload project version: $ProjectVersion" }
+$root = [IO.Path]::GetFullPath($PayloadRoot).TrimEnd('\')
+$output = [IO.Path]::GetFullPath($OutputPath)
+if (-not [IO.Directory]::Exists($root)) { throw "Installer payload root is missing: $root" }
+if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) { throw "Refusing to overwrite installer payload manifest: $output" }
+$rootItem = Get-Item -LiteralPath $root -Force -ErrorAction Stop
+if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Installer payload root is a reparse point: $root" }
+$prefix = $root + '\'
+$records = [Collections.Generic.List[object]]::new()
+foreach ($item in @(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop)) {
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Installer payload contains a reparse point: $($item.FullName)" }
+ if ($item.PSIsContainer) { continue }
+ $full = [IO.Path]::GetFullPath($item.FullName)
+ if (-not $full.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase)) { throw "Installer payload file escaped its root: $full" }
+ $relative = $full.Substring($prefix.Length).Replace('\','/')
+ if (-not (Test-CcodInstallerPayloadRelativePath $relative)) { throw "Installer payload relative path is invalid: $relative" }
+ $records.Add([pscustomobject][ordered]@{path=$relative;length=[int64]$item.Length;sha256=Get-CcodInstallerPayloadSha256 -Path $full})
+}
+$comparison = [System.Comparison[object]]{param($left,$right)[StringComparer]::Ordinal.Compare([string]$left.path,[string]$right.path)}
+$records.Sort($comparison)
+if ($records.Count -eq 0) { throw 'Installer payload is empty.' }
+$manifest = [ordered]@{schemaVersion=1;projectVersion=$ProjectVersion;files=@($records)}
+$outputParent = Split-Path $output -Parent
+if (-not [IO.Directory]::Exists($outputParent)) { [IO.Directory]::CreateDirectory($outputParent) | Out-Null }
+[IO.File]::WriteAllText($output,(($manifest|ConvertTo-Json -Depth 8)+[Environment]::NewLine),[Text.UTF8Encoding]::new($false))
+Write-Output ([pscustomobject]$manifest)
From b9e74657e5439e02a8a4eab8d6b5bc70e84ee751 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 02:17:38 +0800
Subject: [PATCH 004/187] docs: refine stable rebind acceptance
---
...6-08-28-v2522-install-runtime-reliability.md | 17 ++++++++++++++---
...-v2522-install-runtime-reliability-design.md | 16 ++++++++++++----
2 files changed, 26 insertions(+), 7 deletions(-)
diff --git a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
index dc9fb82..49ea10c 100644
--- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
+++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
@@ -97,14 +97,19 @@ fix: bind setup activation to immutable payload
**Files:**
- Modify: `src/persistence/modules/ProcessControl.psm1`
- Modify: `src/persistence/modules/SessionEngine.psm1`
+- Modify: `src/persistence/Supervisor.ps1`
- Test: `tests/persistence/ProcessControl.SelfTest.ps1`
- Test: `tests/persistence/SessionEngine.SelfTest.ps1`
+- Test: `tests/persistence/Supervisor.SelfTest.ps1`
**Interfaces:**
- Add `Get-CcodStableVerifiedProcessTree` that accepts a root snapshot,
`StatusEvidence`, a fixed retry budget, and adapters.
- It returns a verified tree only if every successful attempt preserves the
original root identity; otherwise it returns no tree.
+- `Confirm-CcodSupervisorLifecycleProofCandidate` uses the same bounded
+ candidate-root rebind rule and records a stable failure reason without
+ granting a changed root any authority.
- [ ] **Step 1: Write failing process-tree tests**
@@ -121,6 +126,11 @@ Assert-CcodEqual 2 $tree.Count 'one transient empty tree is retried without rela
Add a companion test where the second root snapshot has a different creation
time; it must return no tree and perform no close mutation.
+Add a Supervisor test where the first post-worker rebind sees no special root
+but the second sees the same candidate root; it must publish `RemoteVerified`.
+Add its counterpart where the second root has a different creation time; it
+must remain `CCOD_REMOTE_PROOF_REBIND_FAILED`.
+
- [ ] **Step 2: Run focused tests and confirm RED**
Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1`
@@ -138,9 +148,10 @@ pre-stop/post-stop/final proofs unchanged.
- [ ] **Step 4: Run focused tests and prove GREEN**
-Run ProcessControl and SessionEngine self-tests. Confirm child churn retries
-successfully, root drift remains fail-closed, and ordinary close tests still
-prove every stopped member.
+Run ProcessControl, SessionEngine, and Supervisor self-tests. Confirm child
+churn retries successfully, a transient same-identity rebind succeeds, root
+drift remains fail-closed, and ordinary close tests still prove every stopped
+member.
- [ ] **Step 5: Commit**
diff --git a/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
index cfdb7bc..021295f 100644
--- a/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
+++ b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
@@ -65,10 +65,18 @@ top-level root identity and a self-consistent tree. If the root PID, creation
time, session, SID, package family, executable path, command line mode, or
recorded debug ports change, the operation remains fail-closed immediately.
-The wrapper is used only before a mutation starts. Once a close transition is
-written, the existing per-member pre-stop, post-stop, and final identity checks
-remain strict. Diagnostics classify the last stable failure boundary so a
-future support log can distinguish tree churn from an unsafe process identity.
+The wrapper is used before a mutation starts and for a transient rich snapshot
+read of an already verified member. Once a close transition is written, the
+existing per-member pre-stop, post-stop, and final identity checks remain
+strict. A `SameIdentity` result is required before retrying a null or
+mismatched rich snapshot; an absent or changed root remains fail-closed.
+
+The same bounded policy applies to Supervisor's `RemoteVerified` rebind: it
+may retry an indeterminate reread of the exact candidate root, but a different
+root, changed creation time, changed ports, or a malformed state read remains
+an immediate failed proof. Diagnostics classify the last stable failure
+boundary so a future support log can distinguish tree churn from an unsafe
+process identity.
### 3. Tray action correlation and diagnostics
From 493245e54884db4e0cf7e982dacef137e5c6d00a Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 02:36:22 +0800
Subject: [PATCH 005/187] fix: close setup payload reparse write windows
---
build/CodexControlOtherDevices.iss | 48 ++++++++++-
src/persistence/modules/InstallLifecycle.psm1 | 59 ++++++++++---
.../persistence/InstallLifecycle.SelfTest.ps1 | 82 +++++++++++++++++++
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 17 +++-
4 files changed, 192 insertions(+), 14 deletions(-)
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index 1a0eade..4e79f0e 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -129,6 +129,42 @@ begin
((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0));
end;
+function IsSafeExistingSetupTree(const DirectoryName: String): Boolean;
+var
+ Attributes: Cardinal;
+ FindRec: TFindRec;
+ ChildPath: String;
+begin
+ Result := IsSafeExistingPayloadDirectory(DirectoryName);
+ if not Result then Exit;
+ Attributes := GetFileAttributesW(DirectoryName);
+ if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then Exit;
+ if FindFirst(AddBackslash(DirectoryName) + '*', FindRec) then
+ begin
+ try
+ repeat
+ if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
+ begin
+ ChildPath := AddBackslash(DirectoryName) + FindRec.Name;
+ if (FindRec.Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0 then
+ begin
+ Result := False;
+ Exit;
+ end;
+ if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) and
+ (not IsSafeExistingSetupTree(ChildPath)) then
+ begin
+ Result := False;
+ Exit;
+ end;
+ end;
+ until not FindNext(FindRec);
+ finally
+ FindClose(FindRec);
+ end;
+ end;
+end;
+
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
AppDirectory, PayloadDirectory, VersionDirectory: String;
@@ -137,7 +173,17 @@ begin
AppDirectory := ExpandConstant('{app}');
PayloadDirectory := ExpandConstant('{app}\payload');
VersionDirectory := ExpandConstant('{app}\payload\{#ProjectVersion}');
- if not IsSafeExistingPayloadDirectory(AppDirectory) or
+ if not IsSafeExistingSetupTree(AppDirectory) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\build')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\bin')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\.github')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\.github\workflows')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\assets')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\assets\codexremote-fix')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\docs')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\src')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\tests')) or
+ not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\tools')) or
not IsSafeExistingPayloadDirectory(PayloadDirectory) or
not IsSafeExistingPayloadDirectory(VersionDirectory) then
Result := 'CodexRemote-fix refused an unsafe existing payload directory.';
diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1
index ecbf18e..329f1ed 100644
--- a/src/persistence/modules/InstallLifecycle.psm1
+++ b/src/persistence/modules/InstallLifecycle.psm1
@@ -174,6 +174,31 @@ function Assert-CcodLifecycleInstallPathSafe {
return $candidate
}
+function Assert-CcodLifecycleInstallTreeSafe {
+ param(
+ [Parameter(Mandatory)][string]$InstallRoot,
+ [Parameter(Mandatory)][string]$Path
+ )
+
+ $tree = Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $Path
+ if (-not [IO.Directory]::Exists($tree)) { return $tree }
+ $prefix = $tree.TrimEnd('\') + '\'
+ $pending = [Collections.Generic.Stack[string]]::new()
+ $pending.Push($tree)
+ while ($pending.Count -gt 0) {
+ $directory = $pending.Pop()
+ foreach ($item in @(Get-ChildItem -LiteralPath $directory -Force -ErrorAction Stop)) {
+ $full = [IO.Path]::GetFullPath($item.FullName)
+ if (-not $full.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or
+ ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install tree contains a reparse point or escaped entry' $full
+ }
+ if ($item.PSIsContainer) { $pending.Push($full) }
+ }
+ }
+ return $tree
+}
+
function Test-CcodLifecycleReparse {
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $false }
@@ -541,6 +566,10 @@ function Copy-CcodLifecycleStaging {
if (-not $destination.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_STAGING_PATH_INVALID' 'Staging path escaped the staging root' $destination
}
+ $destinationParent = Split-Path $destination -Parent
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destinationParent)
+ [IO.Directory]::CreateDirectory($destinationParent) | Out-Null
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destinationParent)
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination)
& $Adapters.CopyFile $file.Source $destination
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination)
@@ -557,6 +586,7 @@ function Copy-CcodLifecycleStaging {
if (-not [IO.File]::Exists($bootstrapSource)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'The verified staging bootstrap script is missing' $bootstrapSource
}
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath)
& $Adapters.CopyFile $bootstrapSource $bootstrapPath
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath)
if ((Get-CcodLifecycleFileSha256 -Path $bootstrapPath) -cne (Get-CcodLifecycleFileSha256 -Path $bootstrapSource)) {
@@ -565,6 +595,7 @@ function Copy-CcodLifecycleStaging {
$copiedStableBootstrap = $true
$uninstallerSource = Join-Path $stagingDirectory 'Uninstall-CodexControlOtherDevices.ps1'
if ([IO.File]::Exists($uninstallerSource)) {
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath)
& $Adapters.CopyFile $uninstallerSource $uninstallerPath
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath)
if ((Get-CcodLifecycleFileSha256 -Path $uninstallerPath) -cne (Get-CcodLifecycleFileSha256 -Path $uninstallerSource)) {
@@ -575,13 +606,13 @@ function Copy-CcodLifecycleStaging {
return $stagingDirectory
} catch {
if ([IO.Directory]::Exists($stagingDirectory)) {
- try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
}
if ($copiedStableBootstrap -and -not $bootstrapExistedBefore -and [IO.File]::Exists($bootstrapPath)) {
- try { Remove-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath); Remove-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop } catch { }
}
if ($copiedStableUninstaller -and -not $uninstallerExistedBefore -and [IO.File]::Exists($uninstallerPath)) {
- try { Remove-Item -LiteralPath $uninstallerPath -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath); Remove-Item -LiteralPath $uninstallerPath -Force -ErrorAction Stop } catch { }
}
$candidateId = ([string]$_.FullyQualifiedErrorId -split ',')[0]
if ($candidateId -clike 'CCOD_INSTALL_*' -or $candidateId -clike 'CCOD_*') {
@@ -1422,6 +1453,10 @@ function Get-CcodLifecycleAdapters {
[IO.Directory]::CreateDirectory((Split-Path $Destination -Parent)) | Out-Null
[IO.File]::Copy($Source, $Destination, $true)
}
+ NewRuntimeManifest = {
+ param($RuntimeDirectory, $ProjectVersion)
+ New-CcodRuntimeManifest -RuntimeDirectory $RuntimeDirectory -ProjectVersion $ProjectVersion
+ }
WriteLog = {
param($InstallRoot, $Record)
$logDirectory = Join-Path $InstallRoot 'logs'
@@ -1664,6 +1699,7 @@ function Remove-CcodLifecycleOldRuntimes {
if ($directory.Name -ceq $ActiveRuntimeId -or $directory.Name -ceq $PreviousRuntimeId) { continue }
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $directory.FullName)
[void](Test-CcodLifecycleRemovePath -Root $InstallRoot -Path $directory.FullName)
+ [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $directory.FullName)
Remove-Item -LiteralPath $directory.FullName -Recurse -Force -ErrorAction Stop
}
}
@@ -1820,12 +1856,15 @@ function Invoke-CcodInstall {
Write-CcodInstallActivationPhase -Activation $activation -Phase 'InstallingRuntime' -RuntimeId $null -PreviousRuntimeId $(if ($upgrade) { [string]$existingPointer.activeRuntime } else { $null }) -ErrorCode $null -Adapters $adapters -InstallRoot $root | Out-Null
$stagingDirectory = Copy-CcodLifecycleStaging -SourceRoot $sourceRoot -InstallRoot $root -Adapters $adapters -Files $files
- $manifest = New-CcodRuntimeManifest -RuntimeDirectory $stagingDirectory -ProjectVersion $projectVersion
+ $manifest = & $adapters.NewRuntimeManifest $stagingDirectory $projectVersion
+ $stagingManifestPath = Join-Path $stagingDirectory 'manifest.json'
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingManifestPath)
[IO.File]::WriteAllText(
- (Join-Path $stagingDirectory 'manifest.json'),
+ $stagingManifestPath,
($manifest | ConvertTo-Json -Depth 16),
[Text.UTF8Encoding]::new($false)
)
+ [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingManifestPath)
$runtimeId = [string]$manifest.runtimeId
$validation = Test-CcodRuntimeManifest -RuntimeDirectory $stagingDirectory -ExpectedRuntimeId $runtimeId
if (-not $validation.Valid) {
@@ -1841,12 +1880,12 @@ function Invoke-CcodInstall {
Throw-CcodLifecycleError 'CCOD_INSTALL_RUNTIME_CONFLICT' 'An invalid runtime already occupies the target runtime id' $runtimeRoot
}
if ([IO.Directory]::Exists($stagingDirectory)) {
- try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
}
} else {
[IO.Directory]::CreateDirectory($runtimeContainer) | Out-Null
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeContainer)
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory)
+ [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory)
[IO.Directory]::Move($stagingDirectory, $runtimeRoot)
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot)
$runtimeCreated = $true
@@ -1976,7 +2015,7 @@ function Invoke-CcodInstall {
}
}
if (-not $pointerCommitted -and $runtimeCreated -and $null -ne $runtimeRoot -and [IO.Directory]::Exists($runtimeRoot)) {
- try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $runtimeRoot); Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $runtimeRoot); Remove-Item -LiteralPath $runtimeRoot -Recurse -Force -ErrorAction Stop } catch { }
}
if ($errorCode -cne 'CCOD_INSTALL_ACTIVATION_RECEIPT_FAILED') {
try {
@@ -1996,11 +2035,11 @@ function Invoke-CcodInstall {
try { & $adapters.ExitLifecycleOwnership $lifecycleOwnership | Out-Null } catch { }
}
if ($null -ne $stagingDirectory -and [IO.Directory]::Exists($stagingDirectory)) {
- try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
}
$stagingRoot = [IO.Path]::GetFullPath((Join-Path $root '.staging'))
if ([IO.Directory]::Exists($stagingRoot) -and -not (Test-CcodLifecycleReparse -Path $stagingRoot)) {
- try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingRoot); Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $stagingRoot); Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction Stop } catch { }
}
}
diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1
index 92cb918..3f3013a 100644
--- a/tests/persistence/InstallLifecycle.SelfTest.ps1
+++ b/tests/persistence/InstallLifecycle.SelfTest.ps1
@@ -840,6 +840,88 @@ $results += Invoke-CcodTest 'runtime junction is rejected before staging promoti
}
}
+$results += Invoke-CcodTest 'stable bootstrap leaf junction is rejected before copy' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $target = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source | Out-Null
+ [IO.Directory]::CreateDirectory($install) | Out-Null
+ [IO.Directory]::CreateDirectory($target) | Out-Null
+ $bootstrapLink = Join-Path $install 'bootstrap.ps1'
+ New-Item -ItemType Junction -Path $bootstrapLink -Target $target | Out-Null
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters | Out-Null } 'CCOD_INSTALL_REPARSE_PATH'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'stable bootstrap junction target receives no bytes'
+ } finally {
+ $bootstrapLink = Join-Path $install 'bootstrap.ps1'
+ if (Test-Path -LiteralPath $bootstrapLink) { [IO.Directory]::Delete($bootstrapLink) }
+ foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+$results += Invoke-CcodTest 'runtime manifest leaf junction is rejected before write' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $target = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source | Out-Null
+ [IO.Directory]::CreateDirectory($target) | Out-Null
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ $fake = New-CcodLifecycleFake -NodePath $nodePath
+ $fake.Adapters.NewRuntimeManifest = {
+ param($RuntimeDirectory,$ProjectVersion)
+ $manifest = New-CcodRuntimeManifest -RuntimeDirectory $RuntimeDirectory -ProjectVersion $ProjectVersion
+ New-Item -ItemType Junction -Path (Join-Path $RuntimeDirectory 'manifest.json') -Target $target | Out-Null
+ return $manifest
+ }.GetNewClosure()
+ Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } 'CCOD_INSTALL_REPARSE_PATH'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $target -Force).Count 'runtime manifest junction target receives no bytes'
+ } finally {
+ foreach ($staging in @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue)) {
+ $link = Join-Path $staging.FullName 'manifest.json'
+ if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) }
+ }
+ foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
+$results += Invoke-CcodTest 'staging cleanup refuses a nested junction after copy failure' {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $target = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source | Out-Null
+ [IO.Directory]::CreateDirectory($target) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $target 'sentinel.txt'),'keep',[Text.UTF8Encoding]::new($false))
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+ $fake = New-CcodLifecycleFake -NodePath $nodePath
+ $fake.World.CopyOverride = [pscustomobject]@{
+ Match = '*Supervisor.ps1'
+ Action = {
+ param($SourcePath,$DestinationPath)
+ $stagingDirectory = Split-Path (Split-Path (Split-Path $DestinationPath -Parent) -Parent) -Parent
+ [IO.Directory]::CreateDirectory($stagingDirectory) | Out-Null
+ New-Item -ItemType Junction -Path (Join-Path $stagingDirectory 'cleanup-escape') -Target $target | Out-Null
+ throw 'PRIVATE_COPY_FAILURE_AFTER_JUNCTION'
+ }.GetNewClosure()
+ }
+ Assert-CcodThrows { Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters $fake.Adapters | Out-Null } 'CCOD_INSTALL_STAGING_FAILED'
+ $retained = @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'cleanup-escape') })
+ Assert-CcodEqual 1 $retained.Count 'unsafe staging tree is retained instead of recursively deleted'
+ Assert-CcodEqual 'keep' ([IO.File]::ReadAllText((Join-Path $target 'sentinel.txt'))) 'cleanup never deletes through the nested junction'
+ } finally {
+ foreach ($staging in @(Get-ChildItem -LiteralPath (Join-Path $install '.staging') -Directory -ErrorAction SilentlyContinue)) {
+ $link = Join-Path $staging.FullName 'cleanup-escape'
+ if (Test-Path -LiteralPath $link) { [IO.Directory]::Delete($link) }
+ }
+ foreach ($path in @($source,$install,$target,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+ }
+}
+
$results += Invoke-CcodTest 'payload manifest ancestry treats Windows root equality case-insensitively' {
$source = New-CcodLifecycleTempRoot
$install = New-CcodLifecycleTempRoot
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index be03513..d51bf1b 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -316,17 +316,25 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
$inno = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8
$helper = [regex]::Match($inno,'(?ms)^function IsSafeExistingPayloadDirectory\(.*?^end;')
Assert-CcodTrue $helper.Success 'production Inno script exposes the directory predicate used before payload writes'
- Assert-CcodTrue ($inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?IsSafeExistingPayloadDirectory') 'PrepareToInstall rejects unsafe app and payload directories before file copy'
+ $treeHelper = [regex]::Match($inno,'(?ms)^function IsSafeExistingSetupTree\(.*?^end;')
+ Assert-CcodTrue $treeHelper.Success 'production Inno script exposes a recursive destination-tree predicate'
+ Assert-CcodTrue ($inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?IsSafeExistingSetupTree') 'PrepareToInstall rejects unsafe nested setup destinations before file copy'
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-reparse-harness-' + [guid]::NewGuid().ToString('N'))
try {
$normal = Join-Path $root 'normal'
$target = Join-Path $root 'target'
$junction = Join-Path $root 'junction'
$missing = Join-Path $root 'missing'
+ $tree = Join-Path $root 'tree'
+ $nested = Join-Path $tree 'src\escape'
+ $fileDirectory = Join-Path $root 'file-as-directory'
$resultPath = Join-Path $root 'result.txt'
[IO.Directory]::CreateDirectory($normal) | Out-Null
[IO.Directory]::CreateDirectory($target) | Out-Null
+ [IO.Directory]::CreateDirectory((Split-Path $nested -Parent)) | Out-Null
New-Item -ItemType Junction -Path $junction -Target $target | Out-Null
+ New-Item -ItemType Junction -Path $nested -Target $target | Out-Null
+ [IO.File]::WriteAllText($fileDirectory,'not a directory',[Text.UTF8Encoding]::new($false))
$harnessPath = Join-Path $root 'ReparseGate.iss'
$harness = @"
[Setup]
@@ -345,11 +353,14 @@ const
function GetFileAttributesW(const FileName: String): Cardinal;
external 'GetFileAttributesW@kernel32.dll stdcall';
$($helper.Value)
+$($treeHelper.Value)
function InitializeSetup(): Boolean;
begin
if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and
IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and
- (not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) then
+ (not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) and
+ (not IsSafeExistingSetupTree('$($tree.Replace("'","''"))')) and
+ (not IsSafeExistingSetupTree('$($fileDirectory.Replace("'","''"))')) then
SaveStringToFile('$($resultPath.Replace("'","''"))','pass',False);
Result := False;
end;
@@ -360,7 +371,7 @@ end;
Assert-CcodEqual 0 $LASTEXITCODE "reparse predicate harness compiles: $($compileOutput -join ' ')"
$process = Start-Process -FilePath (Join-Path $root 'ReparseGate.exe') -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-') -WindowStyle Hidden -Wait -PassThru
try { $null = $process.ExitCode } finally { $process.Dispose() }
- Assert-CcodEqual 'pass' ([IO.File]::ReadAllText($resultPath,[Text.UTF8Encoding]::new($false))) 'production predicate accepts absent/normal directories and rejects a junction'
+ Assert-CcodEqual 'pass' ([IO.File]::ReadAllText($resultPath,[Text.UTF8Encoding]::new($false))) 'production predicate rejects root/nested junctions and file-valued directory paths'
} finally {
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
}
From 6dcb725a8a6fae4e3f1178d7c69ada060f8fb8ac Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 02:52:27 +0800
Subject: [PATCH 006/187] fix: inventory all setup destination directories
---
build/CodexControlOtherDevices.iss | 44 ++++++----
build/build.ps1 | 15 +++-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 73 ++++++++++++++--
tools/New-InstallerDestinationInventory.ps1 | 83 +++++++++++++++++++
4 files changed, 193 insertions(+), 22 deletions(-)
create mode 100644 tools/New-InstallerDestinationInventory.ps1
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index 4e79f0e..c9da999 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -11,6 +11,9 @@
#ifndef InstallerPayloadManifestSha256
#error InstallerPayloadManifestSha256 must be supplied by the release builder
#endif
+#ifndef InstallerDestinationInventoryInclude
+#error InstallerDestinationInventoryInclude must be supplied by the release builder
+#endif
AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}
AppName=CodexRemote-fix
AppVersion={#ProjectVersion}
@@ -75,6 +78,7 @@ Source: "..\Test-CodexControlOtherDevices.ps1"; DestDir: "{app}"; Flags: ignorev
Source: "..\Activate-CcodRemoteFix.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\Prompt-CcodRestart.ps1"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\tools\New-InstallerPayloadManifest.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
+Source: "..\tools\New-InstallerDestinationInventory.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "{#InstallerPayloadDirectory}\*"; DestDir: "{app}\payload\{#ProjectVersion}"; Flags: ignoreversion recursesubdirs createallsubdirs
[InstallDelete]
@@ -95,6 +99,7 @@ Name: "{group}\Uninstall CodexRemote-fix"; Filename: "{app}\unins000.exe"; IconF
Name: "{userdesktop}\CodexRemote-fix"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{localappdata}\CodexControlOtherDevices\bootstrap.ps1"" -InstallRoot ""{localappdata}\CodexControlOtherDevices"" -EntryMode Explicit"; WorkingDir: "{localappdata}\CodexControlOtherDevices"; IconFilename: "{app}\assets\CodexRemote-fix.ico"
[Code]
+#include InstallerDestinationInventoryInclude
const
ACTIVATION_TIMEOUT_MILLISECONDS = 300000;
FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS = 90000;
@@ -165,27 +170,36 @@ begin
end;
end;
+function AreCcodExpectedSetupDirectoriesSafe(const AppDirectory: String): Boolean;
+var
+ Directories: TStringList;
+ Index: Integer;
+ Candidate: String;
+begin
+ Result := False;
+ Directories := TStringList.Create;
+ try
+ AddCcodExpectedSetupDirectories(Directories);
+ if Directories.Count = 0 then Exit;
+ for Index := 0 to Directories.Count - 1 do
+ begin
+ Candidate := AddBackslash(AppDirectory) + Directories[Index];
+ if not IsSafeExistingPayloadDirectory(Candidate) then Exit;
+ end;
+ Result := True;
+ finally
+ Directories.Free;
+ end;
+end;
+
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
- AppDirectory, PayloadDirectory, VersionDirectory: String;
+ AppDirectory: String;
begin
Result := '';
AppDirectory := ExpandConstant('{app}');
- PayloadDirectory := ExpandConstant('{app}\payload');
- VersionDirectory := ExpandConstant('{app}\payload\{#ProjectVersion}');
if not IsSafeExistingSetupTree(AppDirectory) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\build')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\bin')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\.github')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\.github\workflows')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\assets')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\assets\codexremote-fix')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\docs')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\src')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\tests')) or
- not IsSafeExistingPayloadDirectory(ExpandConstant('{app}\tools')) or
- not IsSafeExistingPayloadDirectory(PayloadDirectory) or
- not IsSafeExistingPayloadDirectory(VersionDirectory) then
+ not AreCcodExpectedSetupDirectoriesSafe(AppDirectory) then
Result := 'CodexRemote-fix refused an unsafe existing payload directory.';
end;
diff --git a/build/build.ps1 b/build/build.ps1
index 27b0b29..da5a147 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -139,6 +139,7 @@ foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$release
$stageRoot = Join-Path $PSScriptRoot ('.portable-stage-' + [guid]::NewGuid().ToString('N'))
$installerPayloadDirectory = Join-Path $PSScriptRoot ('.installer-payload-stage-' + [guid]::NewGuid().ToString('N'))
+$installerDestinationInventoryPath = Join-Path $PSScriptRoot ('.installer-destination-inventory-' + [guid]::NewGuid().ToString('N') + '.iss')
try {
[IO.Directory]::CreateDirectory($stageRoot) | Out-Null
$payloadRoot = Join-Path $stageRoot 'payload'
@@ -204,6 +205,9 @@ try {
}
}
$installerPayloadManifestSha256 = Get-CcodBuildFileSha256 -Path $installerPayloadManifestPath
+ $destinationInventoryGenerator = Join-Path $repoRoot 'tools\New-InstallerDestinationInventory.ps1'
+ if (-not [IO.File]::Exists($destinationInventoryGenerator)) { throw "Installer destination inventory generator is missing: $destinationInventoryGenerator" }
+ & $destinationInventoryGenerator -RepositoryRoot $repoRoot -PayloadRoot $installerPayloadDirectory -ProjectVersion $Version -InnoScriptPath (Join-Path $PSScriptRoot 'CodexControlOtherDevices.iss') -OutputPath $installerDestinationInventoryPath | Out-Null
$payloadManifestPath = Join-Path $stageRoot 'payload-manifest.json'
$payloadManifest = [ordered]@{
schemaVersion = 1
@@ -275,7 +279,7 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -
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'
$portableArtifact = Join-Path $PSScriptRoot 'generated\portable'
-& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256" "/O$dist\." $issPath
+& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256" "/DInstallerDestinationInventoryInclude=$installerDestinationInventoryPath" "/O$dist\." $issPath
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) {
throw "Inno Setup compilation failed with exit code $LASTEXITCODE"
}
@@ -313,3 +317,12 @@ if ([IO.Directory]::Exists($installerPayloadDirectory)) {
Remove-Item -LiteralPath $installerPayloadFull -Recurse -Force -ErrorAction SilentlyContinue
}
}
+if ([IO.File]::Exists($installerDestinationInventoryPath)) {
+ $inventoryFull = [IO.Path]::GetFullPath($installerDestinationInventoryPath)
+ $buildFull = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\'
+ if ($inventoryFull.StartsWith($buildFull,[StringComparison]::OrdinalIgnoreCase) -and
+ [IO.Path]::GetFileName($inventoryFull).StartsWith('.installer-destination-inventory-',[StringComparison]::Ordinal) -and
+ -not ((Get-Item -LiteralPath $inventoryFull -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
+ Remove-Item -LiteralPath $inventoryFull -Force -ErrorAction SilentlyContinue
+ }
+}
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index d51bf1b..1013c1b 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -104,6 +104,23 @@ Invoke-CcodTest 'production installer payload generator writes ordered version-b
}
}
+Invoke-CcodTest 'production setup destination inventory derives every nested directory from Inno sources and payload input' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-directory-inventory-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $payload = Join-Path $root 'payload'
+ [IO.Directory]::CreateDirectory((Join-Path $payload 'src\persistence\modules')) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1'),'fixture',[Text.UTF8Encoding]::new($false))
+ $output = Join-Path $root 'InstallerDestinationInventory.iss'
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $output | Out-Null
+ $inventory = [IO.File]::ReadAllText($output,[Text.UTF8Encoding]::new($false))
+ foreach ($relative in @('src','src\persistence','src\persistence\modules','payload','payload\2.5.22','payload\2.5.22\src','payload\2.5.22\src\persistence','payload\2.5.22\src\persistence\modules')) {
+ Assert-CcodTrue ($inventory.Contains("Directories.Add('$relative');")) "generated setup inventory contains $relative"
+ }
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
function New-CcodActivationPayloadFixture {
param([string]$Version = '2.5.22')
@@ -138,7 +155,7 @@ function Invoke-CcodActivationVerifierFixture {
}
function Invoke-CcodInnoPayloadCompileFixture {
- param([switch]$IncludePayloadDefines)
+ param([switch]$IncludePayloadDefines,[switch]$OmitDestinationInventory)
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-payload-contract-' + [guid]::NewGuid().ToString('N'))
$tray = Join-Path $root 'tray'
@@ -157,6 +174,8 @@ function Invoke-CcodInnoPayloadCompileFixture {
$manifest = [ordered]@{schemaVersion=1;projectVersion='2.5.21';files=@([ordered]@{path='package.json';length=[int64](Get-Item -LiteralPath (Join-Path $payload 'package.json')).Length;sha256=$packageHash})}
$manifestPath = Join-Path $payload 'installer-payload.manifest.json'
[IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'InstallerDestinationInventory.iss'
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.21' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $inventoryPath | Out-Null
$iscc = @(
(Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'),
(Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'),
@@ -165,7 +184,9 @@ function Invoke-CcodInnoPayloadCompileFixture {
if (-not $iscc) { throw 'Inno Setup 6 is required for the setup payload contract' }
$arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'))
if ($IncludePayloadDefines) {
- $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'))
+ $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)")
+ if (-not $OmitDestinationInventory) { $arguments += "/DInstallerDestinationInventoryInclude=$inventoryPath" }
+ $arguments += @("/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'))
}
$previousPreference = $ErrorActionPreference
try {
@@ -182,7 +203,7 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload
$activation = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8
$installer = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Install-CodexControlOtherDevices.ps1') -Raw -Encoding UTF8
- Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256') 'build invokes the tested generator and binds its manifest hash into setup compilation'
+ Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'New-InstallerDestinationInventory\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256' -and $build -cmatch 'InstallerDestinationInventoryInclude') 'build invokes the tested generators and binds their outputs into setup compilation'
Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory'
Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version'
Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract'
@@ -306,7 +327,18 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay
try {
Assert-CcodEqual 0 $compile.ExitCode "explicit setup payload contract compiles: $($compile.Output)"
Assert-CcodTrue (Test-Path -LiteralPath $compile.SetupPath -PathType Leaf) 'explicit payload compile produces the setup artifact'
- Assert-CcodTrue ($compile.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the exact payload manifest and manifest-listed file'
+ Assert-CcodTrue ($compile.Output -cmatch 'InstallerDestinationInventory\.iss' -and $compile.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the destination inventory, payload manifest, and manifest-listed file'
+ } finally {
+ if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'Inno compile refuses a missing generated destination inventory' {
+ $compile = Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines -OmitDestinationInventory
+ try {
+ Assert-CcodTrue ($compile.ExitCode -ne 0) 'setup compilation fails when only the destination inventory define is omitted'
+ Assert-CcodTrue ($compile.Output -cmatch 'InstallerDestinationInventoryInclude') 'compiler identifies the missing generated inventory define'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath $compile.SetupPath)) 'missing inventory define produces no setup artifact'
} finally {
if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force }
}
@@ -318,7 +350,9 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
Assert-CcodTrue $helper.Success 'production Inno script exposes the directory predicate used before payload writes'
$treeHelper = [regex]::Match($inno,'(?ms)^function IsSafeExistingSetupTree\(.*?^end;')
Assert-CcodTrue $treeHelper.Success 'production Inno script exposes a recursive destination-tree predicate'
- Assert-CcodTrue ($inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?IsSafeExistingSetupTree') 'PrepareToInstall rejects unsafe nested setup destinations before file copy'
+ $inventoryValidator = [regex]::Match($inno,'(?ms)^function AreCcodExpectedSetupDirectoriesSafe\(.*?^end;')
+ Assert-CcodTrue $inventoryValidator.Success 'production Inno script exposes a generated destination-inventory validator'
+ Assert-CcodTrue ($inno -cmatch 'InstallerDestinationInventoryInclude' -and $inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?AreCcodExpectedSetupDirectoriesSafe') 'PrepareToInstall consumes the compile-bound generated inventory before file copy'
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-reparse-harness-' + [guid]::NewGuid().ToString('N'))
try {
$normal = Join-Path $root 'normal'
@@ -328,6 +362,12 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
$tree = Join-Path $root 'tree'
$nested = Join-Path $tree 'src\escape'
$fileDirectory = Join-Path $root 'file-as-directory'
+ $expectedFileRoot = Join-Path $root 'expected-file-root'
+ $expectedJunctionRoot = Join-Path $root 'expected-junction-root'
+ $normalExpectedRoot = Join-Path $root 'normal-expected-root'
+ $inventoryPayload = Join-Path $root 'inventory-payload'
+ $fileWriteMarker = Join-Path $expectedFileRoot 'payload-write-marker.txt'
+ $junctionWriteMarker = Join-Path $expectedJunctionRoot 'payload-write-marker.txt'
$resultPath = Join-Path $root 'result.txt'
[IO.Directory]::CreateDirectory($normal) | Out-Null
[IO.Directory]::CreateDirectory($target) | Out-Null
@@ -335,6 +375,16 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
New-Item -ItemType Junction -Path $junction -Target $target | Out-Null
New-Item -ItemType Junction -Path $nested -Target $target | Out-Null
[IO.File]::WriteAllText($fileDirectory,'not a directory',[Text.UTF8Encoding]::new($false))
+ [IO.Directory]::CreateDirectory((Join-Path $expectedFileRoot 'src')) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $expectedFileRoot 'src\persistence'),'not a directory',[Text.UTF8Encoding]::new($false))
+ [IO.Directory]::CreateDirectory((Join-Path $expectedJunctionRoot 'payload\2.5.22\src')) | Out-Null
+ New-Item -ItemType Junction -Path (Join-Path $expectedJunctionRoot 'payload\2.5.22\src\persistence') -Target $target | Out-Null
+ [IO.Directory]::CreateDirectory($normalExpectedRoot) | Out-Null
+ [IO.Directory]::CreateDirectory((Join-Path $inventoryPayload 'src\persistence\modules')) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $inventoryPayload 'src\persistence\modules\InstallLifecycle.psm1'),'fixture',[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'InventoryFixture.iss'
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $inventoryPayload -ProjectVersion '2.5.22' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $inventoryPath | Out-Null
+ $inventorySource = [IO.File]::ReadAllText($inventoryPath,[Text.UTF8Encoding]::new($false))
$harnessPath = Join-Path $root 'ReparseGate.iss'
$harness = @"
[Setup]
@@ -354,13 +404,22 @@ function GetFileAttributesW(const FileName: String): Cardinal;
external 'GetFileAttributesW@kernel32.dll stdcall';
$($helper.Value)
$($treeHelper.Value)
+$inventorySource
+$($inventoryValidator.Value)
function InitializeSetup(): Boolean;
begin
+ if AreCcodExpectedSetupDirectoriesSafe('$($expectedFileRoot.Replace("'","''"))') then
+ SaveStringToFile('$($fileWriteMarker.Replace("'","''"))','unsafe write',False);
+ if AreCcodExpectedSetupDirectoriesSafe('$($expectedJunctionRoot.Replace("'","''"))') then
+ SaveStringToFile('$($junctionWriteMarker.Replace("'","''"))','unsafe write',False);
if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and
IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and
(not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) and
(not IsSafeExistingSetupTree('$($tree.Replace("'","''"))')) and
- (not IsSafeExistingSetupTree('$($fileDirectory.Replace("'","''"))')) then
+ (not IsSafeExistingSetupTree('$($fileDirectory.Replace("'","''"))')) and
+ AreCcodExpectedSetupDirectoriesSafe('$($normalExpectedRoot.Replace("'","''"))') and
+ (not AreCcodExpectedSetupDirectoriesSafe('$($expectedFileRoot.Replace("'","''"))')) and
+ (not AreCcodExpectedSetupDirectoriesSafe('$($expectedJunctionRoot.Replace("'","''"))')) then
SaveStringToFile('$($resultPath.Replace("'","''"))','pass',False);
Result := False;
end;
@@ -372,6 +431,8 @@ end;
$process = Start-Process -FilePath (Join-Path $root 'ReparseGate.exe') -ArgumentList @('/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-') -WindowStyle Hidden -Wait -PassThru
try { $null = $process.ExitCode } finally { $process.Dispose() }
Assert-CcodEqual 'pass' ([IO.File]::ReadAllText($resultPath,[Text.UTF8Encoding]::new($false))) 'production predicate rejects root/nested junctions and file-valued directory paths'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath $fileWriteMarker)) 'nested file-as-directory is rejected before simulated payload writes'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath $junctionWriteMarker)) 'nested junction is rejected before simulated payload writes'
} finally {
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
}
diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1
new file mode 100644
index 0000000..6c0ba4b
--- /dev/null
+++ b/tools/New-InstallerDestinationInventory.ps1
@@ -0,0 +1,83 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string]$RepositoryRoot,
+ [Parameter(Mandatory)][string]$PayloadRoot,
+ [Parameter(Mandatory)][string]$ProjectVersion,
+ [Parameter(Mandatory)][string]$InnoScriptPath,
+ [Parameter(Mandatory)][string]$OutputPath
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if ($ProjectVersion -cnotmatch '^\d+\.\d+\.\d+$') { throw "Invalid project version: $ProjectVersion" }
+$repository = [IO.Path]::GetFullPath($RepositoryRoot).TrimEnd('\')
+$payload = [IO.Path]::GetFullPath($PayloadRoot).TrimEnd('\')
+$inno = [IO.Path]::GetFullPath($InnoScriptPath)
+$output = [IO.Path]::GetFullPath($OutputPath)
+if (-not [IO.Directory]::Exists($repository) -or -not [IO.Directory]::Exists($payload) -or -not [IO.File]::Exists($inno)) { throw 'Setup destination inventory input is missing.' }
+if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) { throw "Refusing to overwrite setup destination inventory: $output" }
+
+$directories = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+function Add-CcodExpectedDirectory {
+ param([Parameter(Mandatory)][AllowEmptyString()][string]$Relative)
+ $normalized = $Relative.Replace('/','\').Trim('\')
+ if ([string]::IsNullOrWhiteSpace($normalized)) { return }
+ if ($normalized -notmatch '^[A-Za-z0-9._-]+(?:\\[A-Za-z0-9._-]+)*$') { throw "Unsafe setup destination directory: $normalized" }
+ $segments = $normalized -split '\\'
+ $current = ''
+ foreach ($segment in $segments) {
+ $current = if ([string]::IsNullOrEmpty($current)) { $segment } else { "$current\$segment" }
+ [void]$directories.Add($current)
+ }
+}
+
+$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8)
+$insideFiles = $false
+foreach ($line in $lines) {
+ if ($line -ceq '[Files]') { $insideFiles = $true; continue }
+ if ($insideFiles -and $line -match '^\[') { break }
+ if (-not $insideFiles -or [string]::IsNullOrWhiteSpace($line)) { continue }
+ $match = [regex]::Match($line,'^Source:\s*"(?[^"]+)";\s*DestDir:\s*"(?[^"]+)";(?.*)$')
+ if (-not $match.Success) { throw "Unsupported [Files] entry for destination inventory: $line" }
+ $destination = $match.Groups['destination'].Value.Replace('{#ProjectVersion}',$ProjectVersion)
+ if (-not $destination.StartsWith('{app}',[StringComparison]::Ordinal)) { throw "Setup destination is outside app root: $destination" }
+ $destinationRelative = $destination.Substring(5).TrimStart('\')
+ Add-CcodExpectedDirectory -Relative $destinationRelative
+ if ($match.Groups['tail'].Value -cnotmatch '(?:^|\s)recursesubdirs(?:\s|$)') { continue }
+
+ $sourceExpression = $match.Groups['source'].Value
+ $sourceRoot = $null
+ if ($sourceExpression.StartsWith('{#InstallerPayloadDirectory}',[StringComparison]::Ordinal)) {
+ $sourceRoot = $payload
+ } elseif ($sourceExpression.StartsWith('..\',[StringComparison]::Ordinal) -and $sourceExpression.EndsWith('\*',[StringComparison]::Ordinal)) {
+ $sourceRoot = [IO.Path]::GetFullPath((Join-Path (Split-Path $inno -Parent) $sourceExpression.Substring(0,$sourceExpression.Length-2)))
+ } else {
+ throw "Recursive [Files] source cannot be inventoried deterministically: $sourceExpression"
+ }
+ if (-not [IO.Directory]::Exists($sourceRoot)) { throw "Recursive [Files] source root is missing: $sourceRoot" }
+ $sourcePrefix = $sourceRoot.TrimEnd('\') + '\'
+ foreach ($entry in @(Get-ChildItem -LiteralPath $sourceRoot -Force -Recurse -ErrorAction Stop)) {
+ if (($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Recursive [Files] source contains a reparse point: $($entry.FullName)" }
+ $relativeDirectory = if ($entry.PSIsContainer) {
+ $entry.FullName.Substring($sourcePrefix.Length)
+ } else {
+ $parent = Split-Path $entry.FullName -Parent
+ if ($parent.Equals($sourceRoot,[StringComparison]::OrdinalIgnoreCase)) { '' } else { $parent.Substring($sourcePrefix.Length) }
+ }
+ if (-not [string]::IsNullOrWhiteSpace($relativeDirectory)) {
+ Add-CcodExpectedDirectory -Relative $(if ([string]::IsNullOrWhiteSpace($destinationRelative)) { $relativeDirectory } else { "$destinationRelative\$relativeDirectory" })
+ }
+ }
+}
+if (-not $insideFiles -or $directories.Count -eq 0) { throw 'No setup destination directories were generated.' }
+$ordered = [Collections.Generic.List[string]]::new()
+foreach ($directory in $directories) { $ordered.Add($directory) }
+$ordered.Sort([StringComparer]::Ordinal)
+$builder = [Text.StringBuilder]::new()
+[void]$builder.AppendLine('procedure AddCcodExpectedSetupDirectories(Directories: TStrings);')
+[void]$builder.AppendLine('begin')
+foreach ($directory in $ordered) { [void]$builder.AppendLine(" Directories.Add('$directory');") }
+[void]$builder.AppendLine('end;')
+[IO.File]::WriteAllText($output,$builder.ToString(),[Text.UTF8Encoding]::new($false))
+Write-Output ([pscustomobject]@{Path=$output;Directories=@($ordered)})
From 8611e73b2c51761c260e064b649d86f925735968 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 02:59:37 +0800
Subject: [PATCH 007/187] fix: require one Inno Files section
---
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 35 +++++++++++++++++++
tools/New-InstallerDestinationInventory.ps1 | 4 ++-
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 1013c1b..0c00285 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -121,6 +121,41 @@ Invoke-CcodTest 'production setup destination inventory derives every nested dir
}
}
+Invoke-CcodTest 'production setup destination inventory rejects multiple Files sections' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-multiple-files-sections-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $payload = Join-Path $root 'payload'
+ [IO.Directory]::CreateDirectory($payload) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $payload 'package.json'),'{}',[Text.UTF8Encoding]::new($false))
+ $innoPath = Join-Path $root 'MultipleFilesSections.iss'
+ $innoSource = @'
+[Setup]
+AppName=Fixture
+AppVersion=1.0.0
+DefaultDirName={app}
+[Files]
+Source: "fixture-a.txt"; DestDir: "{app}\first"; Flags: ignoreversion
+[Code]
+procedure Fixture();
+begin
+end;
+[Files]
+Source: "fixture-b.txt"; DestDir: "{app}\second"; Flags: ignoreversion
+'@
+ [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false))
+ $output = Join-Path $root 'Inventory.iss'
+ $failure = $null
+ try {
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $output | Out-Null
+ } catch { $failure = $_ }
+ Assert-CcodTrue ($null -ne $failure) 'generator fails closed when the Inno source contains multiple Files sections'
+ Assert-CcodTrue ($failure.Exception.Message -cmatch 'exactly one \[Files\] section') 'multiple-section failure explains the structural contract'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath $output)) 'multiple Files sections produce no partial inventory artifact'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
function New-CcodActivationPayloadFixture {
param([string]$Version = '2.5.22')
diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1
index 6c0ba4b..1475da2 100644
--- a/tools/New-InstallerDestinationInventory.ps1
+++ b/tools/New-InstallerDestinationInventory.ps1
@@ -33,9 +33,11 @@ function Add-CcodExpectedDirectory {
}
$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8)
+$filesSectionCount = @($lines | Where-Object { $_ -match '^\s*\[Files\]\s*$' }).Count
+if ($filesSectionCount -ne 1) { throw "Setup destination inventory requires exactly one [Files] section; found $filesSectionCount." }
$insideFiles = $false
foreach ($line in $lines) {
- if ($line -ceq '[Files]') { $insideFiles = $true; continue }
+ if ($line -match '^\s*\[Files\]\s*$') { $insideFiles = $true; continue }
if ($insideFiles -and $line -match '^\[') { break }
if (-not $insideFiles -or [string]::IsNullOrWhiteSpace($line)) { continue }
$match = [regex]::Match($line,'^Source:\s*"(?[^"]+)";\s*DestDir:\s*"(?[^"]+)";(?.*)$')
From 8ac6106eb0f6ee0389ec4d9fbbf019e713f852b6 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 03:21:24 +0800
Subject: [PATCH 008/187] fix: enforce Inno inventory include boundary
---
build/build.ps1 | 46 +++++++-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 110 +++++++++++++++++-
tools/New-InstallerDestinationInventory.ps1 | 6 +
3 files changed, 155 insertions(+), 7 deletions(-)
diff --git a/build/build.ps1 b/build/build.ps1
index da5a147..c89f84b 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -2,7 +2,8 @@
param(
[string]$Version,
[switch]$UseExistingTrayHost,
- [string]$TrayHostArtifactDirectory
+ [string]$TrayHostArtifactDirectory,
+ [switch]$Library
)
Set-StrictMode -Version Latest
@@ -104,6 +105,34 @@ function Write-CcodBuildUtf8 {
[IO.File]::WriteAllText($Path,$Text,[Text.UTF8Encoding]::new($false))
}
+function Assert-CcodBuildInstallerDestinationInventory {
+ param([Parameter(Mandatory)][string]$Path)
+ $inventoryPath = Assert-CcodBuildRegularFile -Path $Path -Kind 'Installer destination inventory'
+ foreach ($line in [IO.File]::ReadAllLines($inventoryPath,[Text.UTF8Encoding]::new($false))) {
+ if ($line -match '^\s*\[[^\[\]\r\n]+\]\s*(?:;.*)?$') {
+ throw "Installer destination inventory contains an Inno section header: $line"
+ }
+ }
+ return $inventoryPath
+}
+
+function Invoke-CcodBuildInnoCompiler {
+ param(
+ [Parameter(Mandatory)][string]$InventoryPath,
+ [Parameter(Mandatory)][string]$IsccPath,
+ [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Arguments,
+ [Parameter(Mandatory)][string]$SetupPath
+ )
+ Assert-CcodBuildInstallerDestinationInventory -Path $InventoryPath | Out-Null
+ & $IsccPath @Arguments
+ $compilerExitCode = $LASTEXITCODE
+ if ($compilerExitCode -ne 0 -or -not (Test-Path -LiteralPath $SetupPath -PathType Leaf)) {
+ throw "Inno Setup compilation failed with exit code $compilerExitCode"
+ }
+}
+
+if ($Library) { return }
+
$repoRoot = Split-Path $PSScriptRoot -Parent
$package = Get-Content -LiteralPath (Join-Path $repoRoot 'package.json') -Raw | ConvertFrom-Json
$packageVersion = ([string]$package.version).TrimStart('v')
@@ -279,10 +308,17 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -
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'
$portableArtifact = Join-Path $PSScriptRoot 'generated\portable'
-& $iscc "/DProjectVersion=$Version" "/DTrayHostArtifactDirectory=$trayHostArtifact" "/DPortableArtifactDirectory=$portableArtifact" "/DInstallerPayloadDirectory=$installerPayloadDirectory" "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256" "/DInstallerDestinationInventoryInclude=$installerDestinationInventoryPath" "/O$dist\." $issPath
-if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $setupExe -PathType Leaf)) {
- throw "Inno Setup compilation failed with exit code $LASTEXITCODE"
-}
+$isccArguments = @(
+ "/DProjectVersion=$Version",
+ "/DTrayHostArtifactDirectory=$trayHostArtifact",
+ "/DPortableArtifactDirectory=$portableArtifact",
+ "/DInstallerPayloadDirectory=$installerPayloadDirectory",
+ "/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256",
+ "/DInstallerDestinationInventoryInclude=$installerDestinationInventoryPath",
+ "/O$dist\.",
+ $issPath
+)
+Invoke-CcodBuildInnoCompiler -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe
$setupHash = Get-CcodBuildFileSha256 -Path $setupExe
Write-CcodBuildUtf8 -Path $setupChecksum -Text ("{0} *{1}" -f $setupHash,[IO.Path]::GetFileName($setupExe))
$setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json"
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 0c00285..e203d90 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -104,14 +104,17 @@ Invoke-CcodTest 'production installer payload generator writes ordered version-b
}
}
-Invoke-CcodTest 'production setup destination inventory derives every nested directory from Inno sources and payload input' {
+Invoke-CcodTest 'production setup destination inventory accepts the literal symbolic include and derives every nested directory' {
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-directory-inventory-' + [guid]::NewGuid().ToString('N'))
try {
$payload = Join-Path $root 'payload'
[IO.Directory]::CreateDirectory((Join-Path $payload 'src\persistence\modules')) | Out-Null
[IO.File]::WriteAllText((Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1'),'fixture',[Text.UTF8Encoding]::new($false))
$output = Join-Path $root 'InstallerDestinationInventory.iss'
- & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $output | Out-Null
+ $innoPath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'
+ $allowedIncludes = @([IO.File]::ReadAllLines($innoPath,[Text.UTF8Encoding]::new($false)) | Where-Object { [string]$_ -ceq '#include InstallerDestinationInventoryInclude' })
+ Assert-CcodEqual 1 $allowedIncludes.Count 'production fixture exercises the one permitted literal include directive'
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $output | Out-Null
$inventory = [IO.File]::ReadAllText($output,[Text.UTF8Encoding]::new($false))
foreach ($relative in @('src','src\persistence','src\persistence\modules','payload','payload\2.5.22','payload\2.5.22\src','payload\2.5.22\src\persistence','payload\2.5.22\src\persistence\modules')) {
Assert-CcodTrue ($inventory.Contains("Directories.Add('$relative');")) "generated setup inventory contains $relative"
@@ -156,6 +159,109 @@ Source: "fixture-b.txt"; DestDir: "{app}\second"; Flags: ignoreversion
}
}
+Invoke-CcodTest 'production setup destination inventory rejects an included second Files section before ISCC' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-included-files-section-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $payload = Join-Path $root 'payload'
+ $setupOutput = Join-Path $root 'setup-output'
+ [IO.Directory]::CreateDirectory($payload) | Out-Null
+ [IO.Directory]::CreateDirectory($setupOutput) | Out-Null
+ $firstSource = Join-Path $root 'fixture-a.txt'
+ $secondSource = Join-Path $root 'fixture-b.txt'
+ [IO.File]::WriteAllText($firstSource,'first',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($secondSource,'second',[Text.UTF8Encoding]::new($false))
+ $extraPath = Join-Path $root 'extra.iss'
+ $extraSource = @"
+[Files]
+Source: "$secondSource"; DestDir: "{app}\second"; Flags: ignoreversion
+"@
+ [IO.File]::WriteAllText($extraPath,$extraSource,[Text.UTF8Encoding]::new($false))
+ $innoPath = Join-Path $root 'IncludedFilesSection.iss'
+ $innoSource = @"
+[Setup]
+AppName=IncludedFilesSection
+AppVersion=1.0.0
+DefaultDirName={tmp}\IncludedFilesSection
+OutputDir=$setupOutput
+OutputBaseFilename=IncludedFilesSection
+Uninstallable=no
+[Files]
+Source: "$firstSource"; DestDir: "{app}\first"; Flags: ignoreversion
+[Code]
+procedure Fixture();
+begin
+end;
+#include "extra.iss"
+"@
+ [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $setupPath = Join-Path $setupOutput 'IncludedFilesSection.exe'
+ $generatorFailure = $null
+ $compileExitCode = $null
+ try {
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $inventoryPath | Out-Null
+ $iscc = @(
+ (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')
+ ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
+ if (-not $iscc) { throw 'Inno Setup 6 is required for the include-boundary fixture' }
+ $previousPreference = $ErrorActionPreference
+ try {
+ $ErrorActionPreference = 'Continue'
+ $null = @(& $iscc $innoPath 2>&1)
+ $compileExitCode = $LASTEXITCODE
+ } finally { $ErrorActionPreference = $previousPreference }
+ } catch { $generatorFailure = $_ }
+ Assert-CcodTrue ($null -ne $generatorFailure) ("generator rejects the arbitrary include before ISCC; inventory={0} compileExit={1} setup={2}" -f [IO.File]::Exists($inventoryPath),$compileExitCode,[IO.File]::Exists($setupPath))
+ Assert-CcodTrue ($generatorFailure.Exception.Message -cmatch 'literal include directive') 'arbitrary include failure identifies the literal include boundary'
+ Assert-CcodTrue (-not [IO.File]::Exists($inventoryPath)) 'rejected include produces no destination inventory artifact'
+ Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'rejected include produces no setup artifact'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'build validates generated inventory before invoking ISCC' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-inventory-header-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $inventorySource = @'
+procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
+begin
+end;
+[Files]
+'@
+ [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false))
+ $compilerMarker = Join-Path $root 'iscc-invoked.txt'
+ $compilerPath = Join-Path $root 'fake-iscc.cmd'
+ $setupPath = Join-Path $root 'setup.exe'
+ $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n"
+ [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new())
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ $failure = $null
+ try {
+ Invoke-CcodBuildInnoCompiler -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
+ } catch { $failure = $_ }
+ Assert-CcodTrue ($null -ne $failure) 'build rejects a generated inventory carrying an Inno section header'
+ Assert-CcodTrue ($failure.Exception.Message -cmatch 'section header') 'inventory validation explains the forbidden section header'
+ Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) 'ISCC is not invoked after inventory validation fails'
+ Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'invalid inventory produces no setup artifact'
+ $validInventorySource = @'
+procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
+begin
+end;
+'@
+ [IO.File]::WriteAllText($inventoryPath,$validInventorySource,[Text.UTF8Encoding]::new($false))
+ Invoke-CcodBuildInnoCompiler -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
+ Assert-CcodTrue ([IO.File]::Exists($compilerMarker)) 'validated inventory reaches ISCC'
+ Assert-CcodTrue ([IO.File]::Exists($setupPath)) 'validated inventory accepts the compiler setup artifact'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
function New-CcodActivationPayloadFixture {
param([string]$Version = '2.5.22')
diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1
index 1475da2..4274730 100644
--- a/tools/New-InstallerDestinationInventory.ps1
+++ b/tools/New-InstallerDestinationInventory.ps1
@@ -33,6 +33,12 @@ function Add-CcodExpectedDirectory {
}
$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8)
+$includeDirectives = @($lines | Where-Object { $_ -match '^\s*#\s*include\b' })
+$allowedIncludeDirective = '#include InstallerDestinationInventoryInclude'
+if ($includeDirectives.Count -gt 1 -or
+ ($includeDirectives.Count -eq 1 -and [string]$includeDirectives[0] -cne $allowedIncludeDirective)) {
+ throw "Setup destination inventory permits only one literal include directive: $allowedIncludeDirective"
+}
$filesSectionCount = @($lines | Where-Object { $_ -match '^\s*\[Files\]\s*$' }).Count
if ($filesSectionCount -ne 1) { throw "Setup destination inventory requires exactly one [Files] section; found $filesSectionCount." }
$insideFiles = $false
From c81987e07efc69e7bbe13d7cc08b045afddeb8bd Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 03:31:49 +0800
Subject: [PATCH 009/187] docs: replace Inno include parsing
---
...08-28-v2522-install-runtime-reliability.md | 64 +++++++++++++++++++
...2522-install-runtime-reliability-design.md | 7 ++
2 files changed, 71 insertions(+)
diff --git a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
index 49ea10c..739a622 100644
--- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
+++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
@@ -92,6 +92,68 @@ fixtures fail before active-pointer mutation.
fix: bind setup activation to immutable payload
```
+### Task 1b: Compile an include-free generated setup script
+
+**Files:**
+- Modify: `build/build.ps1`
+- Modify: `build/CodexControlOtherDevices.iss`
+- Modify: `tools/New-InstallerDestinationInventory.ps1`
+- Test: `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+**Interfaces:**
+- The checked-in `.iss` contains exactly one inventory marker comment.
+- Build writes a temporary generated `.iss` by replacing that marker with the
+ verified inventory procedure and invokes ISCC only on the generated file.
+- The template rejects all external `#include` and `#+` directives.
+
+- [ ] **Step 1: Write failing behavior tests**
+
+```powershell
+$template = @'
+[Setup]
+AppName=fixture
+[Files]
+Source: "first.txt"; DestDir: "{app}\first"
+#+ "extra.iss"
+'@
+Assert-CcodThrows {
+ & $generator -RepositoryRoot $root -PayloadRoot $payload -ProjectVersion '2.5.22' `
+ -InnoScriptPath $templatePath -OutputPath $inventoryPath
+} 'include'
+Assert-CcodFalse (Test-Path -LiteralPath $inventoryPath) 'include bypass produces no partial inventory'
+```
+
+Add an actual ISCC test showing that the generated script compiles with its
+injected inventory, while a template with either include spelling is rejected
+before ISCC is launched.
+
+- [ ] **Step 2: Run the focused test and confirm RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+Expected: the include-alias fixture is accepted before the new template
+boundary exists.
+
+- [ ] **Step 3: Implement the generated include-free compiler input**
+
+Replace the source include with one unique marker. Build reads the checked-in
+template, refuses any `#include` or `#+` directive, substitutes the generated
+inventory text once, writes a GUID-named temporary `.iss`, validates that the
+result has no marker or external include, and passes that generated path to
+ISCC. Clean the temporary script in `finally`.
+
+- [ ] **Step 4: Run focused tests and prove GREEN**
+
+Run ReleaseWorkflow and the real ISCC fixture. Confirm the compiled setup
+source contains the expected injected procedure and arbitrary include aliases
+leave neither setup nor inventory output.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: compile include-free setup inventory
+```
+
### Task 2: Retry only transient Electron tree reads
**Files:**
@@ -256,6 +318,8 @@ release: prepare CodexRemote-fix v2.5.22
- Task 1 produces the immutable source/expected-version contract consumed by
the new setup flow and release contract.
+- Task 1b removes the preprocessor include ambiguity from the Task 1 setup
+ source before any release compiler invocation.
- Task 2 changes only pre-mutation tree acquisition and explicitly leaves all
identity and post-stop checks strict.
- Task 3 has no dependency on Task 2 and can be reviewed independently; its
diff --git a/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
index 021295f..a57c866 100644
--- a/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
+++ b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
@@ -55,6 +55,13 @@ This works for new users because the versioned payload directory is created
from scratch, and for upgrades because an old app root cannot substitute its
payload for the requested version.
+Build creates a one-time generated `.iss` from the checked-in template by
+replacing one fixed inventory marker with the verified destination-directory
+procedure. The template contains no external `#include` or `#+` directive;
+build rejects both spellings before ISCC runs. This avoids attempting to
+reimplement ISPP preprocessing while making the directory inventory and the
+exact source compiled by ISCC one artifact.
+
### 2. Bounded stable-tree acquisition
Add a retry wrapper around verified Electron process-tree acquisition for
From ec4194f3791142bbed65150c4466f8c9b8ca5b1e Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 03:33:57 +0800
Subject: [PATCH 010/187] docs: number include boundary task
---
...26-08-28-v2522-install-runtime-reliability.md | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
index 739a622..f83ada1 100644
--- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
+++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
@@ -92,7 +92,7 @@ fixtures fail before active-pointer mutation.
fix: bind setup activation to immutable payload
```
-### Task 1b: Compile an include-free generated setup script
+### Task 2: Compile an include-free generated setup script
**Files:**
- Modify: `build/build.ps1`
@@ -154,7 +154,7 @@ leave neither setup nor inventory output.
fix: compile include-free setup inventory
```
-### Task 2: Retry only transient Electron tree reads
+### Task 3: Retry only transient Electron tree reads
**Files:**
- Modify: `src/persistence/modules/ProcessControl.psm1`
@@ -221,7 +221,7 @@ member.
fix: stabilize verified Electron process trees
```
-### Task 3: Make tray action correlation testable and diagnosable
+### Task 4: Make tray action correlation testable and diagnosable
**Files:**
- Modify: `src/persistence/modules/TrayHostClient.psm1`
@@ -274,7 +274,7 @@ exact terminal code into local diagnostics.
test: cover tray action revision correlation
```
-### Task 4: Version, documentation, and release acceptance
+### Task 5: Version, documentation, and release acceptance
**Files:**
- Modify: `package.json`
@@ -318,13 +318,13 @@ release: prepare CodexRemote-fix v2.5.22
- Task 1 produces the immutable source/expected-version contract consumed by
the new setup flow and release contract.
-- Task 1b removes the preprocessor include ambiguity from the Task 1 setup
+- Task 2 removes the preprocessor include ambiguity from the Task 1 setup
source before any release compiler invocation.
-- Task 2 changes only pre-mutation tree acquisition and explicitly leaves all
+- Task 3 changes only pre-mutation tree acquisition and explicitly leaves all
identity and post-stop checks strict.
-- Task 3 has no dependency on Task 2 and can be reviewed independently; its
+- Task 4 has no dependency on Task 3 and can be reviewed independently; its
diagnostics make future live failures actionable without weakening protocol
authentication.
-- Task 4 consumes the completed behavior and performs no release publication;
+- Task 5 consumes the completed behavior and performs no release publication;
publishing, installation, and reboot remain a separately authorized final
acceptance step.
From 31323b53659799af51478782cbea8bcf216ef1ad Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 03:46:52 +0800
Subject: [PATCH 011/187] fix: compile include-free setup inventory
---
build/CodexControlOtherDevices.iss | 5 +-
build/build.ps1 | 91 +++++-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 301 +++++++++++++-----
tools/New-InstallerDestinationInventory.ps1 | 8 +-
4 files changed, 310 insertions(+), 95 deletions(-)
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index c9da999..ff42b79 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -11,9 +11,6 @@
#ifndef InstallerPayloadManifestSha256
#error InstallerPayloadManifestSha256 must be supplied by the release builder
#endif
-#ifndef InstallerDestinationInventoryInclude
-#error InstallerDestinationInventoryInclude must be supplied by the release builder
-#endif
AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}
AppName=CodexRemote-fix
AppVersion={#ProjectVersion}
@@ -99,7 +96,7 @@ Name: "{group}\Uninstall CodexRemote-fix"; Filename: "{app}\unins000.exe"; IconF
Name: "{userdesktop}\CodexRemote-fix"; Filename: "{sys}\WindowsPowerShell\v1.0\powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""{localappdata}\CodexControlOtherDevices\bootstrap.ps1"" -InstallRoot ""{localappdata}\CodexControlOtherDevices"" -EntryMode Explicit"; WorkingDir: "{localappdata}\CodexControlOtherDevices"; IconFilename: "{app}\assets\CodexRemote-fix.ico"
[Code]
-#include InstallerDestinationInventoryInclude
+// CCOD_INSTALLER_DESTINATION_INVENTORY
const
ACTIVATION_TIMEOUT_MILLISECONDS = 300000;
FIRST_ACTIVATION_RECEIPT_TIMEOUT_MILLISECONDS = 90000;
diff --git a/build/build.ps1 b/build/build.ps1
index c89f84b..426b0f3 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -116,18 +116,93 @@ function Assert-CcodBuildInstallerDestinationInventory {
return $inventoryPath
}
+function New-CcodBuildGeneratedInnoScript {
+ param(
+ [Parameter(Mandatory)][string]$TemplatePath,
+ [Parameter(Mandatory)][string]$InventoryPath,
+ [Parameter(Mandatory)][string]$OutputPath
+ )
+ $template = Assert-CcodBuildRegularFile -Path $TemplatePath -Kind 'Inno Setup template'
+ $inventory = Assert-CcodBuildInstallerDestinationInventory -Path $InventoryPath
+ $output = [IO.Path]::GetFullPath($OutputPath)
+ if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) {
+ throw "Refusing to overwrite generated Inno Setup script: $output"
+ }
+ $templateDirectory = [IO.Path]::GetFullPath((Split-Path $template -Parent)).TrimEnd('\')
+ $outputDirectory = [IO.Path]::GetFullPath((Split-Path $output -Parent)).TrimEnd('\')
+ if (-not $outputDirectory.Equals($templateDirectory,[StringComparison]::OrdinalIgnoreCase) -or
+ [IO.Path]::GetExtension($output) -cne '.iss') {
+ throw 'The generated Inno Setup script must be a sibling .iss file of its template.'
+ }
+
+ $marker = '// CCOD_INSTALLER_DESTINATION_INVENTORY'
+ $templateSource = [IO.File]::ReadAllText($template,[Text.UTF8Encoding]::new($false))
+ if ($templateSource -match '(?m)^\s*#\s*(?:include\b|\+)') {
+ throw 'The Inno Setup template must be include-free; external #include and #+ directives are not permitted.'
+ }
+ $markerCount = [regex]::Matches($templateSource,[regex]::Escape($marker)).Count
+ $markerLineCount = [regex]::Matches($templateSource,'(?m)^\s*// CCOD_INSTALLER_DESTINATION_INVENTORY\s*$').Count
+ if ($markerCount -ne 1 -or $markerLineCount -ne 1) {
+ throw "The Inno Setup template must contain exactly one inventory marker comment; found $markerCount."
+ }
+
+ $inventorySource = [IO.File]::ReadAllText($inventory,[Text.UTF8Encoding]::new($false)).TrimEnd("`r","`n")
+ $generatedSource = $templateSource.Replace($marker,$inventorySource)
+ if ($generatedSource.Contains($marker)) {
+ throw 'The generated Inno Setup script contains an unresolved inventory marker.'
+ }
+ if ($generatedSource -match '(?m)^\s*#\s*(?:include\b|\+)') {
+ throw 'The generated Inno Setup script contains an external #include or #+ directive.'
+ }
+
+ try {
+ Write-CcodBuildUtf8 -Path $output -Text $generatedSource
+ $writtenSource = [IO.File]::ReadAllText((Assert-CcodBuildRegularFile -Path $output -Kind 'Generated Inno Setup script'),[Text.UTF8Encoding]::new($false))
+ if (-not $writtenSource.Equals($generatedSource,[StringComparison]::Ordinal)) {
+ throw 'The generated Inno Setup script changed during write verification.'
+ }
+ return $output
+ } catch {
+ if ([IO.File]::Exists($output)) { Remove-Item -LiteralPath $output -Force -ErrorAction SilentlyContinue }
+ throw
+ }
+}
+
function Invoke-CcodBuildInnoCompiler {
param(
+ [Parameter(Mandatory)][string]$TemplatePath,
[Parameter(Mandatory)][string]$InventoryPath,
[Parameter(Mandatory)][string]$IsccPath,
[Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Arguments,
[Parameter(Mandatory)][string]$SetupPath
)
- Assert-CcodBuildInstallerDestinationInventory -Path $InventoryPath | Out-Null
- & $IsccPath @Arguments
- $compilerExitCode = $LASTEXITCODE
- if ($compilerExitCode -ne 0 -or -not (Test-Path -LiteralPath $SetupPath -PathType Leaf)) {
- throw "Inno Setup compilation failed with exit code $compilerExitCode"
+ $template = Assert-CcodBuildRegularFile -Path $TemplatePath -Kind 'Inno Setup template'
+ $compiler = Assert-CcodBuildRegularFile -Path $IsccPath -Kind 'Inno Setup compiler'
+ $setup = [IO.Path]::GetFullPath($SetupPath)
+ if ([IO.File]::Exists($setup) -or [IO.Directory]::Exists($setup)) {
+ throw "Refusing to compile over an existing setup output: $setup"
+ }
+ $generatedScriptPath = Join-Path (Split-Path $template -Parent) ('.ccod-generated-setup-' + [guid]::NewGuid().ToString('N') + '.iss')
+ try {
+ $generatedScript = New-CcodBuildGeneratedInnoScript -TemplatePath $template -InventoryPath $InventoryPath -OutputPath $generatedScriptPath
+ $compilerArguments = @($Arguments) + @($generatedScript)
+ & $compiler @compilerArguments
+ $compilerExitCode = $LASTEXITCODE
+ if ($compilerExitCode -ne 0 -or -not (Test-Path -LiteralPath $setup -PathType Leaf)) {
+ throw "Inno Setup compilation failed with exit code $compilerExitCode"
+ }
+ } finally {
+ if ([IO.File]::Exists($generatedScriptPath)) {
+ $generatedFull = [IO.Path]::GetFullPath($generatedScriptPath)
+ $templateDirectory = [IO.Path]::GetFullPath((Split-Path $template -Parent)).TrimEnd('\') + '\'
+ $generatedItem = Get-Item -LiteralPath $generatedFull -Force -ErrorAction Stop
+ if (-not $generatedFull.StartsWith($templateDirectory,[StringComparison]::OrdinalIgnoreCase) -or
+ [IO.Path]::GetFileName($generatedFull) -cnotmatch '^\.ccod-generated-setup-[0-9a-f]{32}\.iss$' -or
+ (($generatedItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
+ throw "Refusing to clean an unexpected generated Inno Setup path: $generatedFull"
+ }
+ Remove-Item -LiteralPath $generatedFull -Force -ErrorAction Stop
+ }
}
}
@@ -314,11 +389,9 @@ $isccArguments = @(
"/DPortableArtifactDirectory=$portableArtifact",
"/DInstallerPayloadDirectory=$installerPayloadDirectory",
"/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256",
- "/DInstallerDestinationInventoryInclude=$installerDestinationInventoryPath",
- "/O$dist\.",
- $issPath
+ "/O$dist\."
)
-Invoke-CcodBuildInnoCompiler -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe
+Invoke-CcodBuildInnoCompiler -TemplatePath $issPath -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe
$setupHash = Get-CcodBuildFileSha256 -Path $setupExe
Write-CcodBuildUtf8 -Path $setupChecksum -Text ("{0} *{1}" -f $setupHash,[IO.Path]::GetFileName($setupExe))
$setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json"
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index e203d90..af07a01 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -104,7 +104,7 @@ Invoke-CcodTest 'production installer payload generator writes ordered version-b
}
}
-Invoke-CcodTest 'production setup destination inventory accepts the literal symbolic include and derives every nested directory' {
+Invoke-CcodTest 'production setup template has one inventory marker, no external includes, and derives every nested directory' {
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-directory-inventory-' + [guid]::NewGuid().ToString('N'))
try {
$payload = Join-Path $root 'payload'
@@ -112,8 +112,11 @@ Invoke-CcodTest 'production setup destination inventory accepts the literal symb
[IO.File]::WriteAllText((Join-Path $payload 'src\persistence\modules\InstallLifecycle.psm1'),'fixture',[Text.UTF8Encoding]::new($false))
$output = Join-Path $root 'InstallerDestinationInventory.iss'
$innoPath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'
- $allowedIncludes = @([IO.File]::ReadAllLines($innoPath,[Text.UTF8Encoding]::new($false)) | Where-Object { [string]$_ -ceq '#include InstallerDestinationInventoryInclude' })
- Assert-CcodEqual 1 $allowedIncludes.Count 'production fixture exercises the one permitted literal include directive'
+ $templateLines = @([IO.File]::ReadAllLines($innoPath,[Text.UTF8Encoding]::new($false)))
+ $inventoryMarkers = @($templateLines | Where-Object { [string]$_ -ceq '// CCOD_INSTALLER_DESTINATION_INVENTORY' })
+ $externalIncludes = @($templateLines | Where-Object { [string]$_ -match '^\s*#\s*(?:include\b|\+)' })
+ Assert-CcodEqual 1 $inventoryMarkers.Count 'production template has exactly one destination inventory marker comment'
+ Assert-CcodEqual 0 $externalIncludes.Count 'production template has no external include directive'
& (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $output | Out-Null
$inventory = [IO.File]::ReadAllText($output,[Text.UTF8Encoding]::new($false))
foreach ($relative in @('src','src\persistence','src\persistence\modules','payload','payload\2.5.22','payload\2.5.22\src','payload\2.5.22\src\persistence','payload\2.5.22\src\persistence\modules')) {
@@ -159,104 +162,220 @@ Source: "fixture-b.txt"; DestDir: "{app}\second"; Flags: ignoreversion
}
}
-Invoke-CcodTest 'production setup destination inventory rejects an included second Files section before ISCC' {
- $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-included-files-section-' + [guid]::NewGuid().ToString('N'))
+Invoke-CcodTest 'production setup destination inventory rejects every external include spelling before output' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-external-include-' + [guid]::NewGuid().ToString('N'))
try {
$payload = Join-Path $root 'payload'
- $setupOutput = Join-Path $root 'setup-output'
[IO.Directory]::CreateDirectory($payload) | Out-Null
- [IO.Directory]::CreateDirectory($setupOutput) | Out-Null
$firstSource = Join-Path $root 'fixture-a.txt'
- $secondSource = Join-Path $root 'fixture-b.txt'
[IO.File]::WriteAllText($firstSource,'first',[Text.UTF8Encoding]::new($false))
- [IO.File]::WriteAllText($secondSource,'second',[Text.UTF8Encoding]::new($false))
- $extraPath = Join-Path $root 'extra.iss'
- $extraSource = @"
-[Files]
-Source: "$secondSource"; DestDir: "{app}\second"; Flags: ignoreversion
-"@
- [IO.File]::WriteAllText($extraPath,$extraSource,[Text.UTF8Encoding]::new($false))
- $innoPath = Join-Path $root 'IncludedFilesSection.iss'
- $innoSource = @"
+ $generator = Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1'
+ foreach ($fixture in @(
+ [pscustomobject]@{Name='plus alias';Directive='#+ "extra.iss"'},
+ [pscustomobject]@{Name='include keyword';Directive='#include "extra.iss"'}
+ )) {
+ $innoPath = Join-Path $root (($fixture.Name -replace '[^A-Za-z]','') + '.iss')
+ $innoSource = @"
[Setup]
-AppName=IncludedFilesSection
+AppName=ExternalIncludeFixture
AppVersion=1.0.0
-DefaultDirName={tmp}\IncludedFilesSection
-OutputDir=$setupOutput
-OutputBaseFilename=IncludedFilesSection
-Uninstallable=no
+DefaultDirName={app}
[Files]
Source: "$firstSource"; DestDir: "{app}\first"; Flags: ignoreversion
[Code]
procedure Fixture();
begin
end;
-#include "extra.iss"
+$($fixture.Directive)
"@
- [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false))
- $inventoryPath = Join-Path $root 'Inventory.iss'
- $setupPath = Join-Path $setupOutput 'IncludedFilesSection.exe'
- $generatorFailure = $null
- $compileExitCode = $null
- try {
- & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $inventoryPath | Out-Null
- $iscc = @(
- (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')
- ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
- if (-not $iscc) { throw 'Inno Setup 6 is required for the include-boundary fixture' }
- $previousPreference = $ErrorActionPreference
+ [IO.File]::WriteAllText($innoPath,$innoSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root (($fixture.Name -replace '[^A-Za-z]','') + '-Inventory.iss')
+ $generatorFailure = $null
try {
- $ErrorActionPreference = 'Continue'
- $null = @(& $iscc $innoPath 2>&1)
- $compileExitCode = $LASTEXITCODE
- } finally { $ErrorActionPreference = $previousPreference }
- } catch { $generatorFailure = $_ }
- Assert-CcodTrue ($null -ne $generatorFailure) ("generator rejects the arbitrary include before ISCC; inventory={0} compileExit={1} setup={2}" -f [IO.File]::Exists($inventoryPath),$compileExitCode,[IO.File]::Exists($setupPath))
- Assert-CcodTrue ($generatorFailure.Exception.Message -cmatch 'literal include directive') 'arbitrary include failure identifies the literal include boundary'
- Assert-CcodTrue (-not [IO.File]::Exists($inventoryPath)) 'rejected include produces no destination inventory artifact'
- Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'rejected include produces no setup artifact'
+ & $generator -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $innoPath -OutputPath $inventoryPath | Out-Null
+ } catch { $generatorFailure = $_ }
+ Assert-CcodTrue ($null -ne $generatorFailure) "generator rejects the $($fixture.Name) before writing inventory"
+ Assert-CcodTrue ($generatorFailure.Exception.Message -cmatch 'include') "$($fixture.Name) failure identifies the include-free template boundary"
+ Assert-CcodTrue (-not [IO.File]::Exists($inventoryPath)) "$($fixture.Name) produces no partial destination inventory"
+ }
} finally {
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
}
}
-Invoke-CcodTest 'build validates generated inventory before invoking ISCC' {
- $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-installer-inventory-header-' + [guid]::NewGuid().ToString('N'))
+Invoke-CcodTest 'build rejects invalid generated setup inputs before invoking ISCC' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-invalid-' + [guid]::NewGuid().ToString('N'))
try {
[IO.Directory]::CreateDirectory($root) | Out-Null
+ $templatePath = Join-Path $root 'Template.iss'
$inventoryPath = Join-Path $root 'Inventory.iss'
- $inventorySource = @'
+ $validInventorySource = @'
procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
begin
end;
-[Files]
'@
- [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false))
$compilerMarker = Join-Path $root 'iscc-invoked.txt'
$compilerPath = Join-Path $root 'fake-iscc.cmd'
$setupPath = Join-Path $root 'setup.exe'
$compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n"
[IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new())
. (Join-Path $repositoryRoot 'build\build.ps1') -Library
- $failure = $null
+ foreach ($fixture in @(
+ [pscustomobject]@{Name='include keyword';Directive='#include "extra.iss"'},
+ [pscustomobject]@{Name='plus alias';Directive='#+ "extra.iss"'}
+ )) {
+ $templateSource = @"
+[Setup]
+AppName=InvalidGeneratedSetup
+AppVersion=1.0.0
+DefaultDirName={tmp}\InvalidGeneratedSetup
+OutputBaseFilename=InvalidGeneratedSetup
+Uninstallable=no
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+$($fixture.Directive)
+"@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($inventoryPath,$validInventorySource,[Text.UTF8Encoding]::new($false))
+ $failure = $null
+ try {
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
+ } catch { $failure = $_ }
+ Assert-CcodTrue ($null -ne $failure) "build rejects the $($fixture.Name) before ISCC"
+ Assert-CcodTrue ($failure.Exception.Message -cmatch 'include') "$($fixture.Name) failure identifies the include-free compiler boundary"
+ Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) "$($fixture.Name) does not launch ISCC"
+ Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) "$($fixture.Name) produces no setup artifact"
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count "$($fixture.Name) leaves no generated setup script"
+ }
+
+ $validTemplateSource = @'
+[Setup]
+AppName=InvalidInventory
+AppVersion=1.0.0
+DefaultDirName={tmp}\InvalidInventory
+OutputBaseFilename=InvalidInventory
+Uninstallable=no
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+'@
+ $invalidInventorySource = $validInventorySource + "`r`n[Files]`r`n"
+ [IO.File]::WriteAllText($templatePath,$validTemplateSource,[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($inventoryPath,$invalidInventorySource,[Text.UTF8Encoding]::new($false))
+ $inventoryFailure = $null
try {
- Invoke-CcodBuildInnoCompiler -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
- } catch { $failure = $_ }
- Assert-CcodTrue ($null -ne $failure) 'build rejects a generated inventory carrying an Inno section header'
- Assert-CcodTrue ($failure.Exception.Message -cmatch 'section header') 'inventory validation explains the forbidden section header'
- Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) 'ISCC is not invoked after inventory validation fails'
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
+ } catch { $inventoryFailure = $_ }
+ Assert-CcodTrue ($null -ne $inventoryFailure) 'build rejects a generated inventory carrying an Inno section header'
+ Assert-CcodTrue ($inventoryFailure.Exception.Message -cmatch 'section header') 'inventory validation explains the forbidden section header'
+ Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) 'invalid inventory does not launch ISCC'
Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'invalid inventory produces no setup artifact'
- $validInventorySource = @'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'invalid inventory leaves no generated setup script'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'build passes only a GUID generated include-free setup to ISCC and cleans it' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-valid-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $templatePath = Join-Path $root 'Template.iss'
+ $templateSource = @'
+[Setup]
+AppName=GeneratedSetup
+AppVersion=1.0.0
+DefaultDirName={tmp}\GeneratedSetup
+OutputBaseFilename=GeneratedSetup
+Uninstallable=no
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+'@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $inventorySource = @'
+procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
+begin
+ Directories.Add('payload\2.5.22');
+end;
+'@
+ [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false))
+ $compilerArgument = Join-Path $root 'iscc-argument.txt'
+ $capturedSource = Join-Path $root 'compiled-source.iss'
+ $compilerPath = Join-Path $root 'fake-iscc.cmd'
+ $setupPath = Join-Path $root 'setup.exe'
+ $compilerSource = "@echo off`r`n> `"$compilerArgument`" echo %~f1`r`ncopy /y `"%~1`" `"$capturedSource`" >nul`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n"
+ [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new())
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
+
+ Assert-CcodTrue ([IO.File]::Exists($setupPath)) 'validated generated source accepts the compiler setup artifact'
+ Assert-CcodTrue ([IO.File]::Exists($capturedSource)) 'compiler receives a readable generated setup source'
+ $compiledPath = [IO.File]::ReadAllText($compilerArgument).Trim()
+ Assert-CcodTrue (-not $compiledPath.Equals([IO.Path]::GetFullPath($templatePath),[StringComparison]::OrdinalIgnoreCase)) 'ISCC never receives the checked-in template path'
+ Assert-CcodTrue ([IO.Path]::GetFileName($compiledPath) -cmatch '^\.ccod-generated-setup-[0-9a-f]{32}\.iss$') 'ISCC receives the GUID-named generated setup path'
+ Assert-CcodTrue (-not [IO.File]::Exists($compiledPath)) 'generated compiler input is cleaned after ISCC returns'
+ $compiledSource = [IO.File]::ReadAllText($capturedSource,[Text.UTF8Encoding]::new($false))
+ Assert-CcodTrue ($compiledSource.Contains("Directories.Add('payload\2.5.22');")) 'compiled source contains the verified injected inventory procedure'
+ Assert-CcodTrue (-not $compiledSource.Contains('// CCOD_INSTALLER_DESTINATION_INVENTORY')) 'compiled source contains no unresolved inventory marker'
+ Assert-CcodTrue ($compiledSource -cnotmatch '(?m)^\s*#\s*(?:include\b|\+)') 'compiled source contains no external include directive'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'successful compile leaves no generated setup script'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
+Invoke-CcodTest 'real ISCC compiles the generated setup with its injected inventory procedure' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-iscc-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $output = Join-Path $root 'output'
+ [IO.Directory]::CreateDirectory($output) | Out-Null
+ $templatePath = Join-Path $root 'Template.iss'
+ $templateSource = @"
+[Setup]
+AppName=GeneratedSetupRealCompiler
+AppVersion=1.0.0
+DefaultDirName={tmp}\GeneratedSetupRealCompiler
+OutputDir=$output
+OutputBaseFilename=GeneratedSetupRealCompiler
+Uninstallable=no
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+function InitializeSetup(): Boolean;
+var
+ Directories: TStringList;
+begin
+ Directories := TStringList.Create;
+ try
+ AddCcodExpectedSetupDirectories(Directories);
+ Result := Directories.Count = 1;
+ finally
+ Directories.Free;
+ end;
+end;
+"@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $inventorySource = @'
procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
begin
+ Directories.Add('payload\2.5.22');
end;
'@
- [IO.File]::WriteAllText($inventoryPath,$validInventorySource,[Text.UTF8Encoding]::new($false))
- Invoke-CcodBuildInnoCompiler -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
- Assert-CcodTrue ([IO.File]::Exists($compilerMarker)) 'validated inventory reaches ISCC'
- Assert-CcodTrue ([IO.File]::Exists($setupPath)) 'validated inventory accepts the compiler setup artifact'
+ [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false))
+ $iscc = @(
+ (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')
+ ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
+ if (-not $iscc) { throw 'Inno Setup 6 is required for the generated setup fixture' }
+ $setupPath = Join-Path $output 'GeneratedSetupRealCompiler.exe'
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $iscc -Arguments @() -SetupPath $setupPath
+
+ Assert-CcodTrue ([IO.File]::Exists($setupPath)) 'real ISCC compiles the injected inventory procedure and its call site'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'real ISCC compile leaves no generated setup script'
} finally {
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
}
@@ -316,26 +435,50 @@ function Invoke-CcodInnoPayloadCompileFixture {
$manifestPath = Join-Path $payload 'installer-payload.manifest.json'
[IO.File]::WriteAllText($manifestPath,($manifest|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
$inventoryPath = Join-Path $root 'InstallerDestinationInventory.iss'
- & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.21' -InnoScriptPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -OutputPath $inventoryPath | Out-Null
+ $templatePath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'
+ if (-not $OmitDestinationInventory) {
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.21' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null
+ }
$iscc = @(
(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')
) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
if (-not $iscc) { throw 'Inno Setup 6 is required for the setup payload contract' }
- $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'))
+ $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\")
if ($IncludePayloadDefines) {
$arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)")
- if (-not $OmitDestinationInventory) { $arguments += "/DInstallerDestinationInventoryInclude=$inventoryPath" }
- $arguments += @("/O$output\",(Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'))
+ $arguments += "/O$output\"
}
- $previousPreference = $ErrorActionPreference
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ $generatedPath = Join-Path (Split-Path $templatePath -Parent) ('.ccod-generated-setup-' + [guid]::NewGuid().ToString('N') + '.iss')
+ $generatedSource = ''
+ $compileOutput = @()
+ $exitCode = 1
try {
- $ErrorActionPreference = 'Continue'
- $compileOutput = @(& $iscc @arguments 2>&1)
- $exitCode = $LASTEXITCODE
- } finally { $ErrorActionPreference = $previousPreference }
- return [pscustomobject]@{Root=$root;ExitCode=$exitCode;Output=($compileOutput -join "`n");SetupPath=(Join-Path $output 'CodexRemote-fix-2.5.21-setup.exe')}
+ $generated = New-CcodBuildGeneratedInnoScript -TemplatePath $templatePath -InventoryPath $inventoryPath -OutputPath $generatedPath
+ $generatedSource = [IO.File]::ReadAllText($generated,[Text.UTF8Encoding]::new($false))
+ $arguments += $generated
+ $previousPreference = $ErrorActionPreference
+ try {
+ $ErrorActionPreference = 'Continue'
+ $compileOutput = @(& $iscc @arguments 2>&1)
+ $exitCode = $LASTEXITCODE
+ } finally { $ErrorActionPreference = $previousPreference }
+ } catch {
+ $compileOutput = @($_.Exception.Message)
+ $exitCode = 1
+ } finally {
+ if ([IO.File]::Exists($generatedPath)) { Remove-Item -LiteralPath $generatedPath -Force }
+ }
+ return [pscustomobject]@{
+ Root = $root
+ ExitCode = $exitCode
+ Output = ($compileOutput -join "`n")
+ SetupPath = (Join-Path $output 'CodexRemote-fix-2.5.21-setup.exe')
+ GeneratedPath = $generatedPath
+ GeneratedSource = $generatedSource
+ }
}
Invoke-CcodTest 'setup build and activation bind one immutable versioned payload end to end' {
@@ -344,7 +487,7 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload
$activation = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1') -Raw -Encoding UTF8
$installer = Get-Content -LiteralPath (Join-Path $repositoryRoot 'Install-CodexControlOtherDevices.ps1') -Raw -Encoding UTF8
- Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'New-InstallerDestinationInventory\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256' -and $build -cmatch 'InstallerDestinationInventoryInclude') 'build invokes the tested generators and binds their outputs into setup compilation'
+ Assert-CcodTrue ($build -cmatch 'New-InstallerPayloadManifest\.ps1' -and $build -cmatch 'New-InstallerDestinationInventory\.ps1' -and $build -cmatch 'InstallerPayloadManifestSha256' -and $build -cmatch 'New-CcodBuildGeneratedInnoScript') 'build invokes the tested generators and binds the inventory into generated setup compilation'
Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory'
Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version'
Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract'
@@ -468,7 +611,10 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay
try {
Assert-CcodEqual 0 $compile.ExitCode "explicit setup payload contract compiles: $($compile.Output)"
Assert-CcodTrue (Test-Path -LiteralPath $compile.SetupPath -PathType Leaf) 'explicit payload compile produces the setup artifact'
- Assert-CcodTrue ($compile.Output -cmatch 'InstallerDestinationInventory\.iss' -and $compile.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the destination inventory, payload manifest, and manifest-listed file'
+ Assert-CcodTrue ($compile.GeneratedSource -cmatch 'procedure AddCcodExpectedSetupDirectories' -and $compile.GeneratedSource -cmatch "Directories\.Add\('payload\\2\.5\.21'") 'compiler input contains the generated destination inventory procedure'
+ Assert-CcodTrue ($compile.GeneratedSource -cnotmatch 'CCOD_INSTALLER_DESTINATION_INVENTORY' -and $compile.GeneratedSource -cnotmatch '(?m)^\s*#\s*(?:include\b|\+)') 'compiler input contains neither the marker nor an external include'
+ Assert-CcodTrue (-not [IO.File]::Exists($compile.GeneratedPath)) 'payload compile cleans its generated compiler input'
+ Assert-CcodTrue ($compile.Output -cmatch 'installer-payload\.manifest\.json' -and $compile.Output -cmatch 'payload\\package\.json') 'compiler input trace contains the payload manifest and manifest-listed file'
} finally {
if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force }
}
@@ -477,9 +623,10 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay
Invoke-CcodTest 'Inno compile refuses a missing generated destination inventory' {
$compile = Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines -OmitDestinationInventory
try {
- Assert-CcodTrue ($compile.ExitCode -ne 0) 'setup compilation fails when only the destination inventory define is omitted'
- Assert-CcodTrue ($compile.Output -cmatch 'InstallerDestinationInventoryInclude') 'compiler identifies the missing generated inventory define'
+ Assert-CcodTrue ($compile.ExitCode -ne 0) 'setup compilation fails before ISCC when the generated inventory is missing'
+ Assert-CcodTrue ($compile.Output -cmatch 'destination inventory.*missing') 'generation identifies the missing destination inventory artifact'
Assert-CcodTrue (-not (Test-Path -LiteralPath $compile.SetupPath)) 'missing inventory define produces no setup artifact'
+ Assert-CcodTrue (-not [IO.File]::Exists($compile.GeneratedPath)) 'missing inventory leaves no generated compiler input'
} finally {
if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force }
}
@@ -493,7 +640,7 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
Assert-CcodTrue $treeHelper.Success 'production Inno script exposes a recursive destination-tree predicate'
$inventoryValidator = [regex]::Match($inno,'(?ms)^function AreCcodExpectedSetupDirectoriesSafe\(.*?^end;')
Assert-CcodTrue $inventoryValidator.Success 'production Inno script exposes a generated destination-inventory validator'
- Assert-CcodTrue ($inno -cmatch 'InstallerDestinationInventoryInclude' -and $inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?AreCcodExpectedSetupDirectoriesSafe') 'PrepareToInstall consumes the compile-bound generated inventory before file copy'
+ Assert-CcodTrue ($inno -cmatch '(?m)^// CCOD_INSTALLER_DESTINATION_INVENTORY\s*$' -and $inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?AreCcodExpectedSetupDirectoriesSafe') 'PrepareToInstall consumes the generated inventory injected at the unique marker before file copy'
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-reparse-harness-' + [guid]::NewGuid().ToString('N'))
try {
$normal = Join-Path $root 'normal'
diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1
index 4274730..c12f952 100644
--- a/tools/New-InstallerDestinationInventory.ps1
+++ b/tools/New-InstallerDestinationInventory.ps1
@@ -33,11 +33,9 @@ function Add-CcodExpectedDirectory {
}
$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8)
-$includeDirectives = @($lines | Where-Object { $_ -match '^\s*#\s*include\b' })
-$allowedIncludeDirective = '#include InstallerDestinationInventoryInclude'
-if ($includeDirectives.Count -gt 1 -or
- ($includeDirectives.Count -eq 1 -and [string]$includeDirectives[0] -cne $allowedIncludeDirective)) {
- throw "Setup destination inventory permits only one literal include directive: $allowedIncludeDirective"
+$includeDirectives = @($lines | Where-Object { $_ -match '^\s*#\s*(?:include\b|\+)' })
+if ($includeDirectives.Count -ne 0) {
+ throw 'Setup destination inventory requires an include-free template; external #include and #+ directives are not permitted.'
}
$filesSectionCount = @($lines | Where-Object { $_ -match '^\s*\[Files\]\s*$' }).Count
if ($filesSectionCount -ne 1) { throw "Setup destination inventory requires exactly one [Files] section; found $filesSectionCount." }
From 67b9939b2bfb8caa1a0477b464efe58fecbff08f Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:09:28 +0800
Subject: [PATCH 012/187] docs: constrain setup preprocessor boundary
---
...08-28-v2522-install-runtime-reliability.md | 33 +++++++++++--------
...2522-install-runtime-reliability-design.md | 11 ++++---
2 files changed, 26 insertions(+), 18 deletions(-)
diff --git a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
index f83ada1..39f2442 100644
--- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
+++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
@@ -104,7 +104,8 @@ fix: bind setup activation to immutable payload
- The checked-in `.iss` contains exactly one inventory marker comment.
- Build writes a temporary generated `.iss` by replacing that marker with the
verified inventory procedure and invokes ISCC only on the generated file.
-- The template rejects all external `#include` and `#+` directives.
+- The template accepts only the required literal build directives and rejects
+ all external/line-spanned/pragma/generated-code preprocessor directives.
- [ ] **Step 1: Write failing behavior tests**
@@ -114,7 +115,8 @@ $template = @'
AppName=fixture
[Files]
Source: "first.txt"; DestDir: "{app}\first"
-#+ "extra.iss"
+# \
++ "extra.iss"
'@
Assert-CcodThrows {
& $generator -RepositoryRoot $root -PayloadRoot $payload -ProjectVersion '2.5.22' `
@@ -123,30 +125,33 @@ Assert-CcodThrows {
Assert-CcodFalse (Test-Path -LiteralPath $inventoryPath) 'include bypass produces no partial inventory'
```
-Add an actual ISCC test showing that the generated script compiles with its
-injected inventory, while a template with either include spelling is rejected
-before ISCC is launched.
+Add actual ISCC tests showing that the generated script compiles with its
+injected inventory, while templates with literal or line-spanned include,
+pragma, emit, and unknown preprocessor directives are rejected before ISCC is
+launched.
- [ ] **Step 2: Run the focused test and confirm RED**
Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1`
-Expected: the include-alias fixture is accepted before the new template
-boundary exists.
+Expected: the line-spanned include-alias fixture is accepted before the new
+template boundary exists.
- [ ] **Step 3: Implement the generated include-free compiler input**
-Replace the source include with one unique marker. Build reads the checked-in
-template, refuses any `#include` or `#+` directive, substitutes the generated
-inventory text once, writes a GUID-named temporary `.iss`, validates that the
-result has no marker or external include, and passes that generated path to
-ISCC. Clean the temporary script in `finally`.
+Build reads the checked-in template, verifies every literal preprocessor line
+is one of the fixed required directives, rejects continuation characters and
+all other directives, substitutes the generated inventory text once, writes a
+GUID-named temporary `.iss`, validates that the result has no marker or unsafe
+preprocessor directive, and passes that generated path to ISCC. Clean the
+temporary script in `finally`, including when the compiler returns nonzero.
- [ ] **Step 4: Run focused tests and prove GREEN**
Run ReleaseWorkflow and the real ISCC fixture. Confirm the compiled setup
-source contains the expected injected procedure and arbitrary include aliases
-leave neither setup nor inventory output.
+source contains the expected injected procedure; literal and line-spanned
+include aliases plus other code-generating directives leave neither setup nor
+inventory output; a nonzero compiler also leaves no generated `.iss`.
- [ ] **Step 5: Commit**
diff --git a/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
index a57c866..081e19d 100644
--- a/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
+++ b/docs/superpowers/specs/2026-08-28-v2522-install-runtime-reliability-design.md
@@ -57,10 +57,13 @@ payload for the requested version.
Build creates a one-time generated `.iss` from the checked-in template by
replacing one fixed inventory marker with the verified destination-directory
-procedure. The template contains no external `#include` or `#+` directive;
-build rejects both spellings before ISCC runs. This avoids attempting to
-reimplement ISPP preprocessing while making the directory inventory and the
-exact source compiled by ISCC one artifact.
+procedure. The template accepts only the literal build directives required by
+the checked-in script (`#ifndef`, `#define`, `#error`, `#endif`); it rejects
+every other simple preprocessor directive, any line-spanning continuation, and
+all pragma directives before ISCC runs. This prohibits external inclusion and
+preprocessor-generated `[Files]` entries without reimplementing ISPP, while
+making the directory inventory and the exact source compiled by ISCC one
+artifact.
### 2. Bounded stable-tree acquisition
From d1747115e030f5b36d63667d2e15d82e683e7b4f Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:24:38 +0800
Subject: [PATCH 013/187] fix: reject unsafe Inno preprocessor directives
---
build/build.ps1 | 28 +-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 278 ++++++++++++++++++
tools/New-InstallerDestinationInventory.ps1 | 22 +-
3 files changed, 318 insertions(+), 10 deletions(-)
diff --git a/build/build.ps1 b/build/build.ps1
index 426b0f3..36e7693 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -116,6 +116,26 @@ function Assert-CcodBuildInstallerDestinationInventory {
return $inventoryPath
}
+function Assert-CcodBuildInnoPreprocessorLines {
+ param(
+ [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines,
+ [Parameter(Mandatory)][string]$Kind
+ )
+ $allowedDirectives = @('ifndef','define','error','endif')
+ for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) {
+ $line = [string]$Lines[$lineIndex]
+ $lineNumber = $lineIndex + 1
+ if ($line -match '\\[ \t]*$') {
+ throw "$Kind requires an include-free simple preprocessor source; line continuation is not permitted at line $lineNumber."
+ }
+ if ($line -notmatch '^[ \t]*#') { continue }
+ $directiveMatch = [regex]::Match($line,'^[ \t]*#(?[A-Za-z]+)\b')
+ if (-not $directiveMatch.Success -or $allowedDirectives -cnotcontains $directiveMatch.Groups['directive'].Value) {
+ throw "$Kind requires an include-free simple preprocessor source; unsafe directive at line ${lineNumber}: $line"
+ }
+ }
+}
+
function New-CcodBuildGeneratedInnoScript {
param(
[Parameter(Mandatory)][string]$TemplatePath,
@@ -137,9 +157,7 @@ function New-CcodBuildGeneratedInnoScript {
$marker = '// CCOD_INSTALLER_DESTINATION_INVENTORY'
$templateSource = [IO.File]::ReadAllText($template,[Text.UTF8Encoding]::new($false))
- if ($templateSource -match '(?m)^\s*#\s*(?:include\b|\+)') {
- throw 'The Inno Setup template must be include-free; external #include and #+ directives are not permitted.'
- }
+ Assert-CcodBuildInnoPreprocessorLines -Lines ([IO.File]::ReadAllLines($template,[Text.UTF8Encoding]::new($false))) -Kind 'The Inno Setup template'
$markerCount = [regex]::Matches($templateSource,[regex]::Escape($marker)).Count
$markerLineCount = [regex]::Matches($templateSource,'(?m)^\s*// CCOD_INSTALLER_DESTINATION_INVENTORY\s*$').Count
if ($markerCount -ne 1 -or $markerLineCount -ne 1) {
@@ -151,9 +169,7 @@ function New-CcodBuildGeneratedInnoScript {
if ($generatedSource.Contains($marker)) {
throw 'The generated Inno Setup script contains an unresolved inventory marker.'
}
- if ($generatedSource -match '(?m)^\s*#\s*(?:include\b|\+)') {
- throw 'The generated Inno Setup script contains an external #include or #+ directive.'
- }
+ Assert-CcodBuildInnoPreprocessorLines -Lines ([regex]::Split($generatedSource,'\r\n|\n|\r')) -Kind 'The generated Inno Setup script'
try {
Write-CcodBuildUtf8 -Path $output -Text $generatedSource
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index af07a01..7a70560 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -203,6 +203,237 @@ $($fixture.Directive)
}
}
+Invoke-CcodTest 'line-spanned include forms are rejected before inventory generation or real ISCC' {
+ $iscc = @(
+ (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')
+ ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
+ if (-not $iscc) { throw 'Inno Setup 6 is required for the line-spanned include fixtures' }
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ $results = [Collections.Generic.List[object]]::new()
+ foreach ($fixture in @(
+ [pscustomobject]@{Name='line-spanned include keyword';Lines=@('# \','include "extra.iss"')},
+ [pscustomobject]@{Name='line-spanned plus alias';Lines=@('# \','+ "extra.iss"')}
+ )) {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-line-spanned-include-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $payload = Join-Path $root 'payload'
+ $output = Join-Path $root 'output'
+ [IO.Directory]::CreateDirectory($payload) | Out-Null
+ [IO.Directory]::CreateDirectory($output) | Out-Null
+ $firstSource = Join-Path $root 'fixture-a.txt'
+ $secondSource = Join-Path $root 'fixture-b.txt'
+ [IO.File]::WriteAllText($firstSource,'first',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($secondSource,'second',[Text.UTF8Encoding]::new($false))
+ $extraPath = Join-Path $root 'extra.iss'
+ $extraSource = @"
+[Files]
+Source: "$secondSource"; DestDir: "{app}\second"; Flags: ignoreversion
+"@
+ [IO.File]::WriteAllText($extraPath,$extraSource,[Text.UTF8Encoding]::new($false))
+ $templatePath = Join-Path $root 'Template.iss'
+ $directive = $fixture.Lines -join "`r`n"
+ $templateSource = @"
+[Setup]
+AppName=LineSpannedInclude
+AppVersion=1.0.0
+DefaultDirName={tmp}\LineSpannedInclude
+OutputDir=$output
+OutputBaseFilename=LineSpannedInclude
+Uninstallable=no
+$directive
+[Files]
+Source: "$firstSource"; DestDir: "{app}\first"; Flags: ignoreversion
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+"@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $setupPath = Join-Path $output 'LineSpannedInclude.exe'
+ $failure = $null
+ $compilerAttempted = $false
+ try {
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null
+ $compilerAttempted = $true
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $iscc -Arguments @() -SetupPath $setupPath
+ } catch { $failure = $_ }
+ $results.Add([pscustomobject]@{
+ Name = $fixture.Name
+ Failure = $failure
+ CompilerAttempted = $compilerAttempted
+ InventoryExists = [IO.File]::Exists($inventoryPath)
+ SetupExists = [IO.File]::Exists($setupPath)
+ GeneratedCount = @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count
+ })
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+ }
+ foreach ($result in $results) {
+ Assert-CcodTrue ($null -ne $result.Failure) "$($result.Name) is rejected before the compiler boundary"
+ Assert-CcodTrue ($result.Failure.Exception.Message -cmatch 'preprocessor|continuation') "$($result.Name) failure identifies the strict preprocessor boundary"
+ Assert-CcodTrue (-not $result.CompilerAttempted) "$($result.Name) never reaches the real ISCC boundary"
+ Assert-CcodTrue (-not $result.InventoryExists) "$($result.Name) leaves no inventory output"
+ Assert-CcodTrue (-not $result.SetupExists) "$($result.Name) leaves no setup artifact"
+ Assert-CcodEqual 0 $result.GeneratedCount "$($result.Name) leaves no generated compiler input"
+ }
+}
+
+Invoke-CcodTest 'inventory generation rejects pragma, emit, and unknown simple preprocessor directives' {
+ $results = [Collections.Generic.List[object]]::new()
+ foreach ($fixture in @(
+ [pscustomobject]@{Name='pragma';Directive='#pragma parseroption -u+'},
+ [pscustomobject]@{Name='emit';Directive='#emit "[Files]"'},
+ [pscustomobject]@{Name='unknown';Directive='#futuredirective "extra.iss"'}
+ )) {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-unsafe-inno-directive-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $payload = Join-Path $root 'payload'
+ [IO.Directory]::CreateDirectory($payload) | Out-Null
+ $sourcePath = Join-Path $root 'fixture.txt'
+ [IO.File]::WriteAllText($sourcePath,'fixture',[Text.UTF8Encoding]::new($false))
+ $templatePath = Join-Path $root 'Template.iss'
+ $templateSource = @"
+[Setup]
+AppName=UnsafeDirective
+AppVersion=1.0.0
+DefaultDirName={app}
+[Files]
+Source: "$sourcePath"; DestDir: "{app}\first"; Flags: ignoreversion
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+$($fixture.Directive)
+"@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $failure = $null
+ try {
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null
+ } catch { $failure = $_ }
+ $results.Add([pscustomobject]@{
+ Name = $fixture.Name
+ Failure = $failure
+ InventoryExists = [IO.File]::Exists($inventoryPath)
+ })
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+ }
+ foreach ($result in $results) {
+ Assert-CcodTrue ($null -ne $result.Failure) "$($result.Name) directive is rejected before inventory generation"
+ Assert-CcodTrue ($result.Failure.Exception.Message -cmatch 'preprocessor|directive') "$($result.Name) failure identifies the strict directive boundary"
+ Assert-CcodTrue (-not $result.InventoryExists) "$($result.Name) directive leaves no inventory output"
+ }
+}
+
+Invoke-CcodTest 'generated setup creation applies the same strict preprocessor boundary before ISCC' {
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ $results = [Collections.Generic.List[object]]::new()
+ foreach ($fixture in @(
+ [pscustomobject]@{Name='line-spanned include keyword';Directive=(@('# \','include "extra.iss"') -join "`r`n")},
+ [pscustomobject]@{Name='line-spanned plus alias';Directive=(@('# \','+ "extra.iss"') -join "`r`n")},
+ [pscustomobject]@{Name='pragma';Directive='#pragma parseroption -u+'},
+ [pscustomobject]@{Name='emit';Directive='#emit "[Files]"'},
+ [pscustomobject]@{Name='unknown';Directive='#futuredirective "extra.iss"'}
+ )) {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-unsafe-directive-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $templatePath = Join-Path $root 'Template.iss'
+ $templateSource = @"
+[Setup]
+AppName=GeneratedUnsafeDirective
+AppVersion=1.0.0
+DefaultDirName={tmp}\GeneratedUnsafeDirective
+OutputBaseFilename=GeneratedUnsafeDirective
+Uninstallable=no
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+$($fixture.Directive)
+"@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $inventorySource = @'
+procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
+begin
+end;
+'@
+ [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false))
+ $compilerMarker = Join-Path $root 'iscc-invoked.txt'
+ $compilerPath = Join-Path $root 'fake-iscc.cmd'
+ $setupPath = Join-Path $root 'setup.exe'
+ $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n"
+ [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new())
+ $failure = $null
+ try {
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
+ } catch { $failure = $_ }
+ $results.Add([pscustomobject]@{
+ Name = $fixture.Name
+ Failure = $failure
+ CompilerInvoked = [IO.File]::Exists($compilerMarker)
+ SetupExists = [IO.File]::Exists($setupPath)
+ GeneratedCount = @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count
+ })
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+ }
+ foreach ($result in $results) {
+ Assert-CcodTrue ($null -ne $result.Failure) "$($result.Name) is rejected before generated setup compilation"
+ Assert-CcodTrue ($result.Failure.Exception.Message -cmatch 'preprocessor|continuation|directive') "$($result.Name) failure identifies the strict generated-source boundary"
+ Assert-CcodTrue (-not $result.CompilerInvoked) "$($result.Name) does not invoke ISCC"
+ Assert-CcodTrue (-not $result.SetupExists) "$($result.Name) leaves no setup artifact"
+ Assert-CcodEqual 0 $result.GeneratedCount "$($result.Name) leaves no generated compiler input"
+ }
+}
+
+Invoke-CcodTest 'generated setup rejects an unsafe directive introduced by inventory before ISCC' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-unsafe-inventory-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $templatePath = Join-Path $root 'Template.iss'
+ $templateSource = @'
+[Setup]
+AppName=GeneratedUnsafeInventory
+AppVersion=1.0.0
+DefaultDirName={tmp}\GeneratedUnsafeInventory
+OutputBaseFilename=GeneratedUnsafeInventory
+Uninstallable=no
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+'@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $inventorySource = @'
+procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
+begin
+end;
+#emit "[Files]"
+'@
+ [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false))
+ $compilerMarker = Join-Path $root 'iscc-invoked.txt'
+ $compilerPath = Join-Path $root 'fake-iscc.cmd'
+ $setupPath = Join-Path $root 'setup.exe'
+ $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$setupPath`" echo setup`r`nexit /b 0`r`n"
+ [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new())
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ $failure = $null
+ try {
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
+ } catch { $failure = $_ }
+
+ Assert-CcodTrue ($null -ne $failure) 'unsafe inventory directive is rejected after marker replacement'
+ Assert-CcodTrue ($failure.Exception.Message -cmatch 'preprocessor|directive') 'unsafe inventory failure identifies the final generated-source boundary'
+ Assert-CcodTrue (-not [IO.File]::Exists($compilerMarker)) 'unsafe inventory does not invoke ISCC'
+ Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'unsafe inventory leaves no setup artifact'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'unsafe inventory leaves no generated setup script'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
Invoke-CcodTest 'build rejects invalid generated setup inputs before invoking ISCC' {
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-invalid-' + [guid]::NewGuid().ToString('N'))
try {
@@ -325,6 +556,53 @@ end;
}
}
+Invoke-CcodTest 'build cleans the GUID generated setup when ISCC returns nonzero' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-compiler-failure-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $templatePath = Join-Path $root 'Template.iss'
+ $templateSource = @'
+[Setup]
+AppName=GeneratedSetupCompilerFailure
+AppVersion=1.0.0
+DefaultDirName={tmp}\GeneratedSetupCompilerFailure
+OutputBaseFilename=GeneratedSetupCompilerFailure
+Uninstallable=no
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+'@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $inventorySource = @'
+procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
+begin
+end;
+'@
+ [IO.File]::WriteAllText($inventoryPath,$inventorySource,[Text.UTF8Encoding]::new($false))
+ $compilerArgument = Join-Path $root 'iscc-argument.txt'
+ $compilerPath = Join-Path $root 'fake-iscc.cmd'
+ $setupPath = Join-Path $root 'setup.exe'
+ $compilerSource = "@echo off`r`n> `"$compilerArgument`" echo %~f1`r`nexit /b 23`r`n"
+ [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new())
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ $failure = $null
+ try {
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $setupPath
+ } catch { $failure = $_ }
+
+ Assert-CcodTrue ($null -ne $failure) 'nonzero ISCC exit fails the build boundary'
+ Assert-CcodTrue ($failure.Exception.Message -cmatch 'exit code 23') 'compiler failure retains the exact nonzero exit code'
+ Assert-CcodTrue ([IO.File]::Exists($compilerArgument)) 'nonzero compiler records the generated source argument'
+ $compiledPath = [IO.File]::ReadAllText($compilerArgument).Trim()
+ Assert-CcodTrue ([IO.Path]::GetFileName($compiledPath) -cmatch '^\.ccod-generated-setup-[0-9a-f]{32}\.iss$') 'nonzero compiler receives the GUID generated source'
+ Assert-CcodTrue (-not [IO.File]::Exists($compiledPath)) 'nonzero compiler path is removed by the build finally boundary'
+ Assert-CcodTrue (-not [IO.File]::Exists($setupPath)) 'nonzero compiler produces no accepted setup artifact'
+ Assert-CcodEqual 0 @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count 'nonzero compiler leaves no generated setup script'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
Invoke-CcodTest 'real ISCC compiles the generated setup with its injected inventory procedure' {
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-generated-setup-iscc-' + [guid]::NewGuid().ToString('N'))
try {
diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1
index c12f952..e898f7a 100644
--- a/tools/New-InstallerDestinationInventory.ps1
+++ b/tools/New-InstallerDestinationInventory.ps1
@@ -32,11 +32,25 @@ function Add-CcodExpectedDirectory {
}
}
-$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8)
-$includeDirectives = @($lines | Where-Object { $_ -match '^\s*#\s*(?:include\b|\+)' })
-if ($includeDirectives.Count -ne 0) {
- throw 'Setup destination inventory requires an include-free template; external #include and #+ directives are not permitted.'
+function Assert-CcodInstallerInnoPreprocessorLines {
+ param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines)
+ $allowedDirectives = @('ifndef','define','error','endif')
+ for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) {
+ $line = [string]$Lines[$lineIndex]
+ $lineNumber = $lineIndex + 1
+ if ($line -match '\\[ \t]*$') {
+ throw "Setup destination inventory requires an include-free simple preprocessor template; line continuation is not permitted at line $lineNumber."
+ }
+ if ($line -notmatch '^[ \t]*#') { continue }
+ $directiveMatch = [regex]::Match($line,'^[ \t]*#(?[A-Za-z]+)\b')
+ if (-not $directiveMatch.Success -or $allowedDirectives -cnotcontains $directiveMatch.Groups['directive'].Value) {
+ throw "Setup destination inventory requires an include-free simple preprocessor template; unsafe directive at line ${lineNumber}: $line"
+ }
+ }
}
+
+$lines = @(Get-Content -LiteralPath $inno -Encoding UTF8)
+Assert-CcodInstallerInnoPreprocessorLines -Lines $lines
$filesSectionCount = @($lines | Where-Object { $_ -match '^\s*\[Files\]\s*$' }).Count
if ($filesSectionCount -ne 1) { throw "Setup destination inventory requires exactly one [Files] section; found $filesSectionCount." }
$insideFiles = $false
From 05d3646b5f64e6eff9975722a5bd95c4261ab053 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:47:37 +0800
Subject: [PATCH 014/187] fix: enforce exact Inno preprocessor constructs
---
build/build.ps1 | 39 +++-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 212 ++++++++++++++++++
tools/New-InstallerDestinationInventory.ps1 | 39 +++-
3 files changed, 278 insertions(+), 12 deletions(-)
diff --git a/build/build.ps1 b/build/build.ps1
index 36e7693..2f53eb1 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -121,17 +121,44 @@ function Assert-CcodBuildInnoPreprocessorLines {
[Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines,
[Parameter(Mandatory)][string]$Kind
)
- $allowedDirectives = @('ifndef','define','error','endif')
+ $allowedSimpleDirectives = @(
+ '#ifndef TrayHostArtifactDirectory',
+ '#define TrayHostArtifactDirectory SourcePath + "\generated\trayhost"',
+ '#ifndef PortableArtifactDirectory',
+ '#define PortableArtifactDirectory SourcePath + "\generated\portable"',
+ '#ifndef InstallerPayloadDirectory',
+ '#error InstallerPayloadDirectory must be supplied by the release builder',
+ '#ifndef InstallerPayloadManifestSha256',
+ '#error InstallerPayloadManifestSha256 must be supplied by the release builder',
+ '#endif'
+ )
+ $allowedInlineConstructs = @(
+ '{#ProjectVersion}',
+ '{#TrayHostArtifactDirectory}',
+ '{#PortableArtifactDirectory}',
+ '{#InstallerPayloadDirectory}',
+ '{#InstallerPayloadManifestSha256}'
+ )
for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) {
$line = [string]$Lines[$lineIndex]
$lineNumber = $lineIndex + 1
- if ($line -match '\\[ \t]*$') {
+ if ($line -match '\\\s*$') {
throw "$Kind requires an include-free simple preprocessor source; line continuation is not permitted at line $lineNumber."
}
- if ($line -notmatch '^[ \t]*#') { continue }
- $directiveMatch = [regex]::Match($line,'^[ \t]*#(?[A-Za-z]+)\b')
- if (-not $directiveMatch.Success -or $allowedDirectives -cnotcontains $directiveMatch.Groups['directive'].Value) {
- throw "$Kind requires an include-free simple preprocessor source; unsafe directive at line ${lineNumber}: $line"
+ if ($line -match '^\s*#' -and $allowedSimpleDirectives -cnotcontains $line) {
+ throw "$Kind requires an include-free exact preprocessor source; unsafe simple directive at line ${lineNumber}: $line"
+ }
+ $inlineStart = $line.IndexOf('{#',[StringComparison]::Ordinal)
+ while ($inlineStart -ge 0) {
+ $inlineEnd = $line.IndexOf('}',$inlineStart + 2)
+ if ($inlineEnd -lt 0) {
+ throw "$Kind contains an unterminated inline preprocessor construct at line $lineNumber."
+ }
+ $inlineConstruct = $line.Substring($inlineStart,$inlineEnd - $inlineStart + 1)
+ if ($allowedInlineConstructs -cnotcontains $inlineConstruct) {
+ throw "$Kind contains an unsafe inline preprocessor construct at line ${lineNumber}: $inlineConstruct"
+ }
+ $inlineStart = $line.IndexOf('{#',$inlineEnd + 1,[StringComparison]::Ordinal)
}
}
}
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 7a70560..9c8116a 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -327,6 +327,218 @@ $($fixture.Directive)
}
}
+Invoke-CcodTest 'VT and FF prefixed directives are rejected by inventory and build boundaries before ISCC' {
+ $iscc = @(
+ (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')
+ ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
+ if (-not $iscc) { throw 'Inno Setup 6 is required for the preprocessor whitespace fixtures' }
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ $results = [Collections.Generic.List[object]]::new()
+ foreach ($fixture in @(
+ [pscustomobject]@{Name='vertical tab';Prefix=[char]0x0B},
+ [pscustomobject]@{Name='form feed';Prefix=[char]0x0C}
+ )) {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-preprocessor-whitespace-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $payload = Join-Path $root 'payload'
+ $realOutput = Join-Path $root 'real-output'
+ [IO.Directory]::CreateDirectory($payload) | Out-Null
+ [IO.Directory]::CreateDirectory($realOutput) | Out-Null
+ $sourcePath = Join-Path $root 'fixture.txt'
+ [IO.File]::WriteAllText($sourcePath,'fixture',[Text.UTF8Encoding]::new($false))
+ $unsafeDirective = [string]$fixture.Prefix + '#emit ''AppPublisher=InjectedByWhitespace'''
+ $templatePath = Join-Path $root 'Template.iss'
+ $templateSource = @"
+[Setup]
+AppName=WhitespaceDirective
+AppVersion=1.0.0
+DefaultDirName={tmp}\WhitespaceDirective
+OutputDir=$realOutput
+OutputBaseFilename=WhitespaceDirective
+Uninstallable=no
+$unsafeDirective
+[Files]
+Source: "$sourcePath"; DestDir: "{app}\first"; Flags: ignoreversion
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+"@
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $realSetupPath = Join-Path $realOutput 'WhitespaceDirective.exe'
+ $generatorFailure = $null
+ $realCompilerAttempted = $false
+ try {
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null
+ $realCompilerAttempted = $true
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $iscc -Arguments @() -SetupPath $realSetupPath
+ } catch { $generatorFailure = $_ }
+
+ $buildTemplatePath = Join-Path $root 'BuildTemplate.iss'
+ [IO.File]::WriteAllText($buildTemplatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $buildInventoryPath = Join-Path $root 'BuildInventory.iss'
+ $buildInventorySource = @'
+procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
+begin
+end;
+'@
+ [IO.File]::WriteAllText($buildInventoryPath,$buildInventorySource,[Text.UTF8Encoding]::new($false))
+ $compilerMarker = Join-Path $root 'fake-iscc-invoked.txt'
+ $compilerPath = Join-Path $root 'fake-iscc.cmd'
+ $buildSetupPath = Join-Path $root 'build-setup.exe'
+ $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$buildSetupPath`" echo setup`r`nexit /b 0`r`n"
+ [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new())
+ $buildFailure = $null
+ try {
+ Invoke-CcodBuildInnoCompiler -TemplatePath $buildTemplatePath -InventoryPath $buildInventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $buildSetupPath
+ } catch { $buildFailure = $_ }
+
+ $results.Add([pscustomobject]@{
+ Name = $fixture.Name
+ GeneratorFailure = $generatorFailure
+ InventoryExists = [IO.File]::Exists($inventoryPath)
+ RealCompilerAttempted = $realCompilerAttempted
+ RealSetupExists = [IO.File]::Exists($realSetupPath)
+ BuildFailure = $buildFailure
+ MarkerCompilerInvoked = [IO.File]::Exists($compilerMarker)
+ BuildSetupExists = [IO.File]::Exists($buildSetupPath)
+ GeneratedCount = @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count
+ })
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+ }
+ foreach ($result in $results) {
+ Assert-CcodTrue ($null -ne $result.GeneratorFailure) "$($result.Name) directive is rejected before inventory generation"
+ Assert-CcodTrue ($result.GeneratorFailure.Exception.Message -cmatch 'preprocessor|directive') "$($result.Name) generator failure identifies the preprocessor boundary"
+ Assert-CcodTrue (-not $result.InventoryExists) "$($result.Name) leaves no inventory output"
+ Assert-CcodTrue (-not $result.RealCompilerAttempted) "$($result.Name) never reaches real ISCC"
+ Assert-CcodTrue (-not $result.RealSetupExists) "$($result.Name) leaves no real setup artifact"
+ Assert-CcodTrue ($null -ne $result.BuildFailure) "$($result.Name) directive is rejected by generated setup creation"
+ Assert-CcodTrue ($result.BuildFailure.Exception.Message -cmatch 'preprocessor|directive') "$($result.Name) build failure identifies the preprocessor boundary"
+ Assert-CcodTrue (-not $result.MarkerCompilerInvoked) "$($result.Name) never reaches the marker compiler"
+ Assert-CcodTrue (-not $result.BuildSetupExists) "$($result.Name) leaves no marker setup artifact"
+ Assert-CcodEqual 0 $result.GeneratedCount "$($result.Name) leaves no generated compiler script"
+ }
+}
+
+Invoke-CcodTest 'inline file and unsafe define expansion are rejected before inventory or ISCC' {
+ $iscc = @(
+ (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')
+ ) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
+ if (-not $iscc) { throw 'Inno Setup 6 is required for the inline preprocessor fixtures' }
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ $results = [Collections.Generic.List[object]]::new()
+ foreach ($fixtureName in @('inline file','unsafe define expansion')) {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inline-preprocessor-' + [guid]::NewGuid().ToString('N'))
+ try {
+ $payload = Join-Path $root 'payload'
+ $realOutput = Join-Path $root 'real-output'
+ [IO.Directory]::CreateDirectory($payload) | Out-Null
+ [IO.Directory]::CreateDirectory($realOutput) | Out-Null
+ $mainSource = Join-Path $root 'main.txt'
+ $injectedSource = Join-Path $root 'injected.txt'
+ $externalText = Join-Path $root 'external.txt'
+ [IO.File]::WriteAllText($mainSource,'main',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($injectedSource,'injected',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($externalText,'external text',[Text.UTF8Encoding]::new($false))
+ $templatePath = Join-Path $root 'Template.iss'
+ if ($fixtureName -ceq 'inline file') {
+ $templateSource = @"
+[Setup]
+AppName=InlineFileDirective
+AppVersion=1.0.0
+DefaultDirName={tmp}\InlineFileDirective
+OutputDir=$realOutput
+OutputBaseFilename=InlineFileDirective
+Uninstallable=no
+LicenseFile={#file "external.txt"}
+[Files]
+Source: "$mainSource"; DestDir: "{app}\main"; Flags: ignoreversion
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+"@
+ $setupName = 'InlineFileDirective.exe'
+ } else {
+ $templateSource = @"
+#define TrayHostArtifactDirectory "[Files]" + NewLine + "Source: ""$injectedSource""; DestDir: ""{app}\injected""; Flags: ignoreversion"
+[Setup]
+AppName=UnsafeDefineExpansion
+AppVersion=1.0.0
+DefaultDirName={tmp}\UnsafeDefineExpansion
+OutputDir=$realOutput
+OutputBaseFilename=UnsafeDefineExpansion
+Uninstallable=no
+{#TrayHostArtifactDirectory}
+[Files]
+Source: "$mainSource"; DestDir: "{app}\main"; Flags: ignoreversion
+[Code]
+// CCOD_INSTALLER_DESTINATION_INVENTORY
+"@
+ $setupName = 'UnsafeDefineExpansion.exe'
+ }
+ [IO.File]::WriteAllText($templatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $inventoryPath = Join-Path $root 'Inventory.iss'
+ $realSetupPath = Join-Path $realOutput $setupName
+ $generatorFailure = $null
+ $realCompilerAttempted = $false
+ try {
+ & (Join-Path $repositoryRoot 'tools\New-InstallerDestinationInventory.ps1') -RepositoryRoot $repositoryRoot -PayloadRoot $payload -ProjectVersion '2.5.22' -InnoScriptPath $templatePath -OutputPath $inventoryPath | Out-Null
+ $realCompilerAttempted = $true
+ Invoke-CcodBuildInnoCompiler -TemplatePath $templatePath -InventoryPath $inventoryPath -IsccPath $iscc -Arguments @() -SetupPath $realSetupPath
+ } catch { $generatorFailure = $_ }
+
+ $buildTemplatePath = Join-Path $root 'BuildTemplate.iss'
+ [IO.File]::WriteAllText($buildTemplatePath,$templateSource,[Text.UTF8Encoding]::new($false))
+ $buildInventoryPath = Join-Path $root 'BuildInventory.iss'
+ $buildInventorySource = @'
+procedure AddCcodExpectedSetupDirectories(Directories: TStrings);
+begin
+end;
+'@
+ [IO.File]::WriteAllText($buildInventoryPath,$buildInventorySource,[Text.UTF8Encoding]::new($false))
+ $compilerMarker = Join-Path $root 'fake-iscc-invoked.txt'
+ $compilerPath = Join-Path $root 'fake-iscc.cmd'
+ $buildSetupPath = Join-Path $root 'build-setup.exe'
+ $compilerSource = "@echo off`r`n> `"$compilerMarker`" echo invoked`r`n> `"$buildSetupPath`" echo setup`r`nexit /b 0`r`n"
+ [IO.File]::WriteAllText($compilerPath,$compilerSource,[Text.ASCIIEncoding]::new())
+ $buildFailure = $null
+ try {
+ Invoke-CcodBuildInnoCompiler -TemplatePath $buildTemplatePath -InventoryPath $buildInventoryPath -IsccPath $compilerPath -Arguments @() -SetupPath $buildSetupPath
+ } catch { $buildFailure = $_ }
+
+ $results.Add([pscustomobject]@{
+ Name = $fixtureName
+ GeneratorFailure = $generatorFailure
+ InventoryExists = [IO.File]::Exists($inventoryPath)
+ RealCompilerAttempted = $realCompilerAttempted
+ RealSetupExists = [IO.File]::Exists($realSetupPath)
+ BuildFailure = $buildFailure
+ MarkerCompilerInvoked = [IO.File]::Exists($compilerMarker)
+ BuildSetupExists = [IO.File]::Exists($buildSetupPath)
+ GeneratedCount = @(Get-ChildItem -LiteralPath $root -Filter '.ccod-generated-setup-*.iss' -File -Force).Count
+ })
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+ }
+ foreach ($result in $results) {
+ Assert-CcodTrue ($null -ne $result.GeneratorFailure) "$($result.Name) is rejected before inventory generation"
+ Assert-CcodTrue ($result.GeneratorFailure.Exception.Message -cmatch 'preprocessor|inline|construct|directive') "$($result.Name) generator failure identifies the strict construct boundary"
+ Assert-CcodTrue (-not $result.InventoryExists) "$($result.Name) leaves no inventory output"
+ Assert-CcodTrue (-not $result.RealCompilerAttempted) "$($result.Name) never reaches real ISCC"
+ Assert-CcodTrue (-not $result.RealSetupExists) "$($result.Name) leaves no real setup artifact"
+ Assert-CcodTrue ($null -ne $result.BuildFailure) "$($result.Name) is rejected by generated setup creation"
+ Assert-CcodTrue ($result.BuildFailure.Exception.Message -cmatch 'preprocessor|inline|construct|directive') "$($result.Name) build failure identifies the strict construct boundary"
+ Assert-CcodTrue (-not $result.MarkerCompilerInvoked) "$($result.Name) never reaches the marker compiler"
+ Assert-CcodTrue (-not $result.BuildSetupExists) "$($result.Name) leaves no marker setup artifact"
+ Assert-CcodEqual 0 $result.GeneratedCount "$($result.Name) leaves no generated compiler script"
+ }
+}
+
Invoke-CcodTest 'generated setup creation applies the same strict preprocessor boundary before ISCC' {
. (Join-Path $repositoryRoot 'build\build.ps1') -Library
$results = [Collections.Generic.List[object]]::new()
diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1
index e898f7a..57f543f 100644
--- a/tools/New-InstallerDestinationInventory.ps1
+++ b/tools/New-InstallerDestinationInventory.ps1
@@ -34,17 +34,44 @@ function Add-CcodExpectedDirectory {
function Assert-CcodInstallerInnoPreprocessorLines {
param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines)
- $allowedDirectives = @('ifndef','define','error','endif')
+ $allowedSimpleDirectives = @(
+ '#ifndef TrayHostArtifactDirectory',
+ '#define TrayHostArtifactDirectory SourcePath + "\generated\trayhost"',
+ '#ifndef PortableArtifactDirectory',
+ '#define PortableArtifactDirectory SourcePath + "\generated\portable"',
+ '#ifndef InstallerPayloadDirectory',
+ '#error InstallerPayloadDirectory must be supplied by the release builder',
+ '#ifndef InstallerPayloadManifestSha256',
+ '#error InstallerPayloadManifestSha256 must be supplied by the release builder',
+ '#endif'
+ )
+ $allowedInlineConstructs = @(
+ '{#ProjectVersion}',
+ '{#TrayHostArtifactDirectory}',
+ '{#PortableArtifactDirectory}',
+ '{#InstallerPayloadDirectory}',
+ '{#InstallerPayloadManifestSha256}'
+ )
for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) {
$line = [string]$Lines[$lineIndex]
$lineNumber = $lineIndex + 1
- if ($line -match '\\[ \t]*$') {
+ if ($line -match '\\\s*$') {
throw "Setup destination inventory requires an include-free simple preprocessor template; line continuation is not permitted at line $lineNumber."
}
- if ($line -notmatch '^[ \t]*#') { continue }
- $directiveMatch = [regex]::Match($line,'^[ \t]*#(?[A-Za-z]+)\b')
- if (-not $directiveMatch.Success -or $allowedDirectives -cnotcontains $directiveMatch.Groups['directive'].Value) {
- throw "Setup destination inventory requires an include-free simple preprocessor template; unsafe directive at line ${lineNumber}: $line"
+ if ($line -match '^\s*#' -and $allowedSimpleDirectives -cnotcontains $line) {
+ throw "Setup destination inventory requires an include-free exact preprocessor template; unsafe simple directive at line ${lineNumber}: $line"
+ }
+ $inlineStart = $line.IndexOf('{#',[StringComparison]::Ordinal)
+ while ($inlineStart -ge 0) {
+ $inlineEnd = $line.IndexOf('}',$inlineStart + 2)
+ if ($inlineEnd -lt 0) {
+ throw "Setup destination inventory contains an unterminated inline preprocessor construct at line $lineNumber."
+ }
+ $inlineConstruct = $line.Substring($inlineStart,$inlineEnd - $inlineStart + 1)
+ if ($allowedInlineConstructs -cnotcontains $inlineConstruct) {
+ throw "Setup destination inventory contains an unsafe inline preprocessor construct at line ${lineNumber}: $inlineConstruct"
+ }
+ $inlineStart = $line.IndexOf('{#',$inlineEnd + 1,[StringComparison]::Ordinal)
}
}
}
From b34b0d1aed066ee0a649e34d9845b538e85216ca Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:09:15 +0800
Subject: [PATCH 015/187] fix: stabilize verified Electron process trees
---
src/persistence/Supervisor.ps1 | 24 +++--
src/persistence/modules/ProcessControl.psm1 | 40 ++++++++-
src/persistence/modules/SessionEngine.psm1 | 17 +++-
tests/persistence/ProcessControl.SelfTest.ps1 | 47 ++++++++++
tests/persistence/SessionEngine.SelfTest.ps1 | 38 +++++++-
tests/persistence/Supervisor.SelfTest.ps1 | 90 +++++++++++++++++++
6 files changed, 242 insertions(+), 14 deletions(-)
diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1
index 0dbfb21..9742335 100644
--- a/src/persistence/Supervisor.ps1
+++ b/src/persistence/Supervisor.ps1
@@ -9,7 +9,7 @@ Set-StrictMode -Version 2.0
$script:CcodSupervisorScriptPath=if([string]::IsNullOrWhiteSpace($PSCommandPath)){$null}else{[IO.Path]::GetFullPath($PSCommandPath)}
$script:CcodSupervisorLogPath=$null
$script:CcodSupervisorAdapterNames=@(
- 'GetIdentity','ResolveLayout','StartClock','GetElapsedMilliseconds','GetUtcNow',
+ 'GetIdentity','ResolveLayout','StartClock','GetElapsedMilliseconds','GetUtcNow','Delay',
'EnterLease','ExitLease','OpenReadyEvent','OpenShutdownEvent','IsEventSignaled','SignalEvent','CloseEvent',
'ReadActiveRuntime','GetTrustedLogonIdentity','WriteSafeExitIntent','ClearSafeExitIntent','EnterLifecycleOwnership','AssertLifecycleFence','SuspendLifecycleOwnership','ResumeLifecycleOwnership','ExitLifecycleOwnership','OpenLifecycleWakeEvent','ResetLifecycleWakeEvent',
'ReadLifecycleRequest','ReceiveLifecycleSubmissions','WriteLifecycleSubmissionReceipt','NewLifecycleRequest','WriteLifecycleRequest','MoveLifecyclePhase','CompleteLifecycleRequest','GetLifecycleStep','ReduceLifecycleWorkerResult','NewLifecycleWorkerRequest','AssertLifecycleWorkerResult',
@@ -248,6 +248,7 @@ function Get-CcodSupervisorDefaultAdapters {
$defaults.StartClock={[Diagnostics.Stopwatch]::StartNew()}
$defaults.GetElapsedMilliseconds={param($Clock)[long]$Clock.ElapsedMilliseconds}
$defaults.GetUtcNow={[DateTime]::UtcNow}
+ $defaults.Delay={param($Milliseconds)Start-Sleep -Milliseconds ([int]$Milliseconds)}
$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) $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)}
@@ -662,14 +663,21 @@ 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
+ for($attempt=0;$attempt-lt2;$attempt++){
+ Update-CcodSupervisorProofObservations $HostState $Adapters
+ if(@($HostState.Special).Count-eq1-and(Test-CcodSupervisorExactProcessSnapshotMatch $Candidate $HostState.Special[0].Snapshot)){
+ $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
+ }
+ if($attempt-ne0-or@($HostState.Special).Count-ne0-or@($HostState.Ordinary).Count-ne0){break}
+ $statusEvidence=if($null-ne$HostState.State-and$null-ne$HostState.State.PSObject.Properties['Status']){$HostState.State.Status}else{$null}
+ $current=Invoke-CcodSupervisorNullableAdapter $Adapters.GetProcessSnapshot @([int]$Candidate.Pid,$statusEvidence)
+ if($null-eq$current-or-not(Test-CcodSupervisorExactProcessSnapshotMatch $Candidate $current)){break}
+ Invoke-CcodSupervisorAdapter $Adapters.Delay @([int]50) 0
}
- $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
+ $HostState.SpecialProof=$null;$HostState.FailedSpecialProofKey=$candidateKey;$HostState.SpecialNeedsInspect=$false
+ return $false
}
function New-CcodSupervisorLifecycleProofFailure {
diff --git a/src/persistence/modules/ProcessControl.psm1 b/src/persistence/modules/ProcessControl.psm1
index f723324..98b9f50 100644
--- a/src/persistence/modules/ProcessControl.psm1
+++ b/src/persistence/modules/ProcessControl.psm1
@@ -1408,6 +1408,44 @@ function Get-CcodVerifiedProcessTree {
return @($included | Sort-Object | ForEach-Object { $rereadByPid[[int]$_] })
}
+function Get-CcodStableVerifiedProcessTree {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)]$Root,
+ $StatusEvidence,
+ [ValidateRange(0, 2)][int]$RetryBudget = 1,
+ [hashtable]$Adapters
+ )
+
+ $adapter = Get-CcodProcessAdapters -Adapters $Adapters
+ if (-not $adapter.ContainsKey('GetVerifiedTree')) {
+ $adapter.GetVerifiedTree = {
+ param($ExpectedRoot, $Evidence)
+ Get-CcodVerifiedProcessTree -Root $ExpectedRoot -StatusEvidence $Evidence -Adapters $adapter
+ }.GetNewClosure()
+ }
+
+ for ($attempt = 0; $attempt -le $RetryBudget; $attempt++) {
+ try { $tree = @(& $adapter.GetVerifiedTree $Root $StatusEvidence) } catch { $tree = @() }
+ if ($tree.Count -gt 0) {
+ $matchingRoots = @($tree | Where-Object {
+ $null -ne $_ -and $_.PSObject.Properties['Pid'] -ne $null -and
+ $_.Pid -is [int] -and $_.Pid -eq [int]$Root.Pid -and
+ (Test-CcodProcessMatch -Expected $Root -Actual $_)
+ })
+ if ($matchingRoots.Count -ne 1) { return @() }
+ return @($tree)
+ }
+ if ($attempt -ge $RetryBudget) { return @() }
+
+ try { $rootOutput = @(& $adapter.GetProcess ([int]$Root.Pid) $StatusEvidence 2>&1) } catch { return @() }
+ if ($rootOutput.Count -ne 1 -or $rootOutput[0] -is [Management.Automation.ErrorRecord] -or
+ $null -eq $rootOutput[0] -or -not (Test-CcodProcessMatch -Expected $Root -Actual $rootOutput[0])) { return @() }
+ try { & $adapter.Delay 50 | Out-Null } catch { return @() }
+ }
+ return @()
+}
+
function Get-CcodVerifiedStaleProcessTree {
[CmdletBinding()]
param(
@@ -1995,4 +2033,4 @@ function Start-CcodProcess {
return New-CcodStartResult -Outcome 'Started' -Snapshot $null -Process $process
}
-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
+Export-ModuleMember -Function Get-CcodProcessIdentityObservation, Get-CcodProcessSnapshot, Test-CcodProcessMatch, Get-CcodStalePackageProcessSnapshot, Get-CcodStalePackageRootResult, Get-CcodVerifiedProcessTree, Get-CcodStableVerifiedProcessTree, 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 35440fd..61f1b65 100644
--- a/src/persistence/modules/SessionEngine.psm1
+++ b/src/persistence/modules/SessionEngine.psm1
@@ -457,9 +457,18 @@ function Get-CcodTreeDepth {
return $depth
}
+function Get-CcodStableSessionProcessTree {
+ param($Root,$StatusEvidence,[hashtable]$Adapter)
+ return @(Get-CcodStableVerifiedProcessTree -Root $Root -StatusEvidence $StatusEvidence -RetryBudget 1 -Adapters @{
+ GetVerifiedTree=$Adapter.GetTree
+ GetProcess=$Adapter.GetProcess
+ Delay=$Adapter.Delay
+ })
+}
+
function Get-CcodChildFirstVerifiedTree {
param($Root,$StatusEvidence,[hashtable]$Adapter)
- $tree=@(& $Adapter.GetTree $Root $StatusEvidence)
+ $tree=@(Get-CcodStableSessionProcessTree $Root $StatusEvidence $Adapter)
if($tree.Count -lt 1){Throw-CcodSessionError 'CCOD_RECOVERY_UNPROVEN' 'Recorded special tree could not be verified' $Root}
$byPid=@{};foreach($member in $tree){$byPid[[int]$member.Pid]=$member}
if(-not $byPid.ContainsKey([int]$Root.Pid)){Throw-CcodSessionError 'CCOD_RECOVERY_UNPROVEN' 'Verified tree omitted the recorded root' $Root}
@@ -1366,7 +1375,7 @@ function Invoke-CcodCloseSession {
if($null -eq $target -or -not (& $adapter.ProcessMatch $Request.source $target)){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Requested close source is not the one verified current root' $Request.source}
}
if($null -eq $target){$result.ok=$true;$result.outcome='Closed';$result.safeState='Closed';$result.stage='Closed';return $result}
- $tree=@(& $adapter.GetTree $target $state.Status)
+ $tree=@(Get-CcodStableSessionProcessTree $target $state.Status $adapter)
if($tree.Count -lt 1){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current close target tree is not exact and verified' $target}
$renderer=$null;$main=$null;$source=$target
if($isSpecial){if($null -ne $statusCodex){$renderer=$statusCodex.rendererPort;$main=$statusCodex.mainPort}else{$renderer=$target.RendererPort;$main=$target.MainPort};$source=$null;$result.special=ConvertTo-CcodSessionSpecial $target}else{$result.source=ConvertTo-CcodSessionSource $target}
@@ -1449,7 +1458,7 @@ function Invoke-CcodRecoverSession {
}
}
if($null -eq $target){$result.ok=$true;$result.outcome='Closed';$result.safeState='Closed';$result.stage='Closed';return $result}
- $tree=@(& $adapter.GetTree $target $state.Status)
+ $tree=@(Get-CcodStableSessionProcessTree $target $state.Status $adapter)
if($tree.Count -lt 1){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Current close target tree is not exact and verified' $target}
$renderer=$null;$main=$null;$source=$target
if($isSpecial){if($null -ne $statusCodex){$renderer=$statusCodex.rendererPort;$main=$statusCodex.mainPort}else{$renderer=$target.RendererPort;$main=$target.MainPort};$source=$null;$result.special=ConvertTo-CcodSessionSpecial $target}else{$result.source=ConvertTo-CcodSessionSource $target}
@@ -1531,7 +1540,7 @@ function Invoke-CcodReplayTransition {
$decision=Get-CcodReplayDecision -Transition $Transition -Observed $cold
Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Cold close replay cannot prove absence of the complete recorded tree' $decision
}
- $tree=@(& $adapter.GetTree $current $state.Status)
+ $tree=@(Get-CcodStableSessionProcessTree $current $state.Status $adapter)
if($tree.Count -lt 1){Throw-CcodSessionError 'CCOD_CLOSE_UNPROVEN' 'Recorded close root does not yield a verified tree' $current}
if($null -ne $Transition.specialPid){
$fact=[pscustomobject][ordered]@{Process=$current;Evidence='PersistedIdentity';Validation='Indeterminate'}
diff --git a/tests/persistence/ProcessControl.SelfTest.ps1 b/tests/persistence/ProcessControl.SelfTest.ps1
index 20564a9..008dc43 100644
--- a/tests/persistence/ProcessControl.SelfTest.ps1
+++ b/tests/persistence/ProcessControl.SelfTest.ps1
@@ -892,6 +892,53 @@ try {
Assert-CcodTrue ($seen.RootEvidenceCount -ge 2) 'Special root proof is supplied on initial and final rereads'
}
+ Invoke-CcodTest 'retries one transient verified tree miss while the exact root identity remains stable' {
+ $status = New-CcodSpecialStatus
+ $root = New-CcodSnapshot -Mode Special -RendererPort 41001 -MainPort 41002 -CommandLine 'special'
+ $child = New-CcodSnapshot -ProcessId 101 -CreationTimeUtc '2026-08-02T00:00:01.0000000Z' -ParentPid 100 -IsTopLevel $false -Mode Unrelated
+ $calls = [pscustomobject]@{ Tree = 0; Root = 0; Delay = 0 }
+ $tree = @(Get-CcodStableVerifiedProcessTree -Root $root -StatusEvidence $status -RetryBudget 1 -Adapters @{
+ GetVerifiedTree = {
+ param($ExpectedRoot, $StatusEvidence)
+ $calls.Tree++
+ if ($calls.Tree -eq 1) { return @() }
+ return @($root, $child)
+ }.GetNewClosure()
+ GetProcess = {
+ param($ProcessId, $StatusEvidence)
+ $calls.Root++
+ Assert-CcodEqual $status $StatusEvidence 'stable retry preserves root status evidence'
+ return $root
+ }.GetNewClosure()
+ Delay = { param($Milliseconds) $calls.Delay++ }.GetNewClosure()
+ })
+ Assert-CcodEqual 2 $tree.Count 'one transient empty tree is retried without relaxing root identity'
+ Assert-CcodEqual '100,101' (($tree.Pid | Sort-Object) -join ',') 'successful retry returns the complete verified tree'
+ Assert-CcodEqual 2 $calls.Tree 'the fixed one-retry budget performs exactly two tree attempts'
+ Assert-CcodEqual 1 $calls.Root 'the root is reread exactly once before the retry'
+ Assert-CcodEqual 1 $calls.Delay 'delay is entered only after an empty tree and a stable root reread'
+ }
+
+ Invoke-CcodTest 'rejects a retry when the exact root creation time changes' {
+ $root = New-CcodSnapshot
+ $changed = New-CcodSnapshot -CreationTimeUtc '2026-08-02T00:00:05.0000000Z'
+ $calls = [pscustomobject]@{ Tree = 0; Root = 0; Delay = 0 }
+ $tree = @(Get-CcodStableVerifiedProcessTree -Root $root -RetryBudget 1 -Adapters @{
+ GetVerifiedTree = {
+ param($ExpectedRoot, $StatusEvidence)
+ $calls.Tree++
+ if ($calls.Tree -eq 1) { return @() }
+ return @($changed)
+ }.GetNewClosure()
+ GetProcess = { param($ProcessId, $StatusEvidence) $calls.Root++; $changed }.GetNewClosure()
+ Delay = { param($Milliseconds) $calls.Delay++ }.GetNewClosure()
+ })
+ Assert-CcodEqual 0 $tree.Count 'creation-time drift returns no verified tree'
+ Assert-CcodEqual 1 $calls.Tree 'root drift is not retried'
+ Assert-CcodEqual 1 $calls.Root 'drift is detected by the mandatory root reread'
+ Assert-CcodEqual 0 $calls.Delay 'root drift never enters the retry delay'
+ }
+
Invoke-CcodTest 'adopts exactly one special transaction candidate' {
$command = '"C:\Codex\ChatGPT.exe" --remote-debugging-address=127.0.0.1 --remote-debugging-port=41001 --inspect=127.0.0.1:41002'
$validArgv = @('C:\Codex\ChatGPT.exe','--remote-debugging-address=127.0.0.1','--remote-debugging-port=41001','--inspect=127.0.0.1:41002')
diff --git a/tests/persistence/SessionEngine.SelfTest.ps1 b/tests/persistence/SessionEngine.SelfTest.ps1
index ff0c1d6..cbd2c14 100644
--- a/tests/persistence/SessionEngine.SelfTest.ps1
+++ b/tests/persistence/SessionEngine.SelfTest.ps1
@@ -1029,6 +1029,7 @@ try {
$stopped=[Collections.Generic.List[int]]::new();$adapters=New-CcodEngineAdapters -Processes @($source)
$adapters.InvokeNode={param($NodePath,$Arguments)throw 'bridge failed'}
$adapters.ListProcesses={param($StatusEvidence)@()}
+ $adapters.GetPreferredRendererPort={param($Excluded)41001}
$adapters.GetTree={param($Root,$StatusEvidence)@($rootSpecial,$child)}.GetNewClosure()
$adapters.GetProcess={param($ProcessId,$StatusEvidence)switch($ProcessId){201{$rootSpecial}202{$child}999{$unrelated}default{$source}}}.GetNewClosure()
$adapters.StopProcess={param($Expected,$StatusEvidence,$TimeoutMilliseconds)$stopped.Add([int]$Expected.Pid);[pscustomobject]@{Outcome='Stopped';StoppedByController=$true;Snapshot=$Expected}}.GetNewClosure()
@@ -1396,6 +1397,41 @@ try {
}
}
+ Invoke-CcodTest 'retries transient Electron tree churn before committing and closes every verified member' {
+ $root=New-CcodEngineSnapshot
+ $child=New-CcodEngineSnapshot -Pid 101 -CreationTimeUtc '2030-02-03T04:05:07.0000000Z' -Mode Unrelated -ParentPid 100 -IsTopLevel $false
+ $alive=@{100=$root;101=$child};$events=[Collections.Generic.List[string]]::new();$treeReads=[pscustomobject]@{Count=0}
+ $adapters=New-CcodEngineAdapters -State (New-CcodEngineState -Status ([pscustomobject]@{schemaVersion=1;session=$null})) -Processes @($root) -Events $events
+ $adapters.ListProcesses={param($StatusEvidence)@($root)}.GetNewClosure()
+ $adapters.GetProcess={param($ProcessId,$StatusEvidence)if($alive.ContainsKey([int]$ProcessId)){$alive[[int]$ProcessId]}else{$null}}.GetNewClosure()
+ $adapters.GetTree={param($Root,$StatusEvidence)$treeReads.Count++;if($treeReads.Count-eq1){@()}else{@($root,$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()
+ Set-CcodEngineAliveIdentityObserver -Adapters $adapters -Alive $alive
+
+ $result=Invoke-CcodCloseSession -Request (New-CcodEngineRequest -SchemaVersion 2 -Action Close -RestartOrdinary $false) -Paths $paths -Adapters $adapters
+
+ Assert-CcodEqual 'Closed' $result.outcome 'transient tree churn still reaches durable Closed'
+ Assert-CcodEqual 2 $treeReads.Count 'close retries the one transient empty verified tree'
+ Assert-CcodEqual 'Stop:101,Stop:100' ((@($events|Where-Object{$_ -like 'Stop:*'}))-join ',') 'retry preserves child-first proof for every stopped member'
+ Assert-CcodTrue ([Array]::IndexOf(@($events),'IntentWritten') -lt [Array]::IndexOf(@($events),'Stop:101')) 'all mutations remain after stable tree acquisition'
+ }
+
+ Invoke-CcodTest 'root drift during stable tree acquisition performs no close mutation' {
+ $root=New-CcodEngineSnapshot
+ $changed=New-CcodEngineSnapshot -CreationTimeUtc '2030-02-03T04:05:09.0000000Z'
+ $events=[Collections.Generic.List[string]]::new();$processReads=[pscustomobject]@{Count=0}
+ $adapters=New-CcodEngineAdapters -State (New-CcodEngineState -Status ([pscustomobject]@{schemaVersion=1;session=$null})) -Processes @($root) -Events $events
+ $adapters.ListProcesses={param($StatusEvidence)@($root)}.GetNewClosure()
+ $adapters.GetProcess={param($ProcessId,$StatusEvidence)$processReads.Count++;if($processReads.Count-eq1){$root}else{$changed}}.GetNewClosure()
+ $adapters.GetTree={param($Root,$StatusEvidence)@()}
+
+ $result=Invoke-CcodCloseSession -Request (New-CcodEngineRequest -SchemaVersion 2 -Action Close -RestartOrdinary $false) -Paths $paths -Adapters $adapters
+
+ Assert-CcodEqual 'Error' $result.outcome 'root drift fails close safely'
+ Assert-CcodEqual 'CCOD_CLOSE_UNPROVEN' $result.error.code 'root drift keeps the stable close failure code'
+ Assert-CcodEqual 0 @($events|Where-Object{$_ -in @('IntentWritten','CloseRequested','StopProcess') -or $_ -like 'Stop:*'}).Count 'root drift occurs before every close mutation'
+ }
+
Invoke-CcodTest 'closes an already empty session and leaves unsafe close evidence durable' {
$counters=[pscustomobject]@{SpecialStart=0;OrdinaryStart=0;Recover=0;Node=0};$adapters=New-CcodEngineAdapters -Processes @() -Counters $counters
$empty=Invoke-CcodRecoverSession -Request (New-CcodEngineRequest -Action Recover -RestartOrdinary $false) -Paths $paths -Adapters $adapters
@@ -1678,7 +1714,7 @@ try {
Invoke-CcodTest 'production adapter declarations call upstream APIs and contain no empty process placeholder' {
$moduleText=[IO.File]::ReadAllText((Join-Path $repositoryRoot 'src\persistence\modules\SessionEngine.psm1'))
- foreach($command in @('Invoke-CcodStaticProbe','Get-CcodProcessSnapshot','Test-CcodProcessMatch','Stop-CcodProcessIfMatch','Get-CcodVerifiedProcessTree','Get-CcodTransactionProcessResult','Get-CcodAvailableLoopbackPort','Start-CcodProcess','Wait-CcodPortClosed','Read-CcodState','Write-CcodStatus','Write-CcodVerifiedPackages','New-CcodTransition','Set-CcodTransitionStage','Complete-CcodTransition')){Assert-CcodTrue ($moduleText -cmatch [regex]::Escape($command)) "production adapters wire $command"}
+ foreach($command in @('Invoke-CcodStaticProbe','Get-CcodProcessSnapshot','Test-CcodProcessMatch','Stop-CcodProcessIfMatch','Get-CcodVerifiedProcessTree','Get-CcodStableVerifiedProcessTree','Get-CcodTransactionProcessResult','Get-CcodAvailableLoopbackPort','Start-CcodProcess','Wait-CcodPortClosed','Read-CcodState','Write-CcodStatus','Write-CcodVerifiedPackages','New-CcodTransition','Set-CcodTransitionStage','Complete-CcodTransition')){Assert-CcodTrue ($moduleText -cmatch [regex]::Escape($command)) "production adapters wire $command"}
Assert-CcodTrue ($moduleText -cnotmatch 'ListProcesses=\{\s*param\([^)]*\)\s*@\(\)\s*\}') 'production process enumeration is not an always-empty placeholder'
$inspectionStart=$moduleText.IndexOf('function Merge-CcodInspectionAdapters',[StringComparison]::Ordinal)
$inspectionEnd=$moduleText.IndexOf('function Assert-CcodInspectionState',[StringComparison]::Ordinal)
diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1
index fae9fd7..afe968f 100644
--- a/tests/persistence/Supervisor.SelfTest.ps1
+++ b/tests/persistence/Supervisor.SelfTest.ps1
@@ -106,6 +106,7 @@ function New-CcodSupervisorFake {
$adapters.StartClock={$world.Calls.Add('Clock');[pscustomobject]@{Kind='Clock'}}.GetNewClosure()
$adapters.GetElapsedMilliseconds={param($Clock)if($world.Elapsed.Count){[long]$world.Elapsed.Dequeue()}else{[long]0}}.GetNewClosure()
$adapters.GetUtcNow={[DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime()}.GetNewClosure()
+ $adapters.Delay={param($Milliseconds)}
$adapters.EnterLease={
param($Kind,$UserSid,$SessionId,$TimeoutMilliseconds)
$world.Calls.Add("Enter:$Kind`:$TimeoutMilliseconds")
@@ -811,6 +812,95 @@ Invoke-CcodTest 'successful lifecycle verification binds a fresh special proof b
Assert-CcodEqual $null $hostState.LifecycleRequest 'later guardian decision creates no redundant repair lifecycle'
}
+Invoke-CcodTest 'post-worker rebind retries one transient missing enumeration for the same exact candidate' {
+ $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
+ $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure()
+ $enumerations=[pscustomobject]@{Count=0};$delays=[Collections.Generic.List[int]]::new()
+ $fixture.Fake.Adapters.Delay={param($Milliseconds)$delays.Add([int]$Milliseconds)}.GetNewClosure()
+ $fixture.Fake.Adapters.EnumerateProcessIds={
+ $enumerations.Count++
+ if($enumerations.Count-eq2){Write-Output -NoEnumerate @();return}
+ Write-Output -NoEnumerate @(201)
+ }.GetNewClosure()
+
+ Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters VerifyRemote $null|Out-Null
+ Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters
+
+ Assert-CcodEqual 3 $enumerations.Count 'one transient empty rebind enumeration receives exactly one bounded retry'
+ Assert-CcodEqual '50' (($delays)-join ',') 'same-candidate retry enters one bounded condition-based delay'
+ Assert-CcodTrue ($null-ne$hostState.SpecialProof) 'same exact candidate is rebound after transient enumeration churn'
+ Assert-CcodEqual 201 $hostState.SpecialProof.Pid 'rebound proof retains the original candidate PID'
+ Assert-CcodEqual $special.CreationTimeUtc $hostState.SpecialProof.CreationTimeUtc 'rebound proof retains the original candidate creation time'
+ Assert-CcodEqual 'RemoteVerified' $hostState.LifecycleObservation 'same-candidate retry publishes RemoteVerified'
+}
+
+Invoke-CcodTest 'post-worker rebind does not retry a transient enumeration after candidate creation-time drift' {
+ $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
+ $drift=$special.PSObject.Copy();$drift.CreationTimeUtc='2030-02-03T03:02:01.0000000Z'
+ $world.ProcessIds=@(201);$world.Snapshots[201]=$special
+ $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure()
+ $enumerations=[pscustomobject]@{Count=0};$delays=[Collections.Generic.List[int]]::new()
+ $fixture.Fake.Adapters.Delay={param($Milliseconds)$delays.Add([int]$Milliseconds)}.GetNewClosure()
+ $fixture.Fake.Adapters.EnumerateProcessIds={
+ $enumerations.Count++
+ if($enumerations.Count-eq1){Write-Output -NoEnumerate @(201);return}
+ $world.Snapshots[201]=$drift
+ Write-Output -NoEnumerate @()
+ }.GetNewClosure()
+
+ Start-CcodSupervisorLifecycleWorkerSlot $hostState $fixture.Fake.Adapters VerifyRemote $null|Out-Null
+ Invoke-CcodSupervisorPollLifecycleSlot $hostState $fixture.Fake.Adapters
+
+ Assert-CcodEqual 2 $enumerations.Count 'changed candidate receives no post-drift retry'
+ Assert-CcodEqual 0 $delays.Count 'changed candidate never enters the rebind delay'
+ Assert-CcodEqual 1 $world.CompletedLifecycleRequests.Count 'candidate drift produces one terminal lifecycle receipt'
+ Assert-CcodEqual 'CCOD_REMOTE_PROOF_REBIND_FAILED' $world.CompletedLifecycleRequests[0].error 'candidate drift keeps the stable rebind failure reason'
+ Assert-CcodEqual $null $hostState.SpecialProof 'changed candidate never receives proof authority'
+}
+
+Invoke-CcodTest 'candidate port or mode drift never enters the bounded rebind delay' {
+ foreach($case in @(
+ @{Name='port';Mutate={param($Snapshot)$Snapshot.RendererPort=[int]42001;$Snapshot}},
+ @{Name='mode';Mutate={param($Snapshot)$Snapshot.Mode='Ordinary';$Snapshot.RendererPort=$null;$Snapshot.MainPort=$null;$Snapshot}}
+ )){
+ $fixture=New-CcodTickFixture;$hostState=$fixture.Host
+ $candidate=New-CcodSupervisorTestSnapshot -ProcessId 201 -CreationTimeUtc '2030-02-03T03:02:00.0000000Z'
+ $candidate.Mode='Special';$candidate.RendererPort=[int]41001;$candidate.MainPort=[int]41002
+ $changed=& $case.Mutate $candidate.PSObject.Copy()
+ $delays=[Collections.Generic.List[int]]::new()
+ $fixture.Fake.Adapters.ReadState={param($StateRoot,$SuppressionKey)$hostState.State}.GetNewClosure()
+ $fixture.Fake.Adapters.EnumerateProcessIds={Write-Output -NoEnumerate @()}
+ $fixture.Fake.Adapters.GetProcessSnapshot={param($ProcessId,$StatusEvidence)$changed}.GetNewClosure()
+ $fixture.Fake.Adapters.Delay={param($Milliseconds)$delays.Add([int]$Milliseconds)}.GetNewClosure()
+
+ $confirmed=Confirm-CcodSupervisorLifecycleProofCandidate $hostState $fixture.Fake.Adapters $candidate
+
+ Assert-CcodEqual $false $confirmed "$($case.Name) drift cannot confirm the candidate"
+ Assert-CcodEqual 0 $delays.Count "$($case.Name) drift never enters the retry delay"
+ Assert-CcodEqual $null $hostState.SpecialProof "$($case.Name) drift receives no proof authority"
+ Assert-CcodEqual ('201|'+$candidate.CreationTimeUtc) $hostState.FailedSpecialProofKey "$($case.Name) drift records the original stable failure key"
+ }
+}
+
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
From fe34fdfcd0115b1a2d25d51ff226cced9501aafd Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:10:07 +0800
Subject: [PATCH 016/187] docs: record Task 3 verification
---
.../task-3-report.md | 187 ++++++++++++++++++
1 file changed, 187 insertions(+)
create mode 100644 .superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md
new file mode 100644
index 0000000..c1db9ce
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md
@@ -0,0 +1,187 @@
+# Task 3 report: stable verified Electron process trees
+
+## Outcome and commit
+
+Implemented Task 3 only in the linked worktree
+`C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`.
+
+Implementation commit: `b34b0d1` (`fix: stabilize verified Electron process trees`).
+
+The report is committed separately after this entry so that it can name the
+implementation commit; a commit cannot embed its own final object ID.
+
+## Files changed
+
+- `src/persistence/modules/ProcessControl.psm1`
+- `src/persistence/modules/SessionEngine.psm1`
+- `src/persistence/Supervisor.ps1`
+- `tests/persistence/ProcessControl.SelfTest.ps1`
+- `tests/persistence/SessionEngine.SelfTest.ps1`
+- `tests/persistence/Supervisor.SelfTest.ps1`
+- `.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-3-report.md`
+
+No other source, test, installer, release, WindowsApps, DPAPI, or UI file was
+modified.
+
+## Implementation
+
+`Get-CcodStableVerifiedProcessTree` now wraps the existing one-pass verified
+tree reader with a fixed retry budget. A retry is eligible only after a failed
+or empty verified-tree acquisition. Before the retry it directly rereads the
+original root PID with the same status evidence and applies the existing exact
+`Test-CcodProcessMatch` contract across PID, creation time, session, SID,
+executable path, package family, command line, parent/top-level shape, mode,
+renderer port, and main port. A successful tree must also contain exactly one
+root member that exactly matches the original snapshot. Missing, malformed,
+changed, or differently mapped roots return no tree. The 50 ms adapter delay is
+entered only after the failed-tree condition and exact root reread both hold.
+
+SessionEngine routes every pre-mutation current-package `GetTree` boundary in
+Close, Recover, recovery cleanup, and CloseRequested replay through that stable
+helper. Journal creation and all stop mutations remain after stable acquisition.
+The existing per-member pre-stop, post-stop, and final absence/identity proofs
+were not changed.
+
+Supervisor post-worker proof confirmation now has the same bounded policy. It
+retries one empty ordinary/special enumeration only when a direct read of the
+candidate PID still matches all twelve snapshot fields. It then performs one
+condition-based 50 ms adapter delay and one final refresh. A nonempty mismatch,
+ambiguity, missing or malformed direct read, creation-time drift, debug-port
+drift, or mode drift is not delayed or retried and receives no proof authority.
+The lifecycle result continues to reduce through the stable
+`CCOD_REMOTE_PROOF_REBIND_FAILED` reason, and `FailedSpecialProofKey` remains
+bound to the original candidate PID and creation time.
+
+One pre-existing recovery fixture was made deterministic by fixing its
+preferred renderer port to the exact port in its expected root snapshot. The
+new stable helper correctly exposed that the old fixture supplied a 41001 tree
+root while the launch fixture selected 9335.
+
+## Baseline
+
+Before test or production changes:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1
+exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1
+exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+existing output reached the final production-adapter cases without a failure; the
+initial combined shell capture timed out before its receipt, so the suite was
+rerun independently during RED/GREEN and final verification below.
+```
+
+## RED evidence
+
+Tests were added before production changes.
+
+### RED 1: missing stable process-tree helper
+
+Command:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ProcessControl.SelfTest.ps1
+```
+
+Result: exit `1` at
+`retries one transient verified tree miss while the exact root identity remains stable`:
+
+```text
+The term 'Get-CcodStableVerifiedProcessTree' is not recognized
+```
+
+### RED 2: SessionEngine did not retry transient tree churn
+
+Command:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/SessionEngine.SelfTest.ps1
+```
+
+Result: exit `1` at
+`retries transient Electron tree churn before committing and closes every verified member`:
+
+```text
+expected=[Closed] actual=[Error]
+```
+
+### RED 3: Supervisor had no bounded second rebind
+
+Command:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+```
+
+After correcting the empty-array fixture to emit one array object, result was
+exit `1`:
+
+```text
+one transient empty rebind enumeration receives exactly one bounded retry
+expected=[3] actual=[2]
+```
+
+The count includes one pre-worker candidate observation plus the two required
+post-worker observations.
+
+### RED 4: Supervisor retry had no condition-based delay
+
+After the immediate bounded retry was implemented, a timing boundary test was
+added before adding a Delay adapter. The same Supervisor command exited `1`:
+
+```text
+same-candidate retry enters one bounded condition-based delay
+expected=[50] actual=[]
+```
+
+## GREEN and final verification
+
+Fresh committed-content verification command:
+
+```powershell
+$tests=@(
+ 'tests/persistence/ProcessControl.SelfTest.ps1',
+ 'tests/persistence/SessionEngine.SelfTest.ps1',
+ 'tests/persistence/Supervisor.SelfTest.ps1'
+)
+foreach($test in $tests){
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File $test
+ if($LASTEXITCODE -ne 0){ throw "$test failed" }
+}
+```
+
+Result: exit `0` for all three suites.
+
+- ProcessControl: 40 behavioral cases passed, including transient empty-tree
+ retry and creation-time drift fail-closed coverage.
+- SessionEngine: 58 behavioral cases passed, including retry-before-journal,
+ child-first stop of every captured member, root drift with zero close
+ mutation, ordinary close behavior, and all existing per-member proof cases.
+- Supervisor: 93 behavioral cases passed. The suite printed
+ `Supervisor self-tests passed: 93`, including one exact condition-based
+ 50 ms retry, creation-time drift with zero delay, port/mode drift with zero
+ delay, no proof authority for changed roots, and the stable
+ `CCOD_REMOTE_PROOF_REBIND_FAILED` terminal result.
+
+`git diff --cached --check` exited `0` before the implementation commit.
+
+## Scope, safety, and concerns
+
+- No real Codex, ChatGPT, installer, tray, or UI process was started, stopped,
+ signaled, or controlled. All process behavior used fixtures/adapters.
+- Nothing was installed, pushed, published, released, uploaded, or signed.
+- WindowsApps and DPAPI/device-key boundaries were not changed.
+- Exact root identity was not relaxed. Retry eligibility uses the same full
+ snapshot matcher, including mode and both debug ports.
+- The only delay is fixed at 50 ms and is adapter-driven. It is called only
+ after an empty/failed acquisition plus an exact candidate-root reread; all
+ mismatch tests assert zero delay.
+- The repository-wide suite was not run because other persistence integration
+ paths may exercise real process-stop boundaries, which conflicts with this
+ task's explicit fixture-only authority. The three brief-named focused suites
+ are green.
+- No independent agent review was run because the task explicitly prohibited
+ spawning agents. Parent integration review remains the next external gate.
From b312174a4807463a3b068f6080399cbad297de2f Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:25:31 +0800
Subject: [PATCH 017/187] test: cover tray action revision correlation
---
src/persistence/Supervisor.ps1 | 17 ++++++
tests/persistence/Supervisor.SelfTest.ps1 | 61 +++++++++++++++++++
tests/persistence/TrayHostClient.SelfTest.ps1 | 26 +++++++-
tests/trayhost/TrayHostTransportSelfTest.cs | 4 +-
4 files changed, 105 insertions(+), 3 deletions(-)
diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1
index 9742335..7bab1a3 100644
--- a/src/persistence/Supervisor.ps1
+++ b/src/persistence/Supervisor.ps1
@@ -1154,9 +1154,26 @@ function Throw-CcodSupervisorCommandError {
throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new($Message),$Code,[Management.Automation.ErrorCategory]::InvalidData,$Target)
}
+function Write-CcodSupervisorTrayActionTerminal {
+ param($HostState,[hashtable]$Adapters,$Action,[ValidateSet('Completed','Rejected','Failed')][string]$Status,[AllowNull()][string]$ErrorCode)
+ $code=if($Status-ceq'Completed'){'CCOD_TRAY_ACTION_COMPLETED'}elseif($ErrorCode-is[string]-and$ErrorCode-cmatch'^CCOD_[A-Z0-9_]{1,91}\z'){$ErrorCode}else{'CCOD_TRAY_ACTION_FAILED'}
+ try{
+ $now=Invoke-CcodSupervisorAdapter $Adapters.GetUtcNow @() 1
+ if($now-is[DateTimeOffset]){$timestamp=$now.UtcDateTime.ToString('o',[Globalization.CultureInfo]::InvariantCulture)}
+ elseif($now-is[DateTime]){$timestamp=$now.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture)}
+ else{throw 'tray action clock is invalid'}
+ $record=[pscustomobject][ordered]@{
+ schemaVersion=1;timestampUtc=$timestamp;component='Supervisor';stage='TrayAction';code=$code;outcome=$Status
+ command=[string]$Action.Command;revision=[UInt64]$Action.Revision;status=$Status
+ }
+ Invoke-CcodSupervisorAdapter $Adapters.WriteLog @($record) 0
+ }catch{Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_LOG_FAILED'}
+}
+
function Send-CcodSupervisorTrayActionResult {
param($HostState,[hashtable]$Adapters,$Action,[ValidateSet('Accepted','Completed','Rejected','Failed')][string]$Status,[AllowNull()][string]$ErrorCode,[AllowNull()][string]$TransactionId)
$result=[pscustomobject][ordered]@{ActionId=$Action.ActionId;Revision=[UInt64]$Action.Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId}
+ if($Status-cne'Accepted'){Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode}
try{
$delivered=Invoke-CcodSupervisorAdapter $Adapters.SendTrayActionResult @($HostState.Tray,$result.ActionId,$result.Revision,$result.Status,$result.ErrorCode,$result.TransactionId) 1
if($delivered-isnot[bool]-or-not$delivered){throw 'tray action result was not acknowledged'}
diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1
index afe968f..5686621 100644
--- a/tests/persistence/Supervisor.SelfTest.ps1
+++ b/tests/persistence/Supervisor.SelfTest.ps1
@@ -1566,6 +1566,67 @@ Invoke-CcodTest 'authorizes an acknowledged displayed revision after a newer cap
Assert-CcodEqual 'Accepted' $result.Status 'the displayed and acknowledged revision remains action authority after a newer capable projection'
}
+Invoke-CcodTest 'records an acknowledged OpenLogs terminal outcome after its handler and before result delivery' {
+ # Production mutation caught: omitting the sanitized terminal record, logging before the handler, or bypassing the revision-bound OpenLogs handler.
+ $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host
+ $hostState.Tray.CurrentRevision=[UInt64]7
+ $enabled=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true}
+ $hostState.Tray.AcknowledgedPresentations['7']=$enabled
+ $hostState.LastAcknowledgedPresentation=$enabled
+ $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee71';Command='OpenLogs';Revision=[UInt64]7}
+
+ $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0]
+
+ Assert-CcodEqual 'Completed' $result.Status 'acknowledged revision reaches the OpenLogs handler'
+ Assert-CcodEqual 1 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'current acknowledged action invokes OpenLogs exactly once'
+ Assert-CcodEqual 1 $world.UiFailureRecords.Count 'terminal action writes exactly one local diagnostic record'
+ $record=$world.UiFailureRecords[0]
+ Assert-CcodEqual 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' (@($record.PSObject.Properties.Name)-join ',') 'terminal diagnostic record is an exact sanitized schema'
+ Assert-CcodEqual 'Supervisor' $record.component 'terminal diagnostic identifies the authorizing component'
+ Assert-CcodEqual 'TrayAction' $record.stage 'terminal diagnostic uses the tray action stage'
+ Assert-CcodEqual 'CCOD_TRAY_ACTION_COMPLETED' $record.code 'successful terminal diagnostic uses a safe canonical code'
+ Assert-CcodEqual 'Completed' $record.outcome 'terminal diagnostic outcome is completed'
+ Assert-CcodEqual 'OpenLogs' $record.command 'terminal diagnostic preserves the exact command'
+ Assert-CcodEqual ([UInt64]7) $record.revision 'terminal diagnostic preserves the displayed presentation revision'
+ Assert-CcodEqual 'Completed' $record.status 'terminal diagnostic preserves the exact terminal status'
+ $calls=@($world.Calls)
+ Assert-CcodTrue ([Array]::IndexOf($calls,'Open:Logs') -lt [Array]::IndexOf($calls,'Log:CCOD_TRAY_ACTION_COMPLETED')) 'handler side effect precedes terminal logging'
+ Assert-CcodTrue ([Array]::IndexOf($calls,'Log:CCOD_TRAY_ACTION_COMPLETED') -lt [Array]::IndexOf($calls,'ActionResult:Completed')) 'terminal logging precedes host result delivery'
+}
+
+Invoke-CcodTest 'rejects and records an unacknowledged OpenLogs revision before any handler side effect' {
+ # Production mutation caught: authorizing an unacknowledged revision, invoking OpenLogs before the gate, or losing the stable stale diagnostic.
+ $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host
+ $hostState.Tray.CurrentRevision=[UInt64]8
+ $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee81';Command='OpenLogs';Revision=[UInt64]8}
+
+ $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0]
+
+ Assert-CcodEqual 'Rejected' $result.Status 'unacknowledged revision is rejected'
+ Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'unacknowledged revision returns the stable stale code'
+ Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'stale revision is rejected before the OpenLogs handler'
+ Assert-CcodEqual 1 $world.UiFailureRecords.Count 'stale action writes exactly one local diagnostic record'
+ $record=$world.UiFailureRecords[0]
+ Assert-CcodEqual 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' (@($record.PSObject.Properties.Name)-join ',') 'stale diagnostic record is an exact sanitized schema'
+ Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $record.code 'stale diagnostic preserves the exact canonical code'
+ Assert-CcodEqual 'OpenLogs' $record.command 'stale diagnostic preserves the rejected command'
+ Assert-CcodEqual ([UInt64]8) $record.revision 'stale diagnostic preserves the unacknowledged revision'
+ Assert-CcodEqual 'Rejected' $record.status 'stale diagnostic preserves the rejected terminal status'
+ $calls=@($world.Calls)
+ Assert-CcodTrue ([Array]::IndexOf($calls,'Log:CCOD_TRAY_ACTION_STALE') -lt [Array]::IndexOf($calls,'ActionResult:Rejected')) 'stale diagnostic is written before generic failure feedback can be queued'
+}
+
+Invoke-CcodTest 'sanitizes a malformed terminal action code before local logging' {
+ # Production mutation caught: allowing a newline or other noncanonical text into the local terminal diagnostic record.
+ $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host
+ $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeee91';Command='OpenLogs';Revision=[UInt64]1}
+
+ [void](Send-CcodSupervisorTrayActionResult $hostState $fixture.Fake.Adapters $action Rejected "CCOD_INJECTED`n" $null)
+
+ Assert-CcodEqual 1 $world.UiFailureRecords.Count 'malformed terminal code still produces one safe diagnostic'
+ Assert-CcodEqual 'CCOD_TRAY_ACTION_FAILED' $world.UiFailureRecords[0].code 'malformed terminal code is replaced by the canonical fallback'
+}
+
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
diff --git a/tests/persistence/TrayHostClient.SelfTest.ps1 b/tests/persistence/TrayHostClient.SelfTest.ps1
index c835400..37f650c 100644
--- a/tests/persistence/TrayHostClient.SelfTest.ps1
+++ b/tests/persistence/TrayHostClient.SelfTest.ps1
@@ -51,7 +51,13 @@ Invoke-CcodTest 'TrayHost starts fail-closed until the Supervisor publishes an e
}
Invoke-CcodTest 'TrayHost reuses an identical presentation revision and records only an exact acknowledgement' {
- if($null-eq('PresentationSnapshot' -as [type])){Add-Type -Path (Join-Path $repositoryRoot 'src\trayhost\PresentationSnapshot.cs')}
+ if($null-eq('TrayActionResult' -as [type])){
+ Add-Type -Path @(
+ (Join-Path $repositoryRoot 'src\trayhost\PresentationSnapshot.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\PipeProtocol.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\TransportMessages.cs')
+ )
+ }
Import-Module $localizationPath -Force
$catalog=Get-CcodUiCatalog -ResourcesRoot (Join-Path $repositoryRoot 'src\persistence\resources') -LanguageMode en-US -SystemCultureName en-US
$presentation=[pscustomobject][ordered]@{Color='Green';ConnectionState='Connected';ProtectionState='Running';RepairEnabled=$false;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true;Busy=$false}
@@ -80,4 +86,22 @@ Invoke-CcodTest 'TrayHost reuses an identical presentation revision and records
Assert-CcodTrue (-not$context.AcknowledgedPresentations.Contains('1')) 'an unrecorded revision is never inferred from a higher acknowledgement'
}
+Invoke-CcodTest 'TrayHost client delivers the exact correlated terminal result to the authenticated parent client' {
+ # Production mutation caught: losing the action id, revision, status, or stable terminal code at the PowerShell-to-TrayHost boundary.
+ $received=[Collections.Generic.List[object]]::new()
+ $client=[pscustomobject]@{Received=$received}
+ $client|Add-Member -MemberType ScriptMethod -Name TryAcknowledgeAction -Value {param($Result)$this.Received.Add($Result);return $true}
+ $context=[pscustomobject]@{Client=$client}
+ $actionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeea1'
+
+ $delivered=Send-CcodTrayHostActionResult -Context $context -ActionId $actionId -Revision ([UInt64]8) -Status Rejected -ErrorCode 'CCOD_TRAY_ACTION_STALE' -TransactionId $null
+
+ Assert-CcodEqual $true $delivered 'terminal result is accepted by the authenticated parent client'
+ Assert-CcodEqual 1 $received.Count 'terminal result is delivered exactly once'
+ Assert-CcodEqual $actionId $received[0].ActionId 'terminal result preserves the action id'
+ Assert-CcodEqual ([UInt64]8) $received[0].Revision 'terminal result preserves the presentation revision'
+ Assert-CcodEqual 'Rejected' $received[0].Status.ToString() 'terminal result preserves the rejected status'
+ Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $received[0].ErrorCode 'terminal result preserves the stable stale code'
+}
+
Write-Host 'TrayHost client self-tests passed.'
diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs
index 63d0c7d..928289c 100644
--- a/tests/trayhost/TrayHostTransportSelfTest.cs
+++ b/tests/trayhost/TrayHostTransportSelfTest.cs
@@ -142,8 +142,8 @@ private static void TestRejectedAndFailedActionsQueueUserFeedback()
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 first) && first.ActionId == rejectedId && first.Revision == 20UL && first.Status == TrayActionResultStatus.Rejected && String.Equals(first.ErrorCode, "CCOD_TRAY_ACTION_UNAVAILABLE", StringComparison.Ordinal), "rejected result preserves its exact correlated terminal record before generic feedback");
+ AssertTrue(host.TryTakeFailedAction(out second) && second.ActionId == failedId && second.Revision == 20UL && second.Status == TrayActionResultStatus.Failed && String.Equals(second.ErrorCode, "CCOD_TRAY_ACTION_FAILED", StringComparison.Ordinal), "failed result preserves its exact correlated terminal record before generic feedback");
AssertTrue(!host.TryTakeFailedAction(out none), "each terminal failure queues feedback exactly once");
host.Dispose();
}
From 6554d289a8e155acaf5520ba7f8b0ec6275b8083 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:26:46 +0800
Subject: [PATCH 018/187] docs: record Task 4 verification
---
.../task-4-report.md | 179 ++++++++++++++++++
1 file changed, 179 insertions(+)
create mode 100644 .superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
new file mode 100644
index 0000000..672e452
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
@@ -0,0 +1,179 @@
+# Task 4 report: tray action revision correlation and diagnostics
+
+## Outcome and commit
+
+Implemented Task 4 only in the linked worktree
+`C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`.
+
+Implementation commit: `b312174` (`test: cover tray action revision correlation`).
+
+The report is committed separately after this entry so that it can name the
+implementation commit; a commit cannot embed its own final object ID.
+
+## Files changed
+
+- `src/persistence/Supervisor.ps1`
+- `tests/persistence/Supervisor.SelfTest.ps1`
+- `tests/persistence/TrayHostClient.SelfTest.ps1`
+- `tests/trayhost/TrayHostTransportSelfTest.cs`
+- `.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md`
+
+No protocol, installer, release, lifecycle worker, Codex, WindowsApps, DPAPI,
+or user-facing localization file was changed. The planned production files
+`src/persistence/modules/TrayHostClient.psm1`, `src/trayhost/HostTransport.cs`,
+and `src/trayhost/TrayWindow.cs` required no production change: the existing
+authenticated result boundary already preserved action id, revision, terminal
+status, and canonical error code, and the existing TrayWindow already reduced
+terminal failures to the approved generic localized dialog. Their behavior is
+now covered more precisely by the wrapper, transport, and native tests.
+
+## Root cause and implementation
+
+The native menu action already reached TrayHost, the PowerShell wrapper already
+queued the exact command and presentation revision, and Supervisor already
+authorized actions against the exact acknowledged-presentation map. The missing
+boundary was in `Send-CcodSupervisorTrayActionResult`: it forwarded a terminal
+result to TrayHost without first writing a support-safe record. Once TrayHost
+accepted a rejected or failed result, it queued only the generic failure UI, so
+the end user saw the intended generic wording but the local log had no command,
+revision, terminal status, or stable code with which to diagnose the failure.
+
+`Write-CcodSupervisorTrayActionTerminal` now writes a fixed schema before the
+terminal result is sent to TrayHost:
+
+- `schemaVersion`
+- `timestampUtc`
+- `component`
+- `stage`
+- `code`
+- `outcome`
+- `command`
+- `revision`
+- `status`
+
+The record deliberately excludes action ids, transaction ids, filesystem
+paths, tokens, exception messages, and arbitrary result text. `Completed` uses
+the safe success code `CCOD_TRAY_ACTION_COMPLETED`. `Rejected` and `Failed`
+preserve an exact canonical `CCOD_*` code; malformed codes fall back to
+`CCOD_TRAY_ACTION_FAILED`. Canonical validation uses the absolute `\z` regex
+terminator so a trailing newline cannot be accepted as part of a log code.
+
+`Accepted` is not terminal and is not logged by this helper. Lifecycle actions
+therefore receive one terminal record only when their later `Completed` or
+`Failed` result is produced. Existing result delivery, transaction correlation,
+authenticated pipe framing, and generic dialog wording are unchanged.
+
+## TDD and RED evidence
+
+Tests were written before the production implementation.
+
+### RED 1: terminal diagnostic record was absent
+
+Command:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+```
+
+Result: exit `1` after all earlier cases passed. The acknowledged revision 7
+test reached the real `OpenLogs` adapter, but no local terminal record existed:
+
+```text
+ASSERT_EQUAL_FAILED: terminal action writes exactly one local diagnostic record expected=[1] actual=[0]
+```
+
+This RED established that the failure was the missing diagnostic boundary, not
+an invalid test fixture, PowerShell parse error, or broken command handler.
+
+### RED 2: end-of-line regex semantics allowed an unsafe code
+
+After the initial minimal logger was green, a sanitation test was added before
+tightening the canonical-code matcher. PowerShell's `$` anchor accepted a
+terminal line-feed boundary, and the test exited `1`:
+
+```text
+ASSERT_EQUAL_FAILED: malformed terminal code is replaced by the canonical fallback
+expected=[CCOD_TRAY_ACTION_FAILED] actual=[CCOD_INJECTED\n]
+```
+
+The production matcher was then changed from `$` to `\z`; the test proves the
+newline-bearing value cannot enter the local diagnostic record.
+
+### Focused baseline outside the missing logger
+
+Before the production change, these two brief-named boundaries were already
+green:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostClient.SelfTest.ps1
+exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+exit 0; TrayHost transport self-tests passed: 8
+```
+
+They showed that the authenticated wrapper and host-side action-result queues
+were not the missing production behavior. Their assertions were then extended
+to prove the exact stale result reaches the parent-client boundary once and
+that HostTransport retains the exact revision, terminal status, and canonical
+code before the generic UI consumes the failure.
+
+## GREEN and final verification
+
+Fresh pre-commit verification ran the three brief-named focused suites, the
+native TrayHost suite, and the whitespace gate:
+
+```powershell
+$tests=@(
+ @{Name='TrayHost client';Command=@('tests\persistence\TrayHostClient.SelfTest.ps1')},
+ @{Name='Supervisor';Command=@('tests\persistence\Supervisor.SelfTest.ps1')},
+ @{Name='TrayHost transport';Command=@('tests\trayhost\Invoke-TrayHostSelfTest.ps1','-TransportOnly')},
+ @{Name='TrayHost native';Command=@('tests\trayhost\Invoke-TrayHostSelfTest.ps1','-NativeOnly')}
+)
+foreach($test in $tests){
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File @($test.Command)
+ if($LASTEXITCODE -ne 0){throw "$($test.Name) failed"}
+}
+git diff --check
+```
+
+Result: exit `0` throughout.
+
+- TrayHost client: 8 behavioral cases passed. The only output warning is the
+ pre-existing PowerShell warning about unapproved imported verb names.
+- Supervisor: 96 behavioral cases passed.
+- TrayHost transport: 8 behavioral cases passed.
+- TrayHost native: 15 behavioral cases passed.
+- `git diff --check`: exit `0`.
+- `git diff --cached --check`: exit `0` immediately before commit `b312174`.
+
+The new Supervisor cases prove the required ordering and side-effect boundary:
+
+1. revision 7 is present in `AcknowledgedPresentations`;
+2. `OpenLogs` is invoked exactly once;
+3. `CCOD_TRAY_ACTION_COMPLETED` is logged with command `OpenLogs`, revision 7,
+ and status `Completed`;
+4. the log write occurs after the handler and before `ActionResult:Completed`;
+5. revision 8 has no acknowledged map entry;
+6. the stale action invokes `OpenLogs` zero times;
+7. `CCOD_TRAY_ACTION_STALE` is logged before `ActionResult:Rejected`.
+
+The native suite retains the existing assertion that failure feedback uses only
+the acknowledged snapshot's localized generic title and `Action failed` text.
+No internal code is added to the dialog.
+
+## Scope, safety, and concerns
+
+- No real Codex or ChatGPT process was started, stopped, signaled, or controlled.
+- No production TrayHost process, installer, release build, or interactive
+ product UI was launched. The explicitly required native self-test exercised
+ its test-owned Win32 owner/fake platform and P/Invoke surface only.
+- Nothing was installed, pushed, published, released, uploaded, or signed.
+- WindowsApps, application binaries, and DPAPI/device-key data were untouched.
+- The authenticated pipe protocol, action-result wire schema, capability gate,
+ and revision ACK map were not weakened or changed.
+- The repository-wide suite was not run because this task authorized the three
+ focused suites plus native TrayHost verification; broader persistence tests
+ may cross real process-control boundaries. All task-required suites are green.
+- No independent agent review was run because the task explicitly prohibited
+ spawning agents. Parent integration review remains the next external gate.
From 94ca92fcc65a6e47616f8da82be2e40e7652380d Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:38:01 +0800
Subject: [PATCH 019/187] fix: gate tray feedback on durable diagnostics
---
src/persistence/Supervisor.ps1 | 5 +-
tests/persistence/Supervisor.SelfTest.ps1 | 89 ++++++++++++++++-
.../trayhost/TrayHostCorrelationTestBridge.cs | 99 +++++++++++++++++++
3 files changed, 189 insertions(+), 4 deletions(-)
create mode 100644 tests/trayhost/TrayHostCorrelationTestBridge.cs
diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1
index 7bab1a3..96054fa 100644
--- a/src/persistence/Supervisor.ps1
+++ b/src/persistence/Supervisor.ps1
@@ -1167,13 +1167,14 @@ function Write-CcodSupervisorTrayActionTerminal {
command=[string]$Action.Command;revision=[UInt64]$Action.Revision;status=$Status
}
Invoke-CcodSupervisorAdapter $Adapters.WriteLog @($record) 0
- }catch{Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_LOG_FAILED'}
+ return $true
+ }catch{Add-CcodSupervisorCleanupCode $HostState.RuntimeCleanupCodes 'CCOD_SUPERVISOR_LOG_FAILED';return $false}
}
function Send-CcodSupervisorTrayActionResult {
param($HostState,[hashtable]$Adapters,$Action,[ValidateSet('Accepted','Completed','Rejected','Failed')][string]$Status,[AllowNull()][string]$ErrorCode,[AllowNull()][string]$TransactionId)
$result=[pscustomobject][ordered]@{ActionId=$Action.ActionId;Revision=[UInt64]$Action.Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId}
- if($Status-cne'Accepted'){Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode}
+ if($Status-cne'Accepted'-and-not(Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode)){$result|Add-Member -NotePropertyName Delivered -NotePropertyValue $false;return $result}
try{
$delivered=Invoke-CcodSupervisorAdapter $Adapters.SendTrayActionResult @($HostState.Tray,$result.ActionId,$result.Revision,$result.Status,$result.ErrorCode,$result.TransactionId) 1
if($delivered-isnot[bool]-or-not$delivered){throw 'tray action result was not acknowledged'}
diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1
index 5686621..e2e8a0e 100644
--- a/tests/persistence/Supervisor.SelfTest.ps1
+++ b/tests/persistence/Supervisor.SelfTest.ps1
@@ -812,6 +812,22 @@ Invoke-CcodTest 'successful lifecycle verification binds a fresh special proof b
Assert-CcodEqual $null $hostState.LifecycleRequest 'later guardian decision creates no redundant repair lifecycle'
}
+function Import-CcodTrayHostCorrelationTestBridge {
+ if($null-ne('TrayHostCorrelationTestBridge' -as[type])){return}
+ $trayRoot=Join-Path $repositoryRoot 'src\trayhost'
+ Add-Type -Path @(
+ (Join-Path $trayRoot 'PresentationSnapshot.cs'),
+ (Join-Path $trayRoot 'PipeProtocol.cs'),
+ (Join-Path $trayRoot 'TransportMessages.cs'),
+ (Join-Path $trayRoot 'NativeMethods.cs'),
+ (Join-Path $trayRoot 'InputModeGuard.cs'),
+ (Join-Path $trayRoot 'NativeMenu.cs'),
+ (Join-Path $trayRoot 'TrayWindow.cs'),
+ (Join-Path $trayRoot 'HostTransport.cs'),
+ (Join-Path $repositoryRoot 'tests\trayhost\TrayHostCorrelationTestBridge.cs')
+ )
+}
+
Invoke-CcodTest 'post-worker rebind retries one transient missing enumeration for the same exact candidate' {
$fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host
$transactionModule=Import-Module $lifecycleTransactionPath -Force -PassThru
@@ -1581,7 +1597,8 @@ Invoke-CcodTest 'records an acknowledged OpenLogs terminal outcome after its han
Assert-CcodEqual 1 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'current acknowledged action invokes OpenLogs exactly once'
Assert-CcodEqual 1 $world.UiFailureRecords.Count 'terminal action writes exactly one local diagnostic record'
$record=$world.UiFailureRecords[0]
- Assert-CcodEqual 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' (@($record.PSObject.Properties.Name)-join ',') 'terminal diagnostic record is an exact sanitized schema'
+ foreach($field in @('command','revision','status','code')){Assert-CcodTrue ($null-ne$record.PSObject.Properties[$field]) "terminal diagnostic includes required field $field"}
+ foreach($field in @('ActionId','TransactionId','path','token','exception','message','raw')){Assert-CcodTrue ($null-eq$record.PSObject.Properties[$field]) "terminal diagnostic excludes unsafe field $field"}
Assert-CcodEqual 'Supervisor' $record.component 'terminal diagnostic identifies the authorizing component'
Assert-CcodEqual 'TrayAction' $record.stage 'terminal diagnostic uses the tray action stage'
Assert-CcodEqual 'CCOD_TRAY_ACTION_COMPLETED' $record.code 'successful terminal diagnostic uses a safe canonical code'
@@ -1607,7 +1624,8 @@ Invoke-CcodTest 'rejects and records an unacknowledged OpenLogs revision before
Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'stale revision is rejected before the OpenLogs handler'
Assert-CcodEqual 1 $world.UiFailureRecords.Count 'stale action writes exactly one local diagnostic record'
$record=$world.UiFailureRecords[0]
- Assert-CcodEqual 'schemaVersion,timestampUtc,component,stage,code,outcome,command,revision,status' (@($record.PSObject.Properties.Name)-join ',') 'stale diagnostic record is an exact sanitized schema'
+ foreach($field in @('command','revision','status','code')){Assert-CcodTrue ($null-ne$record.PSObject.Properties[$field]) "stale diagnostic includes required field $field"}
+ foreach($field in @('ActionId','TransactionId','path','token','exception','message','raw')){Assert-CcodTrue ($null-eq$record.PSObject.Properties[$field]) "stale diagnostic excludes unsafe field $field"}
Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $record.code 'stale diagnostic preserves the exact canonical code'
Assert-CcodEqual 'OpenLogs' $record.command 'stale diagnostic preserves the rejected command'
Assert-CcodEqual ([UInt64]8) $record.revision 'stale diagnostic preserves the unacknowledged revision'
@@ -1627,6 +1645,73 @@ Invoke-CcodTest 'sanitizes a malformed terminal action code before local logging
Assert-CcodEqual 'CCOD_TRAY_ACTION_FAILED' $world.UiFailureRecords[0].code 'malformed terminal code is replaced by the canonical fallback'
}
+Invoke-CcodTest 'does not queue generic terminal feedback when its local diagnostic write fails' {
+ # Production mutation caught: delivering a rejected terminal result to TrayHost after the required local diagnostic failed to persist.
+ $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host;$world.FailAt='WriteLog'
+ $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeb1';Command='OpenLogs';Revision=[UInt64]2}
+
+ $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0]
+
+ Assert-CcodEqual 'Rejected' $result.Status 'stale action retains its supervisor terminal status'
+ Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'stale action retains its stable code'
+ Assert-CcodEqual $false $result.Delivered 'failed diagnostic persistence prevents terminal feedback delivery'
+ Assert-CcodEqual 0 $world.TrayActionResults.Count 'TrayHost receives no terminal result without a durable local record'
+ Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'stale action still performs no handler side effect'
+ Assert-CcodTrue ($hostState.RuntimeCleanupCodes.Contains('CCOD_SUPERVISOR_LOG_FAILED')) 'failed diagnostic persistence remains visible in the supervisor cleanup receipt'
+}
+
+Invoke-CcodTest 'correlates native menu actions through HostTransport Supervisor ACK authorization and result ACK' {
+ # Production mutation caught: disconnecting native revision selection, Supervisor acknowledgement authority, or the exact HostTransport terminal ACK.
+ Import-CcodTrayHostCorrelationTestBridge
+ $enabled=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true}
+
+ $current=[TrayHostCorrelationTestBridge]::new([UInt64]7,[TrayCommand]::OpenLogs)
+ try{
+ $current.SelectFromNativeMenu()
+ $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host
+ $hostState.Tray.CurrentRevision=[UInt64]7;$hostState.Tray.AcknowledgedPresentations['7']=$enabled;$hostState.LastAcknowledgedPresentation=$enabled
+ $world|Add-Member -NotePropertyName Bridge -NotePropertyValue $current
+ $fixture.Fake.Adapters.SendTrayActionResult={
+ param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId)
+ $world.Calls.Add("ActionResult:$Status")
+ $world.TrayActionResults.Add([pscustomobject][ordered]@{ActionId=$ActionId;Revision=[UInt64]$Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId})
+ return $world.Bridge.Acknowledge($ActionId,[UInt64]$Revision,$Status,$ErrorCode)
+ }.GetNewClosure()
+ $action=[pscustomobject][ordered]@{ActionId=$current.ActionId;Command=$current.Command;Revision=[UInt64]$current.Revision}
+
+ $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0]
+
+ Assert-CcodEqual 'Completed' $result.Status 'current displayed revision reaches its specific Supervisor handler'
+ Assert-CcodEqual 1 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'native current action invokes OpenLogs exactly once'
+ Assert-CcodEqual 'Completed' $current.TerminalStatus 'real HostTransport acknowledges the completed terminal result'
+ Assert-CcodEqual ([UInt64]7) $current.TerminalRevision 'real HostTransport terminal result retains the displayed revision'
+ }finally{$current.Dispose()}
+
+ $stale=[TrayHostCorrelationTestBridge]::new([UInt64]8,[TrayCommand]::OpenLogs)
+ try{
+ $stale.SelectFromNativeMenu()
+ $fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host
+ $hostState.Tray.CurrentRevision=[UInt64]8
+ $world|Add-Member -NotePropertyName Bridge -NotePropertyValue $stale
+ $fixture.Fake.Adapters.SendTrayActionResult={
+ param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId)
+ $world.Calls.Add("ActionResult:$Status")
+ $world.TrayActionResults.Add([pscustomobject][ordered]@{ActionId=$ActionId;Revision=[UInt64]$Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId})
+ return $world.Bridge.Acknowledge($ActionId,[UInt64]$Revision,$Status,$ErrorCode)
+ }.GetNewClosure()
+ $action=[pscustomobject][ordered]@{ActionId=$stale.ActionId;Command=$stale.Command;Revision=[UInt64]$stale.Revision}
+
+ $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0]
+
+ Assert-CcodEqual 'Rejected' $result.Status 'unacknowledged native revision is rejected'
+ Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'unacknowledged native revision preserves the stale code'
+ Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'unacknowledged native action cannot invoke OpenLogs'
+ Assert-CcodEqual 'Rejected' $stale.TerminalStatus 'real HostTransport acknowledges the stale terminal rejection'
+ Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $stale.TerminalCode 'real HostTransport retains the exact stale code before generic feedback'
+ Assert-CcodEqual ([UInt64]8) $stale.TerminalRevision 'real HostTransport stale result retains the displayed revision'
+ }finally{$stale.Dispose()}
+}
+
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
diff --git a/tests/trayhost/TrayHostCorrelationTestBridge.cs b/tests/trayhost/TrayHostCorrelationTestBridge.cs
new file mode 100644
index 0000000..22d13f1
--- /dev/null
+++ b/tests/trayhost/TrayHostCorrelationTestBridge.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+
+public sealed class TrayHostCorrelationTestBridge : IDisposable
+{
+ private sealed class Platform : INativeTrayPlatform
+ {
+ internal uint Selection;
+ public IntPtr CreateOwner() { return new IntPtr(10); }
+ public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); }
+ public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; }
+ public bool ReleaseInputContext(IntPtr owner, IntPtr context) { return true; }
+ public IntPtr LoadIcon() { return new IntPtr(40); }
+ public bool DestroyIcon(IntPtr icon) { return true; }
+ public bool AddIcon(ref TrayIconData icon) { return true; }
+ public bool SetIconVersion(ref TrayIconData icon) { return true; }
+ public bool DeleteIcon(ref TrayIconData icon) { return true; }
+ public IntPtr CreatePopupMenu() { return new IntPtr(30); }
+ public IntPtr CreateSubMenu() { return new IntPtr(31); }
+ public bool AppendMenu(IntPtr menu, uint flags, UIntPtr command, string text) { return true; }
+ public bool AppendSubMenu(IntPtr menu, IntPtr child, string text) { return true; }
+ public bool ShowOwner(IntPtr owner) { return true; }
+ public bool HideOwner(IntPtr owner) { return true; }
+ public bool SetForegroundWindow(IntPtr owner) { return true; }
+ public IntPtr GetForegroundWindow() { return new IntPtr(10); }
+ public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { return Selection; }
+ public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { return true; }
+ public bool SetNotificationFocus(ref TrayIconData icon) { return true; }
+ public bool ShowMessageBox(IntPtr owner, string text, string caption) { return true; }
+ public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; }
+ public bool DestroyMenu(IntPtr menu) { return true; }
+ public bool EndMenu() { return true; }
+ public bool DestroyOwner(IntPtr owner) { return true; }
+ }
+
+ private readonly Platform _platform;
+ private readonly HostTransport _transport;
+ private readonly TrayWindow _window;
+ private TrayHostAction _selected;
+ private TrayActionResult _terminal;
+
+ public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command)
+ {
+ _platform = new Platform { Selection = (uint)command };
+ _transport = new HostTransport();
+ _window = new TrayWindow(_platform, _transport.SetMenuOpen);
+ _window.CommandSelected += delegate(TrayCommand selected, ulong displayedRevision)
+ {
+ TrayHostAction action = new TrayHostAction(Guid.NewGuid(), selected, displayedRevision);
+ if (!_transport.TryRegisterAction(action)) { throw new InvalidOperationException("test action registration failed"); }
+ _selected = action;
+ };
+ _window.Create(Snapshot(revision));
+ }
+
+ public void SelectFromNativeMenu()
+ {
+ _window.HandleContextMenu(new TrayPoint(5, 5));
+ if (_selected == null) { throw new InvalidOperationException("native menu produced no action"); }
+ }
+
+ public Guid ActionId { get { return _selected == null ? Guid.Empty : _selected.ActionId; } }
+ public string Command { get { return _selected == null ? String.Empty : _selected.Command.ToString(); } }
+ public ulong Revision { get { return _selected == null ? 0UL : _selected.Revision; } }
+
+ public bool Acknowledge(Guid actionId, ulong revision, string status, string errorCode)
+ {
+ TrayActionResultStatus parsed = (TrayActionResultStatus)Enum.Parse(typeof(TrayActionResultStatus), status, false);
+ TrayActionResult result = new TrayActionResult(actionId, revision, parsed, errorCode, null);
+ bool accepted = _transport.TryAcknowledgeAction(result);
+ if (!accepted) { return false; }
+ if (parsed == TrayActionResultStatus.Rejected || parsed == TrayActionResultStatus.Failed)
+ {
+ TrayActionResult queued;
+ if (!_transport.TryTakeFailedAction(out queued)) { return false; }
+ _terminal = queued;
+ }
+ else { _terminal = result; }
+ return true;
+ }
+
+ public string TerminalStatus { get { return _terminal == null ? String.Empty : _terminal.Status.ToString(); } }
+ public string TerminalCode { get { return _terminal == null ? String.Empty : (_terminal.ErrorCode ?? String.Empty); } }
+ public ulong TerminalRevision { get { return _terminal == null ? 0UL : _terminal.Revision; } }
+
+ private static PresentationSnapshot Snapshot(ulong revision)
+ {
+ string[] strings = new string[16];
+ for (int index = 0; index < strings.Length; index++) { strings[index] = "correlation-" + index.ToString(); }
+ return new PresentationSnapshot(revision, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.English,
+ PresentationFlags.OpenLogsEnabled | PresentationFlags.LanguageEnabled | PresentationFlags.AboutEnabled, strings);
+ }
+
+ public void Dispose()
+ {
+ _window.Dispose();
+ _transport.Dispose();
+ }
+}
From 1c8a6454e013828f8a4e92a495711cf88edd4686 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:38:38 +0800
Subject: [PATCH 020/187] docs: record Task 4 fix round 1
---
.../task-4-report.md | 131 ++++++++++++++++++
1 file changed, 131 insertions(+)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
index 672e452..3969c76 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
@@ -177,3 +177,134 @@ No internal code is added to the dialog.
may cross real process-control boundaries. All task-required suites are green.
- No independent agent review was run because the task explicitly prohibited
spawning agents. Parent integration review remains the next external gate.
+
+## Fix round 1: durable diagnostic gate and real correlation chain
+
+Independent review identified that implementation commit `b312174` attempted
+the terminal log before delivery but swallowed `WriteLog` failure and still sent
+the terminal `ActionResult`. That allowed HostTransport to queue the generic
+failure dialog without any durable local terminal record. Review also found
+that the original wrapper, Supervisor, transport, and native assertions were
+separate tests rather than one test traversing their real correlation chain.
+
+Fix implementation commit: `94ca92f`
+(`fix: gate tray feedback on durable diagnostics`).
+
+### RED evidence
+
+The `WriteLog` failure test was added before changing production code:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+```
+
+Result: exit `1` at the new case after all preceding cases passed:
+
+```text
+ASSERT_EQUAL_FAILED: failed diagnostic persistence prevents terminal feedback delivery
+expected=[False] actual=[True]
+```
+
+This proved the exact reviewed defect: the Supervisor returned
+`Delivered=True` even though the local action record had not persisted.
+
+The real correlation test was also written before the production fix. It uses
+the test-only `TrayHostCorrelationTestBridge`, which compiles and composes the
+checked-in production `TrayWindow`, `NativeMenu`, `HostTransport`, action/result
+types, and revision behavior. No replacement protocol or external channel is
+introduced.
+
+### Implementation and pending-action safety
+
+`Write-CcodSupervisorTrayActionTerminal` now returns `true` only after the
+existing `WriteLog` adapter completes without error. It returns `false` after
+adding `CCOD_SUPERVISOR_LOG_FAILED` to the Supervisor cleanup receipt on any
+clock, schema-construction, or log-write exception.
+
+For terminal `Completed`, `Rejected`, and `Failed` results,
+`Send-CcodSupervisorTrayActionResult` now stops immediately with
+`Delivered=false` when that durable-record gate fails. It does not call
+`SendTrayActionResult`, so HostTransport cannot queue About work or generic
+failure feedback without the required local record. `Accepted` remains a
+nonterminal lifecycle acknowledgement and retains its existing authenticated
+delivery path.
+
+The pending HostTransport action is safe under this outcome:
+
+- it receives no false terminal acknowledgement;
+- it cannot display generic terminal feedback without a durable diagnostic;
+- it remains protected by the existing action-id replay cache and eight-entry
+ pending-action bound;
+- lifecycle completion receives `Delivered=false`, so the existing repair and
+ SafeExit gates do not mark the terminal notification complete or exit the UI;
+- the Supervisor cleanup receipt exposes `CCOD_SUPERVISOR_LOG_FAILED` for the
+ enclosing host outcome.
+
+No retry or unauthenticated fallback was added, so the change cannot duplicate
+a lifecycle terminal result or weaken action identity/capability authority.
+
+### Real end-to-end correlation coverage
+
+The new Supervisor behavioral case executes both branches through one actual
+chain:
+
+```text
+TrayWindow.HandleContextMenu
+ -> NativeMenu selected command and displayed revision
+ -> HostTransport.TryRegisterAction
+ -> Supervisor AcknowledgedPresentations revision/capability gate
+ -> actual OpenLogs adapter or stale rejection
+ -> sanitized WriteLog record
+ -> Supervisor SendTrayActionResult boundary
+ -> HostTransport.TryAcknowledgeAction
+```
+
+For revision 7, the same action selected by the native menu is placed in the
+ACK map, invokes `OpenLogs` exactly once, returns `Completed`, and is accepted
+by the real HostTransport with revision 7. For revision 8, no ACK-map entry is
+created; `OpenLogs` is invoked zero times, Supervisor returns
+`CCOD_TRAY_ACTION_STALE`, and the real HostTransport accepts the exact rejected
+terminal result and preserves revision 8 and the stale code before generic UI
+consumption.
+
+The bridge is test-only and calls the production in-process APIs directly. It
+does not start a production TrayHost process, open an external control surface,
+or alter the authenticated wire protocol.
+
+### Diagnostic record shape ruling
+
+Tests now treat `command`, `revision`, `status`, and `code` as required inclusive
+fields. They retain and validate the sanitized standard envelope
+`schemaVersion`, `timestampUtc`, `component`, `stage`, and `outcome`. They also
+explicitly assert the absence of `ActionId`, `TransactionId`, path, token,
+exception, message, and raw arbitrary-text properties. The envelope is a local
+log schema, not a new wire protocol.
+
+### GREEN and fresh verification
+
+After the minimal fail-closed implementation, the Supervisor suite passed 98
+cases, including the new durable-log failure and real correlation tests. Fresh
+pre-commit verification then ran every Task 4 required suite:
+
+```text
+TrayHost client: exit 0; 8 behavioral cases
+Supervisor: exit 0; 98 behavioral cases
+TrayHost transport: exit 0; 8 behavioral cases
+TrayHost native: exit 0; 15 behavioral cases
+git diff --check: exit 0
+git diff --cached --check: exit 0
+```
+
+The existing lifecycle safety tests remained green, including SafeExit refusal
+to shut down when its correlated terminal result is not delivered and terminal
+lifecycle completion only after acknowledgement.
+
+### Fix-round scope and concerns
+
+- Changed only `src/persistence/Supervisor.ps1`, its behavioral self-test, the
+ test-only C# correlation bridge, and this report.
+- No real Codex/ChatGPT or production TrayHost process was started, stopped, or
+ controlled. No installer, release, install, push, signing, WindowsApps, DPAPI,
+ or interactive product UI operation occurred.
+- No independent agent was used because this fix round explicitly prohibited
+ agents. Parent integration review remains the next review gate.
From 3bdd575dd8afd91396cef02f1a4357915d42ec3f Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:50:17 +0800
Subject: [PATCH 021/187] fix: release tray actions after diagnostic failure
---
src/persistence/Supervisor.ps1 | 2 +-
src/trayhost/HostTransport.cs | 22 +++++++--
src/trayhost/Program.cs | 4 +-
src/trayhost/TrayTerminalDiagnostic.cs | 35 ++++++++++++++
tests/persistence/Supervisor.SelfTest.ps1 | 42 +++++++++++-----
tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 1 +
.../trayhost/TrayHostCorrelationTestBridge.cs | 19 ++++++--
tests/trayhost/TrayHostNativeSelfTest.cs | 19 +++++++-
tests/trayhost/TrayHostTransportSelfTest.cs | 48 +++++++++++++++++--
9 files changed, 163 insertions(+), 29 deletions(-)
create mode 100644 src/trayhost/TrayTerminalDiagnostic.cs
diff --git a/src/persistence/Supervisor.ps1 b/src/persistence/Supervisor.ps1
index 96054fa..47219a1 100644
--- a/src/persistence/Supervisor.ps1
+++ b/src/persistence/Supervisor.ps1
@@ -1174,7 +1174,7 @@ function Write-CcodSupervisorTrayActionTerminal {
function Send-CcodSupervisorTrayActionResult {
param($HostState,[hashtable]$Adapters,$Action,[ValidateSet('Accepted','Completed','Rejected','Failed')][string]$Status,[AllowNull()][string]$ErrorCode,[AllowNull()][string]$TransactionId)
$result=[pscustomobject][ordered]@{ActionId=$Action.ActionId;Revision=[UInt64]$Action.Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId}
- if($Status-cne'Accepted'-and-not(Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode)){$result|Add-Member -NotePropertyName Delivered -NotePropertyValue $false;return $result}
+ if($Status-cne'Accepted'){[void](Write-CcodSupervisorTrayActionTerminal $HostState $Adapters $Action $Status $ErrorCode)}
try{
$delivered=Invoke-CcodSupervisorAdapter $Adapters.SendTrayActionResult @($HostState.Tray,$result.ActionId,$result.Revision,$result.Status,$result.ErrorCode,$result.TransactionId) 1
if($delivered-isnot[bool]-or-not$delivered){throw 'tray action result was not acknowledged'}
diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs
index f71a77b..e9d5848 100644
--- a/src/trayhost/HostTransport.cs
+++ b/src/trayhost/HostTransport.cs
@@ -11,6 +11,7 @@ private sealed class PendingAction
private readonly object _gate = new object();
private readonly Action _presentationReady;
+ private readonly Func _writeTerminalDiagnostic;
private readonly Queue _completedAbout = new Queue();
private readonly Queue _failedActions = new Queue();
private readonly Dictionary _pendingActions = new Dictionary();
@@ -20,13 +21,18 @@ private sealed class PendingAction
private bool _menuOpen;
private bool _disposed;
- internal HostTransport() : this(null)
+ internal HostTransport() : this(null, null)
{
}
- internal HostTransport(Action presentationReady)
+ internal HostTransport(Action presentationReady) : this(presentationReady, null)
+ {
+ }
+
+ internal HostTransport(Action presentationReady, Func writeTerminalDiagnostic)
{
_presentationReady = presentationReady;
+ _writeTerminalDiagnostic = writeTerminalDiagnostic;
}
internal void SetMenuOpen(bool value)
@@ -97,10 +103,16 @@ 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; }
+ bool recorded = false;
+ Func writer = _writeTerminalDiagnostic;
+ if (writer != null)
+ {
+ try { recorded = writer(new TrayTerminalDiagnostic(pending.Action.Command, result.Revision, result.Status, result.ErrorCode)); }
+ catch { recorded = 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); }
+ if (recorded && result.Status == TrayActionResultStatus.Completed && pending.Action.Command == TrayCommand.ShowAbout) { _completedAbout.Enqueue(result); }
+ if (recorded && (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) && _failedActions.Count < 8) { _failedActions.Enqueue(result); }
return true;
}
}
diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs
index 106b3e6..d227d2e 100644
--- a/src/trayhost/Program.cs
+++ b/src/trayhost/Program.cs
@@ -39,7 +39,9 @@ private static int RunChild(string[] args)
PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload);
Win32TrayPlatform platform = new Win32TrayPlatform();
TrayHostApplication application = null;
- HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } });
+ string terminalLogPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "trayhost-actions.log");
+ try { Directory.CreateDirectory(Path.GetDirectoryName(terminalLogPath)); } catch { }
+ HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return TrayTerminalDiagnosticLog.TryAppend(terminalLogPath, record); });
TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen);
bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object();
Action command = delegate(TrayCommand selected, ulong revision)
diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs
new file mode 100644
index 0000000..b4f84c8
--- /dev/null
+++ b/src/trayhost/TrayTerminalDiagnostic.cs
@@ -0,0 +1,35 @@
+using System;
+using System.IO;
+
+internal sealed class TrayTerminalDiagnostic
+{
+ internal TrayCommand Command { get; private set; }
+ internal ulong Revision { get; private set; }
+ internal TrayActionResultStatus Status { get; private set; }
+ internal string Code { get; private set; }
+
+ internal TrayTerminalDiagnostic(TrayCommand command, ulong revision, TrayActionResultStatus status, string errorCode)
+ {
+ if (!TrayCommandPolicy.IsWireCommand(command) || revision == 0UL || status == TrayActionResultStatus.Accepted || !Enum.IsDefined(typeof(TrayActionResultStatus), status)) { throw new ArgumentException("terminal diagnostic is invalid"); }
+ string code = status == TrayActionResultStatus.Completed ? "CCOD_TRAY_ACTION_COMPLETED" : errorCode;
+ if (!TrayCommandPolicy.IsCanonicalErrorCode(code)) { code = "CCOD_TRAY_ACTION_FAILED"; }
+ Command = command; Revision = revision; Status = status; Code = code;
+ }
+}
+
+internal static class TrayTerminalDiagnosticLog
+{
+ private static readonly object Gate = new object();
+
+ internal static bool TryAppend(string path, TrayTerminalDiagnostic record)
+ {
+ if (String.IsNullOrEmpty(path) || record == null) { return false; }
+ try
+ {
+ string line = "command=" + record.Command.ToString() + " revision=" + record.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=" + record.Status.ToString() + " code=" + record.Code + Environment.NewLine;
+ lock (Gate) { File.AppendAllText(path, line); }
+ return true;
+ }
+ catch { return false; }
+ }
+}
diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1
index e2e8a0e..42d120d 100644
--- a/tests/persistence/Supervisor.SelfTest.ps1
+++ b/tests/persistence/Supervisor.SelfTest.ps1
@@ -819,6 +819,7 @@ function Import-CcodTrayHostCorrelationTestBridge {
(Join-Path $trayRoot 'PresentationSnapshot.cs'),
(Join-Path $trayRoot 'PipeProtocol.cs'),
(Join-Path $trayRoot 'TransportMessages.cs'),
+ (Join-Path $trayRoot 'TrayTerminalDiagnostic.cs'),
(Join-Path $trayRoot 'NativeMethods.cs'),
(Join-Path $trayRoot 'InputModeGuard.cs'),
(Join-Path $trayRoot 'NativeMenu.cs'),
@@ -1646,18 +1647,37 @@ Invoke-CcodTest 'sanitizes a malformed terminal action code before local logging
}
Invoke-CcodTest 'does not queue generic terminal feedback when its local diagnostic write fails' {
- # Production mutation caught: delivering a rejected terminal result to TrayHost after the required local diagnostic failed to persist.
+ # Production mutation caught: stranding a pending HostTransport action when Supervisor logging fails instead of using the authenticated host-side diagnostic gate.
+ Import-CcodTrayHostCorrelationTestBridge
$fixture=New-CcodTickFixture;$world=$fixture.Fake.World;$hostState=$fixture.Host;$world.FailAt='WriteLog'
- $action=[pscustomobject][ordered]@{ActionId=[guid]'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeb1';Command='OpenLogs';Revision=[UInt64]2}
-
- $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0]
-
- Assert-CcodEqual 'Rejected' $result.Status 'stale action retains its supervisor terminal status'
- Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $result.ErrorCode 'stale action retains its stable code'
- Assert-CcodEqual $false $result.Delivered 'failed diagnostic persistence prevents terminal feedback delivery'
- Assert-CcodEqual 0 $world.TrayActionResults.Count 'TrayHost receives no terminal result without a durable local record'
- Assert-CcodEqual 0 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'stale action still performs no handler side effect'
- Assert-CcodTrue ($hostState.RuntimeCleanupCodes.Contains('CCOD_SUPERVISOR_LOG_FAILED')) 'failed diagnostic persistence remains visible in the supervisor cleanup receipt'
+ $bridge=[TrayHostCorrelationTestBridge]::new([UInt64]2,[TrayCommand]::OpenLogs,$false)
+ try{
+ $world|Add-Member -NotePropertyName Bridge -NotePropertyValue $bridge
+ $fixture.Fake.Adapters.SendTrayActionResult={
+ param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId)
+ $world.TrayActionResults.Add([pscustomobject][ordered]@{ActionId=$ActionId;Revision=[UInt64]$Revision;Status=$Status;ErrorCode=$ErrorCode;TransactionId=$TransactionId})
+ return $world.Bridge.Acknowledge($ActionId,[UInt64]$Revision,$Status,$ErrorCode)
+ }.GetNewClosure()
+ foreach($index in 1..10){
+ $bridge.SelectFromNativeMenu()
+ $action=[pscustomobject][ordered]@{ActionId=$bridge.ActionId;Command=$bridge.Command;Revision=[UInt64]$bridge.Revision}
+ $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $action)[0]
+ Assert-CcodEqual 'Rejected' $result.Status "log-failure stale action $index retains its Supervisor status"
+ Assert-CcodEqual $true $result.Delivered "log-failure stale action $index is terminally accepted by HostTransport"
+ }
+ Assert-CcodEqual 0 $bridge.FeedbackCount 'host-side diagnostic failures suppress every generic feedback item'
+ Assert-CcodTrue ($hostState.RuntimeCleanupCodes.Contains('CCOD_SUPERVISOR_LOG_FAILED')) 'Supervisor log failure remains visible in the cleanup receipt'
+
+ $world.FailAt=$null;$bridge.SetDiagnosticPersistence($true)
+ $enabled=[pscustomobject][ordered]@{RepairEnabled=$true;LanguageEnabled=$true;OpenLogsEnabled=$true;AboutEnabled=$true;ExitEnabled=$true}
+ $hostState.Tray.AcknowledgedPresentations['2']=$enabled;$hostState.LastAcknowledgedPresentation=$enabled
+ $bridge.SelectFromNativeMenu()
+ $recovered=[pscustomobject][ordered]@{ActionId=$bridge.ActionId;Command=$bridge.Command;Revision=[UInt64]$bridge.Revision}
+ $result=@(Invoke-CcodSupervisorCommand $hostState $fixture.Fake.Adapters $recovered)[0]
+ Assert-CcodEqual 'Completed' $result.Status 'new command completes after both diagnostic paths recover'
+ Assert-CcodEqual $true $result.Delivered 'recovered HostTransport acknowledges the new completed result'
+ Assert-CcodEqual 1 @($world.Calls|Where-Object{$_ -eq 'Open:Logs'}).Count 'recovered new command reaches its handler exactly once'
+ }finally{$bridge.Dispose()}
}
Invoke-CcodTest 'correlates native menu actions through HostTransport Supervisor ACK authorization and result ACK' {
diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
index b17c01a..8846e6d 100644
--- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1
+++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
@@ -22,6 +22,7 @@ $nativeFiles=@(
)
$transportFiles=@(
(Join-Path $repositoryRoot 'src\trayhost\TransportMessages.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\TrayTerminalDiagnostic.cs'),
(Join-Path $repositoryRoot 'src\trayhost\ParentTransport.cs'),
(Join-Path $repositoryRoot 'src\trayhost\HostTransport.cs')
)
diff --git a/tests/trayhost/TrayHostCorrelationTestBridge.cs b/tests/trayhost/TrayHostCorrelationTestBridge.cs
index 22d13f1..a79ba20 100644
--- a/tests/trayhost/TrayHostCorrelationTestBridge.cs
+++ b/tests/trayhost/TrayHostCorrelationTestBridge.cs
@@ -38,11 +38,18 @@ private sealed class Platform : INativeTrayPlatform
private readonly TrayWindow _window;
private TrayHostAction _selected;
private TrayActionResult _terminal;
+ private bool _persistDiagnostics = true;
+ private int _feedbackCount;
- public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command)
+ public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command) : this(revision, command, true)
+ {
+ }
+
+ public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command, bool persistDiagnostics)
{
_platform = new Platform { Selection = (uint)command };
- _transport = new HostTransport();
+ _persistDiagnostics = persistDiagnostics;
+ _transport = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { return _persistDiagnostics; });
_window = new TrayWindow(_platform, _transport.SetMenuOpen);
_window.CommandSelected += delegate(TrayCommand selected, ulong displayedRevision)
{
@@ -69,16 +76,18 @@ public bool Acknowledge(Guid actionId, ulong revision, string status, string err
TrayActionResult result = new TrayActionResult(actionId, revision, parsed, errorCode, null);
bool accepted = _transport.TryAcknowledgeAction(result);
if (!accepted) { return false; }
+ _terminal = result;
if (parsed == TrayActionResultStatus.Rejected || parsed == TrayActionResultStatus.Failed)
{
TrayActionResult queued;
- if (!_transport.TryTakeFailedAction(out queued)) { return false; }
- _terminal = queued;
+ if (_transport.TryTakeFailedAction(out queued)) { _terminal = queued; _feedbackCount++; }
}
- else { _terminal = result; }
return true;
}
+ public void SetDiagnosticPersistence(bool value) { _persistDiagnostics = value; }
+ public int FeedbackCount { get { return _feedbackCount; } }
+
public string TerminalStatus { get { return _terminal == null ? String.Empty : _terminal.Status.ToString(); } }
public string TerminalCode { get { return _terminal == null ? String.Empty : (_terminal.ErrorCode ?? String.Empty); } }
public ulong TerminalRevision { get { return _terminal == null ? 0UL : _terminal.Revision; } }
diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs
index 820be16..273c597 100644
--- a/tests/trayhost/TrayHostNativeSelfTest.cs
+++ b/tests/trayhost/TrayHostNativeSelfTest.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.IO;
internal sealed class FakeTrayPlatform : INativeTrayPlatform
{
@@ -208,6 +209,21 @@ private static void TestActionFailureUsesTheAcknowledgedSnapshotStrings()
window.Dispose();
}
+ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ string path = Path.Combine(root, "trayhost-actions.log");
+ try
+ {
+ TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 23UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
+ AssertTrue(TrayTerminalDiagnosticLog.TryAppend(path, record), "terminal diagnostic append reports durable success");
+ AssertEqual("command=OpenLogs revision=23 status=Rejected code=CCOD_TRAY_ACTION_STALE" + Environment.NewLine, File.ReadAllText(path), "terminal diagnostic file contains only the approved correlation fields");
+ AssertTrue(!TrayTerminalDiagnosticLog.TryAppend(root, record), "terminal diagnostic append reports persistence failure without throwing");
+ }
+ finally { try { Directory.Delete(root, true); } catch { } }
+ }
+
private static void TestNoHimcFailureIsSafe()
{
FakeTrayPlatform platform = new FakeTrayPlatform();
@@ -287,12 +303,13 @@ public static int Main(string[] args)
TestAboutCommandDefersProofToSupervisor();
TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion();
TestActionFailureUsesTheAcknowledgedSnapshotStrings();
+ TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence();
TestSimplifiedMenuAndExitConfirmation();
TestNoHimcFailureIsSafe();
TestShellRightClickNotificationMapping();
TestRealNativePInvokeSurface();
TestPostedWorkMessageDispatchesToItsOwnerWindow();
- Console.WriteLine("TrayHost native self-tests passed: 15");
+ Console.WriteLine("TrayHost native self-tests passed: 16");
return 0;
}
catch (Exception error)
diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs
index 928289c..7a55cfa 100644
--- a/tests/trayhost/TrayHostTransportSelfTest.cs
+++ b/tests/trayhost/TrayHostTransportSelfTest.cs
@@ -3,6 +3,8 @@
internal static class TrayHostTransportSelfTest
{
+ private static bool PersistTerminal(TrayTerminalDiagnostic record) { return true; }
+
private static void AssertTrue(bool value, string message)
{
if (!value) { throw new InvalidOperationException(message); }
@@ -124,7 +126,7 @@ private static void TestActionResultCorrelationAndControlPriority()
private static void TestAcknowledgedAboutQueuesOneUiWorkItem()
{
- HostTransport host = new HostTransport(); Guid actionId = Guid.NewGuid();
+ HostTransport host = new HostTransport(null, PersistTerminal); Guid actionId = Guid.NewGuid();
AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.ShowAbout, 14UL)), "About action registers");
AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 14UL, TrayActionResultStatus.Completed, null, null)), "verified About completion is accepted");
TrayActionResult result;
@@ -135,7 +137,7 @@ private static void TestAcknowledgedAboutQueuesOneUiWorkItem()
private static void TestRejectedAndFailedActionsQueueUserFeedback()
{
- HostTransport host = new HostTransport();
+ HostTransport host = new HostTransport(null, PersistTerminal);
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");
@@ -150,14 +152,49 @@ private static void TestRejectedAndFailedActionsQueueUserFeedback()
private static void TestUndisplayedActionFailureFeedbackIsBounded()
{
- HostTransport host = new HostTransport();
+ HostTransport host = new HostTransport(null, PersistTerminal);
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");
+ AssertTrue(accepted, "bounded undisplayed feedback never strands an authenticated terminal action");
+ }
+ host.Dispose();
+ }
+
+ private static void TestTerminalDiagnosticFailureNeverStrandsPendingActions()
+ {
+ HostTransport missingWriter = new HostTransport();
+ Guid missingWriterId = Guid.NewGuid();
+ AssertTrue(missingWriter.TryRegisterAction(new TrayHostAction(missingWriterId, TrayCommand.OpenLogs, 22UL)), "action registers without a diagnostic writer");
+ AssertTrue(missingWriter.TryAcknowledgeAction(new TrayActionResult(missingWriterId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "missing diagnostic writer still releases the authenticated terminal action");
+ TrayActionResult missingWriterFeedback;
+ AssertTrue(!missingWriter.TryTakeFailedAction(out missingWriterFeedback), "missing diagnostic writer cannot authorize generic feedback");
+ missingWriter.Dispose();
+
+ bool persist = false;
+ List records = new List();
+ HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { records.Add(record); return persist; });
+ for (int index = 0; index < 10; index++)
+ {
+ Guid actionId = Guid.NewGuid();
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 22UL)), "diagnostic failure never consumes pending-action capacity");
+ AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "authenticated terminal result releases its pending action even when local persistence fails");
+ TrayActionResult suppressed;
+ AssertTrue(!host.TryTakeFailedAction(out suppressed), "generic feedback is suppressed when its local terminal diagnostic did not persist");
}
+ persist = true;
+ Guid recoveredId = Guid.NewGuid();
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(recoveredId, TrayCommand.OpenLogs, 22UL)), "new action registers after repeated diagnostic failures");
+ AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(recoveredId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "recovered diagnostic persistence accepts a new terminal result");
+ TrayActionResult recoveredFeedback;
+ AssertTrue(host.TryTakeFailedAction(out recoveredFeedback) && recoveredFeedback.ActionId == recoveredId, "generic feedback resumes only after local terminal persistence recovers");
+ Guid completedId = Guid.NewGuid();
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(completedId, TrayCommand.OpenLogs, 22UL)), "post-recovery command can still register");
+ AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(completedId, 22UL, TrayActionResultStatus.Completed, null, null)), "post-recovery command can complete");
+ TrayTerminalDiagnostic last = records[records.Count - 1];
+ AssertTrue(last.Command == TrayCommand.OpenLogs && last.Revision == 22UL && last.Status == TrayActionResultStatus.Completed && String.Equals(last.Code, "CCOD_TRAY_ACTION_COMPLETED", StringComparison.Ordinal), "terminal diagnostic exposes only the exact canonical correlation fields");
host.Dispose();
}
@@ -173,7 +210,8 @@ public static int Main(string[] args)
TestAcknowledgedAboutQueuesOneUiWorkItem();
TestRejectedAndFailedActionsQueueUserFeedback();
TestUndisplayedActionFailureFeedbackIsBounded();
- Console.WriteLine("TrayHost transport self-tests passed: 8");
+ TestTerminalDiagnosticFailureNeverStrandsPendingActions();
+ Console.WriteLine("TrayHost transport self-tests passed: 9");
return 0;
}
catch (Exception error)
From 424f9c84f0d3f853b2091b9f825f8ee5ff55e0f0 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:50:53 +0800
Subject: [PATCH 022/187] docs: record Task 4 fix round 2
---
.../task-4-report.md | 138 ++++++++++++++++++
1 file changed, 138 insertions(+)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
index 3969c76..9c7613a 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
@@ -308,3 +308,141 @@ lifecycle completion only after acknowledgement.
or interactive product UI operation occurred.
- No independent agent was used because this fix round explicitly prohibited
agents. Parent integration review remains the next review gate.
+
+## Fix round 2: release pending actions through a host-side diagnostic gate
+
+Re-review found that fix round 1's suppression of the terminal ACK prevented
+generic feedback without a record, but permanently retained the corresponding
+HostTransport pending entry. Eight Supervisor-log failures could therefore fill
+the pending-action bound and disable later tray commands.
+
+Fix implementation commit: `3bdd575`
+(`fix: release tray actions after diagnostic failure`).
+
+### TDD and RED evidence
+
+Tests were changed before production code. The new transport regression
+required a typed terminal record and a diagnostic-writer HostTransport boundary.
+The focused transport compile failed with the expected missing production API:
+
+```text
+CS0246: TrayTerminalDiagnostic could not be found
+CS1729: HostTransport does not contain a constructor that takes 2 arguments
+CCOD_TRAYHOST_NATIVE_COMPILE_FAILED
+```
+
+The Supervisor correlation suite independently failed to compile its real
+native/transport bridge for the same missing type. This established that the
+repeated-failure recovery behavior did not exist.
+
+After the first implementation, a second behavioral test treated a missing
+writer as a persistence failure. It failed before the default was hardened:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+missing diagnostic writer cannot authorize generic feedback
+```
+
+The default was then changed to fail closed for feedback while still consuming
+the authenticated terminal result.
+
+### Implementation
+
+`TrayTerminalDiagnostic` is a new internal strong type. Its constructor accepts
+only an existing `TrayCommand`, positive revision, terminal
+`TrayActionResultStatus`, and canonical result code. `Completed` maps to
+`CCOD_TRAY_ACTION_COMPLETED`; malformed failure values map to
+`CCOD_TRAY_ACTION_FAILED`. It cannot carry an action id, transaction id, path,
+token, exception, or arbitrary message.
+
+`TrayTerminalDiagnosticLog.TryAppend` writes exactly one fixed-format line:
+
+```text
+command= revision= status= code=
+```
+
+It returns `true` only after `File.AppendAllText` closes successfully and returns
+`false` without throwing on persistence failure. The native self-test writes to
+an isolated temporary file, verifies the line byte-for-byte, and verifies that
+an invalid file target returns `false`.
+
+Production `Program` derives a fixed current-user path under
+`LocalApplicationData\CodexControlOtherDevices\logs\trayhost-actions.log` and
+injects the typed writer into HostTransport. No path or arbitrary payload comes
+from the authenticated message, and no new pipe message, command, external
+control surface, or unauthenticated channel was added.
+
+HostTransport now processes a valid authenticated terminal result in this
+order while holding its existing correlation lock:
+
+1. require the exact pending action id and presentation revision;
+2. enforce lifecycle Accepted-before-Completed rules;
+3. build the typed terminal diagnostic from the pending command and result;
+4. attempt the injected local write;
+5. remove the pending action regardless of write success;
+6. enqueue About or generic failure feedback only when the write succeeded;
+7. return terminal acceptance so the authenticated reader does not treat a
+ consumed valid result as a protocol violation.
+
+If no writer exists or the writer returns/throws failure, no feedback is
+queued, but pending capacity is released. If the bounded feedback queue is
+already full, the additional terminal result is likewise consumed and its
+pending entry released; only the redundant UI item is suppressed.
+
+Supervisor again sends the authenticated terminal result after attempting its
+standard sanitized envelope log. This gives two safe cases:
+
+- Supervisor log succeeds: the standard timestamped local record already
+ exists, and TrayHost also gates feedback on its typed host receipt.
+- Supervisor log fails: `CCOD_SUPERVISOR_LOG_FAILED` remains in the cleanup
+ receipt, while the host receipt either persists before feedback or suppresses
+ feedback. In both cases the authenticated terminal result releases pending
+ capacity.
+
+The standard Supervisor envelope and its inclusive-field ruling were not
+changed.
+
+### Repeated failure and recovery proof
+
+The transport regression submits ten distinct authenticated failed actions
+while the diagnostic writer returns false. Every action registers, every exact
+terminal result is accepted, every pending entry is released, and zero generic
+feedback items are available. After switching the same writer to success, a new
+failed action registers and produces feedback, followed by a new command that
+registers and completes. The last typed record is asserted as exactly
+`OpenLogs`, revision 22, `Completed`, and `CCOD_TRAY_ACTION_COMPLETED`.
+
+The Supervisor end-to-end test repeats ten stale native-menu actions while both
+Supervisor and host diagnostic writes fail. The same real
+`TrayWindow -> NativeMenu -> HostTransport -> Supervisor ACK map -> result ACK`
+chain accepts every terminal result without invoking `OpenLogs` or producing
+feedback. After both writers recover and revision 2 is acknowledged, the next
+native command registers, reaches `OpenLogs` exactly once, and completes.
+
+### Fresh verification
+
+Fresh pre-commit verification results:
+
+```text
+TrayHost client: exit 0; 8 behavioral cases
+Supervisor: exit 0; 98 behavioral cases
+TrayHost transport: exit 0; 9 behavioral cases
+TrayHost native: exit 0; 16 behavioral cases
+TrayHost production compile/headless smoke: exit 0
+git diff --check: exit 0
+git diff --cached --check: exit 0
+```
+
+The production smoke compiles every checked-in TrayHost C# source, including
+the new diagnostic type, and runs only the existing no-UI headless path.
+
+### Fix-round scope and concerns
+
+- No authenticated wire field or result status changed.
+- No real Codex/ChatGPT or production interactive TrayHost process was started,
+ stopped, signaled, or controlled.
+- Nothing was installed, pushed, published, released, signed, or written to
+ WindowsApps/DPAPI. Runtime logging was tested only through an isolated temp
+ path; the production path was compiled but not exercised against user data.
+- No independent agent was used because this fix round explicitly prohibited
+ agents. Parent re-review remains the next gate.
From 9c1e4dfcb9acb4c6f6dc9d7a495b67d28edd6859 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 06:05:01 +0800
Subject: [PATCH 023/187] fix: harden tray terminal diagnostics
---
src/trayhost/HostTransport.cs | 22 +--
src/trayhost/Program.cs | 12 +-
src/trayhost/TrayTerminalDiagnostic.cs | 64 ++++++++-
.../TrayHostProductionTrace.SelfTest.ps1 | 30 +++++
tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 16 ++-
tests/trayhost/TrayHostNativeSelfTest.cs | 35 ++++-
.../trayhost/TrayHostParentClientSelfTest.cs | 125 +++++++++++++++---
tests/trayhost/TrayHostTransportSelfTest.cs | 41 +++++-
8 files changed, 306 insertions(+), 39 deletions(-)
create mode 100644 tests/persistence/TrayHostProductionTrace.SelfTest.ps1
diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs
index e9d5848..be636aa 100644
--- a/src/trayhost/HostTransport.cs
+++ b/src/trayhost/HostTransport.cs
@@ -90,10 +90,11 @@ internal bool TryRegisterAction(TrayHostAction action)
internal bool TryAcknowledgeAction(TrayActionResult result)
{
if (result == null) { return false; }
+ PendingAction pending;
+ TrayTerminalDiagnostic diagnostic;
lock (_gate)
{
if (_disposed) { return false; }
- PendingAction pending;
if (!_pendingActions.TryGetValue(result.ActionId, out pending) || pending.Action.Revision != result.Revision) { return false; }
if (result.Status == TrayActionResultStatus.Accepted)
{
@@ -103,14 +104,19 @@ 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; }
- bool recorded = false;
- Func writer = _writeTerminalDiagnostic;
- if (writer != null)
- {
- try { recorded = writer(new TrayTerminalDiagnostic(pending.Action.Command, result.Revision, result.Status, result.ErrorCode)); }
- catch { recorded = false; }
- }
+ diagnostic = new TrayTerminalDiagnostic(pending.Action.Command, result.Revision, result.Status, result.ErrorCode);
_pendingActions.Remove(result.ActionId);
+ }
+ bool recorded = false;
+ Func writer = _writeTerminalDiagnostic;
+ if (writer != null)
+ {
+ try { recorded = writer(diagnostic); }
+ catch { recorded = false; }
+ }
+ lock (_gate)
+ {
+ if (_disposed) { return true; }
if (recorded && result.Status == TrayActionResultStatus.Completed && pending.Action.Command == TrayCommand.ShowAbout) { _completedAbout.Enqueue(result); }
if (recorded && (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) && _failedActions.Count < 8) { _failedActions.Enqueue(result); }
return true;
diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs
index d227d2e..f438004 100644
--- a/src/trayhost/Program.cs
+++ b/src/trayhost/Program.cs
@@ -39,8 +39,8 @@ private static int RunChild(string[] args)
PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload);
Win32TrayPlatform platform = new Win32TrayPlatform();
TrayHostApplication application = null;
- string terminalLogPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "trayhost-actions.log");
- try { Directory.CreateDirectory(Path.GetDirectoryName(terminalLogPath)); } catch { }
+ string terminalLogPath;
+ if (!TrayTerminalDiagnosticLog.TryGetDefaultPath(out terminalLogPath)) { terminalLogPath = null; }
HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return TrayTerminalDiagnosticLog.TryAppend(terminalLogPath, record); });
TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen);
bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object();
@@ -91,7 +91,7 @@ private static int RunChild(string[] args)
{
ProtocolFrame frame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
if (frame.MessageType == TrayHostMessageType.Presentation) { transport.TryAcceptPresentation(TrayHostWire.ReadPresentation(frame.Payload)); application.PostWork(); }
- else if (frame.MessageType == TrayHostMessageType.ActionResult) { if (!transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(frame.Payload))) { throw new ProtocolViolationException("action result is uncorrelated"); } application.PostWork(); }
+ else if (frame.MessageType == TrayHostMessageType.ActionResult) { if (!TryDispatchAuthenticatedActionResult(transport, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); } application.PostWork(); }
else if (frame.MessageType == TrayHostMessageType.Shutdown) { lock (stateGate) { shutdownRequested = true; } application.PostWork(); }
else if (frame.MessageType == TrayHostMessageType.Ping) { lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Pong, epoch, outboundSequence++, frame.Payload), keys.HostToParent); } }
}
@@ -108,6 +108,12 @@ private static bool VerifyParentIdentity(int pid, long creation)
try { using (Process process = Process.GetProcessById(pid)) { return process.StartTime.ToFileTimeUtc() == creation; } } catch { return false; }
}
+ internal static bool TryDispatchAuthenticatedActionResult(HostTransport transport, byte[] payload)
+ {
+ if (transport == null || payload == null) { return false; }
+ return transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload));
+ }
+
private static int RunHeadlessSmoke()
{
string[] strings = new string[16]; for (int i = 0; i < strings.Length; i++) { strings[i] = "smoke-" + i; }
diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs
index b4f84c8..c3f160e 100644
--- a/src/trayhost/TrayTerminalDiagnostic.cs
+++ b/src/trayhost/TrayTerminalDiagnostic.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Text;
internal sealed class TrayTerminalDiagnostic
{
@@ -19,17 +20,76 @@ internal TrayTerminalDiagnostic(TrayCommand command, ulong revision, TrayActionR
internal static class TrayTerminalDiagnosticLog
{
+ internal const long MaximumBytes = 64L * 1024L;
private static readonly object Gate = new object();
+ internal static bool TryGetDefaultPath(out string path)
+ {
+ path = null;
+ try
+ {
+ string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ if (String.IsNullOrEmpty(local) || !Path.IsPathRooted(local) || !IsSafeExistingDirectory(local)) { return false; }
+ string product = Path.Combine(local, "CodexControlOtherDevices");
+ if (!TryCreateSafeChildDirectory(product)) { return false; }
+ string logs = Path.Combine(product, "logs");
+ if (!TryCreateSafeChildDirectory(logs)) { return false; }
+ path = Path.Combine(logs, "trayhost-actions.log");
+ return IsSafePath(path);
+ }
+ catch { path = null; return false; }
+ }
+
internal static bool TryAppend(string path, TrayTerminalDiagnostic record)
{
- if (String.IsNullOrEmpty(path) || record == null) { return false; }
+ if (String.IsNullOrEmpty(path) || record == null || !Path.IsPathRooted(path)) { return false; }
try
{
string line = "command=" + record.Command.ToString() + " revision=" + record.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=" + record.Status.ToString() + " code=" + record.Code + Environment.NewLine;
- lock (Gate) { File.AppendAllText(path, line); }
+ byte[] bytes = new UTF8Encoding(false).GetBytes(line);
+ if (bytes.LongLength > MaximumBytes) { return false; }
+ string full = Path.GetFullPath(path);
+ lock (Gate)
+ {
+ if (!IsSafePath(full)) { return false; }
+ FileMode mode = File.Exists(full) && new FileInfo(full).Length + bytes.LongLength <= MaximumBytes ? FileMode.Append : FileMode.Create;
+ using (FileStream stream = new FileStream(full, mode, FileAccess.Write, FileShare.Read))
+ {
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(true);
+ }
+ }
return true;
}
catch { return false; }
}
+
+ private static bool TryCreateSafeChildDirectory(string path)
+ {
+ if (Directory.Exists(path)) { return IsSafeExistingDirectory(path); }
+ string parent = Path.GetDirectoryName(path);
+ if (String.IsNullOrEmpty(parent) || !IsSafeExistingDirectory(parent)) { return false; }
+ Directory.CreateDirectory(path);
+ return IsSafeExistingDirectory(path);
+ }
+
+ private static bool IsSafePath(string path)
+ {
+ if (!Path.IsPathRooted(path) || !String.Equals(Path.GetFileName(path), "trayhost-actions.log", StringComparison.OrdinalIgnoreCase) || Directory.Exists(path)) { return false; }
+ if (File.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) { return false; }
+ string directory = Path.GetDirectoryName(path);
+ return !String.IsNullOrEmpty(directory) && IsSafeExistingDirectory(directory);
+ }
+
+ private static bool IsSafeExistingDirectory(string path)
+ {
+ DirectoryInfo current = new DirectoryInfo(path);
+ if (!current.Exists) { return false; }
+ while (current != null)
+ {
+ if (!current.Exists || (current.Attributes & FileAttributes.ReparsePoint) != 0) { return false; }
+ current = current.Parent;
+ }
+ return true;
+ }
}
diff --git a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1
new file mode 100644
index 0000000..a5bff83
--- /dev/null
+++ b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1
@@ -0,0 +1,30 @@
+param([Parameter(Mandatory)][string]$AssemblyPath)
+$ErrorActionPreference='Stop'
+. (Join-Path $PSScriptRoot 'TestSupport.ps1')
+$repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
+[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($AssemblyPath))|Out-Null
+Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\TrayHostClient.psm1') -Force
+
+function Invoke-CcodProductionTraceCase {
+ param([bool]$Stale)
+ $client=[TrayHostProductionTraceFixture]::Start($AssemblyPath,$Stale)
+ try{
+ $queue=[Collections.Generic.Queue[object]]::new()
+ $context=[pscustomobject][ordered]@{Client=$client;CommandQueue=$queue;Exited=$false;LastError=$null;PublishedPresentations=[ordered]@{};AcknowledgedPresentations=[ordered]@{}}
+ $deadline=[DateTime]::UtcNow.AddSeconds(3)
+ while($queue.Count-eq0-and[DateTime]::UtcNow-lt$deadline){[void]$client.WaitForActivity([TimeSpan]::FromMilliseconds(100));Receive-CcodTrayHostEvents -Context $context}
+ Assert-CcodEqual 1 $queue.Count 'real TrayHostClient receives one authenticated production action'
+ $action=$queue.Dequeue()
+ Assert-CcodEqual 'OpenLogs' $action.Command 'production trace preserves command through TrayHostWire and TrayHostClient'
+ $expectedRevision=if($Stale){[UInt64]8}else{[UInt64]1}
+ Assert-CcodEqual $expectedRevision $action.Revision 'production trace preserves the exact presentation revision'
+ $status=if($Stale){'Rejected'}else{'Completed'}
+ $code=if($Stale){'CCOD_TRAY_ACTION_STALE'}else{$null}
+ Assert-CcodEqual $true (Send-CcodTrayHostActionResult -Context $context -ActionId $action.ActionId -Revision $action.Revision -Status $status -ErrorCode $code -TransactionId $null) 'real TrayHostClient sends the authenticated correlated terminal result'
+ Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(3)) 'production trace peer validates Program dispatch and exits'
+ }finally{$client.Dispose()}
+}
+
+Invoke-CcodProductionTraceCase $false
+Invoke-CcodProductionTraceCase $true
+Write-Host 'TrayHost production correlation trace passed: 2'
diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
index 8846e6d..f38ae62 100644
--- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1
+++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
@@ -35,12 +35,20 @@ $parentClientFiles=@(
(Join-Path $repositoryRoot 'src\trayhost\JobObject.cs'),
(Join-Path $repositoryRoot 'src\trayhost\TrayHostParentClient.cs')
)
+$programTraceFiles=@(
+ (Join-Path $repositoryRoot 'src\trayhost\NativeMethods.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\InputModeGuard.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\NativeMenu.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\TrayWindow.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\TrayHostApplication.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\Program.cs')
+)
$testPath=if($ProtocolOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostProtocolSelfTest.cs'}elseif($NativeOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostNativeSelfTest.cs'}elseif($TransportOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostTransportSelfTest.cs'}else{Join-Path $repositoryRoot 'tests\trayhost\TrayHostParentClientSelfTest.cs'}
$requiredFiles=@($protocolPath,$presentationPath,$testPath)
if($ProtocolOnly){$requiredFiles+=$protocolSupportFiles}
if($NativeOnly){$requiredFiles+=$nativeFiles+$transportFiles}
if($TransportOnly){$requiredFiles+=$transportFiles}
-if($ParentClientOnly){$requiredFiles+=$transportFiles+$parentClientFiles}
+if($ParentClientOnly){$requiredFiles+=$transportFiles+$parentClientFiles+$programTraceFiles}
if($ProductionOnly){$requiredFiles+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)}
if($requiredFiles|Where-Object{ -not(Test-Path -LiteralPath $_ -PathType Leaf)}){throw 'CCOD_TRAYHOST_SOURCE_MISSING'}
Import-Module (Join-Path $repositoryRoot 'build\TrayHostReferencePack.psm1') -Force
@@ -62,11 +70,15 @@ try{
if($ProtocolOnly){$args+=$protocolSupportFiles}
if($NativeOnly){$args+=$nativeFiles+$transportFiles}
if($TransportOnly){$args+=$transportFiles}
- if($ParentClientOnly){$args+=$transportFiles+$parentClientFiles}
+ if($ParentClientOnly){$args+=$transportFiles+$parentClientFiles+$programTraceFiles}
}
$args+=$testPath
& $compiler @args
if($LASTEXITCODE -ne 0){if($ProtocolOnly){throw 'CCOD_TRAYHOST_PROTOCOL_COMPILE_FAILED'}elseif($ProductionOnly){throw 'CCOD_TRAYHOST_PRODUCTION_COMPILE_FAILED'}else{throw 'CCOD_TRAYHOST_NATIVE_COMPILE_FAILED'}}
if($ProductionOnly){& $outputPath '--headless-smoke'}else{& $outputPath}
if($LASTEXITCODE -ne 0){if($ProtocolOnly){throw 'CCOD_TRAYHOST_PROTOCOL_TEST_FAILED'}elseif($ProductionOnly){throw 'CCOD_TRAYHOST_HEADLESS_SMOKE_FAILED'}else{throw 'CCOD_TRAYHOST_NATIVE_TEST_FAILED'}}
+ if($ParentClientOnly){
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $outputPath
+ if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED'}
+ }
}finally{if(Test-Path -LiteralPath $temporaryRoot){Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue}}
diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs
index 273c597..67a1980 100644
--- a/tests/trayhost/TrayHostNativeSelfTest.cs
+++ b/tests/trayhost/TrayHostNativeSelfTest.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
internal sealed class FakeTrayPlatform : INativeTrayPlatform
@@ -224,6 +225,37 @@ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence()
finally { try { Directory.Delete(root, true); } catch { } }
}
+ private static void TestTerminalDiagnosticLogIsBoundedAndRejectsReparseComponents()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-bound-" + Guid.NewGuid().ToString("N"));
+ string target = Path.Combine(root, "target");
+ string junction = Path.Combine(root, "junction");
+ Directory.CreateDirectory(target);
+ string path = Path.Combine(target, "trayhost-actions.log");
+ try
+ {
+ string lastLine = String.Empty;
+ for (ulong revision = 1UL; revision <= 1400UL; revision++)
+ {
+ TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, revision, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
+ AssertTrue(TrayTerminalDiagnosticLog.TryAppend(path, record), "bounded terminal diagnostic append succeeds");
+ lastLine = "command=OpenLogs revision=" + revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=Failed code=CCOD_TRAY_ACTION_FAILED" + Environment.NewLine;
+ }
+ FileInfo retained = new FileInfo(path);
+ AssertTrue(retained.Length <= TrayTerminalDiagnosticLog.MaximumBytes, "terminal diagnostic retention never exceeds its fixed byte bound");
+ AssertTrue(File.ReadAllText(path).EndsWith(lastLine, StringComparison.Ordinal), "deterministic rollover retains the complete latest terminal record");
+
+ ProcessStartInfo junctionInfo = new ProcessStartInfo {
+ FileName = Environment.GetEnvironmentVariable("ComSpec"),
+ Arguments = "/d /c mklink /J \"" + junction + "\" \"" + target + "\"",
+ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true
+ };
+ using (Process junctionProcess = Process.Start(junctionInfo)) { junctionProcess.WaitForExit(); AssertTrue(junctionProcess.ExitCode == 0, "test junction is created"); }
+ AssertTrue(!TrayTerminalDiagnosticLog.TryAppend(Path.Combine(junction, "trayhost-actions.log"), new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 1UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE")), "terminal diagnostic rejects a reparse-point path component");
+ }
+ finally { try { if (Directory.Exists(junction)) { Directory.Delete(junction); } } catch { } try { Directory.Delete(root, true); } catch { } }
+ }
+
private static void TestNoHimcFailureIsSafe()
{
FakeTrayPlatform platform = new FakeTrayPlatform();
@@ -304,12 +336,13 @@ public static int Main(string[] args)
TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion();
TestActionFailureUsesTheAcknowledgedSnapshotStrings();
TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence();
+ TestTerminalDiagnosticLogIsBoundedAndRejectsReparseComponents();
TestSimplifiedMenuAndExitConfirmation();
TestNoHimcFailureIsSafe();
TestShellRightClickNotificationMapping();
TestRealNativePInvokeSurface();
TestPostedWorkMessageDispatchesToItsOwnerWindow();
- Console.WriteLine("TrayHost native self-tests passed: 16");
+ Console.WriteLine("TrayHost native self-tests passed: 17");
return 0;
}
catch (Exception error)
diff --git a/tests/trayhost/TrayHostParentClientSelfTest.cs b/tests/trayhost/TrayHostParentClientSelfTest.cs
index ae81f18..f2e8589 100644
--- a/tests/trayhost/TrayHostParentClientSelfTest.cs
+++ b/tests/trayhost/TrayHostParentClientSelfTest.cs
@@ -5,9 +5,39 @@
using System.Security.Cryptography;
using System.Threading;
+internal sealed class ProductionTraceTrayPlatform : INativeTrayPlatform
+{
+ internal string Message;
+ public IntPtr CreateOwner() { return new IntPtr(10); }
+ public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); }
+ public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; }
+ public bool ReleaseInputContext(IntPtr owner, IntPtr context) { return true; }
+ public IntPtr LoadIcon() { return new IntPtr(40); }
+ public bool DestroyIcon(IntPtr icon) { return true; }
+ public bool AddIcon(ref TrayIconData icon) { return true; }
+ public bool SetIconVersion(ref TrayIconData icon) { return true; }
+ public bool DeleteIcon(ref TrayIconData icon) { return true; }
+ public IntPtr CreatePopupMenu() { return new IntPtr(30); }
+ public IntPtr CreateSubMenu() { return new IntPtr(31); }
+ public bool AppendMenu(IntPtr menu, uint flags, UIntPtr command, string text) { return true; }
+ public bool AppendSubMenu(IntPtr menu, IntPtr child, string text) { return true; }
+ public bool ShowOwner(IntPtr owner) { return true; }
+ public bool HideOwner(IntPtr owner) { return true; }
+ public bool SetForegroundWindow(IntPtr owner) { return true; }
+ public IntPtr GetForegroundWindow() { return new IntPtr(10); }
+ public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { return 0U; }
+ public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { return true; }
+ public bool SetNotificationFocus(ref TrayIconData icon) { return true; }
+ public bool ShowMessageBox(IntPtr owner, string text, string caption) { Message = caption + "|" + text; return true; }
+ public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; }
+ public bool DestroyMenu(IntPtr menu) { return true; }
+ public bool EndMenu() { return true; }
+ public bool DestroyOwner(IntPtr owner) { return true; }
+}
+
internal static class TrayHostParentClientSelfTest
{
- private static string PeerArguments = "--peer";
+ internal static string PeerArguments = "--peer";
private static void AssertTrue(bool value, string message) { if (!value) { throw new InvalidOperationException(message); } }
@@ -18,7 +48,7 @@ private static PresentationSnapshot Snapshot(ulong revision)
return new PresentationSnapshot(revision, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.Chinese, PresentationFlags.OpenLogsEnabled, strings);
}
- private static Process StartPeer(ProcessStartInfo requested)
+ internal static Process StartPeer(ProcessStartInfo requested)
{
requested.Arguments = PeerArguments;
requested.UseShellExecute = false;
@@ -29,7 +59,7 @@ private static Process StartPeer(ProcessStartInfo requested)
return Process.Start(requested);
}
- private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction)
+ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction, bool staleAction)
{
Stream input = Console.OpenStandardInput();
Stream output = Console.OpenStandardOutput();
@@ -50,16 +80,27 @@ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction)
if (emitAction)
{
Guid actionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
- Guid transactionId = Guid.Parse("11111111-2222-3333-4444-555555555555");
- ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(new TrayHostAction(actionId, TrayCommand.CheckAndRepair, 1UL))), keys.HostToParent);
- ProtocolFrame acceptedFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
- AssertTrue(acceptedFrame.MessageType == TrayHostMessageType.ActionResult, "peer receives one parent action result");
- TrayActionResult accepted = TrayHostWire.ReadActionResult(acceptedFrame.Payload);
- AssertTrue(accepted.ActionId == actionId && accepted.Revision == 1UL && accepted.Status == TrayActionResultStatus.Accepted && accepted.TransactionId.HasValue && accepted.TransactionId.Value == transactionId, "peer receives the correlated accepted result");
- ProtocolFrame completedFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
- AssertTrue(completedFrame.MessageType == TrayHostMessageType.ActionResult, "peer receives one parent terminal result");
- TrayActionResult completed = TrayHostWire.ReadActionResult(completedFrame.Payload);
- AssertTrue(completed.ActionId == actionId && completed.Status == TrayActionResultStatus.Completed && completed.TransactionId.HasValue && completed.TransactionId.Value == transactionId, "peer receives the correlated completed result");
+ TrayHostAction action = new TrayHostAction(actionId, TrayCommand.OpenLogs, staleAction ? 8UL : 1UL);
+ HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { return true; });
+ ProductionTraceTrayPlatform tracePlatform = new ProductionTraceTrayPlatform();
+ TrayWindow traceWindow = new TrayWindow(tracePlatform); traceWindow.Create(Snapshot(1UL));
+ AssertTrue(host.TryRegisterAction(action), "program peer registers the exact outbound action");
+ ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(action)), keys.HostToParent);
+ ProtocolFrame terminalFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
+ AssertTrue(terminalFrame.MessageType == TrayHostMessageType.ActionResult, "program peer receives one authenticated terminal result");
+ AssertTrue(Program.TryDispatchAuthenticatedActionResult(host, terminalFrame.Payload), "production Program dispatch accepts the correlated authenticated result");
+ TrayActionResult feedback;
+ if (staleAction)
+ {
+ AssertTrue(host.TryTakeFailedAction(out feedback) && feedback.Revision == 8UL && feedback.Status == TrayActionResultStatus.Rejected && String.Equals(feedback.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "stale authenticated result reaches the production generic-feedback queue with its exact code");
+ traceWindow.ShowActionFailed();
+ AssertTrue(String.Equals(tracePlatform.Message, "string-0|string-15", StringComparison.Ordinal), "stale production trace collapses internal details to the generic acknowledged-snapshot dialog");
+ }
+ else
+ {
+ AssertTrue(!host.TryTakeFailedAction(out feedback) && tracePlatform.Message == null, "completed current action produces no generic failure feedback");
+ }
+ traceWindow.Dispose(); host.Dispose();
return;
}
if (emitFaultAndIgnoreShutdown)
@@ -86,7 +127,7 @@ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction)
}
}
- private static TrayHostStartOptions Options()
+ internal static TrayHostStartOptions Options()
{
Process current = Process.GetCurrentProcess();
return new TrayHostStartOptions {
@@ -172,16 +213,30 @@ private static void TestCorrelatedActionResults()
TrayHostEvent value;
while (client.TryDequeueEvent(out value)) { if (value.Kind == TrayHostEventKind.Action) { action = value; break; } }
}
- AssertTrue(action != null && action.Command == TrayCommand.CheckAndRepair && action.Revision == 1UL, "parent receives the exact v2 action event");
- Guid transactionId = Guid.Parse("11111111-2222-3333-4444-555555555555");
- AssertTrue(client.TryAcknowledgeAction(new TrayActionResult(action.ActionId, action.Revision, TrayActionResultStatus.Accepted, null, transactionId)), "parent queues the accepted action result");
+ AssertTrue(action != null && action.Command == TrayCommand.OpenLogs && action.Revision == 1UL, "parent receives the exact current v2 action event");
bool completed = false; deadline = DateTime.UtcNow.AddSeconds(2);
while (!completed && DateTime.UtcNow < deadline)
{
- completed = client.TryAcknowledgeAction(new TrayActionResult(action.ActionId, action.Revision, TrayActionResultStatus.Completed, null, transactionId));
+ completed = client.TryAcknowledgeAction(new TrayActionResult(action.ActionId, action.Revision, TrayActionResultStatus.Completed, null, null));
if (!completed) { Thread.Sleep(20); }
}
- AssertTrue(completed, "parent queues the terminal result after the accepted result drains");
+ AssertTrue(completed, "parent queues the current terminal result through the authenticated writer");
+ }
+ finally { if (client != null) { client.Dispose(); } PeerArguments = "--peer"; }
+
+ PeerArguments = "--stale-action-peer";
+ try
+ {
+ client = TrayHostParentClient.Start(Options());
+ TrayHostEvent action = null; DateTime deadline = DateTime.UtcNow.AddSeconds(2);
+ while (action == null && DateTime.UtcNow < deadline)
+ {
+ client.WaitForActivity(TimeSpan.FromMilliseconds(100));
+ TrayHostEvent value;
+ while (client.TryDequeueEvent(out value)) { if (value.Kind == TrayHostEventKind.Action) { action = value; break; } }
+ }
+ AssertTrue(action != null && action.Command == TrayCommand.OpenLogs && action.Revision == 8UL, "parent receives the exact stale v2 action event");
+ AssertTrue(client.TryAcknowledgeAction(new TrayActionResult(action.ActionId, action.Revision, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE", null)), "parent queues the stale terminal result through the authenticated writer");
}
finally { if (client != null) { client.Dispose(); } PeerArguments = "--peer"; }
}
@@ -190,9 +245,10 @@ public static int Main(string[] args)
{
try
{
- if (args.Length == 1 && args[0] == "--peer") { RunPeer(false, false); return 0; }
- if (args.Length == 1 && args[0] == "--fault-peer") { RunPeer(true, false); return 0; }
- if (args.Length == 1 && args[0] == "--action-peer") { RunPeer(false, true); return 0; }
+ if (args.Length == 1 && args[0] == "--peer") { RunPeer(false, false, false); return 0; }
+ if (args.Length == 1 && args[0] == "--fault-peer") { RunPeer(true, false, false); return 0; }
+ if (args.Length == 1 && args[0] == "--action-peer") { RunPeer(false, true, false); return 0; }
+ if (args.Length == 1 && args[0] == "--stale-action-peer") { RunPeer(false, true, true); return 0; }
TrayHostParentClient.TestProcessFactory = StartPeer;
TrayHostParentClient client = TrayHostParentClient.Start(Options());
AssertTrue(client.Receipt.ProtocolMajor == 2 && client.GetHealth() == TrayHostHealth.Ready, "parent waits for verified v2 ready");
@@ -217,3 +273,28 @@ public static int Main(string[] args)
}
}
}
+
+public static class TrayHostProductionTraceFixture
+{
+ public static TrayHostParentClient Start(string executablePath, bool stale)
+ {
+ if (String.IsNullOrEmpty(executablePath)) { throw new ArgumentException("trace executable is required", "executablePath"); }
+ Process current = Process.GetCurrentProcess();
+ TrayHostParentClient.TestProcessFactory = TrayHostParentClientSelfTest.StartPeer;
+ TrayHostParentClientSelfTest.PeerArguments = stale ? "--stale-action-peer" : "--action-peer";
+ return TrayHostParentClient.Start(new TrayHostStartOptions {
+ ExePath = executablePath,
+ RuntimeId = "trace-runtime",
+ ParentPid = current.Id,
+ ParentCreationFileTimeUtc = current.StartTime.ToFileTimeUtc(),
+ InitialPresentation = CreateSnapshot()
+ });
+ }
+
+ private static PresentationSnapshot CreateSnapshot()
+ {
+ string[] strings = new string[16];
+ for (int index = 0; index < strings.Length; index++) { strings[index] = "trace-" + index.ToString(); }
+ return new PresentationSnapshot(1UL, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.English, PresentationFlags.OpenLogsEnabled, strings);
+ }
+}
diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs
index 7a55cfa..d276a07 100644
--- a/tests/trayhost/TrayHostTransportSelfTest.cs
+++ b/tests/trayhost/TrayHostTransportSelfTest.cs
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
internal static class TrayHostTransportSelfTest
{
@@ -198,6 +200,42 @@ private static void TestTerminalDiagnosticFailureNeverStrandsPendingActions()
host.Dispose();
}
+ private static void TestBlockingTerminalWriterRunsOutsideCorrelationLock()
+ {
+ ManualResetEvent writerEntered = new ManualResetEvent(false);
+ ManualResetEvent releaseWriter = new ManualResetEvent(false);
+ bool first = true;
+ HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record)
+ {
+ if (first) { first = false; writerEntered.Set(); releaseWriter.WaitOne(TimeSpan.FromSeconds(3)); }
+ return true;
+ });
+ Guid blockedId = Guid.NewGuid();
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(blockedId, TrayCommand.OpenLogs, 24UL)), "blocking-writer action registers");
+ bool acknowledged = false;
+ Thread acknowledgement = new Thread((ThreadStart)delegate { acknowledged = host.TryAcknowledgeAction(new TrayActionResult(blockedId, 24UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)); });
+ acknowledgement.IsBackground = true;
+ acknowledgement.Start();
+ try
+ {
+ AssertTrue(writerEntered.WaitOne(TimeSpan.FromSeconds(2)), "terminal writer enters deterministically");
+ Guid concurrentId = Guid.NewGuid();
+ Stopwatch registration = Stopwatch.StartNew();
+ bool registered = host.TryRegisterAction(new TrayHostAction(concurrentId, TrayCommand.OpenLogs, 24UL));
+ registration.Stop();
+ AssertTrue(registered && registration.Elapsed < TimeSpan.FromMilliseconds(250), "blocking terminal I/O cannot hold the correlation lock or pending capacity");
+ releaseWriter.Set();
+ AssertTrue(acknowledgement.Join(TimeSpan.FromSeconds(2)) && acknowledged, "terminal acknowledgement completes after the writer is released");
+ AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(concurrentId, 24UL, TrayActionResultStatus.Completed, null, null)), "concurrent action remains independently completable");
+ }
+ finally
+ {
+ releaseWriter.Set();
+ if (acknowledgement.IsAlive) { acknowledgement.Join(TimeSpan.FromSeconds(2)); }
+ writerEntered.Dispose(); releaseWriter.Dispose(); host.Dispose();
+ }
+ }
+
public static int Main(string[] args)
{
try
@@ -211,7 +249,8 @@ public static int Main(string[] args)
TestRejectedAndFailedActionsQueueUserFeedback();
TestUndisplayedActionFailureFeedbackIsBounded();
TestTerminalDiagnosticFailureNeverStrandsPendingActions();
- Console.WriteLine("TrayHost transport self-tests passed: 9");
+ TestBlockingTerminalWriterRunsOutsideCorrelationLock();
+ Console.WriteLine("TrayHost transport self-tests passed: 10");
return 0;
}
catch (Exception error)
From 4aad2a7f84e3d4cf3acb2608154e9f8c7a123ec9 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 06:05:44 +0800
Subject: [PATCH 024/187] docs: record Task 4 fix round 3
---
.../task-4-report.md | 180 ++++++++++++++++++
1 file changed, 180 insertions(+)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
index 9c7613a..f7a2224 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-4-report.md
@@ -446,3 +446,183 @@ the new diagnostic type, and runs only the existing no-UI headless path.
path; the production path was compiled but not exercised against user data.
- No independent agent was used because this fix round explicitly prohibited
agents. Parent re-review remains the next gate.
+
+## Fix round 3: bounded reparse-safe receipts and production correlation trace
+
+Independent re-review retained three Important findings: the host action log
+was unbounded, its fixed LocalAppData path lacked reparse-component checks,
+diagnostic I/O ran while holding HostTransport's correlation lock, and the
+in-process bridge did not prove the real TrayHostClient/ParentClient/Wire/Program
+authenticated path.
+
+Fix implementation commit: `9c1e4df`
+(`fix: harden tray terminal diagnostics`).
+
+### RED evidence
+
+Tests were added before each production change.
+
+The blocking-writer transport test deliberately held the diagnostic writer for
+up to three seconds, then attempted to register another action. Current code
+failed because writer I/O still held `_gate`:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+blocking terminal I/O cannot hold the correlation lock or pending capacity
+```
+
+The bounded-retention native test failed at compile time before the fixed log
+contract existed:
+
+```text
+CS0117: TrayTerminalDiagnosticLog does not contain a definition for MaximumBytes
+CCOD_TRAYHOST_NATIVE_COMPILE_FAILED
+```
+
+After adding the first lock/retention implementation, a missing-writer assertion
+was retained from fix round 2 and remained green: no writer cannot authorize
+generic feedback but still releases the terminal pending action.
+
+The production authenticated trace test referenced Program's actual result
+dispatch before it was extracted. ParentClient-only compilation failed:
+
+```text
+CS0117: Program does not contain a definition for TryDispatchAuthenticatedActionResult
+CCOD_TRAYHOST_NATIVE_COMPILE_FAILED
+```
+
+This proved the old peer was not traversing Program's production result branch.
+
+### Bounded deterministic retention
+
+`TrayTerminalDiagnosticLog.MaximumBytes` is fixed at 64 KiB. Each record is
+encoded as UTF-8 without a BOM. Under the logger's private file gate, the writer
+selects one of two deterministic modes:
+
+- append when existing bytes plus the complete new record remain within 64 KiB;
+- recreate the file with only the complete newest record when the next append
+ would exceed 64 KiB or an already oversized file is encountered.
+
+The stream is closed after `Flush(true)`. The test writes 1,400 distinct
+records, proves the file never exceeds `MaximumBytes`, and proves the complete
+latest record remains at the end after rollover. This prevents long-running
+tray action volume from growing the file without bound.
+
+### Fixed-path and reparse defense
+
+Production no longer calls an unconditional `Directory.CreateDirectory` over
+the full path. `TryGetDefaultPath` starts from the system-provided current-user
+LocalApplicationData root and creates only the fixed
+`CodexControlOtherDevices` and `logs` child directories, one level at a time.
+Before each creation it verifies the existing parent chain, and after creation
+it verifies the new directory.
+
+`TryAppend` accepts only an absolute path whose leaf is exactly
+`trayhost-actions.log`. It rejects a directory leaf, a reparse-point file, any
+missing directory, or any existing directory component with the
+`FileAttributes.ReparsePoint` bit. Production supplies only the fixed path; no
+wire field, CLI value, action property, or user payload can select a path.
+
+The native test creates a real junction inside an isolated temporary root and
+proves a fixed-name log beneath that junction is rejected. All temporary files
+and the junction are removed by the test.
+
+### Diagnostic I/O outside the correlation lock
+
+HostTransport now validates the exact action id/revision and lifecycle state,
+constructs the typed diagnostic, and removes the terminal action from
+`_pendingActions` while holding `_gate`. It then releases `_gate` before calling
+the writer. After the writer returns, it reacquires `_gate` only to enqueue the
+About/generic feedback item when persistence succeeded and the transport is
+still active.
+
+The blocking-writer regression starts terminal acknowledgement on a background
+thread, waits until the writer is blocked, then registers a distinct action.
+Registration completes within 250 ms, proving neither correlation lock nor
+pending capacity is held by file I/O. After releasing the writer, both the first
+terminal acknowledgement and the concurrently registered action complete.
+
+False, throwing, absent, blocked, and full-feedback-queue outcomes continue to
+release pending capacity. Only persisted records authorize UI feedback.
+
+### Production authenticated correlation trace
+
+`Program.TryDispatchAuthenticatedActionResult` is the exact production reader
+operation extracted from the existing ActionResult branch:
+
+```text
+TrayHostWire.ReadActionResult(payload)
+ -> HostTransport.TryAcknowledgeAction(result)
+```
+
+The production reader itself now calls this internal function. No new CLI mode,
+frame type, wire field, external endpoint, or unauthenticated channel was added.
+
+The ParentClient self-test compiles Program and the real native/transport/wire
+sources into its isolated test executable. Its existing test-only peer process
+performs the real parent/host bootstrap, key derivation, MAC verification,
+sequence checks, and Action/ActionResult serialization. The peer registers the
+same action in real HostTransport and sends it over `TrayHostWire`; the result
+returns over the real authenticated `TrayHostParentClient` writer and is
+consumed by Program's production dispatch.
+
+`TrayHostProductionTrace.SelfTest.ps1` then loads that same compiled assembly
+and uses the real production PowerShell wrapper:
+
+```text
+TrayHostClient.Receive-CcodTrayHostEvents
+ -> TrayHostParentClient authenticated event
+ -> current or stale terminal decision fixture
+ -> TrayHostClient.Send-CcodTrayHostActionResult
+ -> TrayHostParentClient authenticated writer
+ -> TrayHostWire ActionResult
+ -> Program production dispatch
+ -> HostTransport diagnostic and feedback gate
+```
+
+The current case preserves `OpenLogs` revision 1, receives `Completed`, and
+produces no generic failure feedback. The stale case preserves `OpenLogs`
+revision 8 and `CCOD_TRAY_ACTION_STALE`; after the persisted typed receipt, the
+real feedback queue is consumed and `TrayWindow.ShowActionFailed` runs against a
+fake native platform. It proves the dialog contains only the acknowledged
+snapshot's generic `string-0|string-15` title/message, not the internal code.
+The fake platform creates no real window or interactive UI.
+
+Test-only peer arguments remain implemented only by the self-test main type;
+they are not recognized by production Program and therefore create no product
+control surface.
+
+### Fresh verification
+
+Fresh verification completed with these results:
+
+```text
+TrayHost client: exit 0; 8 behavioral cases
+Supervisor: exit 0; 98 behavioral cases
+TrayHost transport: exit 0; 10 behavioral cases
+TrayHost native: exit 0; 17 behavioral cases
+TrayHost parent-client: exit 0; 4 behavioral cases
+TrayHost production correlation trace: exit 0; 2 current/stale cases
+TrayHost production compile/headless smoke: exit 0
+git diff --check: exit 0
+git diff --cached --check: exit 0
+```
+
+The first combined verification command exceeded its 30-second tool window
+after the ParentClient cases and before the trace receipt was printed. It was
+not counted as evidence for the remaining stages. ParentClient+production trace
+and ProductionOnly smoke were rerun independently and both returned explicit
+exit `0` receipts.
+
+### Fix-round scope and concerns
+
+- Changed only TrayHost terminal logging/correlation production files, focused
+ tests/trace orchestration, and this report.
+- No real Codex/ChatGPT or production interactive TrayHost process was started,
+ stopped, signaled, or controlled. Native behavior used fake platforms; the
+ subprocess trace used only the compiled self-test peer.
+- Nothing was installed, pushed, published, released, signed, or written to
+ WindowsApps/DPAPI. Reparse and rollover writes were confined to unique temp
+ roots.
+- No independent agent was used because the fix-round instruction prohibited
+ agents. Parent re-review remains the next gate.
From a7292f6b952a290a23ac852ea6ae56600293f214 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 08:25:59 +0800
Subject: [PATCH 025/187] docs: plan async tray receipt reliability
---
.../2026-08-28-v2522-async-tray-receipts.md | 235 ++++++++++++++++++
...08-28-v2522-install-runtime-reliability.md | 7 +
...-08-28-v2522-async-tray-receipts-design.md | 124 +++++++++
3 files changed, 366 insertions(+)
create mode 100644 docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
create mode 100644 docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
diff --git a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
new file mode 100644
index 0000000..6d2b628
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
@@ -0,0 +1,235 @@
+# v2.5.22 Async Tray Receipt Reliability Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make terminal tray receipts non-blocking, bounded, handle-verified,
+and genuinely traceable through the authenticated production child flow.
+
+**Architecture:** Terminal action correlation remains in `HostTransport`, but
+receipt persistence moves to one bounded background sink. A native pinned
+handle store writes the fixed local receipt without pathname re-resolution.
+An internal child-session seam lets a temporary trace executable exercise the
+same authenticated child behavior with a fake native platform.
+
+**Tech Stack:** Windows PowerShell 5.1, C#/.NET Framework 4.8, Win32 file
+handles, existing TrayHost authenticated protocol and self-tests.
+
+**Spec:** `docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md`
+
+## Global Constraints
+
+- Preserve the existing wire message types, directions, payloads, sequences,
+ MAC checks, command IDs, and current-user boundaries exactly.
+- Receipt disk text contains only command, revision, terminal status, and a
+ canonical code; never persist action IDs, transaction IDs, paths, tokens,
+ exception text, or arbitrary payload text.
+- No production command-line test mode, listener, environment-variable switch,
+ or external control surface may be added.
+- All terminal actions leave the pending set before any receipt I/O or queue
+ wait; no writer hang may create a replacement writer thread.
+- Generic UI feedback is produced only after durable receipt success; its queue
+ is bounded and runs on the existing STA work path.
+- Keep primary checkout untouched; work only in this linked worktree.
+
+---
+
+### Task 1: Add a pinned, bounded receipt store
+
+**Files:**
+- Modify: `src/trayhost/TrayTerminalDiagnostic.cs`
+- Modify: `src/trayhost/Program.cs`
+- Test: `tests/trayhost/TrayHostNativeSelfTest.cs`
+- Test: `tests/trayhost/Invoke-TrayHostSelfTest.ps1`
+
+**Interfaces:**
+- `TrayTerminalDiagnosticStore` owns validated native directory/leaf handles.
+- `TryAppendDurably(TrayTerminalDiagnostic)` returns `true` only after a full
+ write and `Flush(true)` through the validated leaf handle.
+- `TryGetDefaultPath` remains fixed to the current-user receipt leaf; no caller
+ can choose another production path.
+
+- [ ] **Step 1: Write failing native-store tests**
+
+Add a temporary-root test that precreates a reparse directory, reparse leaf,
+hard-link leaf, and an outside sentinel. Add a barrier that attempts to
+replace the logs directory after the store has opened its directory chain.
+Assert each unsafe case returns `false` and the sentinel bytes remain unchanged.
+Add 64-KiB boundary and rollover tests that verify one whole latest record is
+retained through the open handle.
+
+- [ ] **Step 2: Run RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly`
+
+Expected: compile or behavioral failure because the current path-based store
+does not own native handles or the barrier contract.
+
+- [ ] **Step 3: Implement the minimal handle-pinned store**
+
+Open directory components and the fixed leaf through `CreateFileW` safe
+handles. Reject reparse, non-directory, non-file, multi-link, unexpected
+final-path, share, and handle-query failures before writes. Keep handles while
+using `SetLength`, write, and `Flush(true)` for the 64-KiB rollover. Do not
+re-resolve the leaf path for append or truncation.
+
+- [ ] **Step 4: Run GREEN**
+
+Run the native suite and production headless compile smoke. Assert the store
+write is bounded, handle-validated, and cannot alter an outside sentinel.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: pin tray terminal receipt storage
+```
+
+### Task 2: Make terminal receipt dispatch asynchronous and bounded
+
+**Files:**
+- Create: `src/trayhost/TrayTerminalReceiptSink.cs`
+- Modify: `src/trayhost/HostTransport.cs`
+- Modify: `src/trayhost/Program.cs`
+- Test: `tests/trayhost/TrayHostTransportSelfTest.cs`
+- Test: `tests/trayhost/TrayHostNativeSelfTest.cs`
+
+**Interfaces:**
+- `HostTransport.TryAcknowledgeAction` returns a typed terminal receipt for a
+ terminal result after removing its pending entry.
+- `TrayTerminalReceiptSink.TrySubmit` is zero-wait and admits at most eight
+ outstanding receipts.
+- `HostTransport.TryPublishDurableReceipt` accepts only a trusted in-memory
+ receipt and places one bounded UI item.
+
+- [ ] **Step 1: Write failing sink/transport tests**
+
+Use a controllable fake store that blocks before open, during write, and during
+flush. Assert current and following authenticated terminal results return
+within 250 ms, the ninth outstanding receipt is dropped immediately, no pending
+entry remains, and no generic UI work exists until an explicit durable-success
+callback. Add disposal and recovery tests without starting a replacement
+writer.
+
+- [ ] **Step 2: Run RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly`
+
+Expected: the current synchronous writer blocks the terminal call and the new
+receipt-sink API is unavailable.
+
+- [ ] **Step 3: Implement one worker and bounded UI completion**
+
+Separate correlation from receipt work. The reader submits once and returns;
+only the single background writer calls the store. On durable success it posts
+bounded About/failure work to the existing application context. On admission,
+store, callback, queue, or disposal failure, consume the terminal result but
+show no feedback. Do not alter the wire result or add a new message type.
+
+- [ ] **Step 4: Run GREEN**
+
+Run transport and native suites. Confirm a hung writer cannot stall the reader
+or action capacity, and a later durable receipt still produces exactly one
+generic feedback item.
+
+- [ ] **Step 5: Commit**
+
+```text
+fix: isolate tray terminal receipt writes
+```
+
+### Task 3: Trace the actual child session without a shipped test hook
+
+**Files:**
+- Create: `src/trayhost/TrayHostChildSession.cs`
+- Create: `src/trayhost/WindowsTrayHostRuntime.cs`
+- Modify: `src/trayhost/Program.cs`
+- Modify: `src/trayhost/TrayHostApplication.cs`
+- Create: `tests/trayhost/TrayHostProductionTraceSelfTest.cs`
+- Modify: `tests/trayhost/Invoke-TrayHostSelfTest.ps1`
+- Modify: `tests/persistence/TrayHostProductionTrace.SelfTest.ps1`
+- Test: `tests/trayhost/TrayHostParentClientSelfTest.cs`
+- Test: `tests/persistence/TrayHostClient.SelfTest.ps1`
+
+**Interfaces:**
+- `TrayHostChildSession.Run` accepts only internal stream/runtime/sink
+ dependencies; `Program.Main` exposes no new arguments.
+- `WindowsTrayHostRuntime` composes the existing Windows platform, window, and
+ application; the temporary trace executable supplies a fake implementation.
+
+- [ ] **Step 1: Write failing real-trace tests**
+
+Compile a temporary trace executable with the same child session and a fake
+native platform. Start it through normal `TrayHostParentClient.Start` (without
+`TestProcessFactory`). Require a current native `OpenLogs` selection to reach
+the PowerShell TrayHostClient/Supervisor fixture and a stale authenticated
+negative action to show only the generic dialog after durable receipt success.
+
+- [ ] **Step 2: Run RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1`
+
+Expected: it fails because `Program.RunChild` cannot be composed with a
+test-only runtime while preserving the production child session.
+
+- [ ] **Step 3: Extract the minimal internal session seam**
+
+Move the current child bootstrap, reader/writer sequencing, work drain, and
+shutdown behavior into `TrayHostChildSession`. `Program.RunChild` becomes a
+thin argument/identity/Windows-runtime adapter. The trace executable shares
+the child session and real protocol code but has no production CLI selector.
+
+- [ ] **Step 4: Run GREEN**
+
+Run ParentClient, TrayHostClient, production trace, native, transport, and
+headless-smoke suites. Confirm current completion has no generic dialog;
+stale rejection has one generic dialog with no internal code or correlation
+data; no production executable recognizes test arguments.
+
+- [ ] **Step 5: Commit**
+
+```text
+test: trace authenticated tray terminal receipts
+```
+
+### Task 4: Revalidate v2.5.22 release inputs
+
+**Files:**
+- Modify: `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+- Test: `tests/PersistenceSelfTest.ps1`
+- Test: `tests/Validate.ps1`
+
+- [ ] **Step 1: Write a failing release-surface test**
+
+Require the TrayHost provenance record to include every newly compiled source
+and require the release workflow to run the production trace before producing
+setup or portable assets.
+
+- [ ] **Step 2: Run RED**
+
+Run: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+
+Expected: it fails until the release validation includes the receipt trace.
+
+- [ ] **Step 3: Add the smallest release gate**
+
+Invoke the production trace in the existing TrayHost/release validation path;
+preserve existing manifest, Defender, payload, and exact-version checks.
+
+- [ ] **Step 4: Run GREEN and commit**
+
+Run release workflow plus the persistence/validation suites under the existing
+controlled isolation harness. Commit:
+
+```text
+test: gate release on tray receipt trace
+```
+
+## Plan self-review
+
+- Task 1 handles filesystem identity and bounded storage without changing
+ authenticated action semantics.
+- Task 2 consumes terminal actions before zero-wait sink admission and never
+ lets a writer block the reader or pending-action capacity.
+- Task 3 proves the shared production child session end-to-end in a temporary
+ executable, so tests cannot create a production test control path.
+- Task 4 makes the new trace a release gate before the existing v2.5.22
+ metadata/build/reinstall acceptance task begins.
diff --git a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
index 39f2442..246a845 100644
--- a/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
+++ b/docs/superpowers/plans/2026-08-28-v2522-install-runtime-reliability.md
@@ -228,6 +228,13 @@ fix: stabilize verified Electron process trees
### Task 4: Make tray action correlation testable and diagnosable
+> **Superseded for execution:** Task 4 reached an approved architectural
+> correction after three reviewed fix rounds. Execute
+> `docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md` before this
+> plan's Task 5. Its receipt sink preserves this task's authenticated
+> correlation and generic-dialog constraints while making persistence bounded
+> and non-blocking.
+
**Files:**
- Modify: `src/persistence/modules/TrayHostClient.psm1`
- Modify: `src/persistence/Supervisor.ps1`
diff --git a/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
new file mode 100644
index 0000000..8cbc4f8
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
@@ -0,0 +1,124 @@
+# v2.5.22 Async Tray Receipt Reliability Design
+
+## Goal
+
+Replace the synchronous TrayHost terminal-diagnostic write with a bounded,
+asynchronous receipt pipeline. A slow, failed, or unsafe receipt write must
+never block authenticated result consumption, exhaust pending tray actions, or
+show a generic dialog without a durable local receipt.
+
+## Why this is a separate design
+
+The prior task established action correlation, exact acknowledgement gates, and
+sanitized terminal fields. Three repair rounds showed that a synchronous file
+append on the authenticated reader path cannot meet the full contract: a
+path-only reparse check has a time-of-check/time-of-use gap, `Flush(true)` can
+block the only reader, and an in-process test bridge does not prove the real
+authenticated child session. The user approved this architectural correction
+on 2026-08-28.
+
+## Non-negotiable constraints
+
+- Keep the existing authenticated TrayHost wire schema, directions, message
+ types, action-result fields, sequence rules, and capability policy unchanged.
+- Do not add a production test command, listener, environment switch, external
+ control path, or unauthenticated input channel.
+- A terminal action is consumed exactly once. It never returns to the pending
+ set after receipt admission, failure, queue saturation, writer hang, or
+ shutdown.
+- Generic failure feedback and About display require a successfully flushed,
+ sanitized receipt. A failed, full, busy, closed, or hung receipt path drops
+ only the feedback; it never invents a result or blocks later actions.
+- Receipt disk content contains exactly `command`, `revision`, `status`, and a
+ canonical `CCOD_*` code. It excludes action IDs, transaction IDs, paths,
+ tokens, exceptions, messages, and raw wire data.
+- Preserve current-user operation. Do not modify Codex binaries, WindowsApps,
+ account data, or the DPAPI device-key store.
+
+## Architecture
+
+### 1. Terminal correlation and zero-wait admission
+
+`HostTransport` continues to authenticate and validate a terminal result
+against its pending action ID, revision, accepted-before-completed rule, and
+command policy. Under its correlation lock it constructs a typed
+`TrayTerminalReceipt`, removes the pending action, and returns the receipt to
+the child session. It performs no file I/O and no wait for queue capacity.
+
+`TrayTerminalReceiptSink.TrySubmit` accepts at most eight outstanding receipts,
+including one currently writing. It returns immediately with an admission
+result. Busy, full, or closed admission suppresses feedback but leaves the
+action consumed. The sink has one background writer only; it never creates a
+replacement thread for a stuck write.
+
+After a durable write, the sink calls back into `HostTransport` with the
+in-memory receipt. The transport enqueues a single bounded UI work item:
+completed About maps to About; rejected/failed maps to the existing generic
+failure dialog; all other completed commands map to no UI. The callback posts
+work to the STA application context outside transport locks. The UI queue is
+also bounded at eight shared items.
+
+### 2. Pinned, bounded native receipt store
+
+The store uses the fixed path
+`%LOCALAPPDATA%\CodexControlOtherDevices\logs\trayhost-actions.log`. It opens
+the LocalAppData, product, and logs directories as native directory handles,
+with reparse-point opening flags and delete sharing denied, validates each
+opened handle as the expected non-reparse directory, and holds the directory
+chain while it opens the fixed leaf. The leaf is opened through a native handle
+before any write; its handle must prove a regular, non-reparse, single-link file
+at the expected final path. Writes and rollover occur only through that
+validated handle, never through a re-resolved pathname.
+
+The file is UTF-8 without a BOM and has a hard 64 KiB limit. If adding a whole
+record would cross the limit, the validated handle is truncated and receives
+only the new complete record. A receipt is durable only after the write and
+`Flush(true)` both succeed. Handle or validation failure fails closed for UI
+feedback.
+
+### 3. Child-session seam and trace executable
+
+Move the existing `Program.RunChild` session behavior behind an internal child
+session/runtime seam. `Program.Main` retains only its existing `--child` and
+`--headless-smoke` inputs and creates the Windows runtime adapter. A temporary
+self-test executable uses the same child session and authenticated startup but
+substitutes only a fake native tray platform. It is compiled in the existing
+temporary test directory and is never shipped or selectable by the production
+binary.
+
+The production trace uses the real PowerShell TrayHostClient exports,
+TrayHostParentClient, authenticated wire, child session, HostTransport, receipt
+sink, and generic-dialog invocation. It covers both a current native
+`OpenLogs` selection and an explicitly authenticated stale-revision negative
+fixture. The latter is required because an ordinary native click always uses
+the current displayed revision.
+
+## Failure policy
+
+| Condition | Pending action | Receipt/UI |
+| --- | --- | --- |
+| Sink busy, full, or closed | Consumed | Drop receipt and UI feedback |
+| Unsafe path, reparse, hard link, sharing failure | Consumed | Store fails; no UI feedback |
+| Write or flush failure | Consumed | Store fails; no UI feedback |
+| Writer hangs | Consumed | One writer remains blocked; bounded later receipts drop; no UI feedback |
+| Durable success, UI queue full | Consumed | Keep durable receipt; drop only UI item |
+| Disposal | Consumed | Stop admission and callbacks; bounded join only |
+
+## Acceptance criteria
+
+1. A terminal result removes its pending action before sink admission; eight or
+ more failed admissions cannot stop later actions from registering.
+2. A blocked store leaves the authenticated reader responsive to a subsequent
+ presentation or ping and cannot hold the transport correlation lock.
+3. The store rejects reparse directory components, a reparse leaf, a hard-link
+ leaf, a second writer, and a concurrent replacement attempt before any
+ outside target is written.
+4. Receipt storage never exceeds 64 KiB and retains one complete latest record
+ after rollover through the validated handle.
+5. Generic feedback or About work is queued only after durable success; false,
+ throw, full, blocked, or failed paths queue none.
+6. The production trace exercises current and stale paths through TrayHostClient
+ to ParentClient, authenticated wire, child session, HostTransport, receipt
+ sink, and generic dialog, without a production-only test selector.
+7. Existing installer/new-user/upgrade, process identity, package, native
+ TrayHost, and Defender checks remain green before release acceptance.
From 365fde2430723fc8f80106e86490f7fb70be163f Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 08:54:02 +0800
Subject: [PATCH 026/187] fix: pin tray terminal receipt storage
---
src/trayhost/Program.cs | 7 +-
src/trayhost/TrayTerminalDiagnostic.cs | 353 ++++++++++++++++++---
tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 1 +
tests/trayhost/TrayHostNativeSelfTest.cs | 252 +++++++++++++--
4 files changed, 543 insertions(+), 70 deletions(-)
diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs
index f438004..895c118 100644
--- a/src/trayhost/Program.cs
+++ b/src/trayhost/Program.cs
@@ -39,9 +39,8 @@ private static int RunChild(string[] args)
PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload);
Win32TrayPlatform platform = new Win32TrayPlatform();
TrayHostApplication application = null;
- string terminalLogPath;
- if (!TrayTerminalDiagnosticLog.TryGetDefaultPath(out terminalLogPath)) { terminalLogPath = null; }
- HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return TrayTerminalDiagnosticLog.TryAppend(terminalLogPath, record); });
+ TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore();
+ HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return terminalStore.TryAppendDurably(record); });
TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen);
bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object();
Action command = delegate(TrayCommand selected, ulong revision)
@@ -100,7 +99,7 @@ private static int RunChild(string[] args)
})) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" };
reader.Start();
int result = application.Run();
- application.Dispose(); transport.Dispose(); return result;
+ application.Dispose(); transport.Dispose(); terminalStore.Dispose(); return result;
}
private static bool VerifyParentIdentity(int pid, long creation)
diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs
index c3f160e..43497af 100644
--- a/src/trayhost/TrayTerminalDiagnostic.cs
+++ b/src/trayhost/TrayTerminalDiagnostic.cs
@@ -1,6 +1,11 @@
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Runtime.InteropServices;
+using System.Security.AccessControl;
+using System.Security.Principal;
using System.Text;
+using Microsoft.Win32.SafeHandles;
internal sealed class TrayTerminalDiagnostic
{
@@ -18,78 +23,350 @@ internal TrayTerminalDiagnostic(TrayCommand command, ulong revision, TrayActionR
}
}
-internal static class TrayTerminalDiagnosticLog
+internal sealed class TrayTerminalDiagnosticStore : IDisposable
{
+ private sealed class ReceiptPaths
+ {
+ internal string LocalRoot;
+ internal string Product;
+ internal string Logs;
+ internal string Leaf;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct NativeFileTime
+ {
+ internal uint Low;
+ internal uint High;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct ByHandleFileInformation
+ {
+ internal uint FileAttributes;
+ internal NativeFileTime CreationTime;
+ internal NativeFileTime LastAccessTime;
+ internal NativeFileTime LastWriteTime;
+ internal uint VolumeSerialNumber;
+ internal uint FileSizeHigh;
+ internal uint FileSizeLow;
+ internal uint NumberOfLinks;
+ internal uint FileIndexHigh;
+ internal uint FileIndexLow;
+ }
+
internal const long MaximumBytes = 64L * 1024L;
- private static readonly object Gate = new object();
+ private const uint GenericRead = 0x80000000U;
+ private const uint GenericWrite = 0x40000000U;
+ private const uint ReadControl = 0x00020000U;
+ private const uint FileListDirectory = 0x00000001U;
+ private const uint FileReadAttributes = 0x00000080U;
+ private const uint FileShareRead = 0x00000001U;
+ private const uint FileShareWrite = 0x00000002U;
+ private const uint OpenExisting = 3U;
+ private const uint OpenAlways = 4U;
+ private const uint FileAttributeDirectory = 0x00000010U;
+ private const uint FileAttributeDevice = 0x00000040U;
+ private const uint FileAttributeReparsePoint = 0x00000400U;
+ private const uint FileFlagWriteThrough = 0x80000000U;
+ private const uint FileFlagBackupSemantics = 0x02000000U;
+ private const uint FileFlagOpenReparsePoint = 0x00200000U;
+ private const uint FileTypeDisk = 1U;
+ private const uint SecurityInformationOwner = 0x00000001U;
+ private const uint SecurityInformationDacl = 0x00000004U;
+ private const uint SeFileObject = 1U;
+ private const int FullControlAccessMask = 0x001F01FF;
+ private readonly object _gate = new object();
+ private bool _disposed;
+
+ internal TrayTerminalDiagnosticStore()
+ {
+ }
+
+#if TRAYHOST_SELF_TEST
+ private readonly string _testLocalRoot;
+ private readonly Action _testDirectoryChainOpened;
+ private readonly Action _testLeafValidated;
+
+ private TrayTerminalDiagnosticStore(string testLocalRoot, Action directoryChainOpened, Action leafValidated)
+ {
+ _testLocalRoot = testLocalRoot;
+ _testDirectoryChainOpened = directoryChainOpened;
+ _testLeafValidated = leafValidated;
+ }
+
+ internal static TrayTerminalDiagnosticStore CreateForTesting(string localRoot, Action directoryChainOpened, Action leafValidated)
+ {
+ return new TrayTerminalDiagnosticStore(localRoot, directoryChainOpened, leafValidated);
+ }
+#endif
internal static bool TryGetDefaultPath(out string path)
{
path = null;
try
{
- string local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
- if (String.IsNullOrEmpty(local) || !Path.IsPathRooted(local) || !IsSafeExistingDirectory(local)) { return false; }
- string product = Path.Combine(local, "CodexControlOtherDevices");
- if (!TryCreateSafeChildDirectory(product)) { return false; }
- string logs = Path.Combine(product, "logs");
- if (!TryCreateSafeChildDirectory(logs)) { return false; }
- path = Path.Combine(logs, "trayhost-actions.log");
- return IsSafePath(path);
+ ReceiptPaths paths;
+ if (!TryBuildPaths(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), out paths)) { return false; }
+ path = paths.Leaf;
+ return true;
}
catch { path = null; return false; }
}
- internal static bool TryAppend(string path, TrayTerminalDiagnostic record)
+ internal bool TryAppendDurably(TrayTerminalDiagnostic record)
{
- if (String.IsNullOrEmpty(path) || record == null || !Path.IsPathRooted(path)) { return false; }
+ if (record == null) { return false; }
+ byte[] bytes;
try
{
string line = "command=" + record.Command.ToString() + " revision=" + record.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=" + record.Status.ToString() + " code=" + record.Code + Environment.NewLine;
- byte[] bytes = new UTF8Encoding(false).GetBytes(line);
+ bytes = new UTF8Encoding(false).GetBytes(line);
if (bytes.LongLength > MaximumBytes) { return false; }
- string full = Path.GetFullPath(path);
- lock (Gate)
+ }
+ catch { return false; }
+
+ lock (_gate)
+ {
+ if (_disposed) { return false; }
+ try
{
- if (!IsSafePath(full)) { return false; }
- FileMode mode = File.Exists(full) && new FileInfo(full).Length + bytes.LongLength <= MaximumBytes ? FileMode.Append : FileMode.Create;
- using (FileStream stream = new FileStream(full, mode, FileAccess.Write, FileShare.Read))
+ ReceiptPaths paths;
+ if (!TryGetPaths(out paths)) { return false; }
+ return TryAppendThroughValidatedHandles(paths, bytes);
+ }
+ catch { return false; }
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_gate) { _disposed = true; }
+ }
+
+ private bool TryGetPaths(out ReceiptPaths paths)
+ {
+#if TRAYHOST_SELF_TEST
+ if (_testLocalRoot != null) { return TryBuildPaths(_testLocalRoot, out paths); }
+#endif
+ return TryBuildPaths(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), out paths);
+ }
+
+ private bool TryAppendThroughValidatedHandles(ReceiptPaths paths, byte[] bytes)
+ {
+ using (SafeFileHandle local = OpenDirectory(paths.LocalRoot))
+ {
+ if (!IsExpectedDirectory(local, paths.LocalRoot, false)) { return false; }
+ if (!TryCreateChildDirectory(paths.Product)) { return false; }
+ using (SafeFileHandle product = OpenDirectory(paths.Product))
+ {
+ if (!IsExpectedDirectory(product, paths.Product, true)) { return false; }
+ if (!TryCreateChildDirectory(paths.Logs)) { return false; }
+ using (SafeFileHandle logs = OpenDirectory(paths.Logs))
{
- stream.Write(bytes, 0, bytes.Length);
- stream.Flush(true);
+ if (!IsExpectedDirectory(logs, paths.Logs, true)) { return false; }
+#if TRAYHOST_SELF_TEST
+ if (_testDirectoryChainOpened != null) { _testDirectoryChainOpened(); }
+#endif
+ using (SafeFileHandle leaf = CreateFileW(paths.Leaf, GenericRead | GenericWrite | ReadControl, FileShareRead, IntPtr.Zero, OpenAlways, FileFlagOpenReparsePoint | FileFlagWriteThrough, IntPtr.Zero))
+ {
+ if (!IsExpectedLeaf(leaf, paths.Leaf)) { return false; }
+#if TRAYHOST_SELF_TEST
+ if (_testLeafValidated != null) { _testLeafValidated(); }
+#endif
+ using (FileStream stream = new FileStream(leaf, FileAccess.ReadWrite, 4096, false))
+ {
+ long length = stream.Length;
+ if (length > MaximumBytes || length > MaximumBytes - bytes.LongLength)
+ {
+ stream.SetLength(0L);
+ stream.Position = 0L;
+ }
+ else { stream.Position = length; }
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(true);
+ return true;
+ }
+ }
}
}
+ }
+ }
+
+ private static bool TryBuildPaths(string localRoot, out ReceiptPaths paths)
+ {
+ paths = null;
+ if (String.IsNullOrEmpty(localRoot) || !Path.IsPathRooted(localRoot) || localRoot.StartsWith("\\\\?\\", StringComparison.Ordinal) || localRoot.StartsWith("\\\\.\\", StringComparison.Ordinal)) { return false; }
+ string canonicalRoot = NormalizePath(localRoot);
+ if (String.IsNullOrEmpty(canonicalRoot)) { return false; }
+ string product = NormalizePath(Path.Combine(canonicalRoot, "CodexControlOtherDevices"));
+ string logs = NormalizePath(Path.Combine(product, "logs"));
+ string leaf = NormalizePath(Path.Combine(logs, "trayhost-actions.log"));
+ if (String.IsNullOrEmpty(product) || String.IsNullOrEmpty(logs) || String.IsNullOrEmpty(leaf)) { return false; }
+ if (!String.Equals(Path.GetDirectoryName(product), canonicalRoot, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(logs), product, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(leaf), logs, StringComparison.OrdinalIgnoreCase)) { return false; }
+ if (!String.Equals(Path.GetFileName(product), "CodexControlOtherDevices", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(logs), "logs", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(leaf), "trayhost-actions.log", StringComparison.Ordinal) || Path.GetFileName(leaf).IndexOf(':') >= 0) { return false; }
+ paths = new ReceiptPaths { LocalRoot = canonicalRoot, Product = product, Logs = logs, Leaf = leaf };
+ return true;
+ }
+
+ private static string NormalizePath(string path)
+ {
+ try
+ {
+ string full = Path.GetFullPath(path);
+ string root = Path.GetPathRoot(full);
+ while (!String.IsNullOrEmpty(root) && full.Length > root.Length && (full.EndsWith("\\", StringComparison.Ordinal) || full.EndsWith("/", StringComparison.Ordinal))) { full = full.Substring(0, full.Length - 1); }
+ return full;
+ }
+ catch { return null; }
+ }
+
+ private static bool TryCreateChildDirectory(string path)
+ {
+ try
+ {
+ if (Directory.Exists(path)) { return true; }
+ Directory.CreateDirectory(path, CreatePrivateDirectorySecurity());
return true;
}
catch { return false; }
}
- private static bool TryCreateSafeChildDirectory(string path)
+ private static DirectorySecurity CreatePrivateDirectorySecurity()
+ {
+ SecurityIdentifier user = GetCurrentUserSid();
+ if (user == null) { throw new InvalidOperationException("current user SID is unavailable"); }
+ DirectorySecurity security = new DirectorySecurity();
+ security.SetOwner(user);
+ security.SetAccessRuleProtection(true, false);
+ InheritanceFlags inheritance = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
+ SecurityIdentifier[] identities = new SecurityIdentifier[] {
+ user,
+ new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
+ new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null)
+ };
+ for (int i = 0; i < identities.Length; i++)
+ {
+ security.AddAccessRule(new FileSystemAccessRule(identities[i], FileSystemRights.FullControl, inheritance, PropagationFlags.None, AccessControlType.Allow));
+ }
+ return security;
+ }
+
+ private static SecurityIdentifier GetCurrentUserSid()
+ {
+ try
+ {
+ using (WindowsIdentity identity = WindowsIdentity.GetCurrent())
+ {
+ return identity == null || identity.User == null ? null : new SecurityIdentifier(identity.User.Value);
+ }
+ }
+ catch { return null; }
+ }
+
+ private static SafeFileHandle OpenDirectory(string path)
+ {
+ return CreateFileW(path, FileListDirectory | FileReadAttributes | ReadControl, FileShareRead | FileShareWrite, IntPtr.Zero, OpenExisting, FileFlagBackupSemantics | FileFlagOpenReparsePoint, IntPtr.Zero);
+ }
+
+ private static bool IsExpectedDirectory(SafeFileHandle handle, string expectedPath, bool requirePrivateSecurity)
+ {
+ ByHandleFileInformation information;
+ if (handle == null || handle.IsInvalid || GetFileType(handle) != FileTypeDisk || !GetFileInformationByHandle(handle, out information)) { return false; }
+ if ((information.FileAttributes & FileAttributeDirectory) == 0U || (information.FileAttributes & (FileAttributeReparsePoint | FileAttributeDevice)) != 0U) { return false; }
+ if (!IsExpectedFinalPath(handle, expectedPath)) { return false; }
+ return !requirePrivateSecurity || HasPrivateDirectorySecurity(handle);
+ }
+
+ private static bool HasPrivateDirectorySecurity(SafeFileHandle handle)
+ {
+ IntPtr owner; IntPtr group; IntPtr dacl; IntPtr sacl; IntPtr descriptor;
+ uint result = GetSecurityInfo(handle, SeFileObject, SecurityInformationOwner | SecurityInformationDacl, out owner, out group, out dacl, out sacl, out descriptor);
+ if (result != 0U || descriptor == IntPtr.Zero) { return false; }
+ try
+ {
+ uint length = GetSecurityDescriptorLength(descriptor);
+ if (length == 0U || length > 65536U) { return false; }
+ byte[] bytes = new byte[checked((int)length)];
+ Marshal.Copy(descriptor, bytes, 0, bytes.Length);
+ RawSecurityDescriptor security = new RawSecurityDescriptor(bytes, 0);
+ SecurityIdentifier user = GetCurrentUserSid();
+ if (user == null || security.Owner == null || !String.Equals(security.Owner.Value, user.Value, StringComparison.Ordinal)) { return false; }
+ if ((security.ControlFlags & ControlFlags.DiscretionaryAclPresent) == 0 || (security.ControlFlags & ControlFlags.DiscretionaryAclProtected) == 0 || security.DiscretionaryAcl == null || security.DiscretionaryAcl.Count != 3) { return false; }
+ HashSet expected = new HashSet(StringComparer.Ordinal) {
+ user.Value,
+ new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null).Value,
+ new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null).Value
+ };
+ HashSet seen = new HashSet(StringComparer.Ordinal);
+ AceFlags inheritance = AceFlags.ContainerInherit | AceFlags.ObjectInherit;
+ for (int i = 0; i < security.DiscretionaryAcl.Count; i++)
+ {
+ CommonAce ace = security.DiscretionaryAcl[i] as CommonAce;
+ if (ace == null || ace.IsCallback || ace.AceQualifier != AceQualifier.AccessAllowed || ace.SecurityIdentifier == null || ace.AccessMask != FullControlAccessMask) { return false; }
+ if ((ace.AceFlags & inheritance) != inheritance || (ace.AceFlags & ~(inheritance)) != AceFlags.None) { return false; }
+ if (!expected.Contains(ace.SecurityIdentifier.Value) || !seen.Add(ace.SecurityIdentifier.Value)) { return false; }
+ }
+ return seen.Count == expected.Count;
+ }
+ catch { return false; }
+ finally { LocalFree(descriptor); }
+ }
+
+ private static bool IsExpectedLeaf(SafeFileHandle handle, string expectedPath)
{
- if (Directory.Exists(path)) { return IsSafeExistingDirectory(path); }
- string parent = Path.GetDirectoryName(path);
- if (String.IsNullOrEmpty(parent) || !IsSafeExistingDirectory(parent)) { return false; }
- Directory.CreateDirectory(path);
- return IsSafeExistingDirectory(path);
+ ByHandleFileInformation information;
+ if (handle == null || handle.IsInvalid || GetFileType(handle) != FileTypeDisk || !GetFileInformationByHandle(handle, out information)) { return false; }
+ if ((information.FileAttributes & (FileAttributeDirectory | FileAttributeReparsePoint | FileAttributeDevice)) != 0U || information.NumberOfLinks != 1U) { return false; }
+ return IsExpectedFinalPath(handle, expectedPath);
}
- private static bool IsSafePath(string path)
+ private static bool IsExpectedFinalPath(SafeFileHandle handle, string expectedPath)
{
- if (!Path.IsPathRooted(path) || !String.Equals(Path.GetFileName(path), "trayhost-actions.log", StringComparison.OrdinalIgnoreCase) || Directory.Exists(path)) { return false; }
- if (File.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) { return false; }
- string directory = Path.GetDirectoryName(path);
- return !String.IsNullOrEmpty(directory) && IsSafeExistingDirectory(directory);
+ string actual;
+ if (!TryGetFinalPath(handle, out actual)) { return false; }
+ return String.Equals(actual, NormalizePath(expectedPath), StringComparison.OrdinalIgnoreCase);
}
- private static bool IsSafeExistingDirectory(string path)
+ private static bool TryGetFinalPath(SafeFileHandle handle, out string path)
{
- DirectoryInfo current = new DirectoryInfo(path);
- if (!current.Exists) { return false; }
- while (current != null)
+ path = null;
+ StringBuilder buffer = new StringBuilder(512);
+ uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0U);
+ if (length == 0U) { return false; }
+ if (length >= (uint)buffer.Capacity)
{
- if (!current.Exists || (current.Attributes & FileAttributes.ReparsePoint) != 0) { return false; }
- current = current.Parent;
+ if (length > 32767U) { return false; }
+ buffer = new StringBuilder(checked((int)length + 1));
+ length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0U);
+ if (length == 0U || length >= (uint)buffer.Capacity) { return false; }
}
- return true;
+ string value = buffer.ToString();
+ if (value.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase)) { value = "\\\\" + value.Substring(8); }
+ else if (value.StartsWith("\\\\?\\", StringComparison.Ordinal)) { value = value.Substring(4); }
+ path = NormalizePath(value);
+ return !String.IsNullOrEmpty(path);
}
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern SafeFileHandle CreateFileW(string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes, uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetFileInformationByHandle(SafeFileHandle file, out ByHandleFileInformation information);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint GetFileType(SafeFileHandle file);
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern uint GetFinalPathNameByHandleW(SafeFileHandle file, StringBuilder path, uint pathLength, uint flags);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ private static extern uint GetSecurityInfo(SafeFileHandle handle, uint objectType, uint securityInformation, out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr securityDescriptor);
+
+ [DllImport("advapi32.dll", SetLastError = true)]
+ private static extern uint GetSecurityDescriptorLength(IntPtr securityDescriptor);
+
+ [DllImport("kernel32.dll")]
+ private static extern IntPtr LocalFree(IntPtr memory);
}
diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
index f38ae62..ad95efa 100644
--- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1
+++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
@@ -62,6 +62,7 @@ try{
$mainType=if($ProtocolOnly){'TrayHostProtocolSelfTest'}elseif($NativeOnly){'TrayHostNativeSelfTest'}elseif($TransportOnly){'TrayHostTransportSelfTest'}elseif($ParentClientOnly){'TrayHostParentClientSelfTest'}else{'Program'}
$outputPath=Join-Path $temporaryRoot $outputName
$args=@('/nologo','/noconfig','/nostdlib+','/target:exe','/platform:anycpu','/optimize+','/checked+','/warn:4','/warnaserror+',('/out:{0}' -f $outputPath),('/main:{0}' -f $mainType))
+ if($NativeOnly){$args+='/define:TRAYHOST_SELF_TEST'}
foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$args+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))}
if($ProductionOnly){$args+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)}
else
diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs
index 67a1980..316ea72 100644
--- a/tests/trayhost/TrayHostNativeSelfTest.cs
+++ b/tests/trayhost/TrayHostNativeSelfTest.cs
@@ -2,6 +2,11 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
+using System.Runtime.InteropServices;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using System.Text;
+using System.Threading;
internal sealed class FakeTrayPlatform : INativeTrayPlatform
{
@@ -48,9 +53,48 @@ internal sealed class FakeTrayPlatform : INativeTrayPlatform
internal static class TrayHostNativeSelfTest
{
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreateHardLinkW(string fileName, string existingFileName, IntPtr securityAttributes);
+
private static void AssertTrue(bool value, string message) { if (!value) { throw new InvalidOperationException(message); } }
private static void AssertEqual(string expected, string actual, string message) { if (!String.Equals(expected, actual, StringComparison.Ordinal)) { throw new InvalidOperationException(message + " expected=[" + expected + "] actual=[" + actual + "]"); } }
+ private static string ReceiptPath(string root)
+ {
+ return Path.Combine(root, "CodexControlOtherDevices", "logs", "trayhost-actions.log");
+ }
+
+ private static TrayTerminalDiagnosticStore TestStore(string root, Action directoryChainOpened, Action leafValidated)
+ {
+ return TrayTerminalDiagnosticStore.CreateForTesting(root, directoryChainOpened, leafValidated);
+ }
+
+ private static void CreateJunction(string link, string target)
+ {
+ ProcessStartInfo info = new ProcessStartInfo {
+ FileName = Environment.GetEnvironmentVariable("ComSpec"),
+ Arguments = "/d /c mklink /J \"" + link + "\" \"" + target + "\"",
+ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true
+ };
+ using (Process process = Process.Start(info))
+ {
+ process.WaitForExit();
+ AssertTrue(process.ExitCode == 0, "test junction is created: " + process.StandardError.ReadToEnd());
+ }
+ }
+
+ private static void DeleteJunction(string path)
+ {
+ try { if (Directory.Exists(path)) { Directory.Delete(path); } } catch { }
+ }
+
+ private static byte[] ReceiptBytes(TrayCommand command, ulong revision, TrayActionResultStatus status, string code)
+ {
+ string line = "command=" + command.ToString() + " revision=" + revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=" + status.ToString() + " code=" + code + Environment.NewLine;
+ return new UTF8Encoding(false).GetBytes(line);
+ }
+
private static PresentationSnapshot Snapshot(ulong revision)
{
return SnapshotV2(revision);
@@ -214,46 +258,194 @@ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence()
{
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
- string path = Path.Combine(root, "trayhost-actions.log");
+ string path = ReceiptPath(root);
try
{
TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 23UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
- AssertTrue(TrayTerminalDiagnosticLog.TryAppend(path, record), "terminal diagnostic append reports durable success");
- AssertEqual("command=OpenLogs revision=23 status=Rejected code=CCOD_TRAY_ACTION_STALE" + Environment.NewLine, File.ReadAllText(path), "terminal diagnostic file contains only the approved correlation fields");
- AssertTrue(!TrayTerminalDiagnosticLog.TryAppend(root, record), "terminal diagnostic append reports persistence failure without throwing");
+ using (TrayTerminalDiagnosticStore store = TestStore(root, null, null))
+ {
+ AssertTrue(store.TryAppendDurably(record), "terminal diagnostic append reports durable success");
+ }
+ byte[] expected = ReceiptBytes(TrayCommand.OpenLogs, 23UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
+ AssertTrue(Convert.ToBase64String(expected) == Convert.ToBase64String(File.ReadAllBytes(path)), "terminal diagnostic file is exact UTF-8 without a BOM and contains only approved fields");
+ string defaultPath;
+ AssertTrue(TrayTerminalDiagnosticStore.TryGetDefaultPath(out defaultPath), "fixed production receipt path resolves");
+ string expectedDefaultPath = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "trayhost-actions.log"));
+ AssertTrue(String.Equals(expectedDefaultPath, defaultPath, StringComparison.OrdinalIgnoreCase), "production receipt path is fixed to the current user's exact LocalApplicationData leaf");
}
finally { try { Directory.Delete(root, true); } catch { } }
}
- private static void TestTerminalDiagnosticLogIsBoundedAndRejectsReparseComponents()
+ private static void TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel()
{
- string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-bound-" + Guid.NewGuid().ToString("N"));
- string target = Path.Combine(root, "target");
- string junction = Path.Combine(root, "junction");
- Directory.CreateDirectory(target);
- string path = Path.Combine(target, "trayhost-actions.log");
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-unsafe-" + Guid.NewGuid().ToString("N"));
+ string outside = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-outside-" + Guid.NewGuid().ToString("N"));
+ string outsideSentinel = Path.Combine(outside, "trayhost-actions.log");
+ string path = ReceiptPath(root);
+ string logs = Path.GetDirectoryName(path);
+ byte[] sentinel = new byte[] { 0x10, 0x22, 0x34, 0x46, 0x58, 0x6a };
+ Directory.CreateDirectory(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel);
try
{
- string lastLine = String.Empty;
- for (ulong revision = 1UL; revision <= 1400UL; revision++)
+ TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 1UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
+ using (TrayTerminalDiagnosticStore bootstrap = TestStore(root, null, null)) { AssertTrue(bootstrap.TryAppendDurably(record), "safe receipt directories are bootstrapped"); }
+ File.Delete(path); Directory.Delete(logs);
+
+ CreateJunction(logs, outside);
+ using (TrayTerminalDiagnosticStore reparseDirectory = TestStore(root, null, null))
+ {
+ AssertTrue(!reparseDirectory.TryAppendDurably(record), "a reparse logs directory is rejected before writing");
+ }
+ AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse directory cannot alter the outside sentinel");
+ DeleteJunction(logs);
+
+ using (TrayTerminalDiagnosticStore restore = TestStore(root, null, null)) { AssertTrue(restore.TryAppendDurably(record), "safe logs directory is restored for leaf tests"); }
+ File.Delete(path);
+ CreateJunction(path, outside);
+ using (TrayTerminalDiagnosticStore reparseLeaf = TestStore(root, null, null))
+ {
+ AssertTrue(!reparseLeaf.TryAppendDurably(record), "a reparse receipt leaf is rejected before writing");
+ }
+ AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse leaf cannot alter the outside sentinel");
+ DeleteJunction(path);
+
+ AssertTrue(CreateHardLinkW(path, outsideSentinel, IntPtr.Zero), "test hard link is created");
+ using (TrayTerminalDiagnosticStore hardLinkLeaf = TestStore(root, null, null))
{
- TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, revision, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
- AssertTrue(TrayTerminalDiagnosticLog.TryAppend(path, record), "bounded terminal diagnostic append succeeds");
- lastLine = "command=OpenLogs revision=" + revision.ToString(System.Globalization.CultureInfo.InvariantCulture) + " status=Failed code=CCOD_TRAY_ACTION_FAILED" + Environment.NewLine;
+ AssertTrue(!hardLinkLeaf.TryAppendDurably(record), "a multi-link receipt leaf is rejected before writing");
}
- FileInfo retained = new FileInfo(path);
- AssertTrue(retained.Length <= TrayTerminalDiagnosticLog.MaximumBytes, "terminal diagnostic retention never exceeds its fixed byte bound");
- AssertTrue(File.ReadAllText(path).EndsWith(lastLine, StringComparison.Ordinal), "deterministic rollover retains the complete latest terminal record");
-
- ProcessStartInfo junctionInfo = new ProcessStartInfo {
- FileName = Environment.GetEnvironmentVariable("ComSpec"),
- Arguments = "/d /c mklink /J \"" + junction + "\" \"" + target + "\"",
- UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true
+ AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "hard-link leaf cannot alter the outside sentinel");
+ File.Delete(path);
+
+ DirectorySecurity unsafeSecurity = Directory.GetAccessControl(logs);
+ unsafeSecurity.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
+ Directory.SetAccessControl(logs, unsafeSecurity);
+ using (TrayTerminalDiagnosticStore unsafeAcl = TestStore(root, null, null))
+ {
+ AssertTrue(!unsafeAcl.TryAppendDurably(record), "an unexpected logs DACL is rejected before writing");
+ }
+ AssertTrue(!File.Exists(path), "unsafe directory ACL cannot create or write the receipt leaf");
+ AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "unsafe directory ACL cannot alter the outside sentinel");
+ }
+ finally
+ {
+ DeleteJunction(path); DeleteJunction(logs);
+ try { Directory.Delete(root, true); } catch { }
+ try { Directory.Delete(outside, true); } catch { }
+ }
+ }
+
+ private static void TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacement()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-pin-" + Guid.NewGuid().ToString("N"));
+ string outside = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-pin-outside-" + Guid.NewGuid().ToString("N"));
+ string path = ReceiptPath(root); string logs = Path.GetDirectoryName(path); string displaced = logs + ".displaced";
+ string outsideSentinel = Path.Combine(outside, "trayhost-actions.log");
+ byte[] sentinel = new byte[] { 0x71, 0x72, 0x73, 0x74, 0x75 };
+ bool barrierEntered = false; bool replacementSucceeded = false;
+ Directory.CreateDirectory(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel);
+ try
+ {
+ TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 2UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
+ using (TrayTerminalDiagnosticStore bootstrap = TestStore(root, null, null)) { AssertTrue(bootstrap.TryAppendDurably(record), "safe receipt chain is bootstrapped"); }
+ File.Delete(path);
+ Action barrier = delegate
+ {
+ barrierEntered = true;
+ try { Directory.Move(logs, displaced); replacementSucceeded = true; CreateJunction(logs, outside); } catch (IOException) { }
+ catch (UnauthorizedAccessException) { }
+ throw new InvalidOperationException("intentional barrier stop");
};
- using (Process junctionProcess = Process.Start(junctionInfo)) { junctionProcess.WaitForExit(); AssertTrue(junctionProcess.ExitCode == 0, "test junction is created"); }
- AssertTrue(!TrayTerminalDiagnosticLog.TryAppend(Path.Combine(junction, "trayhost-actions.log"), new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 1UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE")), "terminal diagnostic rejects a reparse-point path component");
+ using (TrayTerminalDiagnosticStore store = TestStore(root, barrier, null))
+ {
+ AssertTrue(!store.TryAppendDurably(record), "a replacement attempt at the directory-to-leaf barrier fails closed");
+ }
+ AssertTrue(barrierEntered, "replacement barrier runs after the directory chain is opened");
+ AssertTrue(!replacementSucceeded, "the opened logs handle denies rename replacement");
+ AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "replacement attempt leaves the outside sentinel byte-identical");
+ }
+ finally
+ {
+ DeleteJunction(logs);
+ try { if (Directory.Exists(displaced) && !Directory.Exists(logs)) { Directory.Move(displaced, logs); } } catch { }
+ try { Directory.Delete(root, true); } catch { }
+ try { Directory.Delete(outside, true); } catch { }
+ }
+ }
+
+ private static void TestTerminalDiagnosticStoreRejectsConcurrentWriter()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-share-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ ManualResetEvent leafValidated = new ManualResetEvent(false); ManualResetEvent release = new ManualResetEvent(false);
+ bool firstResult = false;
+ try
+ {
+ TrayTerminalDiagnostic first = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 3UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
+ TrayTerminalDiagnostic second = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 4UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
+ using (TrayTerminalDiagnosticStore firstStore = TestStore(root, null, delegate { leafValidated.Set(); release.WaitOne(); }))
+ using (TrayTerminalDiagnosticStore secondStore = TestStore(root, null, null))
+ {
+ Thread writer = new Thread(new ThreadStart(delegate { firstResult = firstStore.TryAppendDurably(first); }));
+ writer.Start();
+ AssertTrue(leafValidated.WaitOne(5000), "first writer reaches the validated leaf barrier");
+ Stopwatch rejection = Stopwatch.StartNew();
+ AssertTrue(!secondStore.TryAppendDurably(second), "a concurrent writer is rejected by leaf sharing");
+ rejection.Stop(); AssertTrue(rejection.ElapsedMilliseconds < 250L, "a sharing conflict fails without waiting or retrying");
+ release.Set(); AssertTrue(writer.Join(5000), "first writer completes after the sharing probe");
+ AssertTrue(firstResult, "the validated first writer remains durable");
+ }
+ }
+ finally { release.Set(); leafValidated.Dispose(); release.Dispose(); try { Directory.Delete(root, true); } catch { } }
+ }
+
+ private static void TestTerminalDiagnosticStoreRejectsDirectoryLeafWithoutWriting()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-directory-leaf-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root); string path = ReceiptPath(root);
+ try
+ {
+ TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 5UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
+ using (TrayTerminalDiagnosticStore bootstrap = TestStore(root, null, null)) { AssertTrue(bootstrap.TryAppendDurably(record), "safe receipt chain is bootstrapped for directory-leaf test"); }
+ File.Delete(path); Directory.CreateDirectory(path);
+ using (TrayTerminalDiagnosticStore directoryLeaf = TestStore(root, null, null))
+ {
+ AssertTrue(!directoryLeaf.TryAppendDurably(record), "a directory at the fixed receipt leaf is rejected before writing");
+ }
+ AssertTrue(Directory.Exists(path) && Directory.GetFileSystemEntries(path).Length == 0, "directory leaf remains unchanged after rejection");
}
- finally { try { if (Directory.Exists(junction)) { Directory.Delete(junction); } } catch { } try { Directory.Delete(root, true); } catch { } }
+ finally { try { if (Directory.Exists(path)) { Directory.Delete(path, true); } } catch { } try { Directory.Delete(root, true); } catch { } }
+ }
+
+ private static void TestTerminalDiagnosticStoreIsBoundedThroughValidatedHandle()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-bound-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root); string path = ReceiptPath(root);
+ try
+ {
+ TrayTerminalDiagnostic first = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 41UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
+ TrayTerminalDiagnostic second = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 42UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
+ byte[] firstBytes = ReceiptBytes(TrayCommand.OpenLogs, 41UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
+ byte[] secondBytes = ReceiptBytes(TrayCommand.OpenLogs, 42UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
+ using (TrayTerminalDiagnosticStore store = TestStore(root, null, null))
+ {
+ AssertTrue(store.TryAppendDurably(first), "safe receipt chain is created before boundary setup");
+ byte[] boundaryPrefix = new byte[checked((int)(TrayTerminalDiagnosticStore.MaximumBytes - firstBytes.Length))];
+ for (int i = 0; i < boundaryPrefix.Length; i++) { boundaryPrefix[i] = (byte)'x'; }
+ File.WriteAllBytes(path, boundaryPrefix);
+ AssertTrue(store.TryAppendDurably(first), "a whole receipt fitting the exact 64-KiB boundary is appended");
+ AssertTrue(new FileInfo(path).Length == TrayTerminalDiagnosticStore.MaximumBytes, "exact-boundary append retains exactly 64 KiB");
+ AssertTrue(File.ReadAllText(path).EndsWith(Encoding.UTF8.GetString(firstBytes), StringComparison.Ordinal), "exact-boundary append retains the whole latest record");
+
+ AssertTrue(store.TryAppendDurably(second), "a boundary-crossing receipt rolls over through the validated handle");
+ AssertTrue(Convert.ToBase64String(secondBytes) == Convert.ToBase64String(File.ReadAllBytes(path)), "rollover retains exactly one complete latest record");
+
+ File.WriteAllBytes(path, new byte[checked((int)TrayTerminalDiagnosticStore.MaximumBytes + 17)]);
+ AssertTrue(store.TryAppendDurably(first), "an oversized existing receipt rolls over through the validated handle");
+ AssertTrue(Convert.ToBase64String(firstBytes) == Convert.ToBase64String(File.ReadAllBytes(path)), "oversized rollover retains exactly one complete latest record");
+ }
+ }
+ finally { try { Directory.Delete(root, true); } catch { } }
}
private static void TestNoHimcFailureIsSafe()
@@ -336,13 +528,17 @@ public static int Main(string[] args)
TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion();
TestActionFailureUsesTheAcknowledgedSnapshotStrings();
TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence();
- TestTerminalDiagnosticLogIsBoundedAndRejectsReparseComponents();
+ TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel();
+ TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacement();
+ TestTerminalDiagnosticStoreRejectsConcurrentWriter();
+ TestTerminalDiagnosticStoreRejectsDirectoryLeafWithoutWriting();
+ TestTerminalDiagnosticStoreIsBoundedThroughValidatedHandle();
TestSimplifiedMenuAndExitConfirmation();
TestNoHimcFailureIsSafe();
TestShellRightClickNotificationMapping();
TestRealNativePInvokeSurface();
TestPostedWorkMessageDispatchesToItsOwnerWindow();
- Console.WriteLine("TrayHost native self-tests passed: 17");
+ Console.WriteLine("TrayHost native self-tests passed: 21");
return 0;
}
catch (Exception error)
From 592ef5ad682b4c55ba96d4cf4d796a0716b488e2 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 08:55:55 +0800
Subject: [PATCH 027/187] docs: record async tray receipt Task 1
---
.../task-1-report.md | 221 ++++++++++++++++++
1 file changed, 221 insertions(+)
create mode 100644 .superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md
new file mode 100644
index 0000000..90d935c
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md
@@ -0,0 +1,221 @@
+# Task 1 report: pinned, bounded tray receipt store
+
+Date: 2026-08-28
+
+Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+
+Branch: `codex/v2522-install-runtime-reliability`
+
+## Status
+
+Task 1 is implemented and focused verification is green. This report does not
+claim the later asynchronous sink, bounded UI completion, or child-session
+trace from Tasks 2 and 3.
+
+## Files
+
+- `src/trayhost/TrayTerminalDiagnostic.cs`
+ - Replaced the caller-selected path append helper with
+ `TrayTerminalDiagnosticStore`.
+ - The production constructor has no path parameter. `TryGetDefaultPath`
+ resolves only
+ `%LOCALAPPDATA%\CodexControlOtherDevices\logs\trayhost-actions.log`.
+ - Opens LocalAppData, product, and logs through `CreateFileW` directory
+ handles using backup-semantics/open-reparse flags, `FILE_LIST_DIRECTORY`,
+ attribute/security read access, read/write sharing, and no delete sharing.
+ - Validates the actual opened directory handles for disk type, directory and
+ non-reparse attributes, expected final path, and (for product/logs) exact
+ current-user/SYSTEM/Administrators owner/DACL policy.
+ - Opens the fixed leaf through `CreateFileW(OPEN_ALWAYS)` with open-reparse
+ and write-through flags plus read sharing only. It rejects non-disk,
+ directory, reparse, device, multi-link, unexpected-final-path, query, and
+ sharing failures before any content write or truncation.
+ - Reads length, performs rollover with `SetLength(0)`, writes the complete
+ UTF-8 no-BOM record, and calls `Flush(true)` through the already validated
+ leaf handle. It returns `true` only after the flush succeeds.
+- `src/trayhost/Program.cs`
+ - Owns one `TrayTerminalDiagnosticStore` and passes
+ `TryAppendDurably` to the existing transport callback.
+ - Disposes the store during the existing shutdown cleanup.
+ - No command-line input, protocol type, wire field, direction, sequence, or
+ authentication rule changed.
+- `tests/trayhost/TrayHostNativeSelfTest.cs`
+ - Added exact UTF-8/sanitization and fixed-production-path checks.
+ - Added temporary-root reparse-directory, reparse-leaf, ordinary-directory
+ leaf, hard-link leaf, unsafe DACL, directory-replacement barrier, concurrent
+ writer, exact 64-KiB, crossing-rollover, and oversized-file checks.
+ - The directory replacement and hard-link cases retain an outside sentinel
+ byte-for-byte.
+- `tests/trayhost/Invoke-TrayHostSelfTest.ps1`
+ - Defines `TRAYHOST_SELF_TEST` only for the temporary NativeOnly test
+ executable. The temporary-root factory and barriers are compiled out of
+ every production build and do not create a production CLI, environment
+ switch, listener, or external control surface.
+
+## TDD evidence
+
+### Clean baseline
+
+Command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Output before test changes, exit `0`:
+
+```text
+TrayHost native self-tests passed: 17
+```
+
+### Initial RED
+
+The behavioral test surface was added before production code.
+
+Command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Exit `1`; exact decisive output:
+
+```text
+tests\trayhost\TrayHostNativeSelfTest.cs(66,20): error CS0246: ... TrayTerminalDiagnosticStore ...
+CCOD_TRAYHOST_NATIVE_COMPILE_FAILED
+```
+
+This was the expected missing-feature failure: the current implementation did
+not provide a store that owned native handles or exposed the temporary-only
+barrier contract.
+
+### Directory replacement behavior RED and native root cause
+
+After the first minimal implementation compiled, the barrier remained red:
+
+```text
+TrayHost native self-test failed: System.InvalidOperationException
+the opened logs handle denies rename replacement
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+A temporary in-memory Win32 probe isolated the cause. With the same
+no-delete-share flags, an attribute-only directory handle allowed
+`Directory.Move`; adding `FILE_LIST_DIRECTORY` made the move fail with sharing
+violation `0x80070020`:
+
+```text
+ReadAttributes : move-succeeded
+ListDirectory : IOException:80070020
+Delete : IOException:80070020
+GenericRead : IOException:80070020
+ReadAttributesDelete : IOException:80070020
+```
+
+The production directory open therefore requests the minimal
+`FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | READ_CONTROL` access needed for
+the no-delete-share pin to enforce the approved barrier.
+
+### ACL RED
+
+An additional test added an Everyone full-control ACE to the existing logs
+directory. Before handle-based ACL validation, NativeOnly exited `1` with:
+
+```text
+TrayHost native self-test failed: System.InvalidOperationException
+an unexpected logs DACL is rejected before writing
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The green implementation queries the security descriptor through the opened
+directory handle with `GetSecurityInfo`. It never repairs an existing
+unexpected ACL and rejects it before opening or creating the receipt leaf.
+
+## Fresh GREEN evidence
+
+All commands below were rerun after the last source/test change.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost native self-tests passed: 21
+```
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+```
+
+Exit `0`, no output. This compiled the complete production TrayHost and ran
+`--headless-smoke` without the self-test symbol.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost transport self-tests passed: 10
+```
+
+This preserves the existing rule that a `false` persistence result cannot
+produce About/failure feedback.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost protocol self-tests passed: 11
+```
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost parent-client self-tests passed: 4
+TrayHost production correlation trace passed: 2
+```
+
+The ParentClient run also printed the repository's existing PowerShell warning
+about unapproved verbs in `TrayHostClient`; no command in this task changed
+that module or warning surface.
+
+```powershell
+git diff --check
+```
+
+Exit `0`; only Git's existing LF-to-CRLF conversion notices were printed.
+
+## Commit
+
+- `365fde2430723fc8f80106e86490f7fb70be163f` —
+ `fix: pin tray terminal receipt storage`
+
+## Concerns and explicit boundaries
+
+- `Program` still invokes the receipt callback synchronously because the
+ bounded background sink is Task 2. This commit fixes filesystem identity,
+ pinning, safety, and bounded storage only; it does not yet make the
+ authenticated reader non-blocking.
+- Existing product/log directories with a different owner or DACL fail closed
+ and are not repaired. This follows the approved design, but a pre-existing
+ inherited/broadened directory will suppress receipt durability and therefore
+ generic feedback until a separately authorized migration or repair policy is
+ designed.
+- The private ACL, reparse, hard-link, and sharing controls contain accidental
+ replacement and unsafe filesystem objects. They are not represented as a
+ security boundary against a fully hostile process already running as the
+ same Windows user.
+- No installer, installed Codex process, real tray UI, push, release, or public
+ operation was run. Release-wide and real-machine acceptance remain outside
+ Task 1.
From debe0935ea68433816971756679f7aad7ac97a33 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 09:16:01 +0800
Subject: [PATCH 028/187] docs: refine tray receipt compatibility boundary
---
.../2026-08-28-v2522-async-tray-receipts.md | 13 +++++++----
...-08-28-v2522-async-tray-receipts-design.md | 23 ++++++++++++-------
2 files changed, 23 insertions(+), 13 deletions(-)
diff --git a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
index 6d2b628..6b2a809 100644
--- a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
+++ b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
@@ -66,11 +66,14 @@ does not own native handles or the barrier contract.
- [ ] **Step 3: Implement the minimal handle-pinned store**
-Open directory components and the fixed leaf through `CreateFileW` safe
-handles. Reject reparse, non-directory, non-file, multi-link, unexpected
-final-path, share, and handle-query failures before writes. Keep handles while
-using `SetLength`, write, and `Flush(true)` for the 64-KiB rollover. Do not
-re-resolve the leaf path for append or truncation.
+Open the existing LocalAppData/product/logs compatibility parents and the fixed
+leaf through `CreateFileW` safe handles. Create only
+`logs\tray-receipts` as a protected current-user/SYSTEM/Administrators child,
+then reopen and validate its exact handle; legacy direct receipt leaves are
+never reused. Reject reparse, non-directory, non-file, multi-link,
+unexpected-final-path, share, and handle-query failures before writes. Keep
+handles while using `SetLength`, write, and `Flush(true)` for the 64-KiB
+rollover. Do not re-resolve the leaf path for append or truncation.
- [ ] **Step 4: Run GREEN**
diff --git a/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
index 8cbc4f8..6c09ae1 100644
--- a/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
+++ b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
@@ -61,14 +61,21 @@ also bounded at eight shared items.
### 2. Pinned, bounded native receipt store
The store uses the fixed path
-`%LOCALAPPDATA%\CodexControlOtherDevices\logs\trayhost-actions.log`. It opens
-the LocalAppData, product, and logs directories as native directory handles,
-with reparse-point opening flags and delete sharing denied, validates each
-opened handle as the expected non-reparse directory, and holds the directory
-chain while it opens the fixed leaf. The leaf is opened through a native handle
-before any write; its handle must prove a regular, non-reparse, single-link file
-at the expected final path. Writes and rollover occur only through that
-validated handle, never through a re-resolved pathname.
+`%LOCALAPPDATA%\CodexControlOtherDevices\logs\tray-receipts\trayhost-actions.log`.
+The existing product and `logs` directories are created by supported historical
+installations with normal inherited current-user ACLs, so they remain
+compatibility parents: the store opens and pins them as non-reparse directory
+handles but does not require them to have the receipt-private ACL. Beneath the
+pinned `logs` handle it creates the fixed `tray-receipts` child with a protected
+current-user/SYSTEM/Administrators ACL, reopens and validates that exact child
+handle, and fails closed if an existing child does not match. It never reuses
+the legacy direct `logs\trayhost-actions.log` leaf.
+
+The store holds the native directory chain with reparse-point opening flags and
+delete sharing denied while it opens the fixed leaf. The leaf is opened through
+a native handle before any write; its handle must prove a regular, non-reparse,
+single-link file at the expected final path. Writes and rollover occur only
+through that validated handle, never through a re-resolved pathname.
The file is UTF-8 without a BOM and has a hard 64 KiB limit. If adding a whole
record would cross the limit, the validated handle is truncated and receives
From f207496df1ceee744a09bbc5c3d24a48695e58bc Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 09:23:53 +0800
Subject: [PATCH 029/187] fix: preserve tray receipt parent compatibility
---
src/trayhost/TrayTerminalDiagnostic.cs | 55 +++++-----
tests/trayhost/TrayHostNativeSelfTest.cs | 127 +++++++++++++++++++----
2 files changed, 136 insertions(+), 46 deletions(-)
diff --git a/src/trayhost/TrayTerminalDiagnostic.cs b/src/trayhost/TrayTerminalDiagnostic.cs
index 43497af..b1b7774 100644
--- a/src/trayhost/TrayTerminalDiagnostic.cs
+++ b/src/trayhost/TrayTerminalDiagnostic.cs
@@ -30,6 +30,7 @@ private sealed class ReceiptPaths
internal string LocalRoot;
internal string Product;
internal string Logs;
+ internal string ReceiptDirectory;
internal string Leaf;
}
@@ -157,35 +158,38 @@ private bool TryAppendThroughValidatedHandles(ReceiptPaths paths, byte[] bytes)
using (SafeFileHandle local = OpenDirectory(paths.LocalRoot))
{
if (!IsExpectedDirectory(local, paths.LocalRoot, false)) { return false; }
- if (!TryCreateChildDirectory(paths.Product)) { return false; }
using (SafeFileHandle product = OpenDirectory(paths.Product))
{
- if (!IsExpectedDirectory(product, paths.Product, true)) { return false; }
- if (!TryCreateChildDirectory(paths.Logs)) { return false; }
+ if (!IsExpectedDirectory(product, paths.Product, false)) { return false; }
using (SafeFileHandle logs = OpenDirectory(paths.Logs))
{
- if (!IsExpectedDirectory(logs, paths.Logs, true)) { return false; }
-#if TRAYHOST_SELF_TEST
- if (_testDirectoryChainOpened != null) { _testDirectoryChainOpened(); }
-#endif
- using (SafeFileHandle leaf = CreateFileW(paths.Leaf, GenericRead | GenericWrite | ReadControl, FileShareRead, IntPtr.Zero, OpenAlways, FileFlagOpenReparsePoint | FileFlagWriteThrough, IntPtr.Zero))
+ if (!IsExpectedDirectory(logs, paths.Logs, false)) { return false; }
+ if (!TryCreatePrivateReceiptDirectory(paths.ReceiptDirectory)) { return false; }
+ using (SafeFileHandle receiptDirectory = OpenDirectory(paths.ReceiptDirectory))
{
- if (!IsExpectedLeaf(leaf, paths.Leaf)) { return false; }
+ if (!IsExpectedDirectory(receiptDirectory, paths.ReceiptDirectory, true)) { return false; }
#if TRAYHOST_SELF_TEST
- if (_testLeafValidated != null) { _testLeafValidated(); }
+ if (_testDirectoryChainOpened != null) { _testDirectoryChainOpened(); }
#endif
- using (FileStream stream = new FileStream(leaf, FileAccess.ReadWrite, 4096, false))
+ using (SafeFileHandle leaf = CreateFileW(paths.Leaf, GenericRead | GenericWrite | ReadControl, FileShareRead, IntPtr.Zero, OpenAlways, FileFlagOpenReparsePoint | FileFlagWriteThrough, IntPtr.Zero))
{
- long length = stream.Length;
- if (length > MaximumBytes || length > MaximumBytes - bytes.LongLength)
+ if (!IsExpectedLeaf(leaf, paths.Leaf)) { return false; }
+#if TRAYHOST_SELF_TEST
+ if (_testLeafValidated != null) { _testLeafValidated(); }
+#endif
+ using (FileStream stream = new FileStream(leaf, FileAccess.ReadWrite, 4096, false))
{
- stream.SetLength(0L);
- stream.Position = 0L;
+ long length = stream.Length;
+ if (length > MaximumBytes || length > MaximumBytes - bytes.LongLength)
+ {
+ stream.SetLength(0L);
+ stream.Position = 0L;
+ }
+ else { stream.Position = length; }
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(true);
+ return true;
}
- else { stream.Position = length; }
- stream.Write(bytes, 0, bytes.Length);
- stream.Flush(true);
- return true;
}
}
}
@@ -201,11 +205,12 @@ private static bool TryBuildPaths(string localRoot, out ReceiptPaths paths)
if (String.IsNullOrEmpty(canonicalRoot)) { return false; }
string product = NormalizePath(Path.Combine(canonicalRoot, "CodexControlOtherDevices"));
string logs = NormalizePath(Path.Combine(product, "logs"));
- string leaf = NormalizePath(Path.Combine(logs, "trayhost-actions.log"));
- if (String.IsNullOrEmpty(product) || String.IsNullOrEmpty(logs) || String.IsNullOrEmpty(leaf)) { return false; }
- if (!String.Equals(Path.GetDirectoryName(product), canonicalRoot, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(logs), product, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(leaf), logs, StringComparison.OrdinalIgnoreCase)) { return false; }
- if (!String.Equals(Path.GetFileName(product), "CodexControlOtherDevices", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(logs), "logs", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(leaf), "trayhost-actions.log", StringComparison.Ordinal) || Path.GetFileName(leaf).IndexOf(':') >= 0) { return false; }
- paths = new ReceiptPaths { LocalRoot = canonicalRoot, Product = product, Logs = logs, Leaf = leaf };
+ string receiptDirectory = NormalizePath(Path.Combine(logs, "tray-receipts"));
+ string leaf = NormalizePath(Path.Combine(receiptDirectory, "trayhost-actions.log"));
+ if (String.IsNullOrEmpty(product) || String.IsNullOrEmpty(logs) || String.IsNullOrEmpty(receiptDirectory) || String.IsNullOrEmpty(leaf)) { return false; }
+ if (!String.Equals(Path.GetDirectoryName(product), canonicalRoot, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(logs), product, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(receiptDirectory), logs, StringComparison.OrdinalIgnoreCase) || !String.Equals(Path.GetDirectoryName(leaf), receiptDirectory, StringComparison.OrdinalIgnoreCase)) { return false; }
+ if (!String.Equals(Path.GetFileName(product), "CodexControlOtherDevices", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(logs), "logs", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(receiptDirectory), "tray-receipts", StringComparison.Ordinal) || !String.Equals(Path.GetFileName(leaf), "trayhost-actions.log", StringComparison.Ordinal) || Path.GetFileName(leaf).IndexOf(':') >= 0) { return false; }
+ paths = new ReceiptPaths { LocalRoot = canonicalRoot, Product = product, Logs = logs, ReceiptDirectory = receiptDirectory, Leaf = leaf };
return true;
}
@@ -221,7 +226,7 @@ private static string NormalizePath(string path)
catch { return null; }
}
- private static bool TryCreateChildDirectory(string path)
+ private static bool TryCreatePrivateReceiptDirectory(string path)
{
try
{
diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs
index 316ea72..42298dc 100644
--- a/tests/trayhost/TrayHostNativeSelfTest.cs
+++ b/tests/trayhost/TrayHostNativeSelfTest.cs
@@ -60,9 +60,59 @@ internal static class TrayHostNativeSelfTest
private static void AssertTrue(bool value, string message) { if (!value) { throw new InvalidOperationException(message); } }
private static void AssertEqual(string expected, string actual, string message) { if (!String.Equals(expected, actual, StringComparison.Ordinal)) { throw new InvalidOperationException(message + " expected=[" + expected + "] actual=[" + actual + "]"); } }
+ private static string ProductPath(string root)
+ {
+ return Path.Combine(root, "CodexControlOtherDevices");
+ }
+
+ private static string LogsPath(string root)
+ {
+ return Path.Combine(ProductPath(root), "logs");
+ }
+
+ private static string ReceiptDirectoryPath(string root)
+ {
+ return Path.Combine(LogsPath(root), "tray-receipts");
+ }
+
private static string ReceiptPath(string root)
{
- return Path.Combine(root, "CodexControlOtherDevices", "logs", "trayhost-actions.log");
+ return Path.Combine(ReceiptDirectoryPath(root), "trayhost-actions.log");
+ }
+
+ private static string LegacyReceiptPath(string root)
+ {
+ return Path.Combine(LogsPath(root), "trayhost-actions.log");
+ }
+
+ private static void CreateCompatibilityParents(string root)
+ {
+ Directory.CreateDirectory(LogsPath(root));
+ }
+
+ private static void AssertPrivateReceiptDirectory(string path)
+ {
+ DirectorySecurity security = Directory.GetAccessControl(path);
+ SecurityIdentifier owner = security.GetOwner(typeof(SecurityIdentifier)) as SecurityIdentifier;
+ SecurityIdentifier current;
+ using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) { current = identity.User; }
+ AssertTrue(security.AreAccessRulesProtected && owner != null && current != null && String.Equals(owner.Value, current.Value, StringComparison.Ordinal), "receipt child has a protected current-user owner/DACL");
+ HashSet expected = new HashSet(StringComparer.Ordinal) {
+ current.Value,
+ new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null).Value,
+ new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null).Value
+ };
+ HashSet seen = new HashSet(StringComparer.Ordinal);
+ AuthorizationRuleCollection rules = security.GetAccessRules(true, false, typeof(SecurityIdentifier));
+ AssertTrue(rules.Count == 3, "receipt child DACL contains exactly three explicit rules");
+ foreach (FileSystemAccessRule rule in rules)
+ {
+ SecurityIdentifier sid = rule.IdentityReference as SecurityIdentifier;
+ AssertTrue(sid != null && expected.Contains(sid.Value) && seen.Add(sid.Value), "receipt child DACL contains only current-user, SYSTEM, and Administrators rules");
+ AssertTrue(rule.AccessControlType == AccessControlType.Allow && rule.FileSystemRights == FileSystemRights.FullControl, "receipt child principals receive exact full control");
+ AssertTrue(rule.InheritanceFlags == (InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit) && rule.PropagationFlags == PropagationFlags.None, "receipt child private rules inherit to its fixed leaf");
+ }
+ AssertTrue(seen.Count == expected.Count, "receipt child DACL proves every required principal");
}
private static TrayTerminalDiagnosticStore TestStore(string root, Action directoryChainOpened, Action leafValidated)
@@ -258,6 +308,7 @@ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence()
{
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
+ CreateCompatibilityParents(root);
string path = ReceiptPath(root);
try
{
@@ -270,36 +321,69 @@ private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence()
AssertTrue(Convert.ToBase64String(expected) == Convert.ToBase64String(File.ReadAllBytes(path)), "terminal diagnostic file is exact UTF-8 without a BOM and contains only approved fields");
string defaultPath;
AssertTrue(TrayTerminalDiagnosticStore.TryGetDefaultPath(out defaultPath), "fixed production receipt path resolves");
- string expectedDefaultPath = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "trayhost-actions.log"));
+ string expectedDefaultPath = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexControlOtherDevices", "logs", "tray-receipts", "trayhost-actions.log"));
AssertTrue(String.Equals(expectedDefaultPath, defaultPath, StringComparison.OrdinalIgnoreCase), "production receipt path is fixed to the current user's exact LocalApplicationData leaf");
}
finally { try { Directory.Delete(root, true); } catch { } }
}
+ private static void TestTerminalDiagnosticStoreSupportsInheritedCompatibilityParentsAndCreatesPrivateChild()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-compat-" + Guid.NewGuid().ToString("N"));
+ string product = ProductPath(root); string logs = LogsPath(root); string receiptDirectory = ReceiptDirectoryPath(root);
+ string path = ReceiptPath(root); string legacyPath = LegacyReceiptPath(root);
+ byte[] legacySentinel = new byte[] { 0x42, 0x31, 0x37, 0x29 };
+ Directory.CreateDirectory(root); CreateCompatibilityParents(root); File.WriteAllBytes(legacyPath, legacySentinel);
+ try
+ {
+ AssertTrue(!Directory.GetAccessControl(product).AreAccessRulesProtected && !Directory.GetAccessControl(logs).AreAccessRulesProtected, "supported existing product/logs parents retain inherited ACLs");
+ TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 24UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
+ using (TrayTerminalDiagnosticStore store = TestStore(root, null, null))
+ {
+ AssertTrue(store.TryAppendDurably(record), "inherited supported parents accept a durable receipt in the dedicated private child");
+ }
+ AssertPrivateReceiptDirectory(receiptDirectory);
+ byte[] expected = ReceiptBytes(TrayCommand.OpenLogs, 24UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
+ AssertTrue(Convert.ToBase64String(expected) == Convert.ToBase64String(File.ReadAllBytes(path)), "compatible parent flow writes only the fixed dedicated receipt leaf");
+ AssertTrue(Convert.ToBase64String(legacySentinel) == Convert.ToBase64String(File.ReadAllBytes(legacyPath)), "legacy direct logs receipt leaf remains byte-identical and is never reused");
+ }
+ finally { try { Directory.Delete(root, true); } catch { } }
+ }
+
private static void TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel()
{
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-unsafe-" + Guid.NewGuid().ToString("N"));
string outside = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-outside-" + Guid.NewGuid().ToString("N"));
string outsideSentinel = Path.Combine(outside, "trayhost-actions.log");
string path = ReceiptPath(root);
- string logs = Path.GetDirectoryName(path);
+ string receiptDirectory = ReceiptDirectoryPath(root);
+ string logs = LogsPath(root);
byte[] sentinel = new byte[] { 0x10, 0x22, 0x34, 0x46, 0x58, 0x6a };
- Directory.CreateDirectory(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel);
+ Directory.CreateDirectory(root); CreateCompatibilityParents(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel);
try
{
TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 1UL, TrayActionResultStatus.Rejected, "CCOD_TRAY_ACTION_STALE");
using (TrayTerminalDiagnosticStore bootstrap = TestStore(root, null, null)) { AssertTrue(bootstrap.TryAppendDurably(record), "safe receipt directories are bootstrapped"); }
- File.Delete(path); Directory.Delete(logs);
+ File.Delete(path); Directory.Delete(receiptDirectory);
+ Directory.Delete(logs);
CreateJunction(logs, outside);
+ using (TrayTerminalDiagnosticStore reparseParent = TestStore(root, null, null))
+ {
+ AssertTrue(!reparseParent.TryAppendDurably(record), "a reparse compatibility logs parent is rejected before writing");
+ }
+ AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse compatibility parent cannot alter the outside sentinel");
+ DeleteJunction(logs); CreateCompatibilityParents(root);
+
+ CreateJunction(receiptDirectory, outside);
using (TrayTerminalDiagnosticStore reparseDirectory = TestStore(root, null, null))
{
- AssertTrue(!reparseDirectory.TryAppendDurably(record), "a reparse logs directory is rejected before writing");
+ AssertTrue(!reparseDirectory.TryAppendDurably(record), "a reparse private receipt directory is rejected before writing");
}
- AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse directory cannot alter the outside sentinel");
- DeleteJunction(logs);
+ AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "reparse receipt directory cannot alter the outside sentinel");
+ DeleteJunction(receiptDirectory);
- using (TrayTerminalDiagnosticStore restore = TestStore(root, null, null)) { AssertTrue(restore.TryAppendDurably(record), "safe logs directory is restored for leaf tests"); }
+ using (TrayTerminalDiagnosticStore restore = TestStore(root, null, null)) { AssertTrue(restore.TryAppendDurably(record), "safe private receipt directory is restored for leaf tests"); }
File.Delete(path);
CreateJunction(path, outside);
using (TrayTerminalDiagnosticStore reparseLeaf = TestStore(root, null, null))
@@ -317,19 +401,19 @@ private static void TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOu
AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "hard-link leaf cannot alter the outside sentinel");
File.Delete(path);
- DirectorySecurity unsafeSecurity = Directory.GetAccessControl(logs);
+ DirectorySecurity unsafeSecurity = Directory.GetAccessControl(receiptDirectory);
unsafeSecurity.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.WorldSid, null), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
- Directory.SetAccessControl(logs, unsafeSecurity);
+ Directory.SetAccessControl(receiptDirectory, unsafeSecurity);
using (TrayTerminalDiagnosticStore unsafeAcl = TestStore(root, null, null))
{
- AssertTrue(!unsafeAcl.TryAppendDurably(record), "an unexpected logs DACL is rejected before writing");
+ AssertTrue(!unsafeAcl.TryAppendDurably(record), "an unexpected private receipt-child DACL is rejected before writing");
}
- AssertTrue(!File.Exists(path), "unsafe directory ACL cannot create or write the receipt leaf");
- AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "unsafe directory ACL cannot alter the outside sentinel");
+ AssertTrue(!File.Exists(path), "unsafe receipt-child ACL cannot create or write the receipt leaf");
+ AssertTrue(Convert.ToBase64String(sentinel) == Convert.ToBase64String(File.ReadAllBytes(outsideSentinel)), "unsafe receipt-child ACL cannot alter the outside sentinel");
}
finally
{
- DeleteJunction(path); DeleteJunction(logs);
+ DeleteJunction(path); DeleteJunction(receiptDirectory); DeleteJunction(logs);
try { Directory.Delete(root, true); } catch { }
try { Directory.Delete(outside, true); } catch { }
}
@@ -339,11 +423,11 @@ private static void TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacem
{
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-pin-" + Guid.NewGuid().ToString("N"));
string outside = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-pin-outside-" + Guid.NewGuid().ToString("N"));
- string path = ReceiptPath(root); string logs = Path.GetDirectoryName(path); string displaced = logs + ".displaced";
+ string path = ReceiptPath(root); string logs = LogsPath(root); string displaced = logs + ".displaced";
string outsideSentinel = Path.Combine(outside, "trayhost-actions.log");
byte[] sentinel = new byte[] { 0x71, 0x72, 0x73, 0x74, 0x75 };
bool barrierEntered = false; bool replacementSucceeded = false;
- Directory.CreateDirectory(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel);
+ Directory.CreateDirectory(root); CreateCompatibilityParents(root); Directory.CreateDirectory(outside); File.WriteAllBytes(outsideSentinel, sentinel);
try
{
TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 2UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
@@ -376,7 +460,7 @@ private static void TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacem
private static void TestTerminalDiagnosticStoreRejectsConcurrentWriter()
{
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-share-" + Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(root);
+ Directory.CreateDirectory(root); CreateCompatibilityParents(root);
ManualResetEvent leafValidated = new ManualResetEvent(false); ManualResetEvent release = new ManualResetEvent(false);
bool firstResult = false;
try
@@ -402,7 +486,7 @@ private static void TestTerminalDiagnosticStoreRejectsConcurrentWriter()
private static void TestTerminalDiagnosticStoreRejectsDirectoryLeafWithoutWriting()
{
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-directory-leaf-" + Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(root); string path = ReceiptPath(root);
+ Directory.CreateDirectory(root); CreateCompatibilityParents(root); string path = ReceiptPath(root);
try
{
TrayTerminalDiagnostic record = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 5UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
@@ -420,7 +504,7 @@ private static void TestTerminalDiagnosticStoreRejectsDirectoryLeafWithoutWritin
private static void TestTerminalDiagnosticStoreIsBoundedThroughValidatedHandle()
{
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-bound-" + Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(root); string path = ReceiptPath(root);
+ Directory.CreateDirectory(root); CreateCompatibilityParents(root); string path = ReceiptPath(root);
try
{
TrayTerminalDiagnostic first = new TrayTerminalDiagnostic(TrayCommand.OpenLogs, 41UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED");
@@ -527,6 +611,7 @@ public static int Main(string[] args)
TestAboutCommandDefersProofToSupervisor();
TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion();
TestActionFailureUsesTheAcknowledgedSnapshotStrings();
+ TestTerminalDiagnosticStoreSupportsInheritedCompatibilityParentsAndCreatesPrivateChild();
TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence();
TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel();
TestTerminalDiagnosticStorePinsDirectoryChainAgainstReplacement();
@@ -538,7 +623,7 @@ public static int Main(string[] args)
TestShellRightClickNotificationMapping();
TestRealNativePInvokeSurface();
TestPostedWorkMessageDispatchesToItsOwnerWindow();
- Console.WriteLine("TrayHost native self-tests passed: 21");
+ Console.WriteLine("TrayHost native self-tests passed: 22");
return 0;
}
catch (Exception error)
From 6cdbcdbcc77d504c7debdbb95dca0ce5db33d9a2 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 09:24:55 +0800
Subject: [PATCH 030/187] docs: record tray receipt compatibility fix
---
.../task-1-report.md | 82 +++++++++++++++++++
1 file changed, 82 insertions(+)
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md
index 90d935c..4c9448c 100644
--- a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-1-report.md
@@ -219,3 +219,85 @@ Exit `0`; only Git's existing LF-to-CRLF conversion notices were printed.
- No installer, installed Codex process, real tray UI, push, release, or public
operation was run. Release-wide and real-machine acceptance remain outside
Task 1.
+
+## Fix round 1/5: inherited compatibility parents
+
+Review finding: the first implementation required the existing product and
+`logs` directories to have the protected receipt-private ACL. Supported
+current and legacy installations create those parents with normal inherited
+ACLs, so production receipt writes returned `false` before opening a leaf.
+
+### Compatibility ruling and implementation
+
+- The fixed production leaf moved from the legacy direct path to
+ `%LOCALAPPDATA%\CodexControlOtherDevices\logs\tray-receipts\trayhost-actions.log`.
+- LocalAppData, product, and `logs` remain pinned compatibility parents. Their
+ actual opened handles must still prove disk-directory type, non-reparse
+ attributes, and the exact final path, and the handles continue to deny
+ delete sharing while the receipt child and leaf are opened and written.
+- The store no longer creates product or `logs`; supported installation/runtime
+ setup remains responsible for those parents. A missing, wrong-type,
+ reparse-point, or unqueryable parent fails closed.
+- Only the fixed `logs\tray-receipts` child is created with the protected
+ current-user/SYSTEM/Administrators ACL. Its reopened native handle must pass
+ the strict owner/DACL, non-reparse, type, and final-path checks. An existing
+ arbitrary or broadened child remains rejected and is never repaired.
+- The direct legacy `logs\trayhost-actions.log` leaf is never opened, reused,
+ truncated, or written.
+
+### RED evidence
+
+The compatibility test precreated product and `logs` with normal inherited
+ACLs, asserted `AreAccessRulesProtected == false`, placed a byte sentinel in
+the legacy direct leaf, and requested a durable receipt. Before the production
+fix, this command exited `1`:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Exact decisive output:
+
+```text
+TrayHost native self-test failed: System.InvalidOperationException
+inherited supported parents accept a durable receipt in the dedicated private child
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+This is the reviewed compatibility defect: the old store rejected supported
+inherited parents before it could create a safe receipt boundary.
+
+### GREEN and regression evidence
+
+After the change, the compatibility test proves durable success, exact private
+ACL creation on `tray-receipts`, exact UTF-8 content in the new leaf, and
+byte-identical retention of the direct legacy leaf. Existing negative coverage
+was retained or moved to the correct boundary: reparse compatibility parent,
+reparse receipt child, reparse/directory/hard-link leaf, broadened receipt-child
+ACL, concurrent writer, replacement barrier/outside sentinel, and exact or
+crossing 64-KiB rollover.
+
+Fresh pre-commit commands and results:
+
+```text
+NativeOnly: exit 0, TrayHost native self-tests passed: 22
+ProductionOnly: exit 0, headless compile/run emitted no output
+TransportOnly: exit 0, TrayHost transport self-tests passed: 10
+ProtocolOnly: exit 0, TrayHost protocol self-tests passed: 11
+ParentClientOnly: exit 0, TrayHost parent-client self-tests passed: 4
+Production trace: TrayHost production correlation trace passed: 2
+git diff --check: exit 0 (only repository LF-to-CRLF notices)
+```
+
+The ParentClient command again emitted the repository's existing unapproved-
+verbs warning for `TrayHostClient`; this fix did not modify that module.
+
+### Fix-round commit and remaining boundary
+
+- `f207496df1ceee744a09bbc5c3d24a48695e58bc` —
+ `fix: preserve tray receipt parent compatibility`
+
+Task 1 now supports the inherited parent ACLs created by existing
+installations while retaining a dedicated fail-closed private receipt child.
+The asynchronous/non-blocking sink remains Task 2. No installer, installed
+process, real UI, release, push, or public operation was run in this fix round.
From 7549dde154fd6c2430a13aa9ddcf66698560f717 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 09:59:03 +0800
Subject: [PATCH 031/187] fix: isolate tray terminal receipt writes
---
src/trayhost/HostTransport.cs | 94 +++-
src/trayhost/Program.cs | 17 +-
src/trayhost/TrayTerminalReceiptSink.cs | 168 +++++++
tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 1 +
tests/trayhost/TrayHostNativeSelfTest.cs | 32 +-
.../trayhost/TrayHostParentClientSelfTest.cs | 13 +-
tests/trayhost/TrayHostTransportSelfTest.cs | 428 +++++++++++++-----
7 files changed, 619 insertions(+), 134 deletions(-)
create mode 100644 src/trayhost/TrayTerminalReceiptSink.cs
diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs
index be636aa..25d405b 100644
--- a/src/trayhost/HostTransport.cs
+++ b/src/trayhost/HostTransport.cs
@@ -11,28 +11,23 @@ private sealed class PendingAction
private readonly object _gate = new object();
private readonly Action _presentationReady;
- private readonly Func _writeTerminalDiagnostic;
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();
private PresentationSnapshot _pendingPresentation;
+ private int _uiWorkCount;
private bool _menuOpen;
private bool _disposed;
- internal HostTransport() : this(null, null)
+ internal HostTransport() : this(null)
{
}
- internal HostTransport(Action presentationReady) : this(presentationReady, null)
- {
- }
-
- internal HostTransport(Action presentationReady, Func writeTerminalDiagnostic)
+ internal HostTransport(Action presentationReady)
{
_presentationReady = presentationReady;
- _writeTerminalDiagnostic = writeTerminalDiagnostic;
}
internal void SetMenuOpen(bool value)
@@ -87,14 +82,14 @@ internal bool TryRegisterAction(TrayHostAction action)
}
}
- internal bool TryAcknowledgeAction(TrayActionResult result)
+ internal bool TryAcknowledgeAction(TrayActionResult result, out TrayTerminalReceipt receipt)
{
+ receipt = null;
if (result == null) { return false; }
- PendingAction pending;
- TrayTerminalDiagnostic diagnostic;
lock (_gate)
{
if (_disposed) { return false; }
+ PendingAction pending;
if (!_pendingActions.TryGetValue(result.ActionId, out pending) || pending.Action.Revision != result.Revision) { return false; }
if (result.Status == TrayActionResultStatus.Accepted)
{
@@ -104,22 +99,70 @@ 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; }
- diagnostic = new TrayTerminalDiagnostic(pending.Action.Command, result.Revision, result.Status, result.ErrorCode);
+ receipt = new TrayTerminalReceipt(this, pending.Action.Command, result);
_pendingActions.Remove(result.ActionId);
+ return true;
+ }
+ }
+
+ internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt)
+ {
+ if (receipt == null) { return false; }
+ bool notify = false;
+ bool accepted = false;
+ lock (_gate)
+ {
+ if (_disposed || !receipt.TryClaimPublication(this)) { return false; }
+ TrayActionResult result = receipt.Result;
+ if (result.Status == TrayActionResultStatus.Completed && receipt.Command == TrayCommand.ShowAbout)
+ {
+ if (_uiWorkCount >= 8) { return false; }
+ _completedAbout.Enqueue(result);
+ _uiWorkCount++;
+ notify = true;
+ accepted = true;
+ }
+ else if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed)
+ {
+ if (_uiWorkCount >= 8) { return false; }
+ _failedActions.Enqueue(result);
+ _uiWorkCount++;
+ notify = true;
+ accepted = true;
+ }
+ else
+ {
+ accepted = true;
+ }
}
- bool recorded = false;
- Func writer = _writeTerminalDiagnostic;
- if (writer != null)
+ Action presentationReady = _presentationReady;
+ if (notify && presentationReady != null)
{
- try { recorded = writer(diagnostic); }
- catch { recorded = false; }
+ try { presentationReady(); }
+ catch
+ {
+ DropUiWork(receipt.Result);
+ return false;
+ }
}
+ return accepted;
+ }
+
+ private void DropUiWork(TrayActionResult target)
+ {
+ if (target == null) { return; }
lock (_gate)
{
- if (_disposed) { return true; }
- if (recorded && result.Status == TrayActionResultStatus.Completed && pending.Action.Command == TrayCommand.ShowAbout) { _completedAbout.Enqueue(result); }
- if (recorded && (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) && _failedActions.Count < 8) { _failedActions.Enqueue(result); }
- return true;
+ Queue queue = target.Status == TrayActionResultStatus.Completed ? _completedAbout : _failedActions;
+ int count = queue.Count;
+ bool removed = false;
+ for (int index = 0; index < count; index++)
+ {
+ TrayActionResult current = queue.Dequeue();
+ if (!removed && Object.ReferenceEquals(current, target)) { removed = true; }
+ else { queue.Enqueue(current); }
+ }
+ if (removed && _uiWorkCount > 0) { _uiWorkCount--; }
}
}
@@ -128,7 +171,9 @@ internal bool TryTakeCompletedAbout(out TrayActionResult result)
lock (_gate)
{
if (_completedAbout.Count == 0) { result = null; return false; }
- result = _completedAbout.Dequeue(); return true;
+ result = _completedAbout.Dequeue();
+ if (_uiWorkCount > 0) { _uiWorkCount--; }
+ return true;
}
}
@@ -137,7 +182,9 @@ internal bool TryTakeFailedAction(out TrayActionResult result)
lock (_gate)
{
if (_failedActions.Count == 0) { result = null; return false; }
- result = _failedActions.Dequeue(); return true;
+ result = _failedActions.Dequeue();
+ if (_uiWorkCount > 0) { _uiWorkCount--; }
+ return true;
}
}
@@ -149,6 +196,7 @@ public void Dispose()
_disposed = true;
_completedAbout.Clear();
_failedActions.Clear();
+ _uiWorkCount = 0;
_pendingActions.Clear();
_recentActions.Clear();
_recentActionOrder.Clear();
diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs
index 895c118..2cdaed1 100644
--- a/src/trayhost/Program.cs
+++ b/src/trayhost/Program.cs
@@ -40,7 +40,8 @@ private static int RunChild(string[] args)
Win32TrayPlatform platform = new Win32TrayPlatform();
TrayHostApplication application = null;
TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore();
- HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } }, delegate(TrayTerminalDiagnostic record) { return terminalStore.TryAppendDurably(record); });
+ HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } });
+ TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose);
TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen);
bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object();
Action command = delegate(TrayCommand selected, ulong revision)
@@ -90,7 +91,7 @@ private static int RunChild(string[] args)
{
ProtocolFrame frame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
if (frame.MessageType == TrayHostMessageType.Presentation) { transport.TryAcceptPresentation(TrayHostWire.ReadPresentation(frame.Payload)); application.PostWork(); }
- else if (frame.MessageType == TrayHostMessageType.ActionResult) { if (!TryDispatchAuthenticatedActionResult(transport, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); } application.PostWork(); }
+ else if (frame.MessageType == TrayHostMessageType.ActionResult) { if (!TryDispatchAuthenticatedActionResult(transport, receiptSink, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); } }
else if (frame.MessageType == TrayHostMessageType.Shutdown) { lock (stateGate) { shutdownRequested = true; } application.PostWork(); }
else if (frame.MessageType == TrayHostMessageType.Ping) { lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Pong, epoch, outboundSequence++, frame.Payload), keys.HostToParent); } }
}
@@ -99,7 +100,7 @@ private static int RunChild(string[] args)
})) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" };
reader.Start();
int result = application.Run();
- application.Dispose(); transport.Dispose(); terminalStore.Dispose(); return result;
+ receiptSink.Dispose(); application.Dispose(); transport.Dispose(); return result;
}
private static bool VerifyParentIdentity(int pid, long creation)
@@ -107,10 +108,16 @@ private static bool VerifyParentIdentity(int pid, long creation)
try { using (Process process = Process.GetProcessById(pid)) { return process.StartTime.ToFileTimeUtc() == creation; } } catch { return false; }
}
- internal static bool TryDispatchAuthenticatedActionResult(HostTransport transport, byte[] payload)
+ internal static bool TryDispatchAuthenticatedActionResult(HostTransport transport, TrayTerminalReceiptSink receiptSink, byte[] payload)
{
if (transport == null || payload == null) { return false; }
- return transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload));
+ TrayTerminalReceipt receipt;
+ if (!transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload), out receipt)) { return false; }
+ if (receipt != null && receiptSink != null)
+ {
+ try { receiptSink.TrySubmit(receipt); } catch { }
+ }
+ return true;
}
private static int RunHeadlessSmoke()
diff --git a/src/trayhost/TrayTerminalReceiptSink.cs b/src/trayhost/TrayTerminalReceiptSink.cs
new file mode 100644
index 0000000..c9b5f1c
--- /dev/null
+++ b/src/trayhost/TrayTerminalReceiptSink.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+internal sealed class TrayTerminalReceipt
+{
+ private readonly object _authority;
+ private int _publicationClaimed;
+
+ internal TrayTerminalReceipt(object authority, TrayCommand command, TrayActionResult result)
+ {
+ if (authority == null) { throw new ArgumentNullException("authority"); }
+ if (!TrayCommandPolicy.IsWireCommand(command) || result == null || result.Status == TrayActionResultStatus.Accepted) { throw new ArgumentException("terminal receipt is invalid"); }
+ _authority = authority;
+ Command = command;
+ Result = result;
+ Diagnostic = new TrayTerminalDiagnostic(command, result.Revision, result.Status, result.ErrorCode);
+ }
+
+ internal TrayCommand Command { get; private set; }
+ internal TrayActionResult Result { get; private set; }
+ internal TrayTerminalDiagnostic Diagnostic { get; private set; }
+
+ internal bool TryClaimPublication(object authority)
+ {
+ return Object.ReferenceEquals(_authority, authority) && Interlocked.CompareExchange(ref _publicationClaimed, 1, 0) == 0;
+ }
+}
+
+internal sealed class TrayTerminalReceiptSink : IDisposable
+{
+ internal const int MaximumOutstanding = 8;
+ private const int DisposeJoinMilliseconds = 100;
+ private readonly object _gate = new object();
+ private readonly Queue _pending = new Queue();
+ private readonly AutoResetEvent _available = new AutoResetEvent(false);
+ private readonly Func _writeDurably;
+ private readonly Func _publishDurable;
+ private readonly Action _disposeStore;
+ private readonly Thread _writer;
+ private int _outstanding;
+ private int _cleanupStarted;
+ private bool _writerStarted;
+ private bool _closed;
+
+ internal TrayTerminalReceiptSink(Func writeDurably, Func publishDurable)
+ : this(writeDurably, publishDurable, null)
+ {
+ }
+
+ internal TrayTerminalReceiptSink(Func writeDurably, Func publishDurable, Action disposeStore)
+ {
+ _writeDurably = writeDurably;
+ _publishDurable = publishDurable;
+ _disposeStore = disposeStore;
+ if (_writeDurably == null || _publishDurable == null) { _closed = true; return; }
+ try
+ {
+ _writer = new Thread(new ThreadStart(WriterMain)) { IsBackground = true, Name = "CodexRemote.TrayHost.ReceiptWriter" };
+ _writer.Start();
+ _writerStarted = true;
+ }
+ catch { _closed = true; }
+ }
+
+ internal bool TrySubmit(TrayTerminalReceipt receipt)
+ {
+ if (receipt == null || !Monitor.TryEnter(_gate)) { return false; }
+ try
+ {
+ if (_closed || _outstanding >= MaximumOutstanding) { return false; }
+ try { _available.Set(); }
+ catch { return false; }
+ try
+ {
+ _pending.Enqueue(receipt);
+ _outstanding++;
+ return true;
+ }
+ catch { return false; }
+ }
+ finally { Monitor.Exit(_gate); }
+ }
+
+ public void Dispose()
+ {
+ Thread writer;
+ lock (_gate)
+ {
+ if (_closed)
+ {
+ writer = _writer;
+ }
+ else
+ {
+ _closed = true;
+ _pending.Clear();
+ _outstanding = 0;
+ writer = _writer;
+ try { _available.Set(); } catch { }
+ }
+ }
+ if (writer == null || !_writerStarted) { CleanupAfterWriter(); return; }
+ if (Object.ReferenceEquals(writer, Thread.CurrentThread)) { return; }
+ bool stopped = false;
+ try { stopped = writer.Join(DisposeJoinMilliseconds); } catch { }
+ if (stopped) { CleanupAfterWriter(); }
+ }
+
+ private void WriterMain()
+ {
+ try
+ {
+ while (true)
+ {
+ TrayTerminalReceipt receipt = null;
+ lock (_gate)
+ {
+ if (_closed) { return; }
+ if (_pending.Count != 0) { receipt = _pending.Dequeue(); }
+ }
+ if (receipt == null)
+ {
+ try { _available.WaitOne(); }
+ catch { return; }
+ continue;
+ }
+
+ bool durable = false;
+ try { durable = _writeDurably(receipt.Diagnostic); }
+ catch { durable = false; }
+ if (durable && !IsClosed())
+ {
+ try { _publishDurable(receipt); }
+ catch { }
+ }
+ lock (_gate)
+ {
+ if (_outstanding > 0) { _outstanding--; }
+ if (_closed) { return; }
+ }
+ }
+ }
+ finally
+ {
+ lock (_gate)
+ {
+ _closed = true;
+ _pending.Clear();
+ _outstanding = 0;
+ }
+ CleanupAfterWriter();
+ }
+ }
+
+ private bool IsClosed()
+ {
+ lock (_gate) { return _closed; }
+ }
+
+ private void CleanupAfterWriter()
+ {
+ if (Interlocked.CompareExchange(ref _cleanupStarted, 1, 0) != 0) { return; }
+ Action disposeStore = _disposeStore;
+ if (disposeStore != null) { try { disposeStore(); } catch { } }
+ try { _available.Dispose(); } catch { }
+ }
+}
diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
index ad95efa..b249423 100644
--- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1
+++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
@@ -23,6 +23,7 @@ $nativeFiles=@(
$transportFiles=@(
(Join-Path $repositoryRoot 'src\trayhost\TransportMessages.cs'),
(Join-Path $repositoryRoot 'src\trayhost\TrayTerminalDiagnostic.cs'),
+ (Join-Path $repositoryRoot 'src\trayhost\TrayTerminalReceiptSink.cs'),
(Join-Path $repositoryRoot 'src\trayhost\ParentTransport.cs'),
(Join-Path $repositoryRoot 'src\trayhost\HostTransport.cs')
)
diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs
index 42298dc..f9185b9 100644
--- a/tests/trayhost/TrayHostNativeSelfTest.cs
+++ b/tests/trayhost/TrayHostNativeSelfTest.cs
@@ -304,6 +304,35 @@ private static void TestActionFailureUsesTheAcknowledgedSnapshotStrings()
window.Dispose();
}
+ private static void TestDurableNativeReceiptAuthorizesGenericFeedbackOnce()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-native-feedback-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root); CreateCompatibilityParents(root);
+ FakeTrayPlatform platform = new FakeTrayPlatform(); TrayWindow window = new TrayWindow(platform); window.Create(SnapshotV2(1));
+ HostTransport transport = new HostTransport();
+ TrayTerminalDiagnosticStore store = TestStore(root, null, null);
+ TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, transport.TryPublishDurableReceipt);
+ try
+ {
+ Guid actionId = Guid.NewGuid(); TrayTerminalReceipt receipt;
+ AssertTrue(transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 1UL)), "native durable-feedback action registers");
+ AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(actionId, 1UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out receipt) && receipt != null, "native durable-feedback action returns one typed receipt");
+ TrayActionResult feedback;
+ AssertTrue(!transport.TryTakeFailedAction(out feedback) && platform.MessageBoxes.Count == 0, "native generic feedback is unavailable before durable receipt success");
+ AssertTrue(sink.TrySubmit(receipt), "native receipt enters the asynchronous sink");
+ Stopwatch elapsed = Stopwatch.StartNew();
+ while (!transport.TryTakeFailedAction(out feedback) && elapsed.ElapsedMilliseconds < 3000L) { Thread.Sleep(5); }
+ AssertTrue(feedback != null && feedback.ActionId == actionId, "handle-pinned durable success authorizes the exact native feedback item");
+ window.ShowActionFailed();
+ AssertTrue(platform.MessageBoxes.Count == 1, "durable native receipt displays generic feedback exactly once");
+ AssertTrue(!transport.TryTakeFailedAction(out feedback), "durable native feedback has no duplicate queue item");
+ }
+ finally
+ {
+ sink.Dispose(); transport.Dispose(); window.Dispose(); store.Dispose(); try { Directory.Delete(root, true); } catch { }
+ }
+ }
+
private static void TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence()
{
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-" + Guid.NewGuid().ToString("N"));
@@ -611,6 +640,7 @@ public static int Main(string[] args)
TestAboutCommandDefersProofToSupervisor();
TestVerifiedAboutUsesTheAcknowledgedSnapshotVersion();
TestActionFailureUsesTheAcknowledgedSnapshotStrings();
+ TestDurableNativeReceiptAuthorizesGenericFeedbackOnce();
TestTerminalDiagnosticStoreSupportsInheritedCompatibilityParentsAndCreatesPrivateChild();
TestTerminalDiagnosticLogIsSanitizedAndReportsPersistence();
TestTerminalDiagnosticStoreRejectsUnsafeObjectsAndProtectsOutsideSentinel();
@@ -623,7 +653,7 @@ public static int Main(string[] args)
TestShellRightClickNotificationMapping();
TestRealNativePInvokeSurface();
TestPostedWorkMessageDispatchesToItsOwnerWindow();
- Console.WriteLine("TrayHost native self-tests passed: 22");
+ Console.WriteLine("TrayHost native self-tests passed: 23");
return 0;
}
catch (Exception error)
diff --git a/tests/trayhost/TrayHostParentClientSelfTest.cs b/tests/trayhost/TrayHostParentClientSelfTest.cs
index f2e8589..e83f597 100644
--- a/tests/trayhost/TrayHostParentClientSelfTest.cs
+++ b/tests/trayhost/TrayHostParentClientSelfTest.cs
@@ -81,26 +81,31 @@ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction, bo
{
Guid actionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
TrayHostAction action = new TrayHostAction(actionId, TrayCommand.OpenLogs, staleAction ? 8UL : 1UL);
- HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { return true; });
+ ManualResetEvent receiptPersisted = new ManualResetEvent(false);
+ HostTransport host = new HostTransport();
+ TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(delegate(TrayTerminalDiagnostic record) { receiptPersisted.Set(); return true; }, host.TryPublishDurableReceipt);
ProductionTraceTrayPlatform tracePlatform = new ProductionTraceTrayPlatform();
TrayWindow traceWindow = new TrayWindow(tracePlatform); traceWindow.Create(Snapshot(1UL));
AssertTrue(host.TryRegisterAction(action), "program peer registers the exact outbound action");
ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(action)), keys.HostToParent);
ProtocolFrame terminalFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
AssertTrue(terminalFrame.MessageType == TrayHostMessageType.ActionResult, "program peer receives one authenticated terminal result");
- AssertTrue(Program.TryDispatchAuthenticatedActionResult(host, terminalFrame.Payload), "production Program dispatch accepts the correlated authenticated result");
+ AssertTrue(Program.TryDispatchAuthenticatedActionResult(host, receiptSink, terminalFrame.Payload), "production Program dispatch accepts the correlated authenticated result without waiting for receipt storage");
TrayActionResult feedback;
if (staleAction)
{
- AssertTrue(host.TryTakeFailedAction(out feedback) && feedback.Revision == 8UL && feedback.Status == TrayActionResultStatus.Rejected && String.Equals(feedback.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "stale authenticated result reaches the production generic-feedback queue with its exact code");
+ Stopwatch feedbackWait = Stopwatch.StartNew();
+ while (!host.TryTakeFailedAction(out feedback) && feedbackWait.ElapsedMilliseconds < 3000L) { Thread.Sleep(5); }
+ AssertTrue(feedback != null && feedback.Revision == 8UL && feedback.Status == TrayActionResultStatus.Rejected && String.Equals(feedback.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "stale authenticated result reaches the production generic-feedback queue only after its durable callback");
traceWindow.ShowActionFailed();
AssertTrue(String.Equals(tracePlatform.Message, "string-0|string-15", StringComparison.Ordinal), "stale production trace collapses internal details to the generic acknowledged-snapshot dialog");
}
else
{
+ AssertTrue(receiptPersisted.WaitOne(3000), "completed current action reaches durable receipt storage asynchronously");
AssertTrue(!host.TryTakeFailedAction(out feedback) && tracePlatform.Message == null, "completed current action produces no generic failure feedback");
}
- traceWindow.Dispose(); host.Dispose();
+ receiptSink.Dispose(); traceWindow.Dispose(); host.Dispose(); receiptPersisted.Dispose();
return;
}
if (emitFaultAndIgnoreShutdown)
diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs
index d276a07..43436fe 100644
--- a/tests/trayhost/TrayHostTransportSelfTest.cs
+++ b/tests/trayhost/TrayHostTransportSelfTest.cs
@@ -1,11 +1,86 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
+using System.Reflection;
using System.Threading;
internal static class TrayHostTransportSelfTest
{
- private static bool PersistTerminal(TrayTerminalDiagnostic record) { return true; }
+ private enum ReceiptStoreBlockStage
+ {
+ None = 0,
+ BeforeOpen = 1,
+ DuringWrite = 2,
+ DuringFlush = 3
+ }
+
+ private sealed class ControllableReceiptStore : IDisposable
+ {
+ private readonly object _gate = new object();
+ private readonly ReceiptStoreBlockStage _blockStage;
+ private readonly Queue _outcomes = new Queue();
+ private readonly HashSet _writerThreads = new HashSet();
+ private readonly ManualResetEvent _entered = new ManualResetEvent(false);
+ private readonly ManualResetEvent _release = new ManualResetEvent(false);
+ private int _active;
+ private int _callCount;
+ private int _completedCount;
+ private int _maximumConcurrent;
+
+ internal ControllableReceiptStore(ReceiptStoreBlockStage blockStage, params int[] outcomes)
+ {
+ _blockStage = blockStage;
+ if (outcomes != null) { for (int index = 0; index < outcomes.Length; index++) { _outcomes.Enqueue(outcomes[index]); } }
+ }
+
+ internal bool TryAppendDurably(TrayTerminalDiagnostic record)
+ {
+ if (record == null) { return false; }
+ int call = Interlocked.Increment(ref _callCount);
+ int active = Interlocked.Increment(ref _active);
+ lock (_gate)
+ {
+ _writerThreads.Add(Thread.CurrentThread.ManagedThreadId);
+ if (active > _maximumConcurrent) { _maximumConcurrent = active; }
+ }
+ try
+ {
+ BlockAt(call, ReceiptStoreBlockStage.BeforeOpen);
+ BlockAt(call, ReceiptStoreBlockStage.DuringWrite);
+ BlockAt(call, ReceiptStoreBlockStage.DuringFlush);
+ int outcome = 1;
+ lock (_gate) { if (_outcomes.Count != 0) { outcome = _outcomes.Dequeue(); } }
+ if (outcome < 0) { throw new InvalidOperationException("intentional receipt-store failure"); }
+ return outcome > 0;
+ }
+ finally
+ {
+ Interlocked.Decrement(ref _active);
+ Interlocked.Increment(ref _completedCount);
+ }
+ }
+
+ internal bool WaitUntilBlocked(int milliseconds) { return _entered.WaitOne(milliseconds); }
+ internal void Release() { _release.Set(); }
+ internal int CallCount { get { return Interlocked.CompareExchange(ref _callCount, 0, 0); } }
+ internal int CompletedCount { get { return Interlocked.CompareExchange(ref _completedCount, 0, 0); } }
+ internal int MaximumConcurrent { get { lock (_gate) { return _maximumConcurrent; } } }
+ internal int WriterThreadCount { get { lock (_gate) { return _writerThreads.Count; } } }
+
+ private void BlockAt(int call, ReceiptStoreBlockStage stage)
+ {
+ if (call != 1 || _blockStage != stage) { return; }
+ _entered.Set();
+ _release.WaitOne();
+ }
+
+ public void Dispose()
+ {
+ _release.Set();
+ _entered.Dispose();
+ _release.Dispose();
+ }
+ }
private static void AssertTrue(bool value, string message)
{
@@ -19,6 +94,18 @@ private static void AssertThrows(Action action, string message)
AssertTrue(threw, message);
}
+ private static void AssertReturnsQuickly(Stopwatch elapsed, string message)
+ {
+ AssertTrue(elapsed.Elapsed < TimeSpan.FromMilliseconds(250), message + " elapsedMs=" + elapsed.ElapsedMilliseconds.ToString());
+ }
+
+ private static void WaitUntil(Func condition, int milliseconds, string message)
+ {
+ Stopwatch elapsed = Stopwatch.StartNew();
+ while (!condition() && elapsed.ElapsedMilliseconds < milliseconds) { Thread.Sleep(5); }
+ AssertTrue(condition(), message);
+ }
+
private static PresentationSnapshot Snapshot(ulong revision)
{
string[] strings = new string[16];
@@ -26,6 +113,29 @@ private static PresentationSnapshot Snapshot(ulong revision)
return new PresentationSnapshot(revision, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.Chinese, PresentationFlags.OpenLogsEnabled, strings);
}
+ private static bool TryAcknowledge(HostTransport host, TrayActionResult result)
+ {
+ TrayTerminalReceipt ignored;
+ return host.TryAcknowledgeAction(result, out ignored);
+ }
+
+ private static TrayTerminalReceipt AcknowledgeTerminalQuickly(HostTransport host, TrayActionResult result, string message)
+ {
+ TrayTerminalReceipt receipt;
+ Stopwatch elapsed = Stopwatch.StartNew();
+ bool accepted = host.TryAcknowledgeAction(result, out receipt);
+ elapsed.Stop();
+ AssertTrue(accepted && receipt != null, message + " is correlated into a typed receipt");
+ AssertReturnsQuickly(elapsed, message + " never waits for receipt storage");
+ return receipt;
+ }
+
+ private static TrayTerminalReceipt RegisterAndAcknowledgeFailure(HostTransport host, Guid actionId, ulong revision, string message)
+ {
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, revision)), message + " registers");
+ return AcknowledgeTerminalQuickly(host, new TrayActionResult(actionId, revision, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), message);
+ }
+
private static void TestParentLatestAndReservedControl()
{
ParentTransport transport = new ParentTransport();
@@ -69,12 +179,12 @@ private static void TestHostPendingAndReplayBound()
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");
- AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(actionId, 2UL, TrayActionResultStatus.Completed, null, null)), "terminal non-lifecycle result clears the pending action");
+ AssertTrue(TryAcknowledge(transport, new TrayActionResult(actionId, 2UL, TrayActionResultStatus.Completed, null, null)), "terminal non-lifecycle result clears the pending action");
for (int i = 0; i < 63; i++)
{
Guid next = Guid.NewGuid();
AssertTrue(transport.TryRegisterAction(new TrayHostAction(next, TrayCommand.OpenLogs, 2UL)), "replay cache accepts distinct ids");
- AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(next, 2UL, TrayActionResultStatus.Completed, null, null)), "terminal non-lifecycle result drains independently");
+ AssertTrue(TryAcknowledge(transport, new TrayActionResult(next, 2UL, TrayActionResultStatus.Completed, null, null)), "terminal non-lifecycle result drains independently");
}
AssertTrue(!transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 2UL)), "the 64-entry replay cache rejects a replayed id");
transport.Dispose();
@@ -97,17 +207,18 @@ private static void TestActionResultCorrelationAndControlPriority()
Guid actionId = Guid.NewGuid(); Guid transactionId = Guid.Parse("11111111-2222-3333-4444-555555555555");
HostTransport host = new HostTransport();
AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.CheckAndRepair, 12UL)), "host registers one v2 lifecycle action");
- AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Accepted, null, null)), "lifecycle accepted result requires its durable transaction id");
+ AssertTrue(!TryAcknowledge(host, new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Accepted, null, null)), "lifecycle accepted result requires its durable transaction id");
TrayActionResult accepted = new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Accepted, null, transactionId);
- AssertTrue(host.TryAcknowledgeAction(accepted), "host accepts the correlated accepted result");
- AssertTrue(!host.TryAcknowledgeAction(accepted), "host rejects a duplicate accepted result");
- AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(Guid.NewGuid(), 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a result for an unknown action id");
- AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(actionId, 99UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a result with the wrong revision");
- AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host accepts the correlated terminal result after accepted");
- AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a double terminal result");
+ TrayTerminalReceipt terminal;
+ AssertTrue(host.TryAcknowledgeAction(accepted, out terminal) && terminal == null, "host accepts the correlated nonterminal accepted result without a receipt");
+ AssertTrue(!TryAcknowledge(host, accepted), "host rejects a duplicate accepted result");
+ AssertTrue(!TryAcknowledge(host, new TrayActionResult(Guid.NewGuid(), 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a result for an unknown action id");
+ AssertTrue(!TryAcknowledge(host, new TrayActionResult(actionId, 99UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a result with the wrong revision");
+ AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Completed, null, transactionId), out terminal) && terminal != null, "host returns the correlated terminal receipt after accepted");
+ AssertTrue(!TryAcknowledge(host, new TrayActionResult(actionId, 12UL, TrayActionResultStatus.Completed, null, transactionId)), "host rejects a double terminal result");
Guid prematureId = Guid.NewGuid();
AssertTrue(host.TryRegisterAction(new TrayHostAction(prematureId, TrayCommand.CheckAndRepair, 13UL)), "second lifecycle action registers");
- AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(prematureId, 13UL, TrayActionResultStatus.Completed, null, null)), "lifecycle completion before accepted is rejected");
+ AssertTrue(!TryAcknowledge(host, new TrayActionResult(prematureId, 13UL, TrayActionResultStatus.Completed, null, null)), "lifecycle completion before accepted is rejected");
AssertThrows(delegate { new TrayActionResult(Guid.NewGuid(), 1UL, TrayActionResultStatus.Rejected, "not-canonical", null); }, "invalid action result error code is rejected");
AssertThrows(delegate { new TrayActionResult(Guid.NewGuid(), 1UL, TrayActionResultStatus.Completed, null, Guid.Empty); }, "empty action result transaction id is rejected");
host.Dispose();
@@ -126,113 +237,228 @@ private static void TestActionResultCorrelationAndControlPriority()
parent.Dispose();
}
- private static void TestAcknowledgedAboutQueuesOneUiWorkItem()
+ private static void TestBlockedReceiptStagesNeverBlockCorrelationOrUi()
{
- HostTransport host = new HostTransport(null, PersistTerminal); Guid actionId = Guid.NewGuid();
- AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.ShowAbout, 14UL)), "About action registers");
- AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 14UL, TrayActionResultStatus.Completed, null, null)), "verified About completion is accepted");
- TrayActionResult result;
- AssertTrue(host.TryTakeCompletedAbout(out result) && result.ActionId == actionId && result.Status == TrayActionResultStatus.Completed, "verified About completion queues one UI work item");
- AssertTrue(!host.TryTakeCompletedAbout(out result), "About UI work item is consumed exactly once");
- host.Dispose();
+ ReceiptStoreBlockStage[] stages = new ReceiptStoreBlockStage[] { ReceiptStoreBlockStage.BeforeOpen, ReceiptStoreBlockStage.DuringWrite, ReceiptStoreBlockStage.DuringFlush };
+ for (int stageIndex = 0; stageIndex < stages.Length; stageIndex++)
+ {
+ ControllableReceiptStore store = new ControllableReceiptStore(stages[stageIndex], 1, 1);
+ int uiSignals = 0;
+ HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref uiSignals); });
+ TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt);
+ try
+ {
+ Guid firstId = Guid.NewGuid(); Guid followingId = Guid.NewGuid();
+ TrayTerminalReceipt first = RegisterAndAcknowledgeFailure(host, firstId, 24UL, stages[stageIndex].ToString() + " current terminal result");
+ Stopwatch firstSubmit = Stopwatch.StartNew(); bool firstAdmitted = sink.TrySubmit(first); firstSubmit.Stop();
+ AssertTrue(firstAdmitted, "current receipt is admitted before the fake store blocks " + stages[stageIndex].ToString());
+ AssertReturnsQuickly(firstSubmit, "current receipt admission is zero-wait");
+ AssertTrue(store.WaitUntilBlocked(2000), "fake store reaches " + stages[stageIndex].ToString());
+
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(followingId, TrayCommand.ShowAbout, 24UL)), stages[stageIndex].ToString() + " following About action registers");
+ TrayTerminalReceipt following = AcknowledgeTerminalQuickly(host, new TrayActionResult(followingId, 24UL, TrayActionResultStatus.Completed, null, null), stages[stageIndex].ToString() + " following About terminal result");
+ Stopwatch followingSubmit = Stopwatch.StartNew(); bool followingAdmitted = sink.TrySubmit(following); followingSubmit.Stop();
+ AssertTrue(followingAdmitted, "following receipt is admitted while the store is blocked " + stages[stageIndex].ToString());
+ AssertReturnsQuickly(followingSubmit, "following receipt admission never waits for the current store operation");
+ TrayTerminalReceipt duplicate;
+ AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(firstId, 24UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "current terminal action is removed before sink submission");
+ AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(followingId, 24UL, TrayActionResultStatus.Completed, null, null), out duplicate), "following terminal action is removed before sink submission");
+ TrayActionResult prematureFailure; TrayActionResult prematureAbout;
+ AssertTrue(!host.TryTakeFailedAction(out prematureFailure) && !host.TryTakeCompletedAbout(out prematureAbout) && Interlocked.CompareExchange(ref uiSignals, 0, 0) == 0, "blocked store exposes no generic or About UI work before durable success");
+
+ store.Release();
+ WaitUntil(delegate { return Interlocked.CompareExchange(ref uiSignals, 0, 0) == 2; }, 2000, "both durable successes post exactly one work signal");
+ TrayActionResult firstUi; TrayActionResult followingUi; TrayActionResult none;
+ AssertTrue(host.TryTakeFailedAction(out firstUi) && firstUi.ActionId == firstId, "first durable receipt publishes its exact generic-feedback item");
+ AssertTrue(host.TryTakeCompletedAbout(out followingUi) && followingUi.ActionId == followingId, "following durable receipt publishes its exact About item");
+ AssertTrue(!host.TryTakeFailedAction(out none), "durable callbacks publish no duplicate feedback");
+ AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "all blocked stages are serviced by exactly one non-overlapping writer");
+ }
+ finally
+ {
+ store.Release();
+ sink.Dispose();
+ host.Dispose();
+ store.Dispose();
+ }
+ }
}
- private static void TestRejectedAndFailedActionsQueueUserFeedback()
+ private static void TestNinthReceiptAndBusyAdmissionDropWithoutPendingOrUi()
{
- HostTransport host = new HostTransport(null, PersistTerminal);
- 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.Revision == 20UL && first.Status == TrayActionResultStatus.Rejected && String.Equals(first.ErrorCode, "CCOD_TRAY_ACTION_UNAVAILABLE", StringComparison.Ordinal), "rejected result preserves its exact correlated terminal record before generic feedback");
- AssertTrue(host.TryTakeFailedAction(out second) && second.ActionId == failedId && second.Revision == 20UL && second.Status == TrayActionResultStatus.Failed && String.Equals(second.ErrorCode, "CCOD_TRAY_ACTION_FAILED", StringComparison.Ordinal), "failed result preserves its exact correlated terminal record before generic feedback");
- AssertTrue(!host.TryTakeFailedAction(out none), "each terminal failure queues feedback exactly once");
- host.Dispose();
+ ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1);
+ HostTransport host = new HostTransport();
+ TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt);
+ try
+ {
+ TrayTerminalReceipt first = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 25UL, "first hung-store action");
+ AssertTrue(sink.TrySubmit(first), "current receipt occupies the first outstanding slot");
+ AssertTrue(store.WaitUntilBlocked(2000), "single writer enters the hung fake store");
+ for (int index = 1; index < 8; index++)
+ {
+ TrayTerminalReceipt queued = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 25UL, "queued outstanding action " + index.ToString());
+ AssertTrue(sink.TrySubmit(queued), "outstanding receipt slots include current plus seven queued items");
+ }
+
+ Guid ninthId = Guid.NewGuid();
+ TrayTerminalReceipt ninth = RegisterAndAcknowledgeFailure(host, ninthId, 25UL, "ninth full-sink action");
+ Stopwatch ninthSubmit = Stopwatch.StartNew(); bool ninthAdmitted = sink.TrySubmit(ninth); ninthSubmit.Stop();
+ AssertTrue(!ninthAdmitted, "ninth outstanding receipt is dropped");
+ AssertReturnsQuickly(ninthSubmit, "ninth outstanding admission is rejected immediately");
+ TrayTerminalReceipt duplicate;
+ AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(ninthId, 25UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "full-sink terminal action remains consumed");
+ for (int index = 0; index < 8; index++)
+ {
+ Guid droppedId = Guid.NewGuid();
+ TrayTerminalReceipt dropped = RegisterAndAcknowledgeFailure(host, droppedId, 25UL, "additional full-sink action " + index.ToString());
+ AssertTrue(!sink.TrySubmit(dropped), "repeated full-sink paths drop only receipt feedback");
+ }
+ for (int index = 0; index < 8; index++)
+ {
+ Guid capacityId = Guid.NewGuid();
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(capacityId, TrayCommand.OpenLogs, 25UL)), "receipt saturation never consumes pending action capacity");
+ AssertTrue(TryAcknowledge(host, new TrayActionResult(capacityId, 25UL, TrayActionResultStatus.Completed, null, null)), "capacity proof action remains terminally consumable");
+ }
+ AssertTrue(store.CallCount == 1 && store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "hung writer is never replaced");
+ TrayActionResult none;
+ AssertTrue(!host.TryTakeFailedAction(out none), "full and hung paths show no UI");
+
+ FieldInfo gateField = typeof(TrayTerminalReceiptSink).GetField("_gate", BindingFlags.Instance | BindingFlags.NonPublic);
+ AssertTrue(gateField != null, "busy-admission fixture locates the sink admission gate");
+ object admissionGate = gateField.GetValue(sink);
+ ManualResetEvent gateHeld = new ManualResetEvent(false); ManualResetEvent releaseGate = new ManualResetEvent(false);
+ Thread holder = new Thread((ThreadStart)delegate { lock (admissionGate) { gateHeld.Set(); releaseGate.WaitOne(); } });
+ holder.IsBackground = true; holder.Start();
+ try
+ {
+ AssertTrue(gateHeld.WaitOne(2000), "busy-admission fixture holds the sink gate");
+ Guid busyId = Guid.NewGuid();
+ TrayTerminalReceipt busy = RegisterAndAcknowledgeFailure(host, busyId, 25UL, "busy-sink action");
+ bool busyReturned = false; bool busyAdmitted = true;
+ Thread submitter = new Thread((ThreadStart)delegate { busyAdmitted = sink.TrySubmit(busy); busyReturned = true; });
+ submitter.IsBackground = true; submitter.Start();
+ AssertTrue(submitter.Join(250) && busyReturned && !busyAdmitted, "busy admission returns immediately without waiting for the gate");
+ AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(busyId, 25UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "busy-sink terminal action remains consumed");
+ AssertTrue(!host.TryTakeFailedAction(out none), "busy admission shows no UI");
+ }
+ finally
+ {
+ releaseGate.Set(); holder.Join(2000); gateHeld.Dispose(); releaseGate.Dispose();
+ }
+ }
+ finally
+ {
+ store.Release();
+ sink.Dispose();
+ host.Dispose();
+ store.Dispose();
+ }
}
- private static void TestUndisplayedActionFailureFeedbackIsBounded()
+ private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter()
{
- HostTransport host = new HostTransport(null, PersistTerminal);
- for (int index = 0; index < 9; index++)
+ ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.DuringFlush, 0, 1, 1);
+ bool failCallback = true; int successfulSignals = 0;
+ HostTransport host = new HostTransport(delegate
{
- 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(accepted, "bounded undisplayed feedback never strands an authenticated terminal action");
+ if (failCallback) { failCallback = false; throw new InvalidOperationException("intentional UI callback failure"); }
+ Interlocked.Increment(ref successfulSignals);
+ });
+ TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt);
+ try
+ {
+ Guid storeFailureId = Guid.NewGuid(); Guid callbackFailureId = Guid.NewGuid(); Guid recoveredId = Guid.NewGuid();
+ AssertTrue(sink.TrySubmit(RegisterAndAcknowledgeFailure(host, storeFailureId, 26UL, "store-failure action")), "first receipt is admitted before the store failure");
+ AssertTrue(store.WaitUntilBlocked(2000), "first receipt blocks during the simulated flush");
+ AssertTrue(sink.TrySubmit(RegisterAndAcknowledgeFailure(host, callbackFailureId, 26UL, "callback-failure action")), "second receipt queues behind the first store failure");
+ AssertTrue(sink.TrySubmit(RegisterAndAcknowledgeFailure(host, recoveredId, 26UL, "recovered action")), "third receipt queues for recovery");
+ TrayActionResult none;
+ AssertTrue(!host.TryTakeFailedAction(out none), "blocked and not-yet-durable receipts expose no UI");
+ store.Release();
+ WaitUntil(delegate { return store.CompletedCount == 3 && Interlocked.CompareExchange(ref successfulSignals, 0, 0) == 1; }, 3000, "same writer continues through store and callback failures to a later success");
+ TrayActionResult recovered; TrayActionResult extra;
+ AssertTrue(host.TryTakeFailedAction(out recovered) && recovered.ActionId == recoveredId, "only the later durable callback publishes generic feedback");
+ AssertTrue(!host.TryTakeFailedAction(out extra), "store and callback failures publish no latent UI");
+ AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "recovery keeps the original single writer instead of starting a replacement");
+ }
+ finally
+ {
+ store.Release(); sink.Dispose(); host.Dispose(); store.Dispose();
}
- host.Dispose();
}
- private static void TestTerminalDiagnosticFailureNeverStrandsPendingActions()
+ private static void TestDurableReceiptTrustAndSharedUiBound()
{
- HostTransport missingWriter = new HostTransport();
- Guid missingWriterId = Guid.NewGuid();
- AssertTrue(missingWriter.TryRegisterAction(new TrayHostAction(missingWriterId, TrayCommand.OpenLogs, 22UL)), "action registers without a diagnostic writer");
- AssertTrue(missingWriter.TryAcknowledgeAction(new TrayActionResult(missingWriterId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "missing diagnostic writer still releases the authenticated terminal action");
- TrayActionResult missingWriterFeedback;
- AssertTrue(!missingWriter.TryTakeFailedAction(out missingWriterFeedback), "missing diagnostic writer cannot authorize generic feedback");
- missingWriter.Dispose();
-
- bool persist = false;
- List records = new List();
- HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { records.Add(record); return persist; });
- for (int index = 0; index < 10; index++)
+ int signals = 0;
+ HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref signals); });
+ HostTransport foreign = new HostTransport();
+ try
{
- Guid actionId = Guid.NewGuid();
- AssertTrue(host.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 22UL)), "diagnostic failure never consumes pending-action capacity");
- AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(actionId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "authenticated terminal result releases its pending action even when local persistence fails");
- TrayActionResult suppressed;
- AssertTrue(!host.TryTakeFailedAction(out suppressed), "generic feedback is suppressed when its local terminal diagnostic did not persist");
+ Guid aboutId = Guid.NewGuid();
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(aboutId, TrayCommand.ShowAbout, 27UL)), "About action registers for durable authorization");
+ TrayTerminalReceipt about = AcknowledgeTerminalQuickly(host, new TrayActionResult(aboutId, 27UL, TrayActionResultStatus.Completed, null, null), "About terminal result");
+ TrayActionResult beforeDurable;
+ AssertTrue(!host.TryTakeCompletedAbout(out beforeDurable), "typed receipt alone cannot expose About UI before durable publication");
+ AssertTrue(!foreign.TryPublishDurableReceipt(about), "a different transport rejects a receipt it did not correlate");
+ AssertTrue(host.TryPublishDurableReceipt(about), "originating transport accepts one explicit durable-success callback");
+ AssertTrue(!host.TryPublishDurableReceipt(about), "the same durable receipt cannot publish twice");
+
+ for (int index = 0; index < 7; index++)
+ {
+ TrayTerminalReceipt failure = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 27UL, "bounded UI failure " + index.ToString());
+ AssertTrue(host.TryPublishDurableReceipt(failure), "shared UI queue admits through eight total items");
+ }
+ TrayTerminalReceipt overflow = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 27UL, "bounded UI overflow");
+ AssertTrue(!host.TryPublishDurableReceipt(overflow), "ninth shared About/failure UI item is dropped");
+
+ Guid noUiId = Guid.NewGuid();
+ AssertTrue(host.TryRegisterAction(new TrayHostAction(noUiId, TrayCommand.OpenLogs, 27UL)), "non-UI completion registers while UI work is full");
+ TrayTerminalReceipt noUi = AcknowledgeTerminalQuickly(host, new TrayActionResult(noUiId, 27UL, TrayActionResultStatus.Completed, null, null), "non-UI completion");
+ AssertTrue(host.TryPublishDurableReceipt(noUi), "durable non-UI completion is consumed without UI capacity");
+
+ TrayActionResult aboutUi;
+ AssertTrue(host.TryTakeCompletedAbout(out aboutUi) && aboutUi.ActionId == aboutId, "durable About maps to one About UI item");
+ int failures = 0; TrayActionResult failed;
+ while (host.TryTakeFailedAction(out failed)) { failures++; }
+ AssertTrue(failures == 7, "About and generic failure share one eight-item UI bound");
+ AssertTrue(Interlocked.CompareExchange(ref signals, 0, 0) == 8, "only admitted UI items post application work");
}
- persist = true;
- Guid recoveredId = Guid.NewGuid();
- AssertTrue(host.TryRegisterAction(new TrayHostAction(recoveredId, TrayCommand.OpenLogs, 22UL)), "new action registers after repeated diagnostic failures");
- AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(recoveredId, 22UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)), "recovered diagnostic persistence accepts a new terminal result");
- TrayActionResult recoveredFeedback;
- AssertTrue(host.TryTakeFailedAction(out recoveredFeedback) && recoveredFeedback.ActionId == recoveredId, "generic feedback resumes only after local terminal persistence recovers");
- Guid completedId = Guid.NewGuid();
- AssertTrue(host.TryRegisterAction(new TrayHostAction(completedId, TrayCommand.OpenLogs, 22UL)), "post-recovery command can still register");
- AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(completedId, 22UL, TrayActionResultStatus.Completed, null, null)), "post-recovery command can complete");
- TrayTerminalDiagnostic last = records[records.Count - 1];
- AssertTrue(last.Command == TrayCommand.OpenLogs && last.Revision == 22UL && last.Status == TrayActionResultStatus.Completed && String.Equals(last.Code, "CCOD_TRAY_ACTION_COMPLETED", StringComparison.Ordinal), "terminal diagnostic exposes only the exact canonical correlation fields");
- host.Dispose();
+ finally { foreign.Dispose(); host.Dispose(); }
}
- private static void TestBlockingTerminalWriterRunsOutsideCorrelationLock()
+ private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks()
{
- ManualResetEvent writerEntered = new ManualResetEvent(false);
- ManualResetEvent releaseWriter = new ManualResetEvent(false);
- bool first = true;
- HostTransport host = new HostTransport(null, delegate(TrayTerminalDiagnostic record)
- {
- if (first) { first = false; writerEntered.Set(); releaseWriter.WaitOne(TimeSpan.FromSeconds(3)); }
- return true;
- });
- Guid blockedId = Guid.NewGuid();
- AssertTrue(host.TryRegisterAction(new TrayHostAction(blockedId, TrayCommand.OpenLogs, 24UL)), "blocking-writer action registers");
- bool acknowledged = false;
- Thread acknowledgement = new Thread((ThreadStart)delegate { acknowledged = host.TryAcknowledgeAction(new TrayActionResult(blockedId, 24UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null)); });
- acknowledgement.IsBackground = true;
- acknowledgement.Start();
+ ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1);
+ int signals = 0;
+ HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref signals); });
+ TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt);
try
{
- AssertTrue(writerEntered.WaitOne(TimeSpan.FromSeconds(2)), "terminal writer enters deterministically");
- Guid concurrentId = Guid.NewGuid();
- Stopwatch registration = Stopwatch.StartNew();
- bool registered = host.TryRegisterAction(new TrayHostAction(concurrentId, TrayCommand.OpenLogs, 24UL));
- registration.Stop();
- AssertTrue(registered && registration.Elapsed < TimeSpan.FromMilliseconds(250), "blocking terminal I/O cannot hold the correlation lock or pending capacity");
- releaseWriter.Set();
- AssertTrue(acknowledgement.Join(TimeSpan.FromSeconds(2)) && acknowledged, "terminal acknowledgement completes after the writer is released");
- AssertTrue(host.TryAcknowledgeAction(new TrayActionResult(concurrentId, 24UL, TrayActionResultStatus.Completed, null, null)), "concurrent action remains independently completable");
+ TrayTerminalReceipt current = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 28UL, "dispose-hung current action");
+ AssertTrue(sink.TrySubmit(current), "dispose-hung receipt is admitted");
+ AssertTrue(store.WaitUntilBlocked(2000), "writer is hung before sink disposal");
+ Stopwatch disposal = Stopwatch.StartNew(); sink.Dispose(); disposal.Stop();
+ AssertReturnsQuickly(disposal, "sink disposal uses a bounded join when the only writer is hung");
+
+ Guid closedId = Guid.NewGuid();
+ TrayTerminalReceipt closed = RegisterAndAcknowledgeFailure(host, closedId, 28UL, "closed-sink action");
+ Stopwatch closedSubmit = Stopwatch.StartNew(); bool closedAdmitted = sink.TrySubmit(closed); closedSubmit.Stop();
+ AssertTrue(!closedAdmitted, "disposed sink rejects new receipts");
+ AssertReturnsQuickly(closedSubmit, "closed admission is immediate");
+ TrayTerminalReceipt duplicate;
+ AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(closedId, 28UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "closed-sink action remains terminally consumed");
+
+ store.Release();
+ WaitUntil(delegate { return store.CompletedCount == 1; }, 2000, "hung store can return after disposal without reviving the sink");
+ Stopwatch finalJoin = Stopwatch.StartNew(); sink.Dispose(); finalJoin.Stop();
+ AssertReturnsQuickly(finalJoin, "disposed sink remains bounded while joining its original completed writer");
+ TrayActionResult none;
+ AssertTrue(Interlocked.CompareExchange(ref signals, 0, 0) == 0 && !host.TryTakeFailedAction(out none), "late durable return after disposal cannot publish UI");
+ AssertTrue(store.CallCount == 1 && store.WriterThreadCount == 1, "disposal never starts a replacement writer");
}
finally
{
- releaseWriter.Set();
- if (acknowledgement.IsAlive) { acknowledgement.Join(TimeSpan.FromSeconds(2)); }
- writerEntered.Dispose(); releaseWriter.Dispose(); host.Dispose();
+ store.Release(); sink.Dispose(); host.Dispose(); store.Dispose();
}
}
@@ -245,11 +471,11 @@ public static int Main(string[] args)
TestHostPendingAndReplayBound();
TestDirectPresentationAcksDoNotAccumulateAnUnusedControlQueue();
TestActionResultCorrelationAndControlPriority();
- TestAcknowledgedAboutQueuesOneUiWorkItem();
- TestRejectedAndFailedActionsQueueUserFeedback();
- TestUndisplayedActionFailureFeedbackIsBounded();
- TestTerminalDiagnosticFailureNeverStrandsPendingActions();
- TestBlockingTerminalWriterRunsOutsideCorrelationLock();
+ TestBlockedReceiptStagesNeverBlockCorrelationOrUi();
+ TestNinthReceiptAndBusyAdmissionDropWithoutPendingOrUi();
+ TestStoreAndCallbackFailuresRecoverOnTheSameWriter();
+ TestDurableReceiptTrustAndSharedUiBound();
+ TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks();
Console.WriteLine("TrayHost transport self-tests passed: 10");
return 0;
}
From 1f1ecc5f72fbbb1c070d62a745b36be003b50506 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:07:49 +0800
Subject: [PATCH 032/187] fix: hide provisional tray receipt UI
---
src/trayhost/HostTransport.cs | 77 +++++++++++--------
src/trayhost/Program.cs | 11 ++-
tests/trayhost/TrayHostTransportSelfTest.cs | 85 +++++++++++++++++++--
3 files changed, 135 insertions(+), 38 deletions(-)
diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs
index 25d405b..fe426f1 100644
--- a/src/trayhost/HostTransport.cs
+++ b/src/trayhost/HostTransport.cs
@@ -9,10 +9,18 @@ private sealed class PendingAction
internal bool Accepted;
}
+ private sealed class UiWorkItem
+ {
+ internal TrayActionResult Result;
+ internal bool Ready;
+ internal bool ProbeObserved;
+ }
+
private readonly object _gate = new object();
private readonly Action _presentationReady;
- private readonly Queue _completedAbout = new Queue();
- private readonly Queue _failedActions = new Queue();
+ private readonly Action _receiptReady;
+ 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();
@@ -21,13 +29,18 @@ private sealed class PendingAction
private bool _menuOpen;
private bool _disposed;
- internal HostTransport() : this(null)
+ internal HostTransport() : this(null, null)
+ {
+ }
+
+ internal HostTransport(Action presentationReady) : this(presentationReady, presentationReady)
{
}
- internal HostTransport(Action presentationReady)
+ internal HostTransport(Action presentationReady, Action receiptReady)
{
_presentationReady = presentationReady;
+ _receiptReady = receiptReady;
}
internal void SetMenuOpen(bool value)
@@ -108,8 +121,8 @@ internal bool TryAcknowledgeAction(TrayActionResult result, out TrayTerminalRece
internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt)
{
if (receipt == null) { return false; }
- bool notify = false;
- bool accepted = false;
+ UiWorkItem work = null;
+ Action receiptReady = _receiptReady;
lock (_gate)
{
if (_disposed || !receipt.TryClaimPublication(this)) { return false; }
@@ -117,48 +130,48 @@ internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt)
if (result.Status == TrayActionResultStatus.Completed && receipt.Command == TrayCommand.ShowAbout)
{
if (_uiWorkCount >= 8) { return false; }
- _completedAbout.Enqueue(result);
+ work = new UiWorkItem { Result = result, Ready = receiptReady == null };
+ _completedAbout.Enqueue(work);
_uiWorkCount++;
- notify = true;
- accepted = true;
}
else if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed)
{
if (_uiWorkCount >= 8) { return false; }
- _failedActions.Enqueue(result);
+ work = new UiWorkItem { Result = result, Ready = receiptReady == null };
+ _failedActions.Enqueue(work);
_uiWorkCount++;
- notify = true;
- accepted = true;
- }
- else
- {
- accepted = true;
}
}
- Action presentationReady = _presentationReady;
- if (notify && presentationReady != null)
+ if (work == null || receiptReady == null) { return true; }
+ try { receiptReady(); }
+ catch
{
- try { presentationReady(); }
- catch
- {
- DropUiWork(receipt.Result);
- return false;
- }
+ DropUiWork(work);
+ return false;
+ }
+ bool repost;
+ lock (_gate)
+ {
+ if (_disposed) { return false; }
+ work.Ready = true;
+ repost = work.ProbeObserved;
+ work.ProbeObserved = false;
}
- return accepted;
+ if (repost) { try { receiptReady(); } catch { } }
+ return true;
}
- private void DropUiWork(TrayActionResult target)
+ private void DropUiWork(UiWorkItem target)
{
if (target == null) { return; }
lock (_gate)
{
- Queue queue = target.Status == TrayActionResultStatus.Completed ? _completedAbout : _failedActions;
+ Queue queue = target.Result.Status == TrayActionResultStatus.Completed ? _completedAbout : _failedActions;
int count = queue.Count;
bool removed = false;
for (int index = 0; index < count; index++)
{
- TrayActionResult current = queue.Dequeue();
+ UiWorkItem current = queue.Dequeue();
if (!removed && Object.ReferenceEquals(current, target)) { removed = true; }
else { queue.Enqueue(current); }
}
@@ -171,7 +184,9 @@ internal bool TryTakeCompletedAbout(out TrayActionResult result)
lock (_gate)
{
if (_completedAbout.Count == 0) { result = null; return false; }
- result = _completedAbout.Dequeue();
+ UiWorkItem work = _completedAbout.Peek();
+ if (!work.Ready) { work.ProbeObserved = true; result = null; return false; }
+ result = _completedAbout.Dequeue().Result;
if (_uiWorkCount > 0) { _uiWorkCount--; }
return true;
}
@@ -182,7 +197,9 @@ internal bool TryTakeFailedAction(out TrayActionResult result)
lock (_gate)
{
if (_failedActions.Count == 0) { result = null; return false; }
- result = _failedActions.Dequeue();
+ UiWorkItem work = _failedActions.Peek();
+ if (!work.Ready) { work.ProbeObserved = true; result = null; return false; }
+ result = _failedActions.Dequeue().Result;
if (_uiWorkCount > 0) { _uiWorkCount--; }
return true;
}
diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs
index 2cdaed1..82c310a 100644
--- a/src/trayhost/Program.cs
+++ b/src/trayhost/Program.cs
@@ -39,10 +39,17 @@ private static int RunChild(string[] args)
PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload);
Win32TrayPlatform platform = new Win32TrayPlatform();
TrayHostApplication application = null;
+ TrayWindow window = null;
TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore();
- HostTransport transport = new HostTransport(delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } });
+ HostTransport transport = new HostTransport(
+ delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } },
+ delegate
+ {
+ TrayWindow current = window;
+ if (current == null || !Win32TrayPlatform.PostToWindow(current.OwnerHandle, TrayNativeConstants.WmApp + 2U)) { throw new InvalidOperationException("receipt UI callback failed"); }
+ });
TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose);
- TrayWindow window = new TrayWindow(platform, transport.SetMenuOpen);
+ window = new TrayWindow(platform, transport.SetMenuOpen);
bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object();
Action command = delegate(TrayCommand selected, ulong revision)
{
diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs
index 43436fe..1352d8c 100644
--- a/tests/trayhost/TrayHostTransportSelfTest.cs
+++ b/tests/trayhost/TrayHostTransportSelfTest.cs
@@ -102,8 +102,9 @@ private static void AssertReturnsQuickly(Stopwatch elapsed, string message)
private static void WaitUntil(Func condition, int milliseconds, string message)
{
Stopwatch elapsed = Stopwatch.StartNew();
- while (!condition() && elapsed.ElapsedMilliseconds < milliseconds) { Thread.Sleep(5); }
- AssertTrue(condition(), message);
+ bool satisfied = condition();
+ while (!satisfied && elapsed.ElapsedMilliseconds < milliseconds) { Thread.Sleep(5); satisfied = condition(); }
+ AssertTrue(satisfied, message);
}
private static PresentationSnapshot Snapshot(ulong revision)
@@ -269,8 +270,11 @@ private static void TestBlockedReceiptStagesNeverBlockCorrelationOrUi()
store.Release();
WaitUntil(delegate { return Interlocked.CompareExchange(ref uiSignals, 0, 0) == 2; }, 2000, "both durable successes post exactly one work signal");
TrayActionResult firstUi; TrayActionResult followingUi; TrayActionResult none;
- AssertTrue(host.TryTakeFailedAction(out firstUi) && firstUi.ActionId == firstId, "first durable receipt publishes its exact generic-feedback item");
- AssertTrue(host.TryTakeCompletedAbout(out followingUi) && followingUi.ActionId == followingId, "following durable receipt publishes its exact About item");
+ firstUi = null; followingUi = null;
+ WaitUntil(delegate { return host.TryTakeFailedAction(out firstUi); }, 2000, "first durable receipt becomes visible after its callback succeeds");
+ WaitUntil(delegate { return host.TryTakeCompletedAbout(out followingUi); }, 2000, "following durable About becomes visible after its callback succeeds");
+ AssertTrue(firstUi.ActionId == firstId, "first durable receipt publishes its exact generic-feedback item");
+ AssertTrue(followingUi.ActionId == followingId, "following durable receipt publishes its exact About item");
AssertTrue(!host.TryTakeFailedAction(out none), "durable callbacks publish no duplicate feedback");
AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "all blocked stages are serviced by exactly one non-overlapping writer");
}
@@ -377,7 +381,9 @@ private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter()
store.Release();
WaitUntil(delegate { return store.CompletedCount == 3 && Interlocked.CompareExchange(ref successfulSignals, 0, 0) == 1; }, 3000, "same writer continues through store and callback failures to a later success");
TrayActionResult recovered; TrayActionResult extra;
- AssertTrue(host.TryTakeFailedAction(out recovered) && recovered.ActionId == recoveredId, "only the later durable callback publishes generic feedback");
+ recovered = null;
+ WaitUntil(delegate { return host.TryTakeFailedAction(out recovered); }, 2000, "later durable callback makes its recovered feedback visible");
+ AssertTrue(recovered.ActionId == recoveredId, "only the later durable callback publishes generic feedback");
AssertTrue(!host.TryTakeFailedAction(out extra), "store and callback failures publish no latent UI");
AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "recovery keeps the original single writer instead of starting a replacement");
}
@@ -426,6 +432,71 @@ private static void TestDurableReceiptTrustAndSharedUiBound()
finally { foreign.Dispose(); host.Dispose(); }
}
+ private static void TestFailingCallbackNeverExposesProvisionalUiWork()
+ {
+ ManualResetEvent callbackEntered = new ManualResetEvent(false);
+ ManualResetEvent releaseCallback = new ManualResetEvent(false);
+ HostTransport host = new HostTransport(delegate
+ {
+ callbackEntered.Set();
+ releaseCallback.WaitOne();
+ throw new InvalidOperationException("intentional UI callback failure");
+ });
+ try
+ {
+ TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 29UL, "provisional callback-failure action");
+ bool published = true;
+ Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); });
+ publisher.IsBackground = true; publisher.Start();
+ AssertTrue(callbackEntered.WaitOne(2000), "durable UI callback enters before failing");
+ TrayActionResult provisional = null; bool takeReturned = false; bool tookProvisional = true;
+ Thread taker = new Thread((ThreadStart)delegate { tookProvisional = host.TryTakeFailedAction(out provisional); takeReturned = true; });
+ taker.IsBackground = true; taker.Start();
+ AssertTrue(taker.Join(250) && takeReturned && !tookProvisional && provisional == null, "provisional UI work returns immediately but remains unavailable while callback outcome is unknown");
+ releaseCallback.Set();
+ AssertTrue(publisher.Join(2000) && !published, "throwing durable callback reports publication failure");
+ TrayActionResult none;
+ AssertTrue(!host.TryTakeFailedAction(out none), "callback failure leaves no latent generic UI work");
+ }
+ finally
+ {
+ releaseCallback.Set(); host.Dispose(); callbackEntered.Dispose(); releaseCallback.Dispose();
+ }
+ }
+
+ private static void TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe()
+ {
+ ManualResetEvent callbackEntered = new ManualResetEvent(false);
+ ManualResetEvent releaseCallback = new ManualResetEvent(false);
+ int callbackCount = 0;
+ HostTransport host = new HostTransport(delegate
+ {
+ int call = Interlocked.Increment(ref callbackCount);
+ if (call == 1) { callbackEntered.Set(); releaseCallback.WaitOne(); }
+ });
+ try
+ {
+ Guid actionId = Guid.NewGuid();
+ TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, actionId, 30UL, "early-probe durable action");
+ bool published = false;
+ Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); });
+ publisher.IsBackground = true; publisher.Start();
+ AssertTrue(callbackEntered.WaitOne(2000), "first successful UI post is held before returning");
+ TrayActionResult early;
+ AssertTrue(!host.TryTakeFailedAction(out early), "work-message probe cannot consume provisional feedback");
+ releaseCallback.Set();
+ AssertTrue(publisher.Join(2000) && published, "durable publication succeeds after its first UI post returns");
+ AssertTrue(Interlocked.CompareExchange(ref callbackCount, 0, 0) == 2, "an early work-message probe causes one replacement UI post");
+ TrayActionResult ready; TrayActionResult none;
+ AssertTrue(host.TryTakeFailedAction(out ready) && ready.ActionId == actionId, "replacement post observes the now-ready durable feedback");
+ AssertTrue(!host.TryTakeFailedAction(out none), "replacement posting never duplicates the UI item");
+ }
+ finally
+ {
+ releaseCallback.Set(); host.Dispose(); callbackEntered.Dispose(); releaseCallback.Dispose();
+ }
+ }
+
private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks()
{
ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1);
@@ -475,8 +546,10 @@ public static int Main(string[] args)
TestNinthReceiptAndBusyAdmissionDropWithoutPendingOrUi();
TestStoreAndCallbackFailuresRecoverOnTheSameWriter();
TestDurableReceiptTrustAndSharedUiBound();
+ TestFailingCallbackNeverExposesProvisionalUiWork();
+ TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe();
TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks();
- Console.WriteLine("TrayHost transport self-tests passed: 10");
+ Console.WriteLine("TrayHost transport self-tests passed: 12");
return 0;
}
catch (Exception error)
From 1212579676a1b4773450ed961094a02c6322d0f7 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:10:14 +0800
Subject: [PATCH 033/187] fix: drop failed tray receipt reposts
---
src/trayhost/HostTransport.cs | 10 +++++-
tests/trayhost/TrayHostTransportSelfTest.cs | 35 ++++++++++++++++++++-
2 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs
index fe426f1..5a6048b 100644
--- a/src/trayhost/HostTransport.cs
+++ b/src/trayhost/HostTransport.cs
@@ -157,7 +157,15 @@ internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt)
repost = work.ProbeObserved;
work.ProbeObserved = false;
}
- if (repost) { try { receiptReady(); } catch { } }
+ if (repost)
+ {
+ try { receiptReady(); }
+ catch
+ {
+ DropUiWork(work);
+ return false;
+ }
+ }
return true;
}
diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs
index 1352d8c..c5cec99 100644
--- a/tests/trayhost/TrayHostTransportSelfTest.cs
+++ b/tests/trayhost/TrayHostTransportSelfTest.cs
@@ -497,6 +497,38 @@ private static void TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe()
}
}
+ private static void TestReplacementCallbackFailureDropsReadyUiWork()
+ {
+ ManualResetEvent callbackEntered = new ManualResetEvent(false);
+ ManualResetEvent releaseCallback = new ManualResetEvent(false);
+ int callbackCount = 0;
+ HostTransport host = new HostTransport(delegate
+ {
+ int call = Interlocked.Increment(ref callbackCount);
+ if (call == 1) { callbackEntered.Set(); releaseCallback.WaitOne(); return; }
+ throw new InvalidOperationException("intentional replacement UI callback failure");
+ });
+ try
+ {
+ TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 31UL, "replacement-callback failure action");
+ bool published = true;
+ Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); });
+ publisher.IsBackground = true; publisher.Start();
+ AssertTrue(callbackEntered.WaitOne(2000), "first UI post is held before replacement-failure probe");
+ TrayActionResult early;
+ AssertTrue(!host.TryTakeFailedAction(out early), "replacement-failure probe cannot consume provisional feedback");
+ releaseCallback.Set();
+ AssertTrue(publisher.Join(2000) && !published, "replacement callback failure reports publication failure");
+ TrayActionResult none;
+ AssertTrue(!host.TryTakeFailedAction(out none), "replacement callback failure removes the ready-but-unnotified UI item");
+ AssertTrue(Interlocked.CompareExchange(ref callbackCount, 0, 0) == 2, "replacement callback is attempted exactly once");
+ }
+ finally
+ {
+ releaseCallback.Set(); host.Dispose(); callbackEntered.Dispose(); releaseCallback.Dispose();
+ }
+ }
+
private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks()
{
ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1);
@@ -548,8 +580,9 @@ public static int Main(string[] args)
TestDurableReceiptTrustAndSharedUiBound();
TestFailingCallbackNeverExposesProvisionalUiWork();
TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe();
+ TestReplacementCallbackFailureDropsReadyUiWork();
TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks();
- Console.WriteLine("TrayHost transport self-tests passed: 12");
+ Console.WriteLine("TrayHost transport self-tests passed: 13");
return 0;
}
catch (Exception error)
From 3315c33adeb67edd6dce0a22ad905a16ad49e7bd Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:11:26 +0800
Subject: [PATCH 034/187] docs: record async tray receipt Task 2
---
.../task-2-report.md | 268 ++++++++++++++++++
1 file changed, 268 insertions(+)
create mode 100644 .superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md
new file mode 100644
index 0000000..2320aa5
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md
@@ -0,0 +1,268 @@
+# Task 2 report: asynchronous bounded tray terminal receipts
+
+Date: 2026-08-28
+
+Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+
+Branch: `codex/v2522-install-runtime-reliability`
+
+## Status
+
+Task 2 is implemented and its focused transport/native acceptance is green.
+The authenticated reader now consumes a correlated terminal result before one
+zero-wait sink submission; receipt store I/O and durable UI publication run on
+one bounded background writer. This report does not claim the Task 3 shared
+child-session/full production trace or the Task 4 release gate.
+
+## Implementation
+
+- `src/trayhost/TrayTerminalReceiptSink.cs`
+ - Adds a typed `TrayTerminalReceipt` containing the correlated immutable
+ action result plus the existing sanitized `TrayTerminalDiagnostic` used by
+ the accepted handle-pinned store.
+ - Binds each receipt to the `HostTransport` that correlated it and permits
+ publication at most once.
+ - Starts exactly one background receipt writer. No error, timeout, or hang
+ starts a replacement writer.
+ - `TrySubmit` uses a non-waiting admission gate and admits at most eight
+ outstanding receipts, including the receipt currently inside store I/O or
+ its durable callback.
+ - Store `false`/throw and callback `false`/throw drop only feedback and allow
+ the same writer to process later receipts.
+ - Disposal closes admission, clears queued work, suppresses late callbacks,
+ and joins the one writer for at most 100 ms. If store I/O remains hung, the
+ original background writer owns deferred store cleanup after it eventually
+ returns; shutdown never starts or waits for a replacement.
+- `src/trayhost/HostTransport.cs`
+ - `TryAcknowledgeAction` validates correlation under the existing lock,
+ creates the typed terminal receipt, removes the pending action, and returns
+ without file I/O or capacity waiting.
+ - `TryPublishDurableReceipt` accepts only a once-publishable receipt from the
+ same transport. Completed About maps to About work; rejected/failed maps to
+ generic failure; all other completed commands map to no UI.
+ - About and generic feedback share one eight-item UI bound. Queue or callback
+ failure consumes the publication but leaves no retryable feedback.
+ - UI work is provisional while the application-post callback is unresolved.
+ Consumers return immediately without seeing it; callback failure removes
+ it, while callback success makes it ready. If a posted work message probes
+ the provisional item before callback return, the transport posts one
+ replacement work message after marking it ready. Replacement-post failure
+ removes the ready item and returns its shared UI capacity.
+ - Presentation and receipt callbacks are separate. Application receipt work
+ posting remains outside the transport correlation lock.
+- `src/trayhost/Program.cs`
+ - Composes one existing `TrayTerminalDiagnosticStore`, one `HostTransport`,
+ and one `TrayTerminalReceiptSink`.
+ - The authenticated reader calls correlation and one `TrySubmit`, ignores
+ admission failure after the action is consumed, and no longer posts UI work
+ immediately for terminal wire results.
+ - The durable callback is the only terminal path that posts About/failure
+ work. Its production callback checks the actual `PostMessage` result and
+ fails closed before UI visibility when posting fails. Sink disposal
+ precedes window/transport disposal, and the sink owns safe deferred store
+ disposal if its writer is hung.
+ - No command-line selector, message type, wire field, direction, sequence,
+ capability, authentication, or storage-security rule changed.
+- `tests/trayhost/TrayHostTransportSelfTest.cs`
+ - Retains the parent/presentation/pending/replay/result-correlation coverage
+ and replaces the synchronous-writer expectations with Task 2 behavior.
+ - Uses a controllable fake store that blocks before open, during write, and
+ during flush.
+ - Covers current and following terminal returns under 250 ms, pending removal
+ before submission, eight outstanding including current, immediate ninth
+ drop, deterministic busy admission, closed admission, store failure,
+ callback failure, provisional UI invisibility, early-message repost, later
+ recovery on the same writer, shared UI bound, trusted once-only
+ publication, and hung-store disposal.
+ - The blocked following result is completed About, while the current result
+ is failed OpenLogs, so both About and generic feedback are proven absent
+ until explicit durable success.
+- `tests/trayhost/TrayHostNativeSelfTest.cs`
+ - Adds a fake-native-window integration test using the real handle-pinned
+ store through the asynchronous sink. Generic native feedback is unavailable
+ before durable success and appears exactly once afterward.
+- `tests/trayhost/Invoke-TrayHostSelfTest.ps1`
+ - Adds the new source to the existing temporary transport/native compilation
+ list. Production compilation already collects every `src/trayhost/*.cs`.
+- `tests/trayhost/TrayHostParentClientSelfTest.cs`
+ - Migrates the current temporary peer caller to the typed receipt/sink API so
+ the complete production source set compiles. This is only an API adaptation;
+ it is not the Task 3 shared production child-session trace.
+
+## TDD evidence
+
+### Clean baseline
+
+Before test changes:
+
+```text
+TransportOnly: exit 0, TrayHost transport self-tests passed: 10
+NativeOnly: exit 0, TrayHost native self-tests passed: 22
+```
+
+### RED
+
+The new behavioral tests and temporary compile list were written before the
+new production source/API.
+
+Command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+```
+
+Exit `1`; decisive output:
+
+```text
+CCOD_TRAYHOST_SOURCE_MISSING
+FullyQualifiedErrorId : CCOD_TRAYHOST_SOURCE_MISSING
+```
+
+This was the expected missing-feature RED: the planned
+`TrayTerminalReceiptSink.cs` and typed receipt API did not yet exist. No
+production source had been changed when this run was captured.
+
+### Callback-visibility RED
+
+Post-commit concurrency review identified that the first implementation placed
+UI work in a consumable queue before its application callback returned. A
+callback that later threw could therefore race a queue consumer. A test held a
+callback that would throw and probed the UI queue concurrently. Before the
+two-phase UI work fix, TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+provisional UI work returns immediately but remains unavailable while callback outcome is unknown
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The fixed transport keeps the item provisional and nonblocking until callback
+success, or removes it before visibility on callback failure.
+
+### Early-work-message repost RED
+
+The provisional design also needs to recover when the posted work message is
+processed before the callback returns and before the item becomes ready. The
+repost behavior was removed before its test was added. Without the repost,
+TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+an early work-message probe causes one replacement UI post
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The green implementation records the early probe, marks the item ready only
+after callback success, and posts one replacement message without duplicating
+the UI item.
+
+### Replacement-post failure RED
+
+A final callback-failure test made the replacement post throw after the first
+post succeeded but was processed while the item remained provisional. Before
+the fail-closed replacement handling, TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+replacement callback failure reports publication failure
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The green path removes the ready-but-unnotified item, returns `false`, and
+retains no latent UI work or occupied UI capacity.
+
+### Focused GREEN and stability
+
+After the final source/test change, the focused gates were rerun:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost transport self-tests passed: 13
+```
+
+The final transport suite was also run ten consecutive times in one stability
+probe; all ten runs reported `TrayHost transport self-tests passed: 13` with
+exit `0`.
+
+The first attempted ten-run probe exposed a test-helper defect: `WaitUntil`
+evaluated a consuming `TryTake*` condition twice, so the second evaluation saw
+an already-consumed queue and reported a false failure. The helper now caches
+the successful evaluation; no production change was made for that incident,
+and the complete ten-run probe above was rerun from the start.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost native self-tests passed: 23
+```
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+```
+
+Exit `0`, no output. This compiles every production TrayHost source, including
+the new sink and all current production callers, then runs `--headless-smoke`.
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly
+```
+
+Exit `0`:
+
+```text
+TrayHost protocol self-tests passed: 11
+```
+
+No wire implementation file changed; the protocol run is the byte-level
+regression gate for the existing schema/directions/sequences.
+
+```powershell
+git diff --check
+```
+
+Exit `0`. Git printed only repository LF-to-CRLF conversion notices.
+
+## Diagnostic-only run, not Task 3 acceptance
+
+`ParentClientOnly` was run once after adapting its current temporary caller and
+exited `0`. That command still uses the pre-Task-3 fake peer/correlation trace;
+it does not exercise the approved shared `TrayHostChildSession` seam and is not
+counted as production-trace acceptance here.
+
+## Commit
+
+- `7549dde154fd6c2430a13aa9ddcf66698560f717` —
+ `fix: isolate tray terminal receipt writes`
+- `1f1ecc5f72fbbb1c070d62a745b36be003b50506` —
+ `fix: hide provisional tray receipt UI`
+- `1212579676a1b4773450ed961094a02c6322d0f7` —
+ `fix: drop failed tray receipt reposts`
+
+## Concerns and explicit boundaries
+
+- Task 3 remains required to extract the shared child session and prove current
+ and stale results through the real authenticated ParentClient/PowerShell
+ path with a temporary fake native runtime. The existing fake-peer diagnostic
+ is not that proof.
+- The legacy `tests/persistence/Supervisor.SelfTest.ps1` correlation bridge has
+ an explicit pre-Task-2 source list and old synchronous HostTransport calls.
+ It is a test-only caller, not production source, and is intentionally deferred
+ for the Task 3 trace/bridge migration. The full persistence suite is therefore
+ not claimed by Task 2.
+- Task 4 release-workflow/provenance gating and release-wide validation remain
+ deferred. The production build itself discovers every TrayHost `.cs` source,
+ so the new sink is compiled without a new runtime selector.
+- A receipt store that never returns leaves the one background writer alive
+ until process termination; this is the approved no-replacement failure mode.
+ Admission and pending-action capacity remain bounded and responsive.
+- No installer, installed Codex process, real tray UI, push, release, install,
+ signing, or public operation was run.
From 2f028bb1f13d3c55fd74da26c78b6b599c628571 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:35:35 +0800
Subject: [PATCH 035/187] docs: clarify async tray receipt callback boundary
---
.../plans/2026-08-28-v2522-async-tray-receipts.md | 14 +++++++++-----
.../2026-08-28-v2522-async-tray-receipts-design.md | 14 +++++++++++---
2 files changed, 20 insertions(+), 8 deletions(-)
diff --git a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
index 6b2a809..0a3cfea 100644
--- a/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
+++ b/docs/superpowers/plans/2026-08-28-v2522-async-tray-receipts.md
@@ -92,6 +92,7 @@ fix: pin tray terminal receipt storage
- Create: `src/trayhost/TrayTerminalReceiptSink.cs`
- Modify: `src/trayhost/HostTransport.cs`
- Modify: `src/trayhost/Program.cs`
+- Modify: `src/trayhost/TrayHostApplication.cs`
- Test: `tests/trayhost/TrayHostTransportSelfTest.cs`
- Test: `tests/trayhost/TrayHostNativeSelfTest.cs`
@@ -121,11 +122,14 @@ receipt-sink API is unavailable.
- [ ] **Step 3: Implement one worker and bounded UI completion**
-Separate correlation from receipt work. The reader submits once and returns;
-only the single background writer calls the store. On durable success it posts
-bounded About/failure work to the existing application context. On admission,
-store, callback, queue, or disposal failure, consume the terminal result but
-show no feedback. Do not alter the wire result or add a new message type.
+Separate correlation from receipt work. The reader submits once and returns;
+only the single background writer calls the store. On durable success it posts
+bounded tokened About/failure work to an internal STA message path separate from
+presentation/shutdown drains; no unrelated work message may make an unresolved
+receipt visible. Callback admission and disposal must be atomic: no durable
+callback begins after close. On admission, store, callback, queue, token-post,
+or disposal failure, consume the terminal result but show no feedback. Do not
+alter the wire result or add a new message type.
- [ ] **Step 4: Run GREEN**
diff --git a/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
index 6c09ae1..f97052c 100644
--- a/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
+++ b/docs/superpowers/specs/2026-08-28-v2522-async-tray-receipts-design.md
@@ -54,9 +54,17 @@ replacement thread for a stuck write.
After a durable write, the sink calls back into `HostTransport` with the
in-memory receipt. The transport enqueues a single bounded UI work item:
completed About maps to About; rejected/failed maps to the existing generic
-failure dialog; all other completed commands map to no UI. The callback posts
-work to the STA application context outside transport locks. The UI queue is
-also bounded at eight shared items.
+failure dialog; all other completed commands map to no UI. Receipt UI uses an
+internal tokened work message, separate from presentation/shutdown drains. The
+transport makes a token visible only for a successful tokened post; ordinary
+work messages can never consume a receipt item while that post is unresolved.
+If the post fails, the item is retracted before any UI path can see it. The UI
+queue is also bounded at eight shared items.
+
+The sink claims callback admission under its close gate before invoking the
+durable callback. Once `Dispose` closes that gate, no new callback can begin;
+an already-started callback may finish, but the disposed transport refuses to
+publish UI work.
### 2. Pinned, bounded native receipt store
From 85882890202f420479728a9ed1532f2085b970c8 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 11:23:35 +0800
Subject: [PATCH 036/187] fix: seal tray receipt dispatch races
---
src/trayhost/HostTransport.cs | 157 +++++-----
src/trayhost/NativeMethods.cs | 3 +-
src/trayhost/Program.cs | 21 +-
src/trayhost/TrayHostApplication.cs | 30 +-
src/trayhost/TrayTerminalReceiptSink.cs | 28 +-
tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 1 +
.../trayhost/TrayHostCorrelationTestBridge.cs | 1 +
tests/trayhost/TrayHostNativeSelfTest.cs | 106 ++++++-
.../trayhost/TrayHostParentClientSelfTest.cs | 1 +
tests/trayhost/TrayHostTransportSelfTest.cs | 274 ++++++++++++++----
10 files changed, 459 insertions(+), 163 deletions(-)
diff --git a/src/trayhost/HostTransport.cs b/src/trayhost/HostTransport.cs
index 5a6048b..b18171f 100644
--- a/src/trayhost/HostTransport.cs
+++ b/src/trayhost/HostTransport.cs
@@ -1,6 +1,12 @@
using System;
using System.Collections.Generic;
+internal enum TrayTerminalReceiptUiKind : byte
+{
+ About = 1,
+ Failure = 2
+}
+
internal sealed class HostTransport : IDisposable
{
private sealed class PendingAction
@@ -11,21 +17,24 @@ private sealed class PendingAction
private sealed class UiWorkItem
{
+ internal uint Token;
+ internal TrayTerminalReceiptUiKind Kind;
internal TrayActionResult Result;
- internal bool Ready;
- internal bool ProbeObserved;
+ internal bool Committed;
+ internal bool DeliveryObserved;
}
+ private const int MaximumUiWork = 8;
+ private const int MaximumTokenPostAttempts = 2;
private readonly object _gate = new object();
private readonly Action _presentationReady;
- private readonly Action _receiptReady;
- private readonly Queue _completedAbout = new Queue();
- private readonly Queue _failedActions = new Queue();
+ private readonly Func _receiptReady;
+ private readonly Dictionary _receiptUi = new Dictionary();
private readonly Dictionary _pendingActions = new Dictionary();
private readonly Queue _recentActionOrder = new Queue();
private readonly HashSet _recentActions = new HashSet();
private PresentationSnapshot _pendingPresentation;
- private int _uiWorkCount;
+ private uint _nextReceiptToken = unchecked((uint)Guid.NewGuid().GetHashCode());
private bool _menuOpen;
private bool _disposed;
@@ -33,11 +42,11 @@ internal HostTransport() : this(null, null)
{
}
- internal HostTransport(Action presentationReady) : this(presentationReady, presentationReady)
+ internal HostTransport(Action presentationReady) : this(presentationReady, null)
{
}
- internal HostTransport(Action presentationReady, Action receiptReady)
+ internal HostTransport(Action presentationReady, Func receiptReady)
{
_presentationReady = presentationReady;
_receiptReady = receiptReady;
@@ -122,95 +131,71 @@ internal bool TryPublishDurableReceipt(TrayTerminalReceipt receipt)
{
if (receipt == null) { return false; }
UiWorkItem work = null;
- Action receiptReady = _receiptReady;
+ Func receiptReady = _receiptReady;
lock (_gate)
{
if (_disposed || !receipt.TryClaimPublication(this)) { return false; }
TrayActionResult result = receipt.Result;
- if (result.Status == TrayActionResultStatus.Completed && receipt.Command == TrayCommand.ShowAbout)
- {
- if (_uiWorkCount >= 8) { return false; }
- work = new UiWorkItem { Result = result, Ready = receiptReady == null };
- _completedAbout.Enqueue(work);
- _uiWorkCount++;
- }
- else if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed)
- {
- if (_uiWorkCount >= 8) { return false; }
- work = new UiWorkItem { Result = result, Ready = receiptReady == null };
- _failedActions.Enqueue(work);
- _uiWorkCount++;
- }
- }
- if (work == null || receiptReady == null) { return true; }
- try { receiptReady(); }
- catch
- {
- DropUiWork(work);
- return false;
+ TrayTerminalReceiptUiKind kind;
+ if (result.Status == TrayActionResultStatus.Completed && receipt.Command == TrayCommand.ShowAbout) { kind = TrayTerminalReceiptUiKind.About; }
+ else if (result.Status == TrayActionResultStatus.Rejected || result.Status == TrayActionResultStatus.Failed) { kind = TrayTerminalReceiptUiKind.Failure; }
+ else { return true; }
+ if (receiptReady == null || _receiptUi.Count >= MaximumUiWork) { return false; }
+ uint token = NextReceiptTokenLocked();
+ if (token == 0U) { return false; }
+ work = new UiWorkItem { Token = token, Kind = kind, Result = result };
+ _receiptUi.Add(token, work);
}
- bool repost;
- lock (_gate)
- {
- if (_disposed) { return false; }
- work.Ready = true;
- repost = work.ProbeObserved;
- work.ProbeObserved = false;
- }
- if (repost)
+
+ for (int attempt = 0; attempt < MaximumTokenPostAttempts; attempt++)
{
- try { receiptReady(); }
- catch
+ bool posted = false;
+ try { posted = receiptReady(work.Token); }
+ catch { posted = false; }
+ if (!posted) { DropUiWork(work); return false; }
+ lock (_gate)
{
- DropUiWork(work);
- return false;
+ UiWorkItem current;
+ if (_disposed || !_receiptUi.TryGetValue(work.Token, out current) || !Object.ReferenceEquals(current, work)) { return false; }
+ if (!work.DeliveryObserved)
+ {
+ work.Committed = true;
+ return true;
+ }
+ work.DeliveryObserved = false;
}
}
- return true;
+
+ DropUiWork(work);
+ return false;
}
- private void DropUiWork(UiWorkItem target)
+ internal bool TryTakeReceiptUi(uint token, out TrayTerminalReceiptUiKind kind, out TrayActionResult result)
{
- if (target == null) { return; }
+ kind = 0; result = null;
+ if (token == 0U) { return false; }
lock (_gate)
{
- Queue queue = target.Result.Status == TrayActionResultStatus.Completed ? _completedAbout : _failedActions;
- int count = queue.Count;
- bool removed = false;
- for (int index = 0; index < count; index++)
- {
- UiWorkItem current = queue.Dequeue();
- if (!removed && Object.ReferenceEquals(current, target)) { removed = true; }
- else { queue.Enqueue(current); }
- }
- if (removed && _uiWorkCount > 0) { _uiWorkCount--; }
+ if (_disposed) { return false; }
+ UiWorkItem work;
+ if (!_receiptUi.TryGetValue(token, out work)) { return false; }
+ if (!work.Committed) { work.DeliveryObserved = true; return false; }
+ _receiptUi.Remove(token);
+ kind = work.Kind; result = work.Result;
+ return true;
}
}
internal bool TryTakeCompletedAbout(out TrayActionResult result)
{
- lock (_gate)
- {
- if (_completedAbout.Count == 0) { result = null; return false; }
- UiWorkItem work = _completedAbout.Peek();
- if (!work.Ready) { work.ProbeObserved = true; result = null; return false; }
- result = _completedAbout.Dequeue().Result;
- if (_uiWorkCount > 0) { _uiWorkCount--; }
- return true;
- }
+ result = null;
+ return false;
}
internal bool TryTakeFailedAction(out TrayActionResult result)
{
- lock (_gate)
- {
- if (_failedActions.Count == 0) { result = null; return false; }
- UiWorkItem work = _failedActions.Peek();
- if (!work.Ready) { work.ProbeObserved = true; result = null; return false; }
- result = _failedActions.Dequeue().Result;
- if (_uiWorkCount > 0) { _uiWorkCount--; }
- return true;
- }
+ result = null;
+ return false;
}
public void Dispose()
@@ -219,13 +204,31 @@ public void Dispose()
{
if (_disposed) { return; }
_disposed = true;
- _completedAbout.Clear();
- _failedActions.Clear();
- _uiWorkCount = 0;
+ _receiptUi.Clear();
_pendingActions.Clear();
_recentActions.Clear();
_recentActionOrder.Clear();
_pendingPresentation = null;
}
}
+
+ private uint NextReceiptTokenLocked()
+ {
+ for (int attempt = 0; attempt < MaximumUiWork + 2; attempt++)
+ {
+ _nextReceiptToken = unchecked(_nextReceiptToken + 1U);
+ if (_nextReceiptToken != 0U && !_receiptUi.ContainsKey(_nextReceiptToken)) { return _nextReceiptToken; }
+ }
+ return 0U;
+ }
+
+ private void DropUiWork(UiWorkItem target)
+ {
+ if (target == null) { return; }
+ lock (_gate)
+ {
+ UiWorkItem current;
+ if (_receiptUi.TryGetValue(target.Token, out current) && Object.ReferenceEquals(current, target)) { _receiptUi.Remove(target.Token); }
+ }
+ }
}
diff --git a/src/trayhost/NativeMethods.cs b/src/trayhost/NativeMethods.cs
index 9a27055..a71528d 100644
--- a/src/trayhost/NativeMethods.cs
+++ b/src/trayhost/NativeMethods.cs
@@ -77,6 +77,7 @@ internal TrayPoint(int x, int y)
internal interface INativeTrayPlatform
{
+ void SetMessageHandler(Action handler);
IntPtr CreateOwner();
IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context);
IntPtr GetOwnerInputContext(IntPtr owner);
@@ -113,7 +114,7 @@ internal sealed class Win32TrayPlatform : INativeTrayPlatform
private Action _messageHandler;
private IntPtr _owner;
- internal void SetMessageHandler(Action handler) { _messageHandler = handler; }
+ public void SetMessageHandler(Action handler) { _messageHandler = handler; }
public IntPtr CreateOwner()
{
diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs
index 82c310a..c2193a5 100644
--- a/src/trayhost/Program.cs
+++ b/src/trayhost/Program.cs
@@ -43,11 +43,7 @@ private static int RunChild(string[] args)
TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore();
HostTransport transport = new HostTransport(
delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } },
- delegate
- {
- TrayWindow current = window;
- if (current == null || !Win32TrayPlatform.PostToWindow(current.OwnerHandle, TrayNativeConstants.WmApp + 2U)) { throw new InvalidOperationException("receipt UI callback failed"); }
- });
+ delegate(uint token) { TrayHostApplication current = application; return current != null && current.PostReceiptWork(token); });
TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose);
window = new TrayWindow(platform, transport.SetMenuOpen);
bool shutdownRequested = false; bool shutdownSent = false; object stateGate = new object();
@@ -69,10 +65,6 @@ private static int RunChild(string[] args)
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)
@@ -82,8 +74,15 @@ private static int RunChild(string[] args)
application.RequestExit();
}
};
+ Action receiptWork = delegate(uint token)
+ {
+ TrayTerminalReceiptUiKind kind; TrayActionResult receiptResult;
+ if (!transport.TryTakeReceiptUi(token, out kind, out receiptResult)) { return; }
+ if (kind == TrayTerminalReceiptUiKind.About) { window.ShowAbout(); }
+ else if (kind == TrayTerminalReceiptUiKind.Failure) { window.ShowActionFailed(); }
+ };
window.CommandSelected += command;
- application = new TrayHostApplication(platform, window, command, work);
+ application = new TrayHostApplication(platform, window, command, work, receiptWork);
window.Create(initial);
lock (WriteGate)
{
@@ -107,7 +106,7 @@ private static int RunChild(string[] args)
})) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" };
reader.Start();
int result = application.Run();
- receiptSink.Dispose(); application.Dispose(); transport.Dispose(); return result;
+ receiptSink.Dispose(); transport.Dispose(); application.Dispose(); return result;
}
private static bool VerifyParentIdentity(int pid, long creation)
diff --git a/src/trayhost/TrayHostApplication.cs b/src/trayhost/TrayHostApplication.cs
index 9239f79..13ca503 100644
--- a/src/trayhost/TrayHostApplication.cs
+++ b/src/trayhost/TrayHostApplication.cs
@@ -5,9 +5,10 @@
internal sealed class TrayHostApplication : IDisposable
{
private readonly TrayWindow _window;
- private readonly Win32TrayPlatform _platform;
+ private readonly INativeTrayPlatform _platform;
private readonly Action _command;
private readonly Action _work;
+ private readonly Action _receiptWork;
private readonly uint _taskbarCreated;
private bool _exitRequested;
@@ -17,11 +18,16 @@ internal TrayHostApplication(TrayWindow window)
_window = window;
}
- internal TrayHostApplication(Win32TrayPlatform platform, TrayWindow window, Action command, Action work)
+ internal TrayHostApplication(INativeTrayPlatform platform, TrayWindow window, Action command, Action work)
+ : this(platform, window, command, work, null)
+ {
+ }
+
+ internal TrayHostApplication(INativeTrayPlatform platform, TrayWindow window, Action command, Action work, Action receiptWork)
{
if (platform == null) { throw new ArgumentNullException("platform"); }
if (window == null) { throw new ArgumentNullException("window"); }
- _platform = platform; _window = window; _command = command; _work = work; _taskbarCreated = (uint)Win32TrayPlatform.RegisterTaskbarCreatedMessage().ToInt64();
+ _platform = platform; _window = window; _command = command; _work = work; _receiptWork = receiptWork; _taskbarCreated = (uint)Win32TrayPlatform.RegisterTaskbarCreatedMessage().ToInt64();
_platform.SetMessageHandler(HandleMessage);
}
@@ -40,7 +46,8 @@ internal int Run()
return 0;
}
- internal void PostWork() { if (_platform != null) { Win32TrayPlatform.PostToWindow(_window.OwnerHandle, TrayNativeConstants.WmApp + 2U); } }
+ internal void PostWork() { if (_platform != null && _window.OwnerHandle != IntPtr.Zero) { _platform.PostMessage(_window.OwnerHandle, TrayNativeConstants.WmApp + 2U, UIntPtr.Zero, IntPtr.Zero); } }
+ internal bool PostReceiptWork(uint token) { return token != 0U && _platform != null && _window.OwnerHandle != IntPtr.Zero && _platform.PostMessage(_window.OwnerHandle, TrayNativeConstants.WmApp + 3U, new UIntPtr(token), IntPtr.Zero); }
internal void RequestExit()
{
if (_exitRequested) { return; }
@@ -54,6 +61,15 @@ internal static bool IsContextMenuEvent(uint message, IntPtr callbackData)
return IsContextMenuEvent(message, IntPtr.Zero, callbackData);
}
+ internal static bool TryDecodeReceiptWorkToken(uint message, IntPtr wParam, IntPtr lParam, out uint token)
+ {
+ token = 0U;
+ if (message != TrayNativeConstants.WmApp + 3U || lParam != IntPtr.Zero) { return false; }
+ ulong raw = IntPtr.Size == 4 ? unchecked((uint)wParam.ToInt32()) : unchecked((ulong)wParam.ToInt64());
+ if (raw == 0UL || raw > UInt32.MaxValue) { return false; }
+ token = unchecked((uint)raw); return true;
+ }
+
internal static bool IsContextMenuEvent(uint message, IntPtr wParam, IntPtr lParam)
{
const uint wmContextMenu = 0x007bU;
@@ -74,6 +90,12 @@ private static bool HasContextMenuEvent(IntPtr value, uint wmContextMenu, uint w
private void HandleMessage(uint message, IntPtr wParam, IntPtr lParam)
{
+ uint receiptToken;
+ if (TryDecodeReceiptWorkToken(message, wParam, lParam, out receiptToken))
+ {
+ Action receiptWork = _receiptWork; if (receiptWork != null) { receiptWork(receiptToken); }
+ return;
+ }
if (message == TrayNativeConstants.WmApp + 2U)
{
Action work = _work; if (work != null) { work(); }
diff --git a/src/trayhost/TrayTerminalReceiptSink.cs b/src/trayhost/TrayTerminalReceiptSink.cs
index c9b5f1c..9aeb213 100644
--- a/src/trayhost/TrayTerminalReceiptSink.cs
+++ b/src/trayhost/TrayTerminalReceiptSink.cs
@@ -42,6 +42,11 @@ internal sealed class TrayTerminalReceiptSink : IDisposable
private int _cleanupStarted;
private bool _writerStarted;
private bool _closed;
+ private bool _callbackClaimed;
+
+#if TRAYHOST_RECEIPT_SELF_TEST
+ internal static Action BeforeCallbackAdmissionForTesting;
+#endif
internal TrayTerminalReceiptSink(Func writeDurably, Func publishDurable)
: this(writeDurably, publishDurable, null)
@@ -129,10 +134,26 @@ private void WriterMain()
bool durable = false;
try { durable = _writeDurably(receipt.Diagnostic); }
catch { durable = false; }
- if (durable && !IsClosed())
+#if TRAYHOST_RECEIPT_SELF_TEST
+ if (durable)
+ {
+ Action beforeCallbackAdmission = BeforeCallbackAdmissionForTesting;
+ if (beforeCallbackAdmission != null) { try { beforeCallbackAdmission(); } catch { durable = false; } }
+ }
+#endif
+ bool callbackAdmitted = false;
+ if (durable)
+ {
+ lock (_gate)
+ {
+ if (!_closed && !_callbackClaimed) { _callbackClaimed = true; callbackAdmitted = true; }
+ }
+ }
+ if (callbackAdmitted)
{
try { _publishDurable(receipt); }
catch { }
+ finally { lock (_gate) { _callbackClaimed = false; } }
}
lock (_gate)
{
@@ -153,11 +174,6 @@ private void WriterMain()
}
}
- private bool IsClosed()
- {
- lock (_gate) { return _closed; }
- }
-
private void CleanupAfterWriter()
{
if (Interlocked.CompareExchange(ref _cleanupStarted, 1, 0) != 0) { return; }
diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
index b249423..e29ca76 100644
--- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1
+++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
@@ -64,6 +64,7 @@ try{
$outputPath=Join-Path $temporaryRoot $outputName
$args=@('/nologo','/noconfig','/nostdlib+','/target:exe','/platform:anycpu','/optimize+','/checked+','/warn:4','/warnaserror+',('/out:{0}' -f $outputPath),('/main:{0}' -f $mainType))
if($NativeOnly){$args+='/define:TRAYHOST_SELF_TEST'}
+ if($TransportOnly){$args+='/define:TRAYHOST_RECEIPT_SELF_TEST'}
foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$args+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))}
if($ProductionOnly){$args+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)}
else
diff --git a/tests/trayhost/TrayHostCorrelationTestBridge.cs b/tests/trayhost/TrayHostCorrelationTestBridge.cs
index a79ba20..5add2e8 100644
--- a/tests/trayhost/TrayHostCorrelationTestBridge.cs
+++ b/tests/trayhost/TrayHostCorrelationTestBridge.cs
@@ -6,6 +6,7 @@ public sealed class TrayHostCorrelationTestBridge : IDisposable
private sealed class Platform : INativeTrayPlatform
{
internal uint Selection;
+ public void SetMessageHandler(Action handler) { }
public IntPtr CreateOwner() { return new IntPtr(10); }
public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); }
public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; }
diff --git a/tests/trayhost/TrayHostNativeSelfTest.cs b/tests/trayhost/TrayHostNativeSelfTest.cs
index f9185b9..c34083d 100644
--- a/tests/trayhost/TrayHostNativeSelfTest.cs
+++ b/tests/trayhost/TrayHostNativeSelfTest.cs
@@ -10,6 +10,7 @@
internal sealed class FakeTrayPlatform : INativeTrayPlatform
{
+ private Action _messageHandler;
internal readonly List Calls = new List();
internal bool ForegroundResult = true;
internal bool ForegroundProof = true;
@@ -23,6 +24,13 @@ internal sealed class FakeTrayPlatform : INativeTrayPlatform
internal readonly List AppendedText = new List();
internal readonly List Commands = new List();
internal bool ConfirmExitResult = true;
+ internal Func PostMessageBehavior;
+ internal readonly List PostedMessages = new List();
+ internal readonly List PostedWParams = new List();
+ internal readonly List PostedLParams = new List();
+
+ public void SetMessageHandler(Action handler) { _messageHandler = handler; }
+ internal void DispatchMessage(uint message, UIntPtr wParam, IntPtr lParam) { Action handler = _messageHandler; if (handler != null) { IntPtr raw = IntPtr.Size == 4 ? new IntPtr(unchecked((int)wParam.ToUInt32())) : new IntPtr(unchecked((long)wParam.ToUInt64())); handler(message, raw, lParam); } }
public IntPtr CreateOwner() { Calls.Add("CreateOwner"); return new IntPtr(10); }
public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { Calls.Add(context == IntPtr.Zero ? "Associate:null" : "Associate:restore"); return new IntPtr(20); }
@@ -42,7 +50,7 @@ internal sealed class FakeTrayPlatform : INativeTrayPlatform
public bool SetForegroundWindow(IntPtr owner) { Calls.Add("SetForeground"); return ForegroundResult; }
public IntPtr GetForegroundWindow() { Calls.Add("GetForeground"); return ForegroundProof ? new IntPtr(10) : new IntPtr(11); }
public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { Calls.Add("Track"); TrackCalls++; if (DuringTrack != null) { DuringTrack(); } return TrackResult; }
- public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { Calls.Add("WM_NULL"); return true; }
+ public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { Calls.Add("WM_NULL"); PostedMessages.Add(message); PostedWParams.Add(wParam); PostedLParams.Add(lParam); Func behavior = PostMessageBehavior; return behavior == null || behavior(owner, message, wParam, lParam); }
public bool SetNotificationFocus(ref TrayIconData icon) { Calls.Add("NIM_SETFOCUS"); return true; }
public bool ShowMessageBox(IntPtr owner, string text, string caption) { MessageBoxes.Add(caption + "|" + text); Calls.Add("MessageBox"); return true; }
public bool ConfirmExit(IntPtr owner, string text, string caption) { MessageBoxes.Add(caption + "|" + text); Calls.Add("ConfirmExit"); return ConfirmExitResult; }
@@ -309,27 +317,36 @@ private static void TestDurableNativeReceiptAuthorizesGenericFeedbackOnce()
string root = Path.Combine(Path.GetTempPath(), "ccod-tray-terminal-native-feedback-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root); CreateCompatibilityParents(root);
FakeTrayPlatform platform = new FakeTrayPlatform(); TrayWindow window = new TrayWindow(platform); window.Create(SnapshotV2(1));
- HostTransport transport = new HostTransport();
+ TrayHostApplication application = null; HostTransport transport = null;
+ transport = new HostTransport(null, delegate(uint token) { return application != null && application.PostReceiptWork(token); });
+ Action receiptWork = delegate(uint token)
+ {
+ TrayTerminalReceiptUiKind kind; TrayActionResult feedback;
+ if (transport.TryTakeReceiptUi(token, out kind, out feedback) && kind == TrayTerminalReceiptUiKind.Failure) { window.ShowActionFailed(); }
+ };
+ application = new TrayHostApplication(platform, window, null, null, receiptWork);
TrayTerminalDiagnosticStore store = TestStore(root, null, null);
- TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, transport.TryPublishDurableReceipt);
+ ManualResetEvent publicationCompleted = new ManualResetEvent(false);
+ TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, delegate(TrayTerminalReceipt durableReceipt) { bool published = transport.TryPublishDurableReceipt(durableReceipt); publicationCompleted.Set(); return published; });
try
{
Guid actionId = Guid.NewGuid(); TrayTerminalReceipt receipt;
AssertTrue(transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 1UL)), "native durable-feedback action registers");
AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(actionId, 1UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out receipt) && receipt != null, "native durable-feedback action returns one typed receipt");
TrayActionResult feedback;
- AssertTrue(!transport.TryTakeFailedAction(out feedback) && platform.MessageBoxes.Count == 0, "native generic feedback is unavailable before durable receipt success");
+ AssertTrue(!transport.TryTakeFailedAction(out feedback) && platform.PostedMessages.Count == 0 && platform.MessageBoxes.Count == 0, "native generic feedback is unavailable before durable receipt success");
AssertTrue(sink.TrySubmit(receipt), "native receipt enters the asynchronous sink");
- Stopwatch elapsed = Stopwatch.StartNew();
- while (!transport.TryTakeFailedAction(out feedback) && elapsed.ElapsedMilliseconds < 3000L) { Thread.Sleep(5); }
- AssertTrue(feedback != null && feedback.ActionId == actionId, "handle-pinned durable success authorizes the exact native feedback item");
- window.ShowActionFailed();
+ AssertTrue(publicationCompleted.WaitOne(3000), "handle-pinned durable success completes receipt-token publication");
+ AssertTrue(platform.PostedMessages.Count == 1 && platform.PostedMessages[0] == TrayNativeConstants.WmApp + 3U && platform.PostedWParams[0] != UIntPtr.Zero, "handle-pinned durable success posts one exact receipt token to the native application");
+ AssertTrue(platform.MessageBoxes.Count == 0, "posted token does not display generic feedback before STA dispatch");
+ platform.DispatchMessage(platform.PostedMessages[0], platform.PostedWParams[0], platform.PostedLParams[0]);
AssertTrue(platform.MessageBoxes.Count == 1, "durable native receipt displays generic feedback exactly once");
- AssertTrue(!transport.TryTakeFailedAction(out feedback), "durable native feedback has no duplicate queue item");
+ platform.DispatchMessage(platform.PostedMessages[0], platform.PostedWParams[0], platform.PostedLParams[0]);
+ AssertTrue(platform.MessageBoxes.Count == 1 && !transport.TryTakeFailedAction(out feedback), "durable native feedback has no duplicate token or ordinary queue item");
}
finally
{
- sink.Dispose(); transport.Dispose(); window.Dispose(); store.Dispose(); try { Directory.Delete(root, true); } catch { }
+ sink.Dispose(); transport.Dispose(); application.Dispose(); store.Dispose(); publicationCompleted.Dispose(); try { Directory.Delete(root, true); } catch { }
}
}
@@ -626,6 +643,71 @@ private static void TestPostedWorkMessageDispatchesToItsOwnerWindow()
}
}
+ private static void TestReceiptWorkMessageRequiresItsExactInternalToken()
+ {
+ uint token;
+ AssertTrue(!TrayHostApplication.TryDecodeReceiptWorkToken(TrayNativeConstants.WmApp + 2U, new IntPtr(41), IntPtr.Zero, out token), "ordinary work message never decodes a receipt token");
+ AssertTrue(!TrayHostApplication.TryDecodeReceiptWorkToken(TrayNativeConstants.WmApp + 3U, IntPtr.Zero, IntPtr.Zero, out token), "zero receipt token is rejected");
+ AssertTrue(!TrayHostApplication.TryDecodeReceiptWorkToken(TrayNativeConstants.WmApp + 3U, new IntPtr(41), new IntPtr(1), out token), "receipt message rejects unexpected lParam data");
+ AssertTrue(TrayHostApplication.TryDecodeReceiptWorkToken(TrayNativeConstants.WmApp + 3U, new IntPtr(41), IntPtr.Zero, out token) && token == 41U, "receipt-specific work message preserves its exact nonzero token");
+ }
+
+ private static void TestFailedTokenPostCannotLeakIntoOrdinaryApplicationWork()
+ {
+ FakeTrayPlatform platform = new FakeTrayPlatform(); TrayWindow window = new TrayWindow(platform); window.Create(SnapshotV2(1));
+ ManualResetEvent replacementPostEntered = new ManualResetEvent(false); ManualResetEvent releaseReplacementPost = new ManualResetEvent(false);
+ TrayHostApplication application = null; HostTransport transport = null;
+ int receiptPosts = 0; int ordinaryRuns = 0; uint observedToken = 0U; bool published = true;
+ try
+ {
+ transport = new HostTransport(null, delegate(uint token) { return application != null && application.PostReceiptWork(token); });
+ Action ordinaryWork = delegate
+ {
+ Interlocked.Increment(ref ordinaryRuns);
+ TrayActionResult ordinary;
+ if (transport.TryTakeFailedAction(out ordinary)) { window.ShowActionFailed(); }
+ };
+ Action receiptWork = delegate(uint token)
+ {
+ TrayTerminalReceiptUiKind kind; TrayActionResult receiptResult;
+ if (transport.TryTakeReceiptUi(token, out kind, out receiptResult) && kind == TrayTerminalReceiptUiKind.Failure) { window.ShowActionFailed(); }
+ };
+ application = new TrayHostApplication(platform, window, null, ordinaryWork, receiptWork);
+ platform.PostMessageBehavior = delegate(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam)
+ {
+ if (message == TrayNativeConstants.WmApp + 2U) { platform.DispatchMessage(message, wParam, lParam); return true; }
+ if (message != TrayNativeConstants.WmApp + 3U) { return true; }
+ observedToken = unchecked((uint)wParam.ToUInt64());
+ int call = Interlocked.Increment(ref receiptPosts);
+ if (call == 1) { platform.DispatchMessage(message, wParam, lParam); return true; }
+ replacementPostEntered.Set(); releaseReplacementPost.WaitOne();
+ throw new InvalidOperationException("intentional token-post failure");
+ };
+
+ Guid actionId = Guid.NewGuid(); TrayTerminalReceipt receipt;
+ AssertTrue(transport.TryRegisterAction(new TrayHostAction(actionId, TrayCommand.OpenLogs, 1UL)), "token-race native action registers");
+ AssertTrue(transport.TryAcknowledgeAction(new TrayActionResult(actionId, 1UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out receipt) && receipt != null, "token-race native action returns one typed receipt");
+ Thread publisher = new Thread((ThreadStart)delegate { published = transport.TryPublishDurableReceipt(receipt); });
+ publisher.IsBackground = true; publisher.Start();
+ AssertTrue(replacementPostEntered.WaitOne(2000), "replacement receipt-token post is blocked before failure");
+
+ application.PostWork();
+ AssertTrue(Interlocked.CompareExchange(ref ordinaryRuns, 0, 0) == 1 && platform.MessageBoxes.Count == 0, "ordinary WmApp work cannot consume or display the unresolved receipt");
+ releaseReplacementPost.Set();
+ AssertTrue(publisher.Join(2000) && !published, "failed replacement token post reports publication failure");
+ AssertTrue(observedToken != 0U && Interlocked.CompareExchange(ref receiptPosts, 0, 0) == 2, "receipt path uses one exact token across its bounded replacement post");
+
+ platform.DispatchMessage(TrayNativeConstants.WmApp + 3U, new UIntPtr(observedToken), IntPtr.Zero);
+ TrayTerminalReceiptUiKind missingKind; TrayActionResult missingResult;
+ AssertTrue(!transport.TryTakeReceiptUi(observedToken, out missingKind, out missingResult) && platform.MessageBoxes.Count == 0, "failed token post retracts the receipt before stale token dispatch can show a generic dialog");
+ }
+ finally
+ {
+ releaseReplacementPost.Set(); if (transport != null) { transport.Dispose(); } if (application != null) { application.Dispose(); } else { window.Dispose(); }
+ replacementPostEntered.Dispose(); releaseReplacementPost.Dispose();
+ }
+ }
+
public static int Main(string[] args)
{
try
@@ -653,7 +735,9 @@ public static int Main(string[] args)
TestShellRightClickNotificationMapping();
TestRealNativePInvokeSurface();
TestPostedWorkMessageDispatchesToItsOwnerWindow();
- Console.WriteLine("TrayHost native self-tests passed: 23");
+ TestReceiptWorkMessageRequiresItsExactInternalToken();
+ TestFailedTokenPostCannotLeakIntoOrdinaryApplicationWork();
+ Console.WriteLine("TrayHost native self-tests passed: 25");
return 0;
}
catch (Exception error)
diff --git a/tests/trayhost/TrayHostParentClientSelfTest.cs b/tests/trayhost/TrayHostParentClientSelfTest.cs
index e83f597..5e02eec 100644
--- a/tests/trayhost/TrayHostParentClientSelfTest.cs
+++ b/tests/trayhost/TrayHostParentClientSelfTest.cs
@@ -8,6 +8,7 @@
internal sealed class ProductionTraceTrayPlatform : INativeTrayPlatform
{
internal string Message;
+ public void SetMessageHandler(Action handler) { }
public IntPtr CreateOwner() { return new IntPtr(10); }
public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); }
public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; }
diff --git a/tests/trayhost/TrayHostTransportSelfTest.cs b/tests/trayhost/TrayHostTransportSelfTest.cs
index c5cec99..fe2e2ef 100644
--- a/tests/trayhost/TrayHostTransportSelfTest.cs
+++ b/tests/trayhost/TrayHostTransportSelfTest.cs
@@ -82,6 +82,30 @@ public void Dispose()
}
}
+ private sealed class ReceiptTokenInbox
+ {
+ private readonly object _gate = new object();
+ private readonly Queue _tokens = new Queue();
+
+ internal bool Post(uint token)
+ {
+ if (token == 0U) { return false; }
+ lock (_gate) { _tokens.Enqueue(token); }
+ return true;
+ }
+
+ internal bool TryTake(out uint token)
+ {
+ lock (_gate)
+ {
+ if (_tokens.Count == 0) { token = 0U; return false; }
+ token = _tokens.Dequeue(); return true;
+ }
+ }
+
+ internal int Count { get { lock (_gate) { return _tokens.Count; } } }
+ }
+
private static void AssertTrue(bool value, string message)
{
if (!value) { throw new InvalidOperationException(message); }
@@ -137,6 +161,35 @@ private static TrayTerminalReceipt RegisterAndAcknowledgeFailure(HostTransport h
return AcknowledgeTerminalQuickly(host, new TrayActionResult(actionId, revision, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), message);
}
+ private static uint WaitForToken(ReceiptTokenInbox inbox, string message)
+ {
+ uint token = 0U;
+ WaitUntil(delegate { return inbox.TryTake(out token); }, 2000, message);
+ return token;
+ }
+
+ private static TrayActionResult TakeReceiptUi(HostTransport host, uint token, TrayTerminalReceiptUiKind expectedKind, string message)
+ {
+ TrayTerminalReceiptUiKind kind; TrayActionResult result;
+ AssertTrue(host.TryTakeReceiptUi(token, out kind, out result) && kind == expectedKind && result != null, message);
+ return result;
+ }
+
+ private static TrayActionResult WaitForPostedReceiptUi(HostTransport host, ReceiptTokenInbox inbox, TrayTerminalReceiptUiKind expectedKind, string message)
+ {
+ for (int attempt = 0; attempt < 2; attempt++)
+ {
+ uint token = WaitForToken(inbox, message + " token post");
+ TrayTerminalReceiptUiKind kind; TrayActionResult result;
+ if (host.TryTakeReceiptUi(token, out kind, out result))
+ {
+ AssertTrue(kind == expectedKind && result != null, message);
+ return result;
+ }
+ }
+ throw new InvalidOperationException(message + " was never committed");
+ }
+
private static void TestParentLatestAndReservedControl()
{
ParentTransport transport = new ParentTransport();
@@ -244,8 +297,8 @@ private static void TestBlockedReceiptStagesNeverBlockCorrelationOrUi()
for (int stageIndex = 0; stageIndex < stages.Length; stageIndex++)
{
ControllableReceiptStore store = new ControllableReceiptStore(stages[stageIndex], 1, 1);
- int uiSignals = 0;
- HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref uiSignals); });
+ ReceiptTokenInbox inbox = new ReceiptTokenInbox();
+ HostTransport host = new HostTransport(null, inbox.Post);
TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt);
try
{
@@ -265,14 +318,12 @@ private static void TestBlockedReceiptStagesNeverBlockCorrelationOrUi()
AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(firstId, 24UL, TrayActionResultStatus.Failed, "CCOD_TRAY_ACTION_FAILED", null), out duplicate), "current terminal action is removed before sink submission");
AssertTrue(!host.TryAcknowledgeAction(new TrayActionResult(followingId, 24UL, TrayActionResultStatus.Completed, null, null), out duplicate), "following terminal action is removed before sink submission");
TrayActionResult prematureFailure; TrayActionResult prematureAbout;
- AssertTrue(!host.TryTakeFailedAction(out prematureFailure) && !host.TryTakeCompletedAbout(out prematureAbout) && Interlocked.CompareExchange(ref uiSignals, 0, 0) == 0, "blocked store exposes no generic or About UI work before durable success");
+ AssertTrue(!host.TryTakeFailedAction(out prematureFailure) && !host.TryTakeCompletedAbout(out prematureAbout) && inbox.Count == 0, "blocked store exposes no generic or About UI work before durable success");
store.Release();
- WaitUntil(delegate { return Interlocked.CompareExchange(ref uiSignals, 0, 0) == 2; }, 2000, "both durable successes post exactly one work signal");
TrayActionResult firstUi; TrayActionResult followingUi; TrayActionResult none;
- firstUi = null; followingUi = null;
- WaitUntil(delegate { return host.TryTakeFailedAction(out firstUi); }, 2000, "first durable receipt becomes visible after its callback succeeds");
- WaitUntil(delegate { return host.TryTakeCompletedAbout(out followingUi); }, 2000, "following durable About becomes visible after its callback succeeds");
+ firstUi = WaitForPostedReceiptUi(host, inbox, TrayTerminalReceiptUiKind.Failure, "first durable receipt becomes visible only through its exact token");
+ followingUi = WaitForPostedReceiptUi(host, inbox, TrayTerminalReceiptUiKind.About, "following durable About becomes visible only through its exact token");
AssertTrue(firstUi.ActionId == firstId, "first durable receipt publishes its exact generic-feedback item");
AssertTrue(followingUi.ActionId == followingId, "following durable receipt publishes its exact About item");
AssertTrue(!host.TryTakeFailedAction(out none), "durable callbacks publish no duplicate feedback");
@@ -362,11 +413,11 @@ private static void TestNinthReceiptAndBusyAdmissionDropWithoutPendingOrUi()
private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter()
{
ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.DuringFlush, 0, 1, 1);
- bool failCallback = true; int successfulSignals = 0;
- HostTransport host = new HostTransport(delegate
+ bool failCallback = true; ReceiptTokenInbox inbox = new ReceiptTokenInbox();
+ HostTransport host = new HostTransport(null, delegate(uint token)
{
if (failCallback) { failCallback = false; throw new InvalidOperationException("intentional UI callback failure"); }
- Interlocked.Increment(ref successfulSignals);
+ return inbox.Post(token);
});
TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(store.TryAppendDurably, host.TryPublishDurableReceipt);
try
@@ -379,10 +430,9 @@ private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter()
TrayActionResult none;
AssertTrue(!host.TryTakeFailedAction(out none), "blocked and not-yet-durable receipts expose no UI");
store.Release();
- WaitUntil(delegate { return store.CompletedCount == 3 && Interlocked.CompareExchange(ref successfulSignals, 0, 0) == 1; }, 3000, "same writer continues through store and callback failures to a later success");
+ WaitUntil(delegate { return store.CompletedCount == 3 && inbox.Count != 0; }, 3000, "same writer continues through store and callback failures to a later token post");
TrayActionResult recovered; TrayActionResult extra;
- recovered = null;
- WaitUntil(delegate { return host.TryTakeFailedAction(out recovered); }, 2000, "later durable callback makes its recovered feedback visible");
+ recovered = WaitForPostedReceiptUi(host, inbox, TrayTerminalReceiptUiKind.Failure, "later durable callback makes its recovered feedback visible by token");
AssertTrue(recovered.ActionId == recoveredId, "only the later durable callback publishes generic feedback");
AssertTrue(!host.TryTakeFailedAction(out extra), "store and callback failures publish no latent UI");
AssertTrue(store.WriterThreadCount == 1 && store.MaximumConcurrent == 1, "recovery keeps the original single writer instead of starting a replacement");
@@ -395,8 +445,8 @@ private static void TestStoreAndCallbackFailuresRecoverOnTheSameWriter()
private static void TestDurableReceiptTrustAndSharedUiBound()
{
- int signals = 0;
- HostTransport host = new HostTransport(delegate { Interlocked.Increment(ref signals); });
+ ReceiptTokenInbox inbox = new ReceiptTokenInbox();
+ HostTransport host = new HostTransport(null, inbox.Post);
HostTransport foreign = new HostTransport();
try
{
@@ -404,7 +454,7 @@ private static void TestDurableReceiptTrustAndSharedUiBound()
AssertTrue(host.TryRegisterAction(new TrayHostAction(aboutId, TrayCommand.ShowAbout, 27UL)), "About action registers for durable authorization");
TrayTerminalReceipt about = AcknowledgeTerminalQuickly(host, new TrayActionResult(aboutId, 27UL, TrayActionResultStatus.Completed, null, null), "About terminal result");
TrayActionResult beforeDurable;
- AssertTrue(!host.TryTakeCompletedAbout(out beforeDurable), "typed receipt alone cannot expose About UI before durable publication");
+ AssertTrue(!host.TryTakeCompletedAbout(out beforeDurable) && inbox.Count == 0, "typed receipt alone cannot expose About UI before durable publication");
AssertTrue(!foreign.TryPublishDurableReceipt(about), "a different transport rejects a receipt it did not correlate");
AssertTrue(host.TryPublishDurableReceipt(about), "originating transport accepts one explicit durable-success callback");
AssertTrue(!host.TryPublishDurableReceipt(about), "the same durable receipt cannot publish twice");
@@ -422,12 +472,16 @@ private static void TestDurableReceiptTrustAndSharedUiBound()
TrayTerminalReceipt noUi = AcknowledgeTerminalQuickly(host, new TrayActionResult(noUiId, 27UL, TrayActionResultStatus.Completed, null, null), "non-UI completion");
AssertTrue(host.TryPublishDurableReceipt(noUi), "durable non-UI completion is consumed without UI capacity");
- TrayActionResult aboutUi;
- AssertTrue(host.TryTakeCompletedAbout(out aboutUi) && aboutUi.ActionId == aboutId, "durable About maps to one About UI item");
- int failures = 0; TrayActionResult failed;
- while (host.TryTakeFailedAction(out failed)) { failures++; }
+ AssertTrue(inbox.Count == 8, "only the eight admitted shared UI items receive token posts");
+ uint aboutToken; AssertTrue(inbox.TryTake(out aboutToken), "durable About token is retained");
+ TrayActionResult aboutUi = TakeReceiptUi(host, aboutToken, TrayTerminalReceiptUiKind.About, "durable About maps to one tokened About UI item");
+ AssertTrue(aboutUi.ActionId == aboutId, "durable About token retains its correlated action");
+ int failures = 0; uint failedToken;
+ while (inbox.TryTake(out failedToken))
+ {
+ TakeReceiptUi(host, failedToken, TrayTerminalReceiptUiKind.Failure, "durable failure token maps to generic UI"); failures++;
+ }
AssertTrue(failures == 7, "About and generic failure share one eight-item UI bound");
- AssertTrue(Interlocked.CompareExchange(ref signals, 0, 0) == 8, "only admitted UI items post application work");
}
finally { foreign.Dispose(); host.Dispose(); }
}
@@ -436,8 +490,10 @@ private static void TestFailingCallbackNeverExposesProvisionalUiWork()
{
ManualResetEvent callbackEntered = new ManualResetEvent(false);
ManualResetEvent releaseCallback = new ManualResetEvent(false);
- HostTransport host = new HostTransport(delegate
+ uint observedToken = 0U;
+ HostTransport host = new HostTransport(null, delegate(uint token)
{
+ observedToken = token;
callbackEntered.Set();
releaseCallback.WaitOne();
throw new InvalidOperationException("intentional UI callback failure");
@@ -449,14 +505,14 @@ private static void TestFailingCallbackNeverExposesProvisionalUiWork()
Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); });
publisher.IsBackground = true; publisher.Start();
AssertTrue(callbackEntered.WaitOne(2000), "durable UI callback enters before failing");
- TrayActionResult provisional = null; bool takeReturned = false; bool tookProvisional = true;
- Thread taker = new Thread((ThreadStart)delegate { tookProvisional = host.TryTakeFailedAction(out provisional); takeReturned = true; });
+ TrayActionResult provisional = null; TrayTerminalReceiptUiKind provisionalKind = 0; bool takeReturned = false; bool tookProvisional = true;
+ Thread taker = new Thread((ThreadStart)delegate { tookProvisional = host.TryTakeReceiptUi(observedToken, out provisionalKind, out provisional); takeReturned = true; });
taker.IsBackground = true; taker.Start();
AssertTrue(taker.Join(250) && takeReturned && !tookProvisional && provisional == null, "provisional UI work returns immediately but remains unavailable while callback outcome is unknown");
releaseCallback.Set();
AssertTrue(publisher.Join(2000) && !published, "throwing durable callback reports publication failure");
- TrayActionResult none;
- AssertTrue(!host.TryTakeFailedAction(out none), "callback failure leaves no latent generic UI work");
+ TrayActionResult none; TrayTerminalReceiptUiKind noneKind;
+ AssertTrue(!host.TryTakeReceiptUi(observedToken, out noneKind, out none), "callback failure leaves no latent tokened generic UI work");
}
finally
{
@@ -466,35 +522,31 @@ private static void TestFailingCallbackNeverExposesProvisionalUiWork()
private static void TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe()
{
- ManualResetEvent callbackEntered = new ManualResetEvent(false);
- ManualResetEvent releaseCallback = new ManualResetEvent(false);
- int callbackCount = 0;
- HostTransport host = new HostTransport(delegate
+ int callbackCount = 0; uint observedToken = 0U;
+ HostTransport host = null;
+ host = new HostTransport(null, delegate(uint token)
{
+ observedToken = token;
int call = Interlocked.Increment(ref callbackCount);
- if (call == 1) { callbackEntered.Set(); releaseCallback.WaitOne(); }
+ if (call == 1)
+ {
+ TrayTerminalReceiptUiKind earlyKind; TrayActionResult earlyResult;
+ AssertTrue(!host.TryTakeReceiptUi(token, out earlyKind, out earlyResult), "early token dispatch cannot consume provisional feedback");
+ }
+ return true;
});
try
{
Guid actionId = Guid.NewGuid();
TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, actionId, 30UL, "early-probe durable action");
- bool published = false;
- Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); });
- publisher.IsBackground = true; publisher.Start();
- AssertTrue(callbackEntered.WaitOne(2000), "first successful UI post is held before returning");
- TrayActionResult early;
- AssertTrue(!host.TryTakeFailedAction(out early), "work-message probe cannot consume provisional feedback");
- releaseCallback.Set();
- AssertTrue(publisher.Join(2000) && published, "durable publication succeeds after its first UI post returns");
+ AssertTrue(host.TryPublishDurableReceipt(receipt), "durable publication succeeds after one early token delivery retry");
AssertTrue(Interlocked.CompareExchange(ref callbackCount, 0, 0) == 2, "an early work-message probe causes one replacement UI post");
- TrayActionResult ready; TrayActionResult none;
- AssertTrue(host.TryTakeFailedAction(out ready) && ready.ActionId == actionId, "replacement post observes the now-ready durable feedback");
- AssertTrue(!host.TryTakeFailedAction(out none), "replacement posting never duplicates the UI item");
- }
- finally
- {
- releaseCallback.Set(); host.Dispose(); callbackEntered.Dispose(); releaseCallback.Dispose();
+ TrayActionResult ready = TakeReceiptUi(host, observedToken, TrayTerminalReceiptUiKind.Failure, "replacement token observes the now-committed durable feedback");
+ AssertTrue(ready.ActionId == actionId, "replacement token preserves the exact correlated action");
+ TrayActionResult none; TrayTerminalReceiptUiKind noneKind;
+ AssertTrue(!host.TryTakeReceiptUi(observedToken, out noneKind, out none), "replacement posting never duplicates the tokened UI item");
}
+ finally { host.Dispose(); }
}
private static void TestReplacementCallbackFailureDropsReadyUiWork()
@@ -502,10 +554,18 @@ private static void TestReplacementCallbackFailureDropsReadyUiWork()
ManualResetEvent callbackEntered = new ManualResetEvent(false);
ManualResetEvent releaseCallback = new ManualResetEvent(false);
int callbackCount = 0;
- HostTransport host = new HostTransport(delegate
+ uint observedToken = 0U; HostTransport host = null;
+ host = new HostTransport(null, delegate(uint token)
{
+ observedToken = token;
int call = Interlocked.Increment(ref callbackCount);
- if (call == 1) { callbackEntered.Set(); releaseCallback.WaitOne(); return; }
+ if (call == 1)
+ {
+ callbackEntered.Set(); releaseCallback.WaitOne();
+ TrayTerminalReceiptUiKind earlyKind; TrayActionResult earlyResult;
+ AssertTrue(!host.TryTakeReceiptUi(token, out earlyKind, out earlyResult), "replacement-failure early token cannot consume provisional feedback");
+ return true;
+ }
throw new InvalidOperationException("intentional replacement UI callback failure");
});
try
@@ -515,12 +575,10 @@ private static void TestReplacementCallbackFailureDropsReadyUiWork()
Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); });
publisher.IsBackground = true; publisher.Start();
AssertTrue(callbackEntered.WaitOne(2000), "first UI post is held before replacement-failure probe");
- TrayActionResult early;
- AssertTrue(!host.TryTakeFailedAction(out early), "replacement-failure probe cannot consume provisional feedback");
releaseCallback.Set();
AssertTrue(publisher.Join(2000) && !published, "replacement callback failure reports publication failure");
- TrayActionResult none;
- AssertTrue(!host.TryTakeFailedAction(out none), "replacement callback failure removes the ready-but-unnotified UI item");
+ TrayActionResult none; TrayTerminalReceiptUiKind noneKind;
+ AssertTrue(!host.TryTakeReceiptUi(observedToken, out noneKind, out none), "replacement callback failure removes the uncommitted tokened UI item");
AssertTrue(Interlocked.CompareExchange(ref callbackCount, 0, 0) == 2, "replacement callback is attempted exactly once");
}
finally
@@ -529,6 +587,50 @@ private static void TestReplacementCallbackFailureDropsReadyUiWork()
}
}
+ private static void TestTokenedPostFailureNeverCommitsReceiptVisibility()
+ {
+ ManualResetEvent tokenCallbackEntered = new ManualResetEvent(false);
+ ManualResetEvent releaseTokenCallback = new ManualResetEvent(false);
+ int callbackCount = 0; int genericDialogs = 0; uint observedToken = 0U;
+ HostTransport host = null;
+ host = new HostTransport(null, delegate(uint token)
+ {
+ observedToken = token;
+ int call = Interlocked.Increment(ref callbackCount);
+ if (call == 1)
+ {
+ TrayTerminalReceiptUiKind earlyKind; TrayActionResult earlyResult;
+ AssertTrue(!host.TryTakeReceiptUi(token, out earlyKind, out earlyResult), "early token dispatch cannot consume an uncommitted receipt");
+ return true;
+ }
+ tokenCallbackEntered.Set();
+ releaseTokenCallback.WaitOne();
+ throw new InvalidOperationException("intentional token-post failure");
+ });
+ try
+ {
+ TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 34UL, "token-post failure action");
+ bool published = true;
+ Thread publisher = new Thread((ThreadStart)delegate { published = host.TryPublishDurableReceipt(receipt); });
+ publisher.IsBackground = true; publisher.Start();
+ AssertTrue(tokenCallbackEntered.WaitOne(2000), "replacement token post is held before failure");
+
+ TrayTerminalReceiptUiKind ordinaryKind; TrayActionResult ordinaryResult;
+ if (host.TryTakeReceiptUi(0U, out ordinaryKind, out ordinaryResult)) { Interlocked.Increment(ref genericDialogs); }
+ AssertTrue(ordinaryResult == null && Interlocked.CompareExchange(ref genericDialogs, 0, 0) == 0, "ordinary no-token work cannot observe receipt UI while token post is unresolved");
+
+ releaseTokenCallback.Set();
+ AssertTrue(publisher.Join(2000) && !published, "failed replacement token post retracts the receipt");
+ TrayTerminalReceiptUiKind missingKind; TrayActionResult missingResult;
+ AssertTrue(observedToken != 0U && !host.TryTakeReceiptUi(observedToken, out missingKind, out missingResult) && missingResult == null, "failed token post leaves no exact-token receipt item");
+ AssertTrue(Interlocked.CompareExchange(ref genericDialogs, 0, 0) == 0, "failed token post produces no generic dialog");
+ }
+ finally
+ {
+ releaseTokenCallback.Set(); host.Dispose(); tokenCallbackEntered.Dispose(); releaseTokenCallback.Dispose();
+ }
+ }
+
private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks()
{
ControllableReceiptStore store = new ControllableReceiptStore(ReceiptStoreBlockStage.BeforeOpen, 1);
@@ -565,6 +667,69 @@ private static void TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallback
}
}
+ private static void TestDisposeClosesCallbackAdmissionAfterDurableStoreReturn()
+ {
+ ManualResetEvent beforeAdmission = new ManualResetEvent(false);
+ ManualResetEvent releaseAdmission = new ManualResetEvent(false);
+ int callbackStarts = 0;
+ HostTransport host = new HostTransport();
+ TrayTerminalReceiptSink.BeforeCallbackAdmissionForTesting = delegate { beforeAdmission.Set(); releaseAdmission.WaitOne(); };
+ TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(
+ delegate(TrayTerminalDiagnostic record) { return true; },
+ delegate(TrayTerminalReceipt receipt) { Interlocked.Increment(ref callbackStarts); return host.TryPublishDurableReceipt(receipt); },
+ null);
+ try
+ {
+ TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 33UL, "durable-to-callback dispose handoff action");
+ AssertTrue(sink.TrySubmit(receipt), "handoff receipt is admitted before its durable write");
+ AssertTrue(beforeAdmission.WaitOne(2000), "worker reaches the controlled handoff after durable store return and before callback begin");
+ Stopwatch disposal = Stopwatch.StartNew(); sink.Dispose(); disposal.Stop();
+ AssertReturnsQuickly(disposal, "dispose returns while the writer is held before callback admission");
+ releaseAdmission.Set();
+ sink.Dispose();
+ AssertTrue(Interlocked.CompareExchange(ref callbackStarts, 0, 0) == 0, "close gate prevents any new durable callback from starting after Dispose");
+ TrayActionResult none;
+ AssertTrue(!host.TryTakeFailedAction(out none), "post-close handoff cannot publish receipt UI");
+ }
+ finally
+ {
+ releaseAdmission.Set(); sink.Dispose(); TrayTerminalReceiptSink.BeforeCallbackAdmissionForTesting = null; host.Dispose(); beforeAdmission.Dispose(); releaseAdmission.Dispose();
+ }
+ }
+
+ private static void TestAlreadyStartedCallbackCannotPublishToDisposedTransport()
+ {
+ ManualResetEvent callbackStarted = new ManualResetEvent(false);
+ ManualResetEvent releaseCallback = new ManualResetEvent(false);
+ int tokenPosts = 0; bool callbackResult = true;
+ HostTransport host = new HostTransport(null, delegate(uint token) { Interlocked.Increment(ref tokenPosts); return true; });
+ TrayTerminalReceiptSink sink = new TrayTerminalReceiptSink(
+ delegate(TrayTerminalDiagnostic record) { return true; },
+ delegate(TrayTerminalReceipt receipt)
+ {
+ callbackStarted.Set();
+ releaseCallback.WaitOne();
+ callbackResult = host.TryPublishDurableReceipt(receipt);
+ return callbackResult;
+ });
+ try
+ {
+ TrayTerminalReceipt receipt = RegisterAndAcknowledgeFailure(host, Guid.NewGuid(), 35UL, "already-started callback disposal action");
+ AssertTrue(sink.TrySubmit(receipt), "already-started callback receipt is admitted");
+ AssertTrue(callbackStarted.WaitOne(2000), "durable callback starts before sink closure");
+ Stopwatch disposal = Stopwatch.StartNew(); sink.Dispose(); disposal.Stop();
+ AssertReturnsQuickly(disposal, "sink disposal remains bounded with one already-started callback");
+ host.Dispose();
+ releaseCallback.Set();
+ sink.Dispose();
+ AssertTrue(!callbackResult && Interlocked.CompareExchange(ref tokenPosts, 0, 0) == 0, "already-started callback cannot token-post receipt UI to a disposed transport");
+ }
+ finally
+ {
+ releaseCallback.Set(); sink.Dispose(); host.Dispose(); callbackStarted.Dispose(); releaseCallback.Dispose();
+ }
+ }
+
public static int Main(string[] args)
{
try
@@ -582,7 +747,10 @@ public static int Main(string[] args)
TestSuccessfulCallbackRepostsAfterAnEarlyUiProbe();
TestReplacementCallbackFailureDropsReadyUiWork();
TestDisposalReturnsWhileStoreIsHungAndSuppressesLateCallbacks();
- Console.WriteLine("TrayHost transport self-tests passed: 13");
+ TestDisposeClosesCallbackAdmissionAfterDurableStoreReturn();
+ TestAlreadyStartedCallbackCannotPublishToDisposedTransport();
+ TestTokenedPostFailureNeverCommitsReceiptVisibility();
+ Console.WriteLine("TrayHost transport self-tests passed: 16");
return 0;
}
catch (Exception error)
From 924a97ac829f827908c06a8b7c73cfa6f2c5948d Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 11:25:05 +0800
Subject: [PATCH 037/187] docs: record tray receipt race fix
---
.../task-2-report.md | 91 +++++++++++++++++++
1 file changed, 91 insertions(+)
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md
index 2320aa5..86f9638 100644
--- a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-2-report.md
@@ -266,3 +266,94 @@ counted as production-trace acceptance here.
Admission and pending-action capacity remain bounded and responsive.
- No installer, installed Codex process, real tray UI, push, release, install,
signing, or public operation was run.
+
+## Fix round 1/5: tokened STA visibility and atomic callback admission
+
+Review found two remaining races in the first Task 2 implementation:
+
+1. A receipt became consumable before its replacement UI-post callback had
+ returned successfully. An unrelated ordinary `WmApp+2` drain could display
+ the item before a later callback failure attempted to retract it.
+2. The sink checked `_closed` and then invoked the durable callback separately.
+ `Dispose` could close and return in that gap before a new callback began.
+
+### Implementation
+
+- `HostTransport` now assigns each bounded receipt UI item one nonzero,
+ process-randomized `uint` token and keeps the item uncommitted while its
+ token-post callback is unresolved.
+- Receipt UI lives in one token-keyed eight-item map shared by About and
+ failure. Ordinary no-token drains always return no item; only
+ `TryTakeReceiptUi(exactToken, ...)` can consume a committed receipt.
+- An early exact-token delivery records an observation without exposing UI.
+ The transport permits one bounded replacement post while the item remains
+ uncommitted. A false/throwing post, a second early delivery, disposal, or an
+ invalid token retracts the item before any UI result is returned.
+- `TrayHostApplication` owns internal `WmApp+3` receipt-token posting and
+ decoding. `WmApp+2` remains exclusively the presentation/shutdown work path.
+ The pipe protocol, CLI inputs, authenticated message types, and wire fields
+ are unchanged.
+- `Program` removed receipt drains from ordinary work and maps only an exact
+ `WmApp+3` token to About or generic failure. Shutdown closes the sink, then
+ disposes the transport before destroying the window.
+- `TrayTerminalReceiptSink` now sets an explicit `_callbackClaimed` state under
+ the same `_gate` used by `Dispose`, before invoking the callback, and clears
+ it in `finally`. Once close wins the gate, no new callback claim succeeds.
+ An already-started callback may finish, but a disposed transport rejects its
+ token publication.
+- The callback-handoff barrier is compiled only into the temporary
+ `TRAYHOST_RECEIPT_SELF_TEST` TransportOnly executable. No production test
+ selector, environment switch, listener, or external control was added.
+
+### TDD RED evidence
+
+The existing shared-drain race was first captured behaviorally. Before the
+tokened path, TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+ordinary work cannot expose or display receipt UI while the replacement callback is unresolved
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The durable-return/callback-begin handoff was then blocked deterministically.
+Before atomic callback admission, TransportOnly exited `1` with:
+
+```text
+TrayHost transport self-test failed: System.InvalidOperationException
+close gate prevents any new durable callback from starting after Dispose
+CCOD_TRAYHOST_NATIVE_TEST_FAILED
+```
+
+The desired token APIs were also written before implementation. TransportOnly
+then failed compilation because the token-aware callback,
+`TrayTerminalReceiptUiKind`, and `TryTakeReceiptUi` did not exist; NativeOnly
+failed because `TrayHostApplication.TryDecodeReceiptWorkToken` did not exist.
+
+The final deterministic NativeOnly race delivers the first `WmApp+3` token
+before its post returns, blocks the replacement token post, concurrently
+dispatches ordinary `WmApp+2`, then throws the token callback. It asserts zero
+generic dialogs before and after stale-token replay.
+
+### Fresh GREEN and stability evidence
+
+All commands below were run after the final source/test change:
+
+```text
+TransportOnly: 10 consecutive runs, each exit 0, TrayHost transport self-tests passed: 16
+NativeOnly: 5 consecutive runs, each exit 0, TrayHost native self-tests passed: 25
+ProductionOnly: exit 0, complete production compile plus --headless-smoke, no output
+ProtocolOnly: exit 0, TrayHost protocol self-tests passed: 11
+git diff --check: exit 0; only repository LF-to-CRLF conversion notices
+```
+
+### Commit and remaining boundary
+
+- `85882890202f420479728a9ed1532f2085b970c8` —
+ `fix: seal tray receipt dispatch races`
+
+Task 3 still owns the shared child-session/authenticated production trace and
+the legacy Supervisor correlation-bridge migration; neither is claimed by this
+fix round. Task 4 release gating also remains deferred. No installer, installed
+Codex process, real tray UI, push, release, signing, install, or public operation
+was run.
From 31a74c6bbe315a293fca78bf7bfdbb88239b05d7 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:06:02 +0800
Subject: [PATCH 038/187] test: trace authenticated tray terminal receipts
---
src/trayhost/Program.cs | 118 +------
src/trayhost/TrayHostChildSession.cs | 257 +++++++++++++++
src/trayhost/WindowsTrayHostRuntime.cs | 71 +++++
tests/persistence/Supervisor.SelfTest.ps1 | 2 +
tests/persistence/TrayHostClient.SelfTest.ps1 | 9 +
.../TrayHostProductionTrace.SelfTest.ps1 | 146 +++++++--
tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 39 ++-
.../trayhost/TrayHostCorrelationTestBridge.cs | 92 +++++-
.../trayhost/TrayHostParentClientSelfTest.cs | 85 +----
.../TrayHostProductionTraceSelfTest.cs | 298 ++++++++++++++++++
10 files changed, 883 insertions(+), 234 deletions(-)
create mode 100644 src/trayhost/TrayHostChildSession.cs
create mode 100644 src/trayhost/WindowsTrayHostRuntime.cs
create mode 100644 tests/trayhost/TrayHostProductionTraceSelfTest.cs
diff --git a/src/trayhost/Program.cs b/src/trayhost/Program.cs
index c2193a5..04aa76e 100644
--- a/src/trayhost/Program.cs
+++ b/src/trayhost/Program.cs
@@ -1,13 +1,8 @@
using System;
-using System.Diagnostics;
using System.IO;
-using System.Security.Cryptography;
-using System.Threading;
internal static class Program
{
- private static readonly object WriteGate = new object();
-
internal static int Main(string[] args)
{
try
@@ -21,109 +16,18 @@ internal static int Main(string[] args)
private static int RunChild(string[] args)
{
- int expectedParentPid; long expectedParentCreation; string expectedRuntime;
- if (!Int32.TryParse(args[2], out expectedParentPid) || !Int64.TryParse(args[4], out expectedParentCreation) || String.IsNullOrEmpty(args[6]) || !String.Equals(args[1], "--parent-pid", StringComparison.Ordinal) || !String.Equals(args[3], "--parent-created", StringComparison.Ordinal) || !String.Equals(args[5], "--runtime-id", StringComparison.Ordinal)) { return 2; }
- expectedRuntime = args[6];
- Stream input = Console.OpenStandardInput(); Stream output = Console.OpenStandardOutput();
- ProtocolFrame bootstrap = ProtocolCodec.ReadBootstrap(input, ProtocolDirection.ParentToHost);
- TrayHostHello parent = TrayHostWire.ReadParentHello(bootstrap.Payload);
- if (parent.ProcessId != expectedParentPid || parent.CreationFileTimeUtc != expectedParentCreation || !String.Equals(parent.RuntimeId, expectedRuntime, StringComparison.Ordinal) || !VerifyParentIdentity(expectedParentPid, expectedParentCreation)) { return 2; }
- byte[] nonce = new byte[32]; using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) { rng.GetBytes(nonce); }
- ulong epoch = (ulong)DateTime.UtcNow.Ticks;
- Process self = Process.GetCurrentProcess();
- ProtocolCodec.WriteBootstrap(output, ProtocolFrame.Bootstrap(ProtocolDirection.HostToParent, TrayHostMessageType.HostHello, TrayHostWire.WriteHostHello(self.Id, self.StartTime.ToFileTimeUtc(), expectedRuntime, nonce, epoch)));
- SessionKeys keys = ProtocolCodec.DeriveDirectionalKeys(parent.SessionSeed, parent.ParentChallenge, nonce, epoch);
- ulong inboundSequence = 1UL; ulong outboundSequence = 1UL;
- ProtocolFrame initialFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
- if (initialFrame.MessageType != TrayHostMessageType.Presentation) { return 2; }
- PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload);
- Win32TrayPlatform platform = new Win32TrayPlatform();
- TrayHostApplication application = null;
- TrayWindow window = null;
- TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore();
- HostTransport transport = new HostTransport(
- delegate { TrayHostApplication current = application; if (current != null) { current.PostWork(); } },
- delegate(uint token) { TrayHostApplication current = application; return current != null && current.PostReceiptWork(token); });
- TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose);
- 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;
- try { action = new TrayHostAction(Guid.NewGuid(), selected, revision); }
- catch (ArgumentException) { return; }
- if (!transport.TryRegisterAction(action)) { return; }
- lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(action)), keys.HostToParent); }
- };
- Action work = delegate
- {
- PresentationSnapshot next;
- if (transport.TryTakePresentation(out next))
- {
- if (window.Apply(next))
- {
- lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.PresentationAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(next.Revision)), keys.HostToParent); }
- }
- }
- bool shouldShutdown;
- lock (stateGate) { shouldShutdown = shutdownRequested && !shutdownSent; if (shouldShutdown) { shutdownSent = true; } }
- if (shouldShutdown)
- {
- window.RequestShutdown();
- lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.ShutdownAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(window.CurrentRevision)), keys.HostToParent); }
- application.RequestExit();
- }
- };
- Action receiptWork = delegate(uint token)
- {
- TrayTerminalReceiptUiKind kind; TrayActionResult receiptResult;
- if (!transport.TryTakeReceiptUi(token, out kind, out receiptResult)) { return; }
- if (kind == TrayTerminalReceiptUiKind.About) { window.ShowAbout(); }
- else if (kind == TrayTerminalReceiptUiKind.Failure) { window.ShowActionFailed(); }
- };
- window.CommandSelected += command;
- application = new TrayHostApplication(platform, window, command, work, receiptWork);
- window.Create(initial);
- lock (WriteGate)
- {
- ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.PresentationAck, epoch, outboundSequence++, TrayHostWire.WriteRevision(initial.Revision)), keys.HostToParent);
- ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.UiReady, epoch, outboundSequence++, TrayHostWire.WriteRevision(initial.Revision)), keys.HostToParent);
- }
- Thread reader = new Thread(new ThreadStart(delegate
- {
- try
+ TrayHostChildIdentity identity;
+ if (!TrayHostChildSession.TryCreateIdentity(args, out identity)) { return 2; }
+ return TrayHostChildSession.Run(
+ Console.OpenStandardInput(),
+ Console.OpenStandardOutput(),
+ identity,
+ new WindowsTrayHostRuntime(),
+ delegate(HostTransport transport)
{
- while (true)
- {
- ProtocolFrame frame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
- if (frame.MessageType == TrayHostMessageType.Presentation) { transport.TryAcceptPresentation(TrayHostWire.ReadPresentation(frame.Payload)); application.PostWork(); }
- else if (frame.MessageType == TrayHostMessageType.ActionResult) { if (!TryDispatchAuthenticatedActionResult(transport, receiptSink, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); } }
- else if (frame.MessageType == TrayHostMessageType.Shutdown) { lock (stateGate) { shutdownRequested = true; } application.PostWork(); }
- else if (frame.MessageType == TrayHostMessageType.Ping) { lock (WriteGate) { ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Pong, epoch, outboundSequence++, frame.Payload), keys.HostToParent); } }
- }
- }
- catch { application.RequestExit(); }
- })) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" };
- reader.Start();
- int result = application.Run();
- receiptSink.Dispose(); transport.Dispose(); application.Dispose(); return result;
- }
-
- private static bool VerifyParentIdentity(int pid, long creation)
- {
- try { using (Process process = Process.GetProcessById(pid)) { return process.StartTime.ToFileTimeUtc() == creation; } } catch { return false; }
- }
-
- internal static bool TryDispatchAuthenticatedActionResult(HostTransport transport, TrayTerminalReceiptSink receiptSink, byte[] payload)
- {
- if (transport == null || payload == null) { return false; }
- TrayTerminalReceipt receipt;
- if (!transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload), out receipt)) { return false; }
- if (receipt != null && receiptSink != null)
- {
- try { receiptSink.TrySubmit(receipt); } catch { }
- }
- return true;
+ TrayTerminalDiagnosticStore terminalStore = new TrayTerminalDiagnosticStore();
+ return new TrayTerminalReceiptSink(terminalStore.TryAppendDurably, transport.TryPublishDurableReceipt, terminalStore.Dispose);
+ });
}
private static int RunHeadlessSmoke()
diff --git a/src/trayhost/TrayHostChildSession.cs b/src/trayhost/TrayHostChildSession.cs
new file mode 100644
index 0000000..122d046
--- /dev/null
+++ b/src/trayhost/TrayHostChildSession.cs
@@ -0,0 +1,257 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Security.Cryptography;
+using System.Threading;
+
+internal sealed class TrayHostChildIdentity
+{
+ internal int ParentProcessId { get; private set; }
+ internal long ParentCreationFileTimeUtc { get; private set; }
+ internal string RuntimeId { get; private set; }
+
+ internal TrayHostChildIdentity(int parentProcessId, long parentCreationFileTimeUtc, string runtimeId)
+ {
+ ParentProcessId = parentProcessId;
+ ParentCreationFileTimeUtc = parentCreationFileTimeUtc;
+ RuntimeId = runtimeId;
+ }
+}
+
+internal interface ITrayHostRuntime : IDisposable
+{
+ ulong CurrentRevision { get; }
+ void Initialize(Action menuOpenChanged, Action commandSelected, Action work, Action receiptWork);
+ void Create(PresentationSnapshot initial);
+ bool Apply(PresentationSnapshot snapshot);
+ void PostWork();
+ bool PostReceiptWork(uint token);
+ void ShowAbout();
+ void ShowActionFailed();
+ void RequestShutdown();
+ void RequestExit();
+ int Run();
+}
+
+internal static class TrayHostChildSession
+{
+ internal static bool TryCreateIdentity(string[] args, out TrayHostChildIdentity identity)
+ {
+ identity = null;
+ int parentProcessId;
+ long parentCreation;
+ if (args == null || args.Length != 7 ||
+ !String.Equals(args[0], "--child", StringComparison.Ordinal) ||
+ !String.Equals(args[1], "--parent-pid", StringComparison.Ordinal) ||
+ !Int32.TryParse(args[2], out parentProcessId) ||
+ !String.Equals(args[3], "--parent-created", StringComparison.Ordinal) ||
+ !Int64.TryParse(args[4], out parentCreation) ||
+ !String.Equals(args[5], "--runtime-id", StringComparison.Ordinal) ||
+ String.IsNullOrEmpty(args[6])) { return false; }
+ identity = new TrayHostChildIdentity(parentProcessId, parentCreation, args[6]);
+ return true;
+ }
+
+ internal static int Run(
+ Stream input,
+ Stream output,
+ TrayHostChildIdentity identity,
+ ITrayHostRuntime runtime,
+ Func createReceiptSink)
+ {
+ if (input == null || output == null || identity == null || runtime == null || createReceiptSink == null) { throw new ArgumentNullException("child session dependency"); }
+ HostTransport transport = null;
+ TrayTerminalReceiptSink receiptSink = null;
+ try
+ {
+ ProtocolFrame bootstrap = ProtocolCodec.ReadBootstrap(input, ProtocolDirection.ParentToHost);
+ TrayHostHello parent = TrayHostWire.ReadParentHello(bootstrap.Payload);
+ if (parent.ProcessId != identity.ParentProcessId ||
+ parent.CreationFileTimeUtc != identity.ParentCreationFileTimeUtc ||
+ !String.Equals(parent.RuntimeId, identity.RuntimeId, StringComparison.Ordinal) ||
+ !VerifyParentIdentity(identity.ParentProcessId, identity.ParentCreationFileTimeUtc)) { return 2; }
+
+ byte[] nonce = new byte[32];
+ using (RandomNumberGenerator random = RandomNumberGenerator.Create()) { random.GetBytes(nonce); }
+ ulong epoch = (ulong)DateTime.UtcNow.Ticks;
+ Process self = Process.GetCurrentProcess();
+ try
+ {
+ ProtocolCodec.WriteBootstrap(output, ProtocolFrame.Bootstrap(
+ ProtocolDirection.HostToParent,
+ TrayHostMessageType.HostHello,
+ TrayHostWire.WriteHostHello(self.Id, self.StartTime.ToFileTimeUtc(), identity.RuntimeId, nonce, epoch)));
+ }
+ finally { self.Dispose(); }
+
+ SessionKeys keys = ProtocolCodec.DeriveDirectionalKeys(parent.SessionSeed, parent.ParentChallenge, nonce, epoch);
+ ulong inboundSequence = 1UL;
+ ulong outboundSequence = 1UL;
+ object writeGate = new object();
+ ProtocolFrame initialFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
+ if (initialFrame.MessageType != TrayHostMessageType.Presentation) { return 2; }
+ PresentationSnapshot initial = TrayHostWire.ReadPresentation(initialFrame.Payload);
+
+ transport = new HostTransport(
+ delegate { runtime.PostWork(); },
+ delegate(uint token) { return runtime.PostReceiptWork(token); });
+ receiptSink = createReceiptSink(transport);
+ if (receiptSink == null) { throw new InvalidOperationException("receipt sink is unavailable"); }
+
+ bool shutdownRequested = false;
+ bool shutdownSent = false;
+ object stateGate = new object();
+ HostTransport currentTransport = transport;
+ TrayTerminalReceiptSink currentReceiptSink = receiptSink;
+
+ Action command = delegate(TrayCommand selected, ulong revision)
+ {
+ TrayHostAction action;
+ try { action = new TrayHostAction(Guid.NewGuid(), selected, revision); }
+ catch (ArgumentException) { return; }
+ if (!currentTransport.TryRegisterAction(action)) { return; }
+ lock (writeGate)
+ {
+ ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(
+ ProtocolDirection.HostToParent,
+ TrayHostMessageType.Action,
+ epoch,
+ outboundSequence++,
+ TrayHostWire.WriteAction(action)), keys.HostToParent);
+ }
+ };
+
+ Action work = delegate
+ {
+ PresentationSnapshot next;
+ if (currentTransport.TryTakePresentation(out next) && runtime.Apply(next))
+ {
+ lock (writeGate)
+ {
+ ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(
+ ProtocolDirection.HostToParent,
+ TrayHostMessageType.PresentationAck,
+ epoch,
+ outboundSequence++,
+ TrayHostWire.WriteRevision(next.Revision)), keys.HostToParent);
+ }
+ }
+ bool shouldShutdown;
+ lock (stateGate)
+ {
+ shouldShutdown = shutdownRequested && !shutdownSent;
+ if (shouldShutdown) { shutdownSent = true; }
+ }
+ if (shouldShutdown)
+ {
+ runtime.RequestShutdown();
+ lock (writeGate)
+ {
+ ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(
+ ProtocolDirection.HostToParent,
+ TrayHostMessageType.ShutdownAck,
+ epoch,
+ outboundSequence++,
+ TrayHostWire.WriteRevision(runtime.CurrentRevision)), keys.HostToParent);
+ }
+ runtime.RequestExit();
+ }
+ };
+
+ Action receiptWork = delegate(uint token)
+ {
+ TrayTerminalReceiptUiKind kind;
+ TrayActionResult receiptResult;
+ if (!currentTransport.TryTakeReceiptUi(token, out kind, out receiptResult)) { return; }
+ if (kind == TrayTerminalReceiptUiKind.About) { runtime.ShowAbout(); }
+ else if (kind == TrayTerminalReceiptUiKind.Failure) { runtime.ShowActionFailed(); }
+ };
+
+ runtime.Initialize(currentTransport.SetMenuOpen, command, work, receiptWork);
+ runtime.Create(initial);
+ lock (writeGate)
+ {
+ ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(
+ ProtocolDirection.HostToParent,
+ TrayHostMessageType.PresentationAck,
+ epoch,
+ outboundSequence++,
+ TrayHostWire.WriteRevision(initial.Revision)), keys.HostToParent);
+ ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(
+ ProtocolDirection.HostToParent,
+ TrayHostMessageType.UiReady,
+ epoch,
+ outboundSequence++,
+ TrayHostWire.WriteRevision(initial.Revision)), keys.HostToParent);
+ }
+
+ Thread reader = new Thread(new ThreadStart(delegate
+ {
+ try
+ {
+ while (true)
+ {
+ ProtocolFrame frame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
+ if (frame.MessageType == TrayHostMessageType.Presentation)
+ {
+ currentTransport.TryAcceptPresentation(TrayHostWire.ReadPresentation(frame.Payload));
+ runtime.PostWork();
+ }
+ else if (frame.MessageType == TrayHostMessageType.ActionResult)
+ {
+ if (!TryDispatchAuthenticatedActionResult(currentTransport, currentReceiptSink, frame.Payload)) { throw new ProtocolViolationException("action result is uncorrelated"); }
+ }
+ else if (frame.MessageType == TrayHostMessageType.Shutdown)
+ {
+ lock (stateGate) { shutdownRequested = true; }
+ runtime.PostWork();
+ }
+ else if (frame.MessageType == TrayHostMessageType.Ping)
+ {
+ lock (writeGate)
+ {
+ ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(
+ ProtocolDirection.HostToParent,
+ TrayHostMessageType.Pong,
+ epoch,
+ outboundSequence++,
+ frame.Payload), keys.HostToParent);
+ }
+ }
+ }
+ }
+ catch { runtime.RequestExit(); }
+ })) { IsBackground = true, Name = "CodexRemote.TrayHost.Reader" };
+ reader.Start();
+ return runtime.Run();
+ }
+ finally
+ {
+ if (receiptSink != null) { receiptSink.Dispose(); }
+ if (transport != null) { transport.Dispose(); }
+ runtime.Dispose();
+ }
+ }
+
+ private static bool TryDispatchAuthenticatedActionResult(HostTransport transport, TrayTerminalReceiptSink receiptSink, byte[] payload)
+ {
+ if (transport == null || receiptSink == null || payload == null) { return false; }
+ TrayTerminalReceipt receipt;
+ if (!transport.TryAcknowledgeAction(TrayHostWire.ReadActionResult(payload), out receipt)) { return false; }
+ if (receipt != null)
+ {
+ try { receiptSink.TrySubmit(receipt); }
+ catch { }
+ }
+ return true;
+ }
+
+ private static bool VerifyParentIdentity(int processId, long creationFileTimeUtc)
+ {
+ try
+ {
+ using (Process process = Process.GetProcessById(processId)) { return process.StartTime.ToFileTimeUtc() == creationFileTimeUtc; }
+ }
+ catch { return false; }
+ }
+}
diff --git a/src/trayhost/WindowsTrayHostRuntime.cs b/src/trayhost/WindowsTrayHostRuntime.cs
new file mode 100644
index 0000000..4280440
--- /dev/null
+++ b/src/trayhost/WindowsTrayHostRuntime.cs
@@ -0,0 +1,71 @@
+using System;
+
+internal sealed class WindowsTrayHostRuntime : ITrayHostRuntime
+{
+ private Win32TrayPlatform _platform;
+ private TrayWindow _window;
+ private TrayHostApplication _application;
+ private bool _disposed;
+
+ public ulong CurrentRevision { get { return _window == null ? 0UL : _window.CurrentRevision; } }
+
+ public void Initialize(Action menuOpenChanged, Action commandSelected, Action work, Action receiptWork)
+ {
+ if (_disposed || _application != null || commandSelected == null || work == null || receiptWork == null) { throw new InvalidOperationException("Windows tray runtime initialization is invalid"); }
+ _platform = new Win32TrayPlatform();
+ _window = new TrayWindow(_platform, menuOpenChanged);
+ _window.CommandSelected += commandSelected;
+ _application = new TrayHostApplication(_platform, _window, commandSelected, work, receiptWork);
+ }
+
+ public void Create(PresentationSnapshot initial) { RequireWindow().Create(initial); }
+ public bool Apply(PresentationSnapshot snapshot) { return RequireWindow().Apply(snapshot); }
+ public void PostWork()
+ {
+ TrayHostApplication application = _application;
+ if (!_disposed && application != null) { application.PostWork(); }
+ }
+
+ public bool PostReceiptWork(uint token)
+ {
+ TrayHostApplication application = _application;
+ return !_disposed && application != null && application.PostReceiptWork(token);
+ }
+ public void ShowAbout() { RequireWindow().ShowAbout(); }
+ public void ShowActionFailed() { RequireWindow().ShowActionFailed(); }
+ public void RequestShutdown()
+ {
+ TrayWindow window = _window;
+ if (!_disposed && window != null) { window.RequestShutdown(); }
+ }
+
+ public void RequestExit()
+ {
+ TrayHostApplication application = _application;
+ if (!_disposed && application != null) { application.RequestExit(); }
+ }
+ public int Run() { return RequireApplication().Run(); }
+
+ public void Dispose()
+ {
+ if (_disposed) { return; }
+ _disposed = true;
+ if (_application != null) { _application.Dispose(); }
+ else if (_window != null) { _window.Dispose(); }
+ _application = null;
+ _window = null;
+ _platform = null;
+ }
+
+ private TrayWindow RequireWindow()
+ {
+ if (_disposed || _window == null) { throw new InvalidOperationException("Windows tray runtime is unavailable"); }
+ return _window;
+ }
+
+ private TrayHostApplication RequireApplication()
+ {
+ if (_disposed || _application == null) { throw new InvalidOperationException("Windows tray runtime is unavailable"); }
+ return _application;
+ }
+}
diff --git a/tests/persistence/Supervisor.SelfTest.ps1 b/tests/persistence/Supervisor.SelfTest.ps1
index 42d120d..7cda07f 100644
--- a/tests/persistence/Supervisor.SelfTest.ps1
+++ b/tests/persistence/Supervisor.SelfTest.ps1
@@ -820,10 +820,12 @@ function Import-CcodTrayHostCorrelationTestBridge {
(Join-Path $trayRoot 'PipeProtocol.cs'),
(Join-Path $trayRoot 'TransportMessages.cs'),
(Join-Path $trayRoot 'TrayTerminalDiagnostic.cs'),
+ (Join-Path $trayRoot 'TrayTerminalReceiptSink.cs'),
(Join-Path $trayRoot 'NativeMethods.cs'),
(Join-Path $trayRoot 'InputModeGuard.cs'),
(Join-Path $trayRoot 'NativeMenu.cs'),
(Join-Path $trayRoot 'TrayWindow.cs'),
+ (Join-Path $trayRoot 'TrayHostApplication.cs'),
(Join-Path $trayRoot 'HostTransport.cs'),
(Join-Path $repositoryRoot 'tests\trayhost\TrayHostCorrelationTestBridge.cs')
)
diff --git a/tests/persistence/TrayHostClient.SelfTest.ps1 b/tests/persistence/TrayHostClient.SelfTest.ps1
index 37f650c..e22877b 100644
--- a/tests/persistence/TrayHostClient.SelfTest.ps1
+++ b/tests/persistence/TrayHostClient.SelfTest.ps1
@@ -104,4 +104,13 @@ Invoke-CcodTest 'TrayHost client delivers the exact correlated terminal result t
Assert-CcodEqual 'CCOD_TRAY_ACTION_STALE' $received[0].ErrorCode 'terminal result preserves the stable stale code'
}
+Invoke-CcodTest 'production trace launches the temporary child through the normal parent client' {
+ # Production mutation caught: replacing the real child process with TestProcessFactory or bypassing TrayHostParentClient.Start.
+ $traceSource=Get-Content -LiteralPath (Join-Path $repositoryRoot 'tests\trayhost\TrayHostProductionTraceSelfTest.cs') -Raw -Encoding UTF8
+ $traceScript=Get-Content -LiteralPath (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -Raw -Encoding UTF8
+ Assert-CcodTrue ($traceSource -cmatch 'TrayHostParentClient\.Start\(') 'temporary trace uses the normal parent-client start path'
+ Assert-CcodTrue ($traceSource -cnotmatch 'TestProcessFactory\s*=') 'temporary trace never assigns the parent-client test process factory'
+ Assert-CcodTrue ($traceScript -cnotmatch 'TryDispatchAuthenticatedActionResult|TryTakeFailedAction') 'PowerShell trace cannot bypass authenticated child dispatch or tokened receipt work'
+}
+
Write-Host 'TrayHost client self-tests passed.'
diff --git a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1
index a5bff83..31db00a 100644
--- a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1
+++ b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1
@@ -1,30 +1,136 @@
-param([Parameter(Mandatory)][string]$AssemblyPath)
+param(
+ [string]$AssemblyPath,
+ [string]$CurrentTracePath,
+ [string]$StaleTracePath,
+ [string]$ProductionExePath
+)
+
$ErrorActionPreference='Stop'
-. (Join-Path $PSScriptRoot 'TestSupport.ps1')
$repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
+if([string]::IsNullOrEmpty($AssemblyPath)-and[string]::IsNullOrEmpty($CurrentTracePath)-and[string]::IsNullOrEmpty($StaleTracePath)-and[string]::IsNullOrEmpty($ProductionExePath)){
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\trayhost\Invoke-TrayHostSelfTest.ps1') -ProductionTraceOnly
+ exit $LASTEXITCODE
+}
+foreach($path in @($AssemblyPath,$CurrentTracePath,$StaleTracePath,$ProductionExePath)){if([string]::IsNullOrEmpty($path)-or-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_INPUT_INVALID'}}
+
+. (Join-Path $PSScriptRoot 'TestSupport.ps1')
[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($AssemblyPath))|Out-Null
-Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\TrayHostClient.psm1') -Force
+Import-Module (Join-Path $repositoryRoot 'src\persistence\modules\TrayHostClient.psm1') -Force -WarningAction SilentlyContinue
+. (Join-Path $repositoryRoot 'src\persistence\Supervisor.ps1') -ReadyToken ('a'*64)
+
+function New-CcodProductionTraceContext {
+ param($Client)
+ $enabled=[pscustomobject][ordered]@{RepairEnabled=$false;LanguageEnabled=$false;OpenLogsEnabled=$true;AboutEnabled=$false;ExitEnabled=$false;Busy=$false}
+ $acknowledged=[ordered]@{};$acknowledged['1']=$enabled
+ $queue=[Collections.Generic.Queue[object]]::new()
+ $context=[pscustomobject][ordered]@{
+ Client=$Client;CommandQueue=$queue;CurrentRevision=[UInt64]1;LastAcknowledgedRevision=[UInt64]1
+ PublishedPresentations=[ordered]@{};AcknowledgedPresentations=$acknowledged;Exited=$false;LastError=$null
+ }
+ return [pscustomobject][ordered]@{Context=$context;Enabled=$enabled}
+}
+
+function New-CcodProductionTraceHostState {
+ param($Context,$Enabled)
+ return [pscustomobject][ordered]@{
+ Tray=$Context;LastAcknowledgedPresentation=$Enabled;TrayActionIds=[ordered]@{}
+ WorkerSlot=$null;LifecycleWorkerSlot=$null;LifecycleRequest=$null;Journal=$null
+ State=[pscustomobject][ordered]@{AutomationEnabled=$true;Settings=[pscustomobject]@{candidateCompatibleOptIn=$false};VerifiedPackages=[pscustomobject][ordered]@{};Damage=$null}
+ Identity=[pscustomobject][ordered]@{UserSid='S-1-5-21-111-222-333-1001';SessionId=[int]1}
+ Layout=[pscustomobject][ordered]@{RuntimeId='trace-runtime';LogDirectory='C:\Trace\Logs'}
+ PackageFullName=$null;AppAsarSha256=$null;Classification=$null;Ordinary=[object[]]@();Special=[object[]]@()
+ AttemptKeys=[ordered]@{};RecoveryIgnoreKeys=[ordered]@{};SuppressionKeys=[ordered]@{}
+ RuntimeCleanupCodes=[Collections.Generic.List[string]]::new()
+ }
+}
+
+function Wait-CcodProductionTraceWitness {
+ param([string]$Path,$Context,[scriptblock]$Condition)
+ $deadline=[DateTime]::UtcNow.AddSeconds(4)
+ while([DateTime]::UtcNow-lt$deadline){
+ Receive-CcodTrayHostEvents -Context $Context
+ $lines=if(Test-Path -LiteralPath $Path -PathType Leaf){@(Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue)}else{@()}
+ if(& $Condition $lines){return @($lines)}
+ [void]$Context.Client.WaitForActivity([TimeSpan]::FromMilliseconds(25))
+ }
+ throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_WITNESS_TIMEOUT'
+}
function Invoke-CcodProductionTraceCase {
- param([bool]$Stale)
- $client=[TrayHostProductionTraceFixture]::Start($AssemblyPath,$Stale)
+ param([string]$TracePath,[bool]$Stale)
+ $client=[TrayHostProductionTraceFixture]::Start($TracePath)
try{
- $queue=[Collections.Generic.Queue[object]]::new()
- $context=[pscustomobject][ordered]@{Client=$client;CommandQueue=$queue;Exited=$false;LastError=$null;PublishedPresentations=[ordered]@{};AcknowledgedPresentations=[ordered]@{}}
- $deadline=[DateTime]::UtcNow.AddSeconds(3)
- while($queue.Count-eq0-and[DateTime]::UtcNow-lt$deadline){[void]$client.WaitForActivity([TimeSpan]::FromMilliseconds(100));Receive-CcodTrayHostEvents -Context $context}
- Assert-CcodEqual 1 $queue.Count 'real TrayHostClient receives one authenticated production action'
- $action=$queue.Dequeue()
- Assert-CcodEqual 'OpenLogs' $action.Command 'production trace preserves command through TrayHostWire and TrayHostClient'
+ $parentProcess=[Diagnostics.Process]::GetCurrentProcess()
+ try{Assert-CcodTrue ($client.Receipt.HostPid-gt0-and$client.Receipt.HostPid-ne$parentProcess.Id) 'normal parent start launches a distinct child process'}finally{$parentProcess.Dispose()}
+ $childProcess=[Diagnostics.Process]::GetProcessById($client.Receipt.HostPid)
+ try{Assert-CcodTrue ([string]::Equals([IO.Path]::GetFullPath($TracePath),[IO.Path]::GetFullPath($childProcess.MainModule.FileName),[StringComparison]::OrdinalIgnoreCase)) 'normal parent start launches the requested temporary trace executable'}finally{$childProcess.Dispose()}
+ Assert-CcodEqual 'trace-runtime' $client.Receipt.RuntimeId 'real child handshake preserves the opaque runtime identity'
+ $fixture=New-CcodProductionTraceContext $client;$context=$fixture.Context
+ $hostState=New-CcodProductionTraceHostState $context $fixture.Enabled
+ $opened=[Collections.Generic.List[string]]::new();$supervisorReceipts=[Collections.Generic.List[object]]::new()
+ $adapters=@{
+ GetUtcNow={ [DateTime]::Parse('2030-02-03T03:04:05Z').ToUniversalTime() }
+ OpenLogs={param($Path)[void]$opened.Add([string]$Path)}.GetNewClosure()
+ WriteLog={param($Record)[void]$supervisorReceipts.Add($Record)}.GetNewClosure()
+ SendTrayActionResult={param($Tray,$ActionId,$Revision,$Status,$ErrorCode,$TransactionId)[bool](Send-CcodTrayHostActionResult -Context $context -ActionId $ActionId -Revision ([UInt64]$Revision) -Status $Status -ErrorCode $ErrorCode -TransactionId $TransactionId)}.GetNewClosure()
+ }
+
+ $deadline=[DateTime]::UtcNow.AddSeconds(4)
+ while($context.CommandQueue.Count-eq0-and[DateTime]::UtcNow-lt$deadline){[void]$client.WaitForActivity([TimeSpan]::FromMilliseconds(25));Receive-CcodTrayHostEvents -Context $context}
+ Assert-CcodEqual 1 $context.CommandQueue.Count 'real TrayHostClient receives one authenticated child-session action'
+ $action=$context.CommandQueue.Dequeue()
+ Assert-CcodTrue ($action.ActionId-ne[guid]::Empty) 'production trace preserves a nonempty action id'
+ Assert-CcodEqual 'OpenLogs' $action.Command 'production trace preserves OpenLogs through native/session/wire/client'
$expectedRevision=if($Stale){[UInt64]8}else{[UInt64]1}
- Assert-CcodEqual $expectedRevision $action.Revision 'production trace preserves the exact presentation revision'
- $status=if($Stale){'Rejected'}else{'Completed'}
- $code=if($Stale){'CCOD_TRAY_ACTION_STALE'}else{$null}
- Assert-CcodEqual $true (Send-CcodTrayHostActionResult -Context $context -ActionId $action.ActionId -Revision $action.Revision -Status $status -ErrorCode $code -TransactionId $null) 'real TrayHostClient sends the authenticated correlated terminal result'
- Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(3)) 'production trace peer validates Program dispatch and exits'
+ Assert-CcodEqual $expectedRevision $action.Revision 'production trace preserves the exact action revision'
+
+ $results=@(Invoke-CcodSupervisorCommand $hostState $adapters $action)
+ Assert-CcodEqual 1 $results.Count 'Supervisor produces one terminal result'
+ $expectedStatus=if($Stale){'Rejected'}else{'Completed'}
+ $expectedCode=if($Stale){'CCOD_TRAY_ACTION_STALE'}else{$null}
+ Assert-CcodEqual $expectedStatus $results[0].Status 'Supervisor makes the expected revision-bound decision'
+ if($Stale){Assert-CcodEqual $expectedCode $results[0].ErrorCode 'Supervisor preserves the expected terminal code'}else{Assert-CcodTrue ([string]::IsNullOrEmpty($results[0].ErrorCode)) 'current completion carries no terminal error code'}
+ Assert-CcodEqual $true $results[0].Delivered 'Supervisor sends the terminal result through real TrayHostClient'
+ Assert-CcodEqual $(if($Stale){0}else{1}) $opened.Count 'OpenLogs side effect occurs only for the current action'
+ Assert-CcodEqual 1 $supervisorReceipts.Count 'Supervisor records exactly one terminal decision'
+
+ $witnessPath=[TrayHostProductionTraceFixture]::GetWitnessPath($TracePath)
+ $condition=if($Stale){{param($Items)@($Items|Where-Object{$_-like'dialog *'}).Count-eq1}}else{{param($Items)@($Items|Where-Object{$_-eq'publication accepted=true'}).Count-eq1}}
+ $lines=Wait-CcodProductionTraceWitness $witnessPath $context $condition
+ $receipt="receipt command=OpenLogs revision=$expectedRevision status=$expectedStatus code=$(if($Stale){'CCOD_TRAY_ACTION_STALE'}else{'CCOD_TRAY_ACTION_COMPLETED'})"
+ Assert-CcodEqual 1 @($lines|Where-Object{$_-like'receipt *'}).Count 'child async sink writes exactly one terminal receipt'
+ Assert-CcodTrue ($lines-ccontains$receipt) 'child receipt is exact and sanitized'
+ Assert-CcodEqual 1 @($lines|Where-Object{$_-eq'publication accepted=true'}).Count 'durable receipt callback publishes exactly once'
+ if($Stale){
+ Assert-CcodTrue ($lines-ccontains'fixture command=OpenLogs revision=8') 'stale action comes from the test-only authenticated fixture'
+ $tokenPosts=@($lines|Where-Object{$_-cmatch'^post message=32771 token=[1-9][0-9]*$'})
+ Assert-CcodTrue ($tokenPosts.Count-ge1) 'stale durable callback uses nonzero tokened WmApp+3 work'
+ $dialog='dialog caption=trace-0 text=trace-15'
+ Assert-CcodEqual 1 @($lines|Where-Object{$_-like'dialog *'}).Count 'stale result displays one generic dialog'
+ Assert-CcodTrue ($lines-ccontains$dialog) 'stale dialog contains only acknowledged generic UI text'
+ Assert-CcodTrue ([Array]::IndexOf([object[]]$lines,$receipt)-lt[Array]::IndexOf([object[]]$lines,$dialog)) 'generic dialog occurs only after durable receipt success'
+ Assert-CcodTrue ([Array]::IndexOf([object[]]$lines,$tokenPosts[0])-lt[Array]::IndexOf([object[]]$lines,$dialog)) 'generic dialog is drained through tokened receipt work'
+ Assert-CcodTrue ($dialog-cnotmatch'CCOD_|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') 'generic dialog leaks no internal code or correlation id'
+ }else{
+ Assert-CcodTrue ($lines-ccontains'native-menu command=OpenLogs') 'current action originates from real TrayWindow.HandleContextMenu'
+ Assert-CcodTrue ($lines-ccontains'native command=OpenLogs revision=1') 'current native selection retains the displayed revision'
+ Assert-CcodEqual 0 @($lines|Where-Object{$_-like'dialog *'}).Count 'current completion displays no generic dialog'
+ }
+
+ Receive-CcodTrayHostEvents -Context $context
+ Assert-CcodEqual 0 $context.CommandQueue.Count 'production trace emits no duplicate action'
+ Assert-CcodTrue ([string]::IsNullOrEmpty($context.LastError)) 'production trace emits no remote fault'
+ Assert-CcodTrue $client.BeginShutdown([ShutdownReason]::SupervisorExit,[UInt64]1) 'parent requests normal authenticated shutdown'
+ Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(4)) 'child session returns an authenticated shutdown acknowledgement'
+ Receive-CcodTrayHostEvents -Context $context
+ Assert-CcodEqual $true $context.Exited 'TrayHostClient observes the normal child exit'
+ Assert-CcodTrue ([string]::IsNullOrEmpty($context.LastError)) 'normal child shutdown has no fault'
+ Assert-CcodEqual 0 $context.CommandQueue.Count 'normal child shutdown adds no extra action'
}finally{$client.Dispose()}
}
-Invoke-CcodProductionTraceCase $false
-Invoke-CcodProductionTraceCase $true
-Write-Host 'TrayHost production correlation trace passed: 2'
+& $ProductionExePath '--production-trace-test-selector'
+Assert-CcodEqual 2 $LASTEXITCODE 'shipped Program.Main rejects the temporary trace selector'
+Invoke-CcodProductionTraceCase $CurrentTracePath $false
+Invoke-CcodProductionTraceCase $StaleTracePath $true
+Write-Host 'TrayHost production child-session trace passed: 2'
diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
index e29ca76..661f0d1 100644
--- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1
+++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
@@ -4,12 +4,13 @@ param(
[switch]$NativeOnly,
[switch]$TransportOnly,
[switch]$ParentClientOnly,
+ [switch]$ProductionTraceOnly,
[switch]$ProductionOnly
)
$ErrorActionPreference='Stop'
$repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
-if((@($ProtocolOnly,$NativeOnly,$TransportOnly,$ParentClientOnly,$ProductionOnly)|Where-Object{$_}).Count -ne 1){throw 'CCOD_TRAYHOST_TEST_MODE_REQUIRED'}
+if((@($ProtocolOnly,$NativeOnly,$TransportOnly,$ParentClientOnly,$ProductionTraceOnly,$ProductionOnly)|Where-Object{$_}).Count -ne 1){throw 'CCOD_TRAYHOST_TEST_MODE_REQUIRED'}
$protocolPath=Join-Path $repositoryRoot 'src\trayhost\PipeProtocol.cs'
$presentationPath=Join-Path $repositoryRoot 'src\trayhost\PresentationSnapshot.cs'
$nativeFiles=@(
@@ -36,20 +37,13 @@ $parentClientFiles=@(
(Join-Path $repositoryRoot 'src\trayhost\JobObject.cs'),
(Join-Path $repositoryRoot 'src\trayhost\TrayHostParentClient.cs')
)
-$programTraceFiles=@(
- (Join-Path $repositoryRoot 'src\trayhost\NativeMethods.cs'),
- (Join-Path $repositoryRoot 'src\trayhost\InputModeGuard.cs'),
- (Join-Path $repositoryRoot 'src\trayhost\NativeMenu.cs'),
- (Join-Path $repositoryRoot 'src\trayhost\TrayWindow.cs'),
- (Join-Path $repositoryRoot 'src\trayhost\TrayHostApplication.cs'),
- (Join-Path $repositoryRoot 'src\trayhost\Program.cs')
-)
-$testPath=if($ProtocolOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostProtocolSelfTest.cs'}elseif($NativeOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostNativeSelfTest.cs'}elseif($TransportOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostTransportSelfTest.cs'}else{Join-Path $repositoryRoot 'tests\trayhost\TrayHostParentClientSelfTest.cs'}
+$testPath=if($ProtocolOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostProtocolSelfTest.cs'}elseif($NativeOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostNativeSelfTest.cs'}elseif($TransportOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostTransportSelfTest.cs'}elseif($ProductionTraceOnly){Join-Path $repositoryRoot 'tests\trayhost\TrayHostProductionTraceSelfTest.cs'}else{Join-Path $repositoryRoot 'tests\trayhost\TrayHostParentClientSelfTest.cs'}
$requiredFiles=@($protocolPath,$presentationPath,$testPath)
if($ProtocolOnly){$requiredFiles+=$protocolSupportFiles}
if($NativeOnly){$requiredFiles+=$nativeFiles+$transportFiles}
if($TransportOnly){$requiredFiles+=$transportFiles}
-if($ParentClientOnly){$requiredFiles+=$transportFiles+$parentClientFiles+$programTraceFiles}
+if($ParentClientOnly){$requiredFiles+=$transportFiles+$parentClientFiles}
+if($ProductionTraceOnly){$requiredFiles+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)}
if($ProductionOnly){$requiredFiles+=(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File | ForEach-Object FullName)}
if($requiredFiles|Where-Object{ -not(Test-Path -LiteralPath $_ -PathType Leaf)}){throw 'CCOD_TRAYHOST_SOURCE_MISSING'}
Import-Module (Join-Path $repositoryRoot 'build\TrayHostReferencePack.psm1') -Force
@@ -59,6 +53,23 @@ $compiler=$compilerCandidates|Where-Object{Test-Path -LiteralPath $_ -PathType L
if($null -eq $compiler){throw 'CCOD_TRAYHOST_COMPILER_MISSING'}
$temporaryRoot=Join-Path $env:TEMP ('ccod-trayhost-protocol-'+[Guid]::NewGuid().ToString('N'));New-Item -ItemType Directory -Path $temporaryRoot -Force|Out-Null
try{
+ if($ProductionTraceOnly){
+ $productionSources=@(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File|Sort-Object Name|ForEach-Object FullName)
+ $productionPath=Join-Path $temporaryRoot 'CodexRemote.TrayHost.exe'
+ $currentTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.Current.exe'
+ $staleTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.Stale.exe'
+ $compilerBase=@('/nologo','/noconfig','/nostdlib+','/target:exe','/platform:anycpu','/optimize+','/checked+','/warn:4','/warnaserror+')
+ foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$compilerBase+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))}
+ & $compiler @compilerBase ('/out:'+ $productionPath) '/main:Program' @productionSources
+ if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_COMPILE_FAILED'}
+ & $compiler @compilerBase ('/out:'+ $currentTracePath) '/main:TrayHostProductionTraceMain' @productionSources $testPath
+ if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'}
+ & $compiler @compilerBase '/define:TRAYHOST_TRACE_STALE' ('/out:'+ $staleTracePath) '/main:TrayHostProductionTraceMain' @productionSources $testPath
+ if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'}
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $currentTracePath -CurrentTracePath $currentTracePath -StaleTracePath $staleTracePath -ProductionExePath $productionPath
+ if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED'}
+ return
+ }
$outputName=if($ProtocolOnly){'TrayHostProtocolSelfTest.exe'}elseif($NativeOnly){'TrayHostNativeSelfTest.exe'}elseif($TransportOnly){'TrayHostTransportSelfTest.exe'}elseif($ParentClientOnly){'TrayHostParentClientSelfTest.exe'}else{'CodexRemote.TrayHost.exe'}
$mainType=if($ProtocolOnly){'TrayHostProtocolSelfTest'}elseif($NativeOnly){'TrayHostNativeSelfTest'}elseif($TransportOnly){'TrayHostTransportSelfTest'}elseif($ParentClientOnly){'TrayHostParentClientSelfTest'}else{'Program'}
$outputPath=Join-Path $temporaryRoot $outputName
@@ -73,15 +84,11 @@ try{
if($ProtocolOnly){$args+=$protocolSupportFiles}
if($NativeOnly){$args+=$nativeFiles+$transportFiles}
if($TransportOnly){$args+=$transportFiles}
- if($ParentClientOnly){$args+=$transportFiles+$parentClientFiles+$programTraceFiles}
+ if($ParentClientOnly){$args+=$transportFiles+$parentClientFiles}
}
$args+=$testPath
& $compiler @args
if($LASTEXITCODE -ne 0){if($ProtocolOnly){throw 'CCOD_TRAYHOST_PROTOCOL_COMPILE_FAILED'}elseif($ProductionOnly){throw 'CCOD_TRAYHOST_PRODUCTION_COMPILE_FAILED'}else{throw 'CCOD_TRAYHOST_NATIVE_COMPILE_FAILED'}}
if($ProductionOnly){& $outputPath '--headless-smoke'}else{& $outputPath}
if($LASTEXITCODE -ne 0){if($ProtocolOnly){throw 'CCOD_TRAYHOST_PROTOCOL_TEST_FAILED'}elseif($ProductionOnly){throw 'CCOD_TRAYHOST_HEADLESS_SMOKE_FAILED'}else{throw 'CCOD_TRAYHOST_NATIVE_TEST_FAILED'}}
- if($ParentClientOnly){
- & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $outputPath
- if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED'}
- }
}finally{if(Test-Path -LiteralPath $temporaryRoot){Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue}}
diff --git a/tests/trayhost/TrayHostCorrelationTestBridge.cs b/tests/trayhost/TrayHostCorrelationTestBridge.cs
index 5add2e8..a7e4c6d 100644
--- a/tests/trayhost/TrayHostCorrelationTestBridge.cs
+++ b/tests/trayhost/TrayHostCorrelationTestBridge.cs
@@ -1,12 +1,25 @@
using System;
using System.Collections.Generic;
+using System.Threading;
public sealed class TrayHostCorrelationTestBridge : IDisposable
{
private sealed class Platform : INativeTrayPlatform
{
+ private sealed class Message
+ {
+ internal uint Id;
+ internal IntPtr WParam;
+ internal IntPtr LParam;
+ }
+
+ private readonly object _gate = new object();
+ private readonly Queue _messages = new Queue();
+ private readonly AutoResetEvent _available = new AutoResetEvent(false);
+ private Action _handler;
internal uint Selection;
- public void SetMessageHandler(Action handler) { }
+ internal int MessageBoxCount;
+ public void SetMessageHandler(Action handler) { _handler = handler; }
public IntPtr CreateOwner() { return new IntPtr(10); }
public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); }
public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; }
@@ -25,22 +38,48 @@ public void SetMessageHandler(Action handler) { }
public bool SetForegroundWindow(IntPtr owner) { return true; }
public IntPtr GetForegroundWindow() { return new IntPtr(10); }
public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { return Selection; }
- public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { return true; }
+ public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam)
+ {
+ ulong raw = wParam.ToUInt64();
+ IntPtr converted = IntPtr.Size == 4 ? new IntPtr(unchecked((int)raw)) : new IntPtr(unchecked((long)raw));
+ lock (_gate) { _messages.Enqueue(new Message { Id = message, WParam = converted, LParam = lParam }); }
+ _available.Set();
+ return true;
+ }
public bool SetNotificationFocus(ref TrayIconData icon) { return true; }
- public bool ShowMessageBox(IntPtr owner, string text, string caption) { return true; }
+ public bool ShowMessageBox(IntPtr owner, string text, string caption) { MessageBoxCount++; return true; }
public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; }
public bool DestroyMenu(IntPtr menu) { return true; }
public bool EndMenu() { return true; }
public bool DestroyOwner(IntPtr owner) { return true; }
+
+ internal bool DispatchNext(TimeSpan timeout)
+ {
+ Message message = null;
+ lock (_gate) { if (_messages.Count != 0) { message = _messages.Dequeue(); } }
+ if (message == null)
+ {
+ if (!_available.WaitOne(timeout)) { return false; }
+ lock (_gate) { if (_messages.Count != 0) { message = _messages.Dequeue(); } }
+ }
+ if (message == null || _handler == null) { return false; }
+ _handler(message.Id, message.WParam, message.LParam);
+ return true;
+ }
+
+ internal void Dispose() { _available.Dispose(); }
}
private readonly Platform _platform;
private readonly HostTransport _transport;
private readonly TrayWindow _window;
+ private readonly TrayHostApplication _application;
+ private readonly TrayTerminalReceiptSink _receiptSink;
+ private readonly ManualResetEvent _receiptAttempted = new ManualResetEvent(false);
+ private readonly ManualResetEvent _publicationCompleted = new ManualResetEvent(false);
private TrayHostAction _selected;
private TrayActionResult _terminal;
private bool _persistDiagnostics = true;
- private int _feedbackCount;
public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command) : this(revision, command, true)
{
@@ -50,7 +89,8 @@ public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command, bool p
{
_platform = new Platform { Selection = (uint)command };
_persistDiagnostics = persistDiagnostics;
- _transport = new HostTransport(null, delegate(TrayTerminalDiagnostic record) { return _persistDiagnostics; });
+ TrayHostApplication application = null;
+ _transport = new HostTransport(null, delegate(uint token) { return application != null && application.PostReceiptWork(token); });
_window = new TrayWindow(_platform, _transport.SetMenuOpen);
_window.CommandSelected += delegate(TrayCommand selected, ulong displayedRevision)
{
@@ -58,6 +98,20 @@ public TrayHostCorrelationTestBridge(ulong revision, TrayCommand command, bool p
if (!_transport.TryRegisterAction(action)) { throw new InvalidOperationException("test action registration failed"); }
_selected = action;
};
+ Action receiptWork = delegate(uint token)
+ {
+ TrayTerminalReceiptUiKind kind;
+ TrayActionResult result;
+ if (!_transport.TryTakeReceiptUi(token, out kind, out result)) { return; }
+ _terminal = result;
+ if (kind == TrayTerminalReceiptUiKind.Failure) { _window.ShowActionFailed(); }
+ else if (kind == TrayTerminalReceiptUiKind.About) { _window.ShowAbout(); }
+ };
+ application = new TrayHostApplication(_platform, _window, null, delegate { }, receiptWork);
+ _application = application;
+ _receiptSink = new TrayTerminalReceiptSink(
+ delegate(TrayTerminalDiagnostic record) { bool persisted = _persistDiagnostics; _receiptAttempted.Set(); return persisted; },
+ delegate(TrayTerminalReceipt receipt) { bool published = _transport.TryPublishDurableReceipt(receipt); _publicationCompleted.Set(); return published; });
_window.Create(Snapshot(revision));
}
@@ -75,19 +129,31 @@ public bool Acknowledge(Guid actionId, ulong revision, string status, string err
{
TrayActionResultStatus parsed = (TrayActionResultStatus)Enum.Parse(typeof(TrayActionResultStatus), status, false);
TrayActionResult result = new TrayActionResult(actionId, revision, parsed, errorCode, null);
- bool accepted = _transport.TryAcknowledgeAction(result);
+ _receiptAttempted.Reset();
+ _publicationCompleted.Reset();
+ TrayTerminalReceipt receipt;
+ bool accepted = _transport.TryAcknowledgeAction(result, out receipt);
if (!accepted) { return false; }
_terminal = result;
- if (parsed == TrayActionResultStatus.Rejected || parsed == TrayActionResultStatus.Failed)
+ if (receipt != null && _receiptSink.TrySubmit(receipt))
{
- TrayActionResult queued;
- if (_transport.TryTakeFailedAction(out queued)) { _terminal = queued; _feedbackCount++; }
+ if (!_receiptAttempted.WaitOne(TimeSpan.FromSeconds(2))) { throw new InvalidOperationException("test receipt writer did not run"); }
+ if (_persistDiagnostics)
+ {
+ if (!_publicationCompleted.WaitOne(TimeSpan.FromSeconds(2))) { throw new InvalidOperationException("test receipt publication did not complete"); }
+ if (parsed == TrayActionResultStatus.Rejected || parsed == TrayActionResultStatus.Failed)
+ {
+ DateTime deadline = DateTime.UtcNow.AddSeconds(2);
+ while (_platform.MessageBoxCount == 0 && DateTime.UtcNow < deadline) { _platform.DispatchNext(TimeSpan.FromMilliseconds(25)); }
+ if (_platform.MessageBoxCount != 1) { throw new InvalidOperationException("test receipt UI was not token-dispatched"); }
+ }
+ }
}
return true;
}
public void SetDiagnosticPersistence(bool value) { _persistDiagnostics = value; }
- public int FeedbackCount { get { return _feedbackCount; } }
+ public int FeedbackCount { get { return _platform.MessageBoxCount; } }
public string TerminalStatus { get { return _terminal == null ? String.Empty : _terminal.Status.ToString(); } }
public string TerminalCode { get { return _terminal == null ? String.Empty : (_terminal.ErrorCode ?? String.Empty); } }
@@ -103,7 +169,11 @@ private static PresentationSnapshot Snapshot(ulong revision)
public void Dispose()
{
- _window.Dispose();
+ _receiptSink.Dispose();
_transport.Dispose();
+ _application.Dispose();
+ _receiptAttempted.Dispose();
+ _publicationCompleted.Dispose();
+ _platform.Dispose();
}
}
diff --git a/tests/trayhost/TrayHostParentClientSelfTest.cs b/tests/trayhost/TrayHostParentClientSelfTest.cs
index 5e02eec..d455f73 100644
--- a/tests/trayhost/TrayHostParentClientSelfTest.cs
+++ b/tests/trayhost/TrayHostParentClientSelfTest.cs
@@ -5,37 +5,6 @@
using System.Security.Cryptography;
using System.Threading;
-internal sealed class ProductionTraceTrayPlatform : INativeTrayPlatform
-{
- internal string Message;
- public void SetMessageHandler(Action handler) { }
- public IntPtr CreateOwner() { return new IntPtr(10); }
- public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); }
- public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; }
- public bool ReleaseInputContext(IntPtr owner, IntPtr context) { return true; }
- public IntPtr LoadIcon() { return new IntPtr(40); }
- public bool DestroyIcon(IntPtr icon) { return true; }
- public bool AddIcon(ref TrayIconData icon) { return true; }
- public bool SetIconVersion(ref TrayIconData icon) { return true; }
- public bool DeleteIcon(ref TrayIconData icon) { return true; }
- public IntPtr CreatePopupMenu() { return new IntPtr(30); }
- public IntPtr CreateSubMenu() { return new IntPtr(31); }
- public bool AppendMenu(IntPtr menu, uint flags, UIntPtr command, string text) { return true; }
- public bool AppendSubMenu(IntPtr menu, IntPtr child, string text) { return true; }
- public bool ShowOwner(IntPtr owner) { return true; }
- public bool HideOwner(IntPtr owner) { return true; }
- public bool SetForegroundWindow(IntPtr owner) { return true; }
- public IntPtr GetForegroundWindow() { return new IntPtr(10); }
- public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters) { return 0U; }
- public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam) { return true; }
- public bool SetNotificationFocus(ref TrayIconData icon) { return true; }
- public bool ShowMessageBox(IntPtr owner, string text, string caption) { Message = caption + "|" + text; return true; }
- public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; }
- public bool DestroyMenu(IntPtr menu) { return true; }
- public bool EndMenu() { return true; }
- public bool DestroyOwner(IntPtr owner) { return true; }
-}
-
internal static class TrayHostParentClientSelfTest
{
internal static string PeerArguments = "--peer";
@@ -82,32 +51,13 @@ private static void RunPeer(bool emitFaultAndIgnoreShutdown, bool emitAction, bo
{
Guid actionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
TrayHostAction action = new TrayHostAction(actionId, TrayCommand.OpenLogs, staleAction ? 8UL : 1UL);
- ManualResetEvent receiptPersisted = new ManualResetEvent(false);
- HostTransport host = new HostTransport();
- TrayTerminalReceiptSink receiptSink = new TrayTerminalReceiptSink(delegate(TrayTerminalDiagnostic record) { receiptPersisted.Set(); return true; }, host.TryPublishDurableReceipt);
- ProductionTraceTrayPlatform tracePlatform = new ProductionTraceTrayPlatform();
- TrayWindow traceWindow = new TrayWindow(tracePlatform); traceWindow.Create(Snapshot(1UL));
- AssertTrue(host.TryRegisterAction(action), "program peer registers the exact outbound action");
ProtocolCodec.WriteAuthenticated(output, ProtocolFrame.Authenticated(ProtocolDirection.HostToParent, TrayHostMessageType.Action, epoch, outboundSequence++, TrayHostWire.WriteAction(action)), keys.HostToParent);
ProtocolFrame terminalFrame = ProtocolCodec.ReadAuthenticated(input, ProtocolDirection.ParentToHost, epoch, inboundSequence++, keys.ParentToHost);
- AssertTrue(terminalFrame.MessageType == TrayHostMessageType.ActionResult, "program peer receives one authenticated terminal result");
- AssertTrue(Program.TryDispatchAuthenticatedActionResult(host, receiptSink, terminalFrame.Payload), "production Program dispatch accepts the correlated authenticated result without waiting for receipt storage");
- TrayActionResult feedback;
- if (staleAction)
- {
- Stopwatch feedbackWait = Stopwatch.StartNew();
- while (!host.TryTakeFailedAction(out feedback) && feedbackWait.ElapsedMilliseconds < 3000L) { Thread.Sleep(5); }
- AssertTrue(feedback != null && feedback.Revision == 8UL && feedback.Status == TrayActionResultStatus.Rejected && String.Equals(feedback.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "stale authenticated result reaches the production generic-feedback queue only after its durable callback");
- traceWindow.ShowActionFailed();
- AssertTrue(String.Equals(tracePlatform.Message, "string-0|string-15", StringComparison.Ordinal), "stale production trace collapses internal details to the generic acknowledged-snapshot dialog");
- }
- else
- {
- AssertTrue(receiptPersisted.WaitOne(3000), "completed current action reaches durable receipt storage asynchronously");
- AssertTrue(!host.TryTakeFailedAction(out feedback) && tracePlatform.Message == null, "completed current action produces no generic failure feedback");
- }
- receiptSink.Dispose(); traceWindow.Dispose(); host.Dispose(); receiptPersisted.Dispose();
- return;
+ AssertTrue(terminalFrame.MessageType == TrayHostMessageType.ActionResult, "synthetic peer receives one authenticated terminal result");
+ TrayActionResult terminal = TrayHostWire.ReadActionResult(terminalFrame.Payload);
+ AssertTrue(terminal.ActionId == action.ActionId && terminal.Revision == action.Revision, "parent writer preserves the exact correlated action identity");
+ if (staleAction) { AssertTrue(terminal.Status == TrayActionResultStatus.Rejected && String.Equals(terminal.ErrorCode, "CCOD_TRAY_ACTION_STALE", StringComparison.Ordinal), "parent writer preserves the stale terminal result"); }
+ else { AssertTrue(terminal.Status == TrayActionResultStatus.Completed && terminal.ErrorCode == null, "parent writer preserves the current completion result"); }
}
if (emitFaultAndIgnoreShutdown)
{
@@ -279,28 +229,3 @@ public static int Main(string[] args)
}
}
}
-
-public static class TrayHostProductionTraceFixture
-{
- public static TrayHostParentClient Start(string executablePath, bool stale)
- {
- if (String.IsNullOrEmpty(executablePath)) { throw new ArgumentException("trace executable is required", "executablePath"); }
- Process current = Process.GetCurrentProcess();
- TrayHostParentClient.TestProcessFactory = TrayHostParentClientSelfTest.StartPeer;
- TrayHostParentClientSelfTest.PeerArguments = stale ? "--stale-action-peer" : "--action-peer";
- return TrayHostParentClient.Start(new TrayHostStartOptions {
- ExePath = executablePath,
- RuntimeId = "trace-runtime",
- ParentPid = current.Id,
- ParentCreationFileTimeUtc = current.StartTime.ToFileTimeUtc(),
- InitialPresentation = CreateSnapshot()
- });
- }
-
- private static PresentationSnapshot CreateSnapshot()
- {
- string[] strings = new string[16];
- for (int index = 0; index < strings.Length; index++) { strings[index] = "trace-" + index.ToString(); }
- return new PresentationSnapshot(1UL, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.English, PresentationFlags.OpenLogsEnabled, strings);
- }
-}
diff --git a/tests/trayhost/TrayHostProductionTraceSelfTest.cs b/tests/trayhost/TrayHostProductionTraceSelfTest.cs
new file mode 100644
index 0000000..869102a
--- /dev/null
+++ b/tests/trayhost/TrayHostProductionTraceSelfTest.cs
@@ -0,0 +1,298 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Threading;
+
+public static class TrayHostProductionTraceFixture
+{
+ public static TrayHostParentClient Start(string executablePath)
+ {
+ if (String.IsNullOrEmpty(executablePath) || !Path.IsPathRooted(executablePath) || !File.Exists(executablePath)) { throw new ArgumentException("trace executable is required", "executablePath"); }
+ string witnessPath = GetWitnessPath(executablePath);
+ if (File.Exists(witnessPath)) { File.Delete(witnessPath); }
+ using (Process current = Process.GetCurrentProcess())
+ {
+ return TrayHostParentClient.Start(new TrayHostStartOptions {
+ ExePath = executablePath,
+ RuntimeId = "trace-runtime",
+ ParentPid = current.Id,
+ ParentCreationFileTimeUtc = current.StartTime.ToFileTimeUtc(),
+ InitialPresentation = CreateSnapshot()
+ });
+ }
+ }
+
+ public static string GetWitnessPath(string executablePath)
+ {
+ return Path.GetFullPath(executablePath) + ".witness.log";
+ }
+
+ private static PresentationSnapshot CreateSnapshot()
+ {
+ string[] strings = new string[16];
+ for (int index = 0; index < strings.Length; index++) { strings[index] = "trace-" + index.ToString(); }
+ return new PresentationSnapshot(1UL, TrayColor.Green, ConnectionState.Connected, ProtectionState.Running, LanguageMode.English, PresentationFlags.OpenLogsEnabled, strings);
+ }
+}
+
+internal static class TrayHostProductionTraceMain
+{
+ public static int Main(string[] args)
+ {
+ string witnessPath = TrayHostProductionTraceFixture.GetWitnessPath(Process.GetCurrentProcess().MainModule.FileName);
+ TraceWitness witness = new TraceWitness(witnessPath);
+ try
+ {
+ TrayHostChildIdentity identity;
+ if (!TrayHostChildSession.TryCreateIdentity(args, out identity)) { return 2; }
+#if TRAYHOST_TRACE_STALE
+ bool stale = true;
+#else
+ bool stale = false;
+#endif
+ using (ScriptedTraceRuntime runtime = new ScriptedTraceRuntime(witness, stale))
+ {
+ return TrayHostChildSession.Run(
+ Console.OpenStandardInput(),
+ Console.OpenStandardOutput(),
+ identity,
+ runtime,
+ delegate(HostTransport transport)
+ {
+ return new TrayTerminalReceiptSink(
+ witness.TryWriteDurably,
+ delegate(TrayTerminalReceipt receipt)
+ {
+ bool published = transport.TryPublishDurableReceipt(receipt);
+ witness.Append("publication accepted=" + published.ToString().ToLowerInvariant());
+ runtime.ReleaseReceiptDispatch();
+ return published;
+ });
+ });
+ }
+ }
+ catch (Exception error)
+ {
+ witness.Append("fatal type=" + error.GetType().Name);
+ return 1;
+ }
+ }
+}
+
+internal sealed class ScriptedTraceRuntime : ITrayHostRuntime
+{
+ private readonly TraceWitness _witness;
+ private readonly bool _stale;
+ private readonly ScriptedTracePlatform _platform;
+ private TrayWindow _window;
+ private TrayHostApplication _application;
+ private Action _commandSelected;
+ private bool _exitRequested;
+ private bool _started;
+ private bool _disposed;
+
+ internal ScriptedTraceRuntime(TraceWitness witness, bool stale)
+ {
+ if (witness == null) { throw new ArgumentNullException("witness"); }
+ _witness = witness;
+ _stale = stale;
+ _platform = new ScriptedTracePlatform(witness);
+ }
+
+ public ulong CurrentRevision { get { return _window == null ? 0UL : _window.CurrentRevision; } }
+
+ public void Initialize(Action menuOpenChanged, Action commandSelected, Action work, Action receiptWork)
+ {
+ if (_window != null || commandSelected == null || work == null || receiptWork == null) { throw new InvalidOperationException("trace runtime initialization is invalid"); }
+ _commandSelected = commandSelected;
+ _window = new TrayWindow(_platform, menuOpenChanged);
+ _window.CommandSelected += commandSelected;
+ _application = new TrayHostApplication(_platform, _window, commandSelected, work, receiptWork);
+ }
+
+ public void Create(PresentationSnapshot initial) { _window.Create(initial); }
+ public bool Apply(PresentationSnapshot snapshot) { return _window.Apply(snapshot); }
+ public void PostWork() { _application.PostWork(); }
+ public bool PostReceiptWork(uint token) { return _application.PostReceiptWork(token); }
+ public void ShowAbout() { _window.ShowAbout(); }
+ public void ShowActionFailed() { _window.ShowActionFailed(); }
+ public void RequestShutdown() { _window.RequestShutdown(); }
+
+ internal void ReleaseReceiptDispatch() { _platform.ReleaseReceiptDispatch(); }
+
+ public void RequestExit()
+ {
+ _exitRequested = true;
+ _platform.Signal();
+ }
+
+ public int Run()
+ {
+ if (_started || _window == null) { throw new InvalidOperationException("trace runtime lifecycle is invalid"); }
+ _started = true;
+ if (_stale)
+ {
+ _witness.Append("fixture command=OpenLogs revision=8");
+ _commandSelected(TrayCommand.OpenLogs, 8UL);
+ }
+ else
+ {
+ uint? selected = _window.HandleContextMenu(new TrayPoint(4, 5));
+ if (!selected.HasValue || selected.Value != (uint)TrayCommand.OpenLogs) { throw new InvalidOperationException("native OpenLogs selection failed"); }
+ _witness.Append("native command=OpenLogs revision=" + CurrentRevision.ToString());
+ }
+ while (!_exitRequested) { _platform.TryDispatch(TimeSpan.FromMilliseconds(250)); }
+ return 0;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) { return; }
+ _disposed = true;
+ if (_application != null) { _application.Dispose(); }
+ _platform.Dispose();
+ }
+}
+
+internal sealed class ScriptedTracePlatform : INativeTrayPlatform, IDisposable
+{
+ private sealed class QueuedMessage
+ {
+ internal uint Message;
+ internal IntPtr WParam;
+ internal IntPtr LParam;
+ }
+
+ private readonly object _gate = new object();
+ private readonly Queue _messages = new Queue();
+ private readonly AutoResetEvent _available = new AutoResetEvent(false);
+ private readonly TraceWitness _witness;
+ private Action _handler;
+ private bool _receiptDispatchReleased;
+ private bool _disposed;
+
+ internal ScriptedTracePlatform(TraceWitness witness) { _witness = witness; }
+
+ public void SetMessageHandler(Action handler) { _handler = handler; }
+ public IntPtr CreateOwner() { return new IntPtr(10); }
+ public IntPtr AssociateOwnerInputContext(IntPtr owner, IntPtr context) { return new IntPtr(20); }
+ public IntPtr GetOwnerInputContext(IntPtr owner) { return IntPtr.Zero; }
+ public bool ReleaseInputContext(IntPtr owner, IntPtr context) { return true; }
+ public IntPtr LoadIcon() { return new IntPtr(40); }
+ public bool DestroyIcon(IntPtr icon) { return true; }
+ public bool AddIcon(ref TrayIconData icon) { return true; }
+ public bool SetIconVersion(ref TrayIconData icon) { return true; }
+ public bool DeleteIcon(ref TrayIconData icon) { return true; }
+ public IntPtr CreatePopupMenu() { return new IntPtr(30); }
+ public IntPtr CreateSubMenu() { return new IntPtr(31); }
+ public bool AppendMenu(IntPtr menu, uint flags, UIntPtr command, string text) { return true; }
+ public bool AppendSubMenu(IntPtr menu, IntPtr child, string text) { return true; }
+ public bool ShowOwner(IntPtr owner) { return true; }
+ public bool HideOwner(IntPtr owner) { return true; }
+ public bool SetForegroundWindow(IntPtr owner) { return true; }
+ public IntPtr GetForegroundWindow() { return new IntPtr(10); }
+
+ public uint TrackPopupMenuEx(IntPtr menu, uint flags, int x, int y, IntPtr owner, IntPtr parameters)
+ {
+ _witness.Append("native-menu command=OpenLogs");
+ return (uint)TrayCommand.OpenLogs;
+ }
+
+ public bool PostMessage(IntPtr owner, uint message, UIntPtr wParam, IntPtr lParam)
+ {
+ ulong raw = wParam.ToUInt64();
+ IntPtr converted = IntPtr.Size == 4 ? new IntPtr(unchecked((int)raw)) : new IntPtr(unchecked((long)raw));
+ lock (_gate)
+ {
+ if (_disposed) { return false; }
+ _messages.Enqueue(new QueuedMessage { Message = message, WParam = converted, LParam = lParam });
+ }
+ _witness.Append("post message=" + message.ToString() + " token=" + raw.ToString());
+ _available.Set();
+ return true;
+ }
+
+ public bool SetNotificationFocus(ref TrayIconData icon) { return true; }
+
+ public bool ShowMessageBox(IntPtr owner, string text, string caption)
+ {
+ _witness.Append("dialog caption=" + caption + " text=" + text);
+ return true;
+ }
+
+ public bool ConfirmExit(IntPtr owner, string text, string caption) { return true; }
+ public bool DestroyMenu(IntPtr menu) { return true; }
+ public bool EndMenu() { return true; }
+ public bool DestroyOwner(IntPtr owner) { return true; }
+
+ internal bool TryDispatch(TimeSpan timeout)
+ {
+ QueuedMessage queued = null;
+ lock (_gate)
+ {
+ if (_messages.Count != 0 && (_messages.Peek().Message != TrayNativeConstants.WmApp + 3U || _receiptDispatchReleased)) { queued = _messages.Dequeue(); }
+ }
+ if (queued == null)
+ {
+ try { if (!_available.WaitOne(timeout)) { return false; } }
+ catch (ObjectDisposedException) { return false; }
+ lock (_gate)
+ {
+ if (_messages.Count != 0 && (_messages.Peek().Message != TrayNativeConstants.WmApp + 3U || _receiptDispatchReleased)) { queued = _messages.Dequeue(); }
+ }
+ }
+ if (queued == null) { return false; }
+ Action handler = _handler;
+ if (handler == null) { throw new InvalidOperationException("trace native handler is unavailable"); }
+ handler(queued.Message, queued.WParam, queued.LParam);
+ return true;
+ }
+
+ internal void Signal() { try { _available.Set(); } catch (ObjectDisposedException) { } }
+
+ internal void ReleaseReceiptDispatch()
+ {
+ lock (_gate) { if (!_disposed) { _receiptDispatchReleased = true; } }
+ Signal();
+ }
+
+ public void Dispose()
+ {
+ lock (_gate) { if (_disposed) { return; } _disposed = true; _messages.Clear(); }
+ _available.Dispose();
+ }
+}
+
+internal sealed class TraceWitness
+{
+ private readonly object _gate = new object();
+ private readonly string _path;
+
+ internal TraceWitness(string path)
+ {
+ if (String.IsNullOrEmpty(path) || !Path.IsPathRooted(path)) { throw new ArgumentException("trace witness path is invalid", "path"); }
+ _path = path;
+ }
+
+ internal bool TryWriteDurably(TrayTerminalDiagnostic record)
+ {
+ if (record == null) { return false; }
+ Append("receipt command=" + record.Command.ToString() + " revision=" + record.Revision.ToString() + " status=" + record.Status.ToString() + " code=" + record.Code);
+ return true;
+ }
+
+ internal void Append(string value)
+ {
+ byte[] bytes = new UTF8Encoding(false).GetBytes(value + "\r\n");
+ lock (_gate)
+ {
+ using (FileStream stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite))
+ {
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(true);
+ }
+ }
+ }
+}
From 939f2fac6df639d998d10fb94dd815bb8801a8d7 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:06:12 +0800
Subject: [PATCH 039/187] docs: record authenticated tray receipt trace
---
.../task-3-report.md | 159 ++++++++++++++++++
1 file changed, 159 insertions(+)
create mode 100644 .superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md
new file mode 100644
index 0000000..e729421
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md
@@ -0,0 +1,159 @@
+# Task 3 report: authenticated child-session production trace
+
+## Outcome
+
+Task 3 now runs the real authenticated TrayHost child session in temporary
+child processes without adding a shipped test selector or using
+`TrayHostParentClient.TestProcessFactory`.
+
+- `Program.RunChild` is a thin argument/identity/Windows-runtime adapter.
+- `TrayHostChildSession` owns the existing bootstrap, directional keys,
+ authenticated reader/writer sequences, action registration, presentation and
+ shutdown work, async receipt admission, and token-bound UI drain.
+- `WindowsTrayHostRuntime` composes the unchanged `Win32TrayPlatform`,
+ `TrayWindow`, and `TrayHostApplication` behavior.
+- `TrayHostProductionTraceSelfTest.cs` is compiled only under the runner's
+ temporary directory. Its fake native platform remains under `tests/`; no
+ test runtime, scenario, witness, macro, or selector is present in
+ `src/trayhost`.
+- The old ParentClient fake peer is again only a narrow authenticated
+ parent-writer test. It no longer calls production dispatch, HostTransport
+ receipt drains, or the dialog directly.
+- The deferred Supervisor correlation bridge now uses the real async
+ `TrayTerminalReceiptSink` and `TrayHostApplication` token dispatch rather
+ than the removed synchronous `TryTakeFailedAction` path.
+
+## End-to-end evidence
+
+### Current action
+
+The trace launches a distinct requested executable through the normal
+`TrayHostParentClient.Start` path. After the real parent/child identity and
+authenticated handshake, the temporary runtime calls the real
+`TrayWindow.HandleContextMenu`; its fake `INativeTrayPlatform` returns
+`OpenLogs` from `TrackPopupMenuEx`. The shared child session registers and
+writes that exact action. The real PowerShell `Receive-CcodTrayHostEvents`
+delivers it to `Invoke-CcodSupervisorCommand`, whose in-memory `OpenLogs`
+adapter runs once and whose terminal result returns through
+`Send-CcodTrayHostActionResult` and the real ParentClient writer.
+
+The child reader authenticates and correlates the completion, the real async
+sink flushes exactly one sanitized receipt, and `HostTransport` publishes no
+receipt UI for a completed `OpenLogs`. The witness contains no dialog, no
+duplicate action, no fault, and the normal parent-requested shutdown receives
+an authenticated acknowledgement.
+
+### Stale authenticated negative
+
+The separately compiled temporary stale fixture emits an explicit revision-8
+`OpenLogs` through the same child-session command delegate; this is test-only
+and does not use a runtime-id convention or production selector. The real
+PowerShell client receives the authenticated action. Because only revision 1
+is acknowledged, `Invoke-CcodSupervisorCommand` rejects it with
+`CCOD_TRAY_ACTION_STALE` before the `OpenLogs` adapter and sends the exact
+correlated terminal result through ParentClient.
+
+The child consumes the pending action, flushes one receipt containing only
+`command`, `revision`, `status`, and canonical `code`, then posts a nonzero
+token on `WmApp+3`. The test-only message pump invokes the real
+`TrayHostApplication` handler; only `TryTakeReceiptUi(exactToken, ...)` reaches
+the runtime's `TrayWindow.ShowActionFailed` delegation. The witness asserts
+the order `durable receipt -> token post -> generic dialog`, exactly one dialog
+with caption `trace-0` and text `trace-15`, and no internal code or correlation
+identifier in that dialog. Normal authenticated shutdown then completes.
+
+## TDD RED evidence
+
+The real-process trace and runner were written before the production seam.
+Running the required command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1
+```
+
+exited 1 with the expected missing-seam failure:
+
+```text
+tests\trayhost\TrayHostProductionTraceSelfTest.cs(84,46): error CS0246: ITrayHostRuntime
+CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED
+```
+
+No `src/trayhost` file had been changed when this RED was captured.
+
+Running the complete Supervisor fixture then exposed the Task 2 report's
+explicitly deferred legacy bridge migration. It exited 1 at
+`Supervisor.SelfTest.ps1:818`: `HostTransport.cs(107)` could not resolve
+`TrayTerminalReceipt` because the pre-Task-2 Add-Type list omitted the async
+sink, and the bridge still used the old synchronous drain. Adding the actual
+sink/application sources and migrating the bridge to token dispatch made the
+same fixture pass 98 tests.
+
+## Fresh GREEN and stability evidence
+
+All commands below ran on the final production/test source tree and exited 0:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1
+# TrayHost production child-session trace passed: 2
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly
+# TrayHost parent-client self-tests passed: 4
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostClient.SelfTest.ps1
+# 9 cases passed; TrayHost client self-tests passed.
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -NativeOnly
+# TrayHost native self-tests passed: 25
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -TransportOnly
+# TrayHost transport self-tests passed: 16
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProtocolOnly
+# TrayHost protocol self-tests passed: 11
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+# complete production compile and --headless-smoke, exit 0
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/Supervisor.SelfTest.ps1
+# Supervisor self-tests passed: 98
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1
+# 6 build/provenance cases; TrayHost build self-tests passed.
+```
+
+The direct production-trace command was additionally run three consecutive
+times on the final tree. Every iteration launched both current and stale child
+processes and printed `TrayHost production child-session trace passed: 2`.
+
+`git diff --check` exited 0; Git printed only the repository's expected
+LF-to-CRLF conversion notices.
+
+## Production CLI and Task 2 boundary
+
+The trace runner separately compiles the production executable with
+`/main:Program` and invokes it with `--production-trace-test-selector`; the
+test requires exit code 2. A final source audit found no trace selector,
+`TRAYHOST_TRACE` define, trace runtime-id convention, or test-factory
+assignment in `Program.cs`, `TrayHostChildSession.cs`, or
+`WindowsTrayHostRuntime.cs`. `Program.Main` still recognizes only the existing
+single `--headless-smoke` form and seven-argument `--child` form.
+
+`HostTransport`, `TrayTerminalReceiptSink`, `TrayHostWire`,
+`TrayHostParentClient`, and the PowerShell wire surface were not changed.
+Native 25/25 and transport 16/16 confirm the Task 2 tokened `WmApp+3`
+publication, provisional-visibility, repost, disposal, and failure behavior
+remains green.
+
+## Remaining risks and safety boundary
+
+- The broad trace deliberately uses a fake native platform and a temporary
+ handle-flushed witness store. It proves the shared session, authenticated
+ wire, ParentClient, PowerShell client/Supervisor decision, async sink,
+ tokened application work, and generic dialog mapping; it is not an installed
+ desktop automation or real shell-notification-area test.
+- Native receipt-store pinning/reparse/hard-link/rollover behavior remains
+ covered by the focused Task 1/native tests, not duplicated here.
+- Installer, installed-runtime, Defender, release workflow, signing, and
+ independent review remain later acceptance boundaries.
+- No real UI, Codex process, installer, installation, release, push, signing,
+ or public operation was started by Task 3.
From efd2f902a03c20140e3dfd9960da3fdbf6970502 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:26:25 +0800
Subject: [PATCH 040/187] fix: require tray child natural exit after ack
---
.../task-3-report.md | 68 +++++++++++++++++++
src/trayhost/TrayHostParentClient.cs | 15 +++-
.../TrayHostProductionTrace.SelfTest.ps1 | 44 ++++++++++--
tests/trayhost/Invoke-TrayHostSelfTest.ps1 | 5 +-
.../TrayHostProductionTraceSelfTest.cs | 18 ++++-
5 files changed, 140 insertions(+), 10 deletions(-)
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md
index e729421..af3ef87 100644
--- a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-3-report.md
@@ -157,3 +157,71 @@ remains green.
independent review remain later acceptance boundaries.
- No real UI, Codex process, installer, installation, release, push, signing,
or public operation was started by Task 3.
+
+## Fix round 1/5: ShutdownAck is not process-exit proof
+
+Independent review found that the first trace treated `ShutdownAck` as natural
+child termination. `TrayHostParentClient.ReaderLoop` enqueued `Exited` and set
+its stopped event immediately after reading the authenticated ACK, while
+`Dispose` subsequently terminated the child job. A child that wrote the ACK
+and then hung could therefore pass the trace before being force-killed.
+
+### Deterministic RED
+
+The trace runner now compiles a third temporary executable with a test-only
+`TRAYHOST_TRACE_HANG_AFTER_ACK` define. It uses the same child session,
+handshake, authenticated action/result, and shutdown wire. Its fake runtime
+records that the shared child session has written the shutdown ACK but
+deliberately does not return from its run loop. The PowerShell test retains the
+actual process identified by `TrayHostStartReceipt.HostPid` before `Dispose`
+and asserts that ACK alone cannot publish `Exited` or satisfy
+`WaitForStopped` while that process is still alive.
+
+Before the production fix, the required trace command exited 1 with the exact
+behavioral failure:
+
+```text
+ASSERT_EQUAL: ShutdownAck alone cannot satisfy WaitForStopped while the child is alive expected=[False] actual=[True]
+CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED
+```
+
+### Production correction
+
+`TrayHostParentClient` now treats the authenticated ACK only as permission to
+wait for graceful termination. Its reader performs a bounded wait on the exact
+launched `Process` and requires exit code 0 before it enqueues `Exited` and
+signals stopped. A timeout, inaccessible process, or nonzero exit becomes the
+existing bounded `CCOD_TRAYHOST_TRANSPORT_FAILED` fault; `Dispose` may then use
+the existing job cleanup, but that cleanup can no longer masquerade as natural
+exit.
+
+The normal current and stale cases retain their child `Process` handles and
+independently require `WaitForExit(4000)` and exit code 0 before calling
+`Dispose`. The hung-after-ACK case requires a 250 ms stopped wait to return
+false, no `Exited` event, and a still-live child; after the bounded parent wait
+it requires the transport fault while the child remains live until explicit
+cleanup.
+
+No production CLI argument, listener, environment switch, external control
+surface, wire field, message type, or authentication rule was added or
+changed. The hang behavior and compile define exist only in the temporary
+test executable.
+
+### Fresh fix-round verification
+
+All covering commands ran after the correction and exited 0:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostProductionTrace.SelfTest.ps1
+# TrayHost production child-session trace passed: 3
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ParentClientOnly
+# TrayHost parent-client self-tests passed: 4
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+# complete production compile plus --headless-smoke, exit 0
+```
+
+`git diff --check` exited 0 with only the repository's expected LF-to-CRLF
+conversion notices. No real UI, Codex process, installer, release, install,
+signing, push, or public operation ran in this fix round.
diff --git a/src/trayhost/TrayHostParentClient.cs b/src/trayhost/TrayHostParentClient.cs
index 56ab25f..dd4a5ec 100644
--- a/src/trayhost/TrayHostParentClient.cs
+++ b/src/trayhost/TrayHostParentClient.cs
@@ -26,6 +26,7 @@ public sealed class TrayHostStartReceipt
public sealed class TrayHostParentClient : IDisposable
{
+ private const int GracefulProcessExitMilliseconds = 1500;
internal static Func TestProcessFactory = null;
internal static Action TestBeforeReaderStopSignal = null;
internal static Action TestBeforeWaitHandleDispose = null;
@@ -180,7 +181,11 @@ private void ReaderLoop()
ProtocolFrame frame = ReadAuthenticated();
if (frame.MessageType == TrayHostMessageType.PresentationAck) { EnqueueEvent(TrayHostEvent.Ack(TrayHostWire.ReadRevision(frame.Payload))); }
else if (frame.MessageType == TrayHostMessageType.Action) { EnqueueEvent(TrayHostEvent.Action(TrayHostWire.ReadAction(frame.Payload))); }
- else if (frame.MessageType == TrayHostMessageType.ShutdownAck) { EnqueueEvent(TrayHostEvent.Exited()); _transport.Dispose(); SignalStopped(); return; }
+ else if (frame.MessageType == TrayHostMessageType.ShutdownAck)
+ {
+ if (!WaitForNaturalProcessExit()) { HandleTransportFailure(new InvalidOperationException("CCOD_TRAYHOST_GRACEFUL_EXIT_FAILED")); return; }
+ EnqueueEvent(TrayHostEvent.Exited()); _transport.Dispose(); SignalStopped(); return;
+ }
else if (frame.MessageType == TrayHostMessageType.Fault) { EnqueueEvent(TrayHostEvent.Fault("CCOD_TRAYHOST_REMOTE_FAULT")); }
else if (frame.MessageType == TrayHostMessageType.Pong) { _work.Set(); }
}
@@ -216,6 +221,14 @@ private ProtocolFrame ReadAuthenticated()
return ProtocolCodec.ReadAuthenticated(_childOutput, ProtocolDirection.HostToParent, _keys.HostEpoch, _readSequence++, _keys.HostToParent);
}
+ private bool WaitForNaturalProcessExit()
+ {
+ Process process = _process;
+ if (process == null) { return false; }
+ try { return process.WaitForExit(GracefulProcessExitMilliseconds) && process.ExitCode == 0; }
+ catch { return false; }
+ }
+
private bool IsDisposing() { return Volatile.Read(ref _disposed) != 0; }
private bool IsClosing() { return Volatile.Read(ref _disposeGate) != 0; }
private void SignalWork() { try { _work.Set(); } catch (ObjectDisposedException) { } }
diff --git a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1 b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1
index 31db00a..4173b02 100644
--- a/tests/persistence/TrayHostProductionTrace.SelfTest.ps1
+++ b/tests/persistence/TrayHostProductionTrace.SelfTest.ps1
@@ -2,16 +2,17 @@ param(
[string]$AssemblyPath,
[string]$CurrentTracePath,
[string]$StaleTracePath,
+ [string]$HungTracePath,
[string]$ProductionExePath
)
$ErrorActionPreference='Stop'
$repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
-if([string]::IsNullOrEmpty($AssemblyPath)-and[string]::IsNullOrEmpty($CurrentTracePath)-and[string]::IsNullOrEmpty($StaleTracePath)-and[string]::IsNullOrEmpty($ProductionExePath)){
+if([string]::IsNullOrEmpty($AssemblyPath)-and[string]::IsNullOrEmpty($CurrentTracePath)-and[string]::IsNullOrEmpty($StaleTracePath)-and[string]::IsNullOrEmpty($HungTracePath)-and[string]::IsNullOrEmpty($ProductionExePath)){
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\trayhost\Invoke-TrayHostSelfTest.ps1') -ProductionTraceOnly
exit $LASTEXITCODE
}
-foreach($path in @($AssemblyPath,$CurrentTracePath,$StaleTracePath,$ProductionExePath)){if([string]::IsNullOrEmpty($path)-or-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_INPUT_INVALID'}}
+foreach($path in @($AssemblyPath,$CurrentTracePath,$StaleTracePath,$HungTracePath,$ProductionExePath)){if([string]::IsNullOrEmpty($path)-or-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_INPUT_INVALID'}}
. (Join-Path $PSScriptRoot 'TestSupport.ps1')
[Reflection.Assembly]::LoadFrom([IO.Path]::GetFullPath($AssemblyPath))|Out-Null
@@ -59,11 +60,12 @@ function Wait-CcodProductionTraceWitness {
function Invoke-CcodProductionTraceCase {
param([string]$TracePath,[bool]$Stale)
$client=[TrayHostProductionTraceFixture]::Start($TracePath)
+ $childProcess=$null
try{
$parentProcess=[Diagnostics.Process]::GetCurrentProcess()
try{Assert-CcodTrue ($client.Receipt.HostPid-gt0-and$client.Receipt.HostPid-ne$parentProcess.Id) 'normal parent start launches a distinct child process'}finally{$parentProcess.Dispose()}
$childProcess=[Diagnostics.Process]::GetProcessById($client.Receipt.HostPid)
- try{Assert-CcodTrue ([string]::Equals([IO.Path]::GetFullPath($TracePath),[IO.Path]::GetFullPath($childProcess.MainModule.FileName),[StringComparison]::OrdinalIgnoreCase)) 'normal parent start launches the requested temporary trace executable'}finally{$childProcess.Dispose()}
+ Assert-CcodTrue ([string]::Equals([IO.Path]::GetFullPath($TracePath),[IO.Path]::GetFullPath($childProcess.MainModule.FileName),[StringComparison]::OrdinalIgnoreCase)) 'normal parent start launches the requested temporary trace executable'
Assert-CcodEqual 'trace-runtime' $client.Receipt.RuntimeId 'real child handshake preserves the opaque runtime identity'
$fixture=New-CcodProductionTraceContext $client;$context=$fixture.Context
$hostState=New-CcodProductionTraceHostState $context $fixture.Enabled
@@ -121,16 +123,46 @@ function Invoke-CcodProductionTraceCase {
Assert-CcodEqual 0 $context.CommandQueue.Count 'production trace emits no duplicate action'
Assert-CcodTrue ([string]::IsNullOrEmpty($context.LastError)) 'production trace emits no remote fault'
Assert-CcodTrue $client.BeginShutdown([ShutdownReason]::SupervisorExit,[UInt64]1) 'parent requests normal authenticated shutdown'
- Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(4)) 'child session returns an authenticated shutdown acknowledgement'
+ Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(4)) 'parent observes graceful child termination'
+ Assert-CcodTrue $childProcess.WaitForExit(4000) 'graceful shutdown waits for the actual child process to exit naturally'
+ Assert-CcodEqual 0 ([int]$childProcess.ExitCode) 'graceful child process exits naturally with code zero before Dispose'
Receive-CcodTrayHostEvents -Context $context
Assert-CcodEqual $true $context.Exited 'TrayHostClient observes the normal child exit'
Assert-CcodTrue ([string]::IsNullOrEmpty($context.LastError)) 'normal child shutdown has no fault'
Assert-CcodEqual 0 $context.CommandQueue.Count 'normal child shutdown adds no extra action'
- }finally{$client.Dispose()}
+ }finally{$client.Dispose();if($null-ne$childProcess){$childProcess.Dispose()}}
+}
+
+function Invoke-CcodHungAfterAckTraceCase {
+ param([string]$TracePath)
+ $client=[TrayHostProductionTraceFixture]::Start($TracePath)
+ $childProcess=$null
+ try{
+ $childProcess=[Diagnostics.Process]::GetProcessById($client.Receipt.HostPid)
+ $fixture=New-CcodProductionTraceContext $client;$context=$fixture.Context
+ $deadline=[DateTime]::UtcNow.AddSeconds(4)
+ while($context.CommandQueue.Count-eq0-and[DateTime]::UtcNow-lt$deadline){[void]$client.WaitForActivity([TimeSpan]::FromMilliseconds(25));Receive-CcodTrayHostEvents -Context $context}
+ Assert-CcodEqual 1 $context.CommandQueue.Count 'hung-after-ACK fixture reaches the authenticated action loop'
+ $action=$context.CommandQueue.Dequeue()
+ Assert-CcodTrue (Send-CcodTrayHostActionResult -Context $context -ActionId $action.ActionId -Revision $action.Revision -Status Completed -ErrorCode $null -TransactionId $null) 'hung-after-ACK fixture consumes its current action normally'
+ $witnessPath=[TrayHostProductionTraceFixture]::GetWitnessPath($TracePath)
+ [void](Wait-CcodProductionTraceWitness $witnessPath $context {param($Items)$Items-ccontains'publication accepted=true'})
+ Assert-CcodTrue $client.BeginShutdown([ShutdownReason]::SupervisorExit,[UInt64]1) 'hung-after-ACK fixture accepts authenticated shutdown'
+ [void](Wait-CcodProductionTraceWitness $witnessPath $context {param($Items)$Items-ccontains'shutdown-ack-written-exit-blocked'})
+ Assert-CcodEqual $false $client.WaitForStopped([TimeSpan]::FromMilliseconds(250)) 'ShutdownAck alone cannot satisfy WaitForStopped while the child is alive'
+ Receive-CcodTrayHostEvents -Context $context
+ Assert-CcodEqual $false $context.Exited 'ShutdownAck alone cannot publish an Exited event'
+ Assert-CcodEqual $false $childProcess.HasExited 'hung-after-ACK child remains observable and alive before Dispose cleanup'
+ Assert-CcodTrue $client.WaitForStopped([TimeSpan]::FromSeconds(3)) 'hung-after-ACK child reaches a bounded transport failure'
+ Receive-CcodTrayHostEvents -Context $context
+ Assert-CcodEqual 'CCOD_TRAYHOST_TRANSPORT_FAILED' $context.LastError 'hung-after-ACK child reports a fault instead of a normal exit'
+ Assert-CcodEqual $false $childProcess.HasExited 'transport failure does not masquerade a forced kill as natural child exit'
+ }finally{$client.Dispose();if($null-ne$childProcess){$childProcess.Dispose()}}
}
& $ProductionExePath '--production-trace-test-selector'
Assert-CcodEqual 2 $LASTEXITCODE 'shipped Program.Main rejects the temporary trace selector'
Invoke-CcodProductionTraceCase $CurrentTracePath $false
Invoke-CcodProductionTraceCase $StaleTracePath $true
-Write-Host 'TrayHost production child-session trace passed: 2'
+Invoke-CcodHungAfterAckTraceCase $HungTracePath
+Write-Host 'TrayHost production child-session trace passed: 3'
diff --git a/tests/trayhost/Invoke-TrayHostSelfTest.ps1 b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
index 661f0d1..1d3ed9c 100644
--- a/tests/trayhost/Invoke-TrayHostSelfTest.ps1
+++ b/tests/trayhost/Invoke-TrayHostSelfTest.ps1
@@ -58,6 +58,7 @@ try{
$productionPath=Join-Path $temporaryRoot 'CodexRemote.TrayHost.exe'
$currentTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.Current.exe'
$staleTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.Stale.exe'
+ $hungTracePath=Join-Path $temporaryRoot 'TrayHostProductionTrace.HungAfterAck.exe'
$compilerBase=@('/nologo','/noconfig','/nostdlib+','/target:exe','/platform:anycpu','/optimize+','/checked+','/warn:4','/warnaserror+')
foreach($leaf in @('mscorlib.dll','System.dll','System.Core.dll','System.Drawing.dll')){$compilerBase+=('/reference:'+ (Join-Path $reference.ReferenceRoot $leaf))}
& $compiler @compilerBase ('/out:'+ $productionPath) '/main:Program' @productionSources
@@ -66,7 +67,9 @@ try{
if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'}
& $compiler @compilerBase '/define:TRAYHOST_TRACE_STALE' ('/out:'+ $staleTracePath) '/main:TrayHostProductionTraceMain' @productionSources $testPath
if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'}
- & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $currentTracePath -CurrentTracePath $currentTracePath -StaleTracePath $staleTracePath -ProductionExePath $productionPath
+ & $compiler @compilerBase '/define:TRAYHOST_TRACE_HANG_AFTER_ACK' ('/out:'+ $hungTracePath) '/main:TrayHostProductionTraceMain' @productionSources $testPath
+ if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_COMPILE_FAILED'}
+ & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $repositoryRoot 'tests\persistence\TrayHostProductionTrace.SelfTest.ps1') -AssemblyPath $currentTracePath -CurrentTracePath $currentTracePath -StaleTracePath $staleTracePath -HungTracePath $hungTracePath -ProductionExePath $productionPath
if($LASTEXITCODE -ne 0){throw 'CCOD_TRAYHOST_PRODUCTION_TRACE_FAILED'}
return
}
diff --git a/tests/trayhost/TrayHostProductionTraceSelfTest.cs b/tests/trayhost/TrayHostProductionTraceSelfTest.cs
index 869102a..49cffd7 100644
--- a/tests/trayhost/TrayHostProductionTraceSelfTest.cs
+++ b/tests/trayhost/TrayHostProductionTraceSelfTest.cs
@@ -52,7 +52,12 @@ public static int Main(string[] args)
#else
bool stale = false;
#endif
- using (ScriptedTraceRuntime runtime = new ScriptedTraceRuntime(witness, stale))
+#if TRAYHOST_TRACE_HANG_AFTER_ACK
+ bool hangAfterShutdownAck = true;
+#else
+ bool hangAfterShutdownAck = false;
+#endif
+ using (ScriptedTraceRuntime runtime = new ScriptedTraceRuntime(witness, stale, hangAfterShutdownAck))
{
return TrayHostChildSession.Run(
Console.OpenStandardInput(),
@@ -85,6 +90,7 @@ internal sealed class ScriptedTraceRuntime : ITrayHostRuntime
{
private readonly TraceWitness _witness;
private readonly bool _stale;
+ private readonly bool _hangAfterShutdownAck;
private readonly ScriptedTracePlatform _platform;
private TrayWindow _window;
private TrayHostApplication _application;
@@ -92,12 +98,14 @@ internal sealed class ScriptedTraceRuntime : ITrayHostRuntime
private bool _exitRequested;
private bool _started;
private bool _disposed;
+ private bool _blockedExitRecorded;
- internal ScriptedTraceRuntime(TraceWitness witness, bool stale)
+ internal ScriptedTraceRuntime(TraceWitness witness, bool stale, bool hangAfterShutdownAck)
{
if (witness == null) { throw new ArgumentNullException("witness"); }
_witness = witness;
_stale = stale;
+ _hangAfterShutdownAck = hangAfterShutdownAck;
_platform = new ScriptedTracePlatform(witness);
}
@@ -124,6 +132,12 @@ public void Initialize(Action menuOpenChanged, Action
public void RequestExit()
{
+ if (_hangAfterShutdownAck)
+ {
+ if (!_blockedExitRecorded) { _blockedExitRecorded = true; _witness.Append("shutdown-ack-written-exit-blocked"); }
+ _platform.Signal();
+ return;
+ }
_exitRequested = true;
_platform.Signal();
}
From 1743de6b40744c0b549d7eb6a3db962afafd36d1 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:48:57 +0800
Subject: [PATCH 041/187] test: gate release on tray receipt trace
---
.github/workflows/ci.yml | 4 +
.github/workflows/release.yml | 4 +
build/TrayHostBuild.psm1 | 17 +-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 310 ++++++++++++++++++
tests/persistence/TrayHostBuild.SelfTest.ps1 | 23 ++
5 files changed, 357 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 46fdb75..27ef0a8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,3 +49,7 @@ jobs:
- name: Run production TrayHost smoke test
shell: pwsh
run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index de23853..16dfd70 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -65,6 +65,10 @@ jobs:
shell: pwsh
run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionOnly
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+
- name: Build CodexRemote-fix portable bundle
shell: pwsh
env:
diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1
index cbfd380..7609bfd 100644
--- a/build/TrayHostBuild.psm1
+++ b/build/TrayHostBuild.psm1
@@ -91,7 +91,22 @@ function Test-CcodTrayHostArtifact {
if([int]$provenance.schemaVersion -ne 1 -or [string]$provenance.version -cne $Version -or [string]$provenance.targetFramework -cne 'net48' -or $null -eq $commit -or $null -eq $timestamp -or $commit.Value -isnot [string] -or $commit.Value -cnotmatch '^[0-9a-f]{40}$' -or $timestamp.Value -isnot [string] -or -not(Test-CcodTrayHostCanonicalUtc $timestamp.Value) -or (-not [string]::IsNullOrWhiteSpace($ExpectedGitCommit) -and $commit.Value -cne $ExpectedGitCommit)){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'}
$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon)){throw 'CCOD_TRAYHOST_ARTIFACT_TAMPERED'}
- $sourceRoot=Join-Path $repo 'src\trayhost';foreach($record in @($provenance.sourceFiles)){ $source=Join-Path $sourceRoot ([string]$record.name);if(-not(Test-Path -LiteralPath $source -PathType Leaf) -or [string]$record.sha256 -cne (Get-CcodTrayHostHash $source)){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'} }
+ $sourceRoot=Join-Path $repo 'src\trayhost'
+ $currentSources=@(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File|Sort-Object Name)
+ $sourceFilesProperty=$provenance.PSObject.Properties['sourceFiles']
+ $sourceRecords=if($null -eq $sourceFilesProperty){@()}else{@($sourceFilesProperty.Value)}
+ if($sourceRecords.Count -ne $currentSources.Count){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
+ $expectedSources=[Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal)
+ foreach($source in $currentSources){$expectedSources.Add($source.Name,(Get-CcodTrayHostHash $source.FullName))}
+ $seenSources=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach($record in $sourceRecords){
+ if($null -eq $record){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
+ $nameProperty=$record.PSObject.Properties['name'];$hashProperty=$record.PSObject.Properties['sha256']
+ if($null -eq $nameProperty -or $null -eq $hashProperty -or $nameProperty.Value -isnot [string] -or $hashProperty.Value -isnot [string]){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
+ $name=[string]$nameProperty.Value;$hash=[string]$hashProperty.Value
+ if(-not $seenSources.Add($name) -or -not $expectedSources.ContainsKey($name) -or $hash -cnotmatch '^[0-9a-f]{64}$' -or $hash -cne $expectedSources[$name]){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
+ }
+ if($seenSources.Count -ne $expectedSources.Count){throw 'CCOD_TRAYHOST_SOURCE_TAMPERED'}
return [pscustomobject][ordered]@{Valid=$true;Executable=$exe;Version=$Version;GitCommit=[string]$commit.Value;Sha256=(Get-CcodTrayHostHash $exe)}
}
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 9c8116a..11c7998 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -4,6 +4,149 @@ $ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$defenderPath = Join-Path $repositoryRoot 'tools\Test-ReleaseDefender.ps1'
+function ConvertFrom-CcodWorkflowScalar {
+ param([Parameter(Mandatory)][string]$Value)
+ $scalar = $Value.Trim()
+ if ($scalar.Length -ge 2 -and (($scalar[0] -ceq '"' -and $scalar[$scalar.Length - 1] -ceq '"') -or ($scalar[0] -ceq "'" -and $scalar[$scalar.Length - 1] -ceq "'"))) {
+ return $scalar.Substring(1, $scalar.Length - 2)
+ }
+ return $scalar
+}
+
+function Get-CcodWorkflowStructure {
+ param([Parameter(Mandatory)][string]$Path)
+ $jobs = [Collections.Generic.List[object]]::new()
+ $currentJob = $null
+ $currentStep = $null
+ $inJobs = $false
+ $inSteps = $false
+ $runBlock = $false
+ $runLines = [Collections.Generic.List[string]]::new()
+ $lines = [IO.File]::ReadAllLines($Path, [Text.UTF8Encoding]::new($false))
+ for ($index = 0; $index -lt $lines.Length; $index++) {
+ $line = $lines[$index]
+ if ($runBlock) {
+ if ([string]::IsNullOrWhiteSpace($line)) {
+ $runLines.Add('')
+ continue
+ }
+ if ($line -cmatch '^ {10,}') {
+ $runLines.Add($line.Substring(10))
+ continue
+ }
+ $currentStep.Run = $runLines -join "`n"
+ $runLines.Clear()
+ $runBlock = $false
+ }
+ if (-not $inJobs) {
+ if ($line -cmatch '^jobs:\s*(?:#.*)?$') { $inJobs = $true }
+ continue
+ }
+ if ($line -cmatch '^ (?[A-Za-z0-9_-]+):\s*(?:#.*)?$') {
+ $currentJob = [pscustomobject]@{ Name = $Matches.name; Steps = [Collections.Generic.List[object]]::new() }
+ $jobs.Add($currentJob)
+ $currentStep = $null
+ $inSteps = $false
+ continue
+ }
+ if ($null -eq $currentJob) { continue }
+ if ($line -cmatch '^ steps:\s*(?:#.*)?$') {
+ $inSteps = $true
+ continue
+ }
+ if (-not $inSteps) { continue }
+ if ($line -cmatch '^ -(?:\s+(?[A-Za-z][A-Za-z0-9_-]*):\s*(?.*))?\s*$') {
+ $currentStep = [pscustomobject]@{ Name = ''; Shell = ''; Run = '' }
+ $currentJob.Steps.Add($currentStep)
+ $key = [string]$Matches.key
+ $value = [string]$Matches.value
+ if ($key -in @('name', 'shell', 'run')) {
+ if ($key -ceq 'run' -and $value.Trim() -in @('|', '|-', '|+')) {
+ $runBlock = $true
+ $runLines.Clear()
+ } else {
+ $currentStep.$key = ConvertFrom-CcodWorkflowScalar $value
+ }
+ }
+ continue
+ }
+ if ($null -eq $currentStep) { continue }
+ if ($line -cmatch '^ (?shell|run):\s*(?.*)$') {
+ $key = $Matches.key
+ $value = $Matches.value.Trim()
+ if ($key -ceq 'run' -and $value -in @('|', '|-', '|+')) {
+ $runBlock = $true
+ $runLines.Clear()
+ } else {
+ $currentStep.$key = ConvertFrom-CcodWorkflowScalar $value
+ }
+ }
+ }
+ if ($runBlock) { $currentStep.Run = $runLines -join "`n" }
+ return [pscustomobject]@{ Jobs = @($jobs) }
+}
+
+function Test-CcodWorkflowStepInvokesBuild {
+ param([Parameter(Mandatory)]$Step)
+ $tokens = $null
+ $parseErrors = $null
+ $ast = [Management.Automation.Language.Parser]::ParseInput([string]$Step.Run, [ref]$tokens, [ref]$parseErrors)
+ if (@($parseErrors).Count -ne 0) { throw 'CCOD_RELEASE_TRACE_GATE_INVALID' }
+ $commands = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true))
+ foreach ($command in $commands) {
+ $commandName = [string]$command.GetCommandName()
+ $elements = @($command.CommandElements | ForEach-Object { $_.Extent.Text })
+ if ($commandName.Replace('\', '/') -ceq './build/build.ps1' -and $elements -ccontains '-Version') { return $true }
+ }
+ return $false
+}
+
+function Test-CcodWorkflowStepInvokesProductionTrace {
+ param([Parameter(Mandatory)]$Step)
+ $tokens = $null
+ $parseErrors = $null
+ $ast = [Management.Automation.Language.Parser]::ParseInput([string]$Step.Run, [ref]$tokens, [ref]$parseErrors)
+ if (@($parseErrors).Count -ne 0) { throw 'CCOD_RELEASE_TRACE_GATE_INVALID' }
+ $commands = @($ast.FindAll({ param($node) $node -is [Management.Automation.Language.CommandAst] }, $true))
+ foreach ($command in $commands) {
+ $commandName = [string]$command.GetCommandName()
+ $elements = @($command.CommandElements | ForEach-Object { $_.Extent.Text })
+ if ($commandName.Replace('\', '/') -ceq './tests/trayhost/Invoke-TrayHostSelfTest.ps1' -and $elements -ccontains '-ProductionTraceOnly') { return $true }
+ }
+ return $false
+}
+
+function Assert-CcodAuthenticatedTraceWorkflowContract {
+ param(
+ [Parameter(Mandatory)][string]$CiPath,
+ [Parameter(Mandatory)][string]$ReleasePath
+ )
+ $traceName = 'Run authenticated TrayHost production trace'
+ $traceRun = './tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly'
+ foreach ($target in @(
+ [pscustomobject]@{ Path = $CiPath; Job = 'validate'; RequireBuildOrder = $false },
+ [pscustomobject]@{ Path = $ReleasePath; Job = 'build'; RequireBuildOrder = $true }
+ )) {
+ $workflow = Get-CcodWorkflowStructure -Path $target.Path
+ $jobs = @($workflow.Jobs | Where-Object { $_.Name -ceq $target.Job })
+ if ($jobs.Count -ne 1) { throw 'CCOD_RELEASE_TRACE_GATE_INVALID' }
+ $allTraceSteps = @($workflow.Jobs | ForEach-Object { @($_.Steps) } | Where-Object { $_.Name -ceq $traceName })
+ $allTraceInvocationSteps = @($workflow.Jobs | ForEach-Object { @($_.Steps) } | Where-Object { Test-CcodWorkflowStepInvokesProductionTrace -Step $_ })
+ $traceSteps = @($jobs[0].Steps | Where-Object { $_.Name -ceq $traceName })
+ if ($allTraceSteps.Count -ne 1 -or $allTraceInvocationSteps.Count -ne 1 -or $traceSteps.Count -ne 1 -or $traceSteps[0].Shell -cne 'pwsh' -or $traceSteps[0].Run -cne $traceRun) {
+ throw 'CCOD_RELEASE_TRACE_GATE_INVALID'
+ }
+ if ($target.RequireBuildOrder) {
+ $buildIndexes = [Collections.Generic.List[int]]::new()
+ for ($stepIndex = 0; $stepIndex -lt $jobs[0].Steps.Count; $stepIndex++) {
+ if (Test-CcodWorkflowStepInvokesBuild -Step $jobs[0].Steps[$stepIndex]) { $buildIndexes.Add($stepIndex) }
+ }
+ $traceIndex = $jobs[0].Steps.IndexOf($traceSteps[0])
+ if ($buildIndexes.Count -ne 1 -or $traceIndex -lt 0 -or $traceIndex -ge $buildIndexes[0]) { throw 'CCOD_RELEASE_TRACE_GATE_INVALID' }
+ }
+ }
+}
+
function New-CcodReleaseFixture {
$root = Join-Path $env:TEMP ('ccod-release-workflow-' + [guid]::NewGuid().ToString('N'))
$null = [IO.Directory]::CreateDirectory($root)
@@ -1333,6 +1476,173 @@ Invoke-CcodTest 'Defender gate requires Internet Zone before a custom scan and w
}
}
+Invoke-CcodTest 'TrayHost artifact validation requires the exact compiled source name and hash set' {
+ Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force
+ $artifact = Join-Path ([IO.Path]::GetTempPath()) ('ccod-trayhost-provenance-fixture-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($artifact) | Out-Null
+ $executable = Join-Path $artifact 'CodexRemote.TrayHost.exe'
+ $config = Join-Path $artifact 'CodexRemote.TrayHost.exe.config'
+ $provenancePath = Join-Path $artifact 'trayhost-build-provenance.json'
+ [IO.File]::WriteAllBytes($executable, [byte[]](7, 5, 2, 2, 1))
+ [IO.File]::WriteAllText($config, '', [Text.UTF8Encoding]::new($false))
+ $sourceRoot = Join-Path $repositoryRoot 'src\trayhost'
+ $sourceRecords = @(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File | Sort-Object Name | ForEach-Object {
+ [ordered]@{ name = $_.Name; sha256 = Get-CcodTestFileSha256 -Path $_.FullName }
+ })
+ Assert-CcodTrue (@($sourceRecords.name) -ccontains 'TrayHostChildSession.cs') 'release provenance fixture includes the shared production child session'
+ Assert-CcodTrue (@($sourceRecords.name) -ccontains 'WindowsTrayHostRuntime.cs') 'release provenance fixture includes the production Windows runtime adapter'
+ $commit = 'c' * 40
+ $baseline = [ordered]@{
+ schemaVersion = 1
+ product = 'CodexRemote-fix'
+ version = '2.5.21'
+ gitCommit = $commit
+ buildTimestampUtc = '2026-08-28T00:00:00.0000000Z'
+ targetFramework = 'net48'
+ sourceFiles = $sourceRecords
+ iconSha256 = Get-CcodTestFileSha256 -Path (Join-Path $repositoryRoot 'assets\codexremote-fix\codexremote-fix.ico')
+ artifactSha256 = Get-CcodTestFileSha256 -Path $executable
+ configArtifactSha256 = Get-CcodTestFileSha256 -Path $config
+ }
+ $baselineJson = $baseline | ConvertTo-Json -Depth 8
+ [IO.File]::WriteAllText($provenancePath, $baselineJson, [Text.UTF8Encoding]::new($false))
+ Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null
+ $mutations = @(
+ [pscustomobject]@{ Name = 'missing shared child session'; Apply = { param($record) $record.sourceFiles = @($record.sourceFiles | Where-Object { $_.name -cne 'TrayHostChildSession.cs' }) } },
+ [pscustomobject]@{ Name = 'duplicate shared child session'; Apply = { param($record) $child = @($record.sourceFiles | Where-Object { $_.name -ceq 'TrayHostChildSession.cs' })[0]; $record.sourceFiles = @($record.sourceFiles | Where-Object { $_.name -cne 'WindowsTrayHostRuntime.cs' }) + @([pscustomobject]@{ name = $child.name; sha256 = $child.sha256 }) } },
+ [pscustomobject]@{ Name = 'different source name set'; Apply = { param($record) $child = @($record.sourceFiles | Where-Object { $_.name -ceq 'TrayHostChildSession.cs' })[0]; $child.name = 'TrayHostChildSession-copy.cs' } },
+ [pscustomobject]@{ Name = 'Windows runtime source hash mismatch'; Apply = { param($record) $runtime = @($record.sourceFiles | Where-Object { $_.name -ceq 'WindowsTrayHostRuntime.cs' })[0]; $runtime.sha256 = '0' * 64 } }
+ )
+ foreach ($mutationCase in $mutations) {
+ $mutated = $baselineJson | ConvertFrom-Json
+ $applyMutation = [scriptblock]$mutationCase.Apply
+ & $applyMutation $mutated
+ [IO.File]::WriteAllText($provenancePath, ($mutated | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false))
+ Assert-CcodThrows {
+ Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null
+ } 'CCOD_TRAYHOST_SOURCE_TAMPERED'
+ }
+ } finally {
+ if (Test-Path -LiteralPath $artifact) { Remove-Item -LiteralPath $artifact -Recurse -Force -ErrorAction SilentlyContinue }
+ }
+}
+
+Invoke-CcodTest 'CI and release build jobs uniquely gate asset production on the authenticated TrayHost trace' {
+ Assert-CcodAuthenticatedTraceWorkflowContract `
+ -CiPath (Join-Path $repositoryRoot '.github\workflows\ci.yml') `
+ -ReleasePath (Join-Path $repositoryRoot '.github\workflows\release.yml')
+}
+
+Invoke-CcodTest 'authenticated trace workflow gate rejects comments wrong jobs duplicates and post-build placement' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-trace-workflow-fixtures-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $ciPath = Join-Path $root 'ci.yml'
+ $releasePath = Join-Path $root 'release.yml'
+ $validCi = @'
+name: fixture CI
+jobs:
+ validate:
+ steps:
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+'@
+ $validRelease = @'
+name: fixture release
+jobs:
+ build:
+ steps:
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+ - name: Build assets
+ shell: pwsh
+ run: |
+ $version = '2.5.21'
+ ./build/build.ps1 -Version $version
+ publish:
+ steps:
+ - name: Publish fixture
+ shell: pwsh
+ run: Write-Output publish
+'@
+ [IO.File]::WriteAllText($ciPath, $validCi, [Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($releasePath, $validRelease, [Text.UTF8Encoding]::new($false))
+ Assert-CcodAuthenticatedTraceWorkflowContract -CiPath $ciPath -ReleasePath $releasePath
+ $invalidFixtures = @(
+ [pscustomobject]@{ Name = 'comment-only CI decoy'; Ci = @'
+name: fixture CI
+jobs:
+ validate:
+ steps:
+ # - name: Run authenticated TrayHost production trace
+ # shell: pwsh
+ # run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+ - name: Validate
+ shell: pwsh
+ run: Write-Output validate
+'@; Release = $validRelease },
+ [pscustomobject]@{ Name = 'release publish-job decoy'; Ci = $validCi; Release = @'
+name: fixture release
+jobs:
+ build:
+ steps:
+ - name: Build assets
+ shell: pwsh
+ run: ./build/build.ps1 -Version 2.5.21
+ publish:
+ steps:
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+'@ },
+ [pscustomobject]@{ Name = 'post-build release gate'; Ci = $validCi; Release = @'
+name: fixture release
+jobs:
+ build:
+ steps:
+ - name: Build assets
+ shell: pwsh
+ run: ./build/build.ps1 -Version 2.5.21
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+'@ },
+ [pscustomobject]@{ Name = 'anonymous pre-build release step'; Ci = $validCi; Release = @'
+name: fixture release
+jobs:
+ build:
+ steps:
+ - run: ./build/build.ps1 -Version 2.5.21
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+'@ },
+ [pscustomobject]@{ Name = 'duplicate CI gate'; Ci = $validCi + "`n" + @'
+ - name: Run authenticated TrayHost production trace
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+'@; Release = $validRelease },
+ [pscustomobject]@{ Name = 'different-name duplicate CI trace invocation'; Ci = $validCi + "`n" + @'
+ - name: Trace alias
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+'@; Release = $validRelease }
+ )
+ foreach ($fixture in $invalidFixtures) {
+ [IO.File]::WriteAllText($ciPath, [string]$fixture.Ci, [Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($releasePath, [string]$fixture.Release, [Text.UTF8Encoding]::new($false))
+ Assert-CcodThrows {
+ Assert-CcodAuthenticatedTraceWorkflowContract -CiPath $ciPath -ReleasePath $releasePath
+ } 'CCOD_RELEASE_TRACE_GATE_INVALID'
+ }
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
+ }
+}
+
Invoke-CcodTest 'package scripts build provenance and workflows retain the release-contract gates' {
$package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json
Assert-CcodTrue ($null -ne $package.scripts.'test:installed-lifecycle') 'package exposes installed-lifecycle deterministic tests'
diff --git a/tests/persistence/TrayHostBuild.SelfTest.ps1 b/tests/persistence/TrayHostBuild.SelfTest.ps1
index 2e9153e..ad74661 100644
--- a/tests/persistence/TrayHostBuild.SelfTest.ps1
+++ b/tests/persistence/TrayHostBuild.SelfTest.ps1
@@ -75,6 +75,29 @@ Invoke-CcodTest 'TrayHost build emits one source-auditable artifact and rejects
$currentCommit=(& git -C $repositoryRoot rev-parse HEAD).Trim().ToLowerInvariant()
$validated=Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit
Assert-CcodEqual $currentCommit ([string]$validated.GitCommit) 'artifact validation binds the current source commit when requested'
+ $expectedSourceNames=@(Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Filter '*.cs' -File|Sort-Object Name|ForEach-Object Name)
+ Assert-CcodEqual ($expectedSourceNames -join ',') (@($provenance.sourceFiles.name) -join ',') 'built provenance records the exact compiled TrayHost source set in canonical order'
+ Assert-CcodTrue ($expectedSourceNames -ccontains 'TrayHostChildSession.cs') 'compiled source provenance includes the shared production child session'
+ Assert-CcodTrue ($expectedSourceNames -ccontains 'WindowsTrayHostRuntime.cs') 'compiled source provenance includes the production Windows runtime adapter'
+ $provenancePath=Join-Path $artifact 'trayhost-build-provenance.json'
+ $baselineJson=[IO.File]::ReadAllText($provenancePath,[Text.UTF8Encoding]::new($false))
+ $mutations=@(
+ [pscustomobject]@{Name='missing shared child session';Apply={param($record)$record.sourceFiles=@($record.sourceFiles|Where-Object{$_.name -cne 'TrayHostChildSession.cs'})}},
+ [pscustomobject]@{Name='duplicate shared child session';Apply={param($record)$child=@($record.sourceFiles|Where-Object{$_.name -ceq 'TrayHostChildSession.cs'})[0];$record.sourceFiles=@($record.sourceFiles|Where-Object{$_.name -cne 'WindowsTrayHostRuntime.cs'})+@([pscustomobject]@{name=$child.name;sha256=$child.sha256})}},
+ [pscustomobject]@{Name='different source name set';Apply={param($record)$child=@($record.sourceFiles|Where-Object{$_.name -ceq 'TrayHostChildSession.cs'})[0];$child.name='TrayHostChildSession-copy.cs'}},
+ [pscustomobject]@{Name='Windows runtime source hash mismatch';Apply={param($record)$runtime=@($record.sourceFiles|Where-Object{$_.name -ceq 'WindowsTrayHostRuntime.cs'})[0];$runtime.sha256='0'*64}}
+ )
+ foreach($mutationCase in $mutations){
+ $mutated=$baselineJson|ConvertFrom-Json
+ $applyMutation=[scriptblock]$mutationCase.Apply
+ & $applyMutation $mutated
+ [IO.File]::WriteAllText($provenancePath,($mutated|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ try{
+ Assert-CcodThrows { Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit|Out-Null } 'CCOD_TRAYHOST_SOURCE_TAMPERED'
+ }finally{
+ [IO.File]::WriteAllText($provenancePath,$baselineJson,[Text.UTF8Encoding]::new($false))
+ }
+ }
$tampered=Join-Path $artifact 'CodexRemote.TrayHost.exe.config'; Add-Content -LiteralPath $tampered -Value 'x'
$threw=$false; try{Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact|Out-Null}catch{$threw=$true}
Assert-CcodTrue $threw 'tampered artifact is rejected'
From 2e0b49af4c940c683f6d37c915138c0ec4c949ae Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:51:41 +0800
Subject: [PATCH 042/187] docs: record release trace gate validation
---
.../task-4-report.md | 179 ++++++++++++++++++
1 file changed, 179 insertions(+)
create mode 100644 .superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md
new file mode 100644
index 0000000..4481998
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md
@@ -0,0 +1,179 @@
+# Task 4 report: fail-closed release trace and TrayHost provenance
+
+## Outcome
+
+Task 4 closes both release-boundary gaps without changing the TrayHost wire,
+CLI, authentication, installer, version metadata, or installed runtime.
+
+- CI job `validate` and release job `build` each contain exactly one step named
+ `Run authenticated TrayHost production trace`, with `shell: pwsh` and the
+ exact run value
+ `./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly`.
+- The release trace is immediately before the asset-producing build step and
+ structurally precedes the real PowerShell command
+ `./build/build.ps1 -Version $version`.
+- `Test-CcodTrayHostArtifact` now compares provenance with the exact current
+ `src/trayhost/*.cs` name-to-SHA-256 set. Missing records, count differences,
+ duplicate names, unexpected or case-different names, malformed hashes, and
+ name/hash mismatches fail with `CCOD_TRAYHOST_SOURCE_TAMPERED`.
+- The production validator never resolves a path supplied by provenance. It
+ hashes the enumerated current sources first, stores them in an ordinal
+ dictionary, and validates records against that bounded set.
+
+Implementation commit:
+
+```text
+1743de6 test: gate release on tray receipt trace
+```
+
+## TDD RED evidence
+
+### Release workflow gate
+
+Only the structural release test had been changed when this command ran:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1
+```
+
+It exited 1 after all preceding release/manifest/Defender cases passed:
+
+```text
+CCOD_SELFTEST_FAILED case=CI-and-release-build-jobs-uniquely-gate-asset-production-on-the-authenticated-TrayHost-trace error=CCOD_RELEASE_TRACE_GATE_INVALID
+CCOD_RELEASE_TRACE_GATE_INVALID
+```
+
+No workflow production file had been changed before this RED. Adding the exact
+CI/release steps then made the same command exit 0.
+
+### Exact source provenance
+
+The provenance fixture and negative tests were added before changing
+`build/TrayHostBuild.psm1`. The same required ReleaseWorkflow command exited 1:
+
+```text
+CCOD_SELFTEST_FAILED case=TrayHost-artifact-validation-requires-the-exact-compiled-source-name-and-hash-set error=ASSERT_THROWS
+ASSERT_THROWS: expected CCOD_TRAYHOST_SOURCE_TAMPERED
+```
+
+The old verifier had accepted provenance with `TrayHostChildSession.cs`
+removed. After the exact-set validator was implemented, the same command
+exited 0 and all four provenance mutations were rejected.
+
+## Workflow evidence
+
+- `.github/workflows/ci.yml:53-55` contains the sole CI trace step under
+ `jobs.validate.steps`.
+- `.github/workflows/release.yml:68-70` contains the sole release trace step
+ under `jobs.build.steps`.
+- `.github/workflows/release.yml:72-78` places it before the build step and
+ the AST-confirmed `build.ps1 -Version` invocation.
+- `ReleaseWorkflow.SelfTest.ps1` parses job and step indentation rather than
+ searching the whole file. It parses each PowerShell `run` body into an AST,
+ counts the real trace/build commands, and compares their step indexes.
+- Controlled negative YAML fixtures reject a comment-only decoy, a trace in
+ `publish`, a post-build trace, an anonymous pre-build command, duplicate
+ named steps, and a duplicate trace invocation under a different name.
+
+The checked-in workflows each had one trace name and one exact trace run value
+at final review.
+
+## Provenance evidence
+
+The final source enumeration contained 19 `.cs` files. Both the lightweight
+release fixture and the real compiled artifact test cover:
+
+- omission of `TrayHostChildSession.cs`;
+- a same-count duplicate of `TrayHostChildSession.cs` replacing
+ `WindowsTrayHostRuntime.cs`;
+- a changed source-name set;
+- a wrong hash for `WindowsTrayHostRuntime.cs`.
+
+The real build test also requires the provenance names to equal the canonical
+sorted source names and explicitly requires both new production source files.
+The validator uses ordinal name comparison and canonical lowercase 64-digit
+SHA-256 values.
+
+## Command ledger
+
+### Required/focused tests
+
+| Command | Result |
+| --- | --- |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` | RED 1: exit 1, missing workflow gate (`CCOD_RELEASE_TRACE_GATE_INVALID`). |
+| same ReleaseWorkflow command after workflow change | exit 0. |
+| same ReleaseWorkflow command after provenance test, before verifier change | RED 2: exit 1, missing source record was wrongly accepted (`ASSERT_THROWS`). |
+| final same ReleaseWorkflow command | exit 0; release workflow self-tests passed, including structural decoys and provenance mutations. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1` | exit 0; six build/provenance cases passed against a real compiled artifact. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly` | exit 0; `TrayHost production child-session trace passed: 3`. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` | exit 0; 20 controlled adapter/temp-root scenarios passed. |
+| all repository `.ps1`/`.psm1` files through `System.Management.Automation.Language.Parser` | exit 0; 92 files parsed. |
+| all `src` JS/MJS files through `node --check` | exit 0; 5 files parsed. |
+| `node.exe tests/CleanroomSelfTest.js` | exit 0; JSON result `ok: true`, 19 cases. |
+| `node.exe tests/PackageCheckerSelfTest.mjs` | exit 0. |
+| `git diff --check` | exit 0; only the repository's existing LF-to-CRLF warnings. |
+
+### Full Persistence/Validate isolation boundary
+
+The direct, non-npm aggregate command was attempted once:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/PersistenceSelfTest.ps1
+```
+
+It exited 1 before reaching Task 4 tests:
+
+```text
+CCOD_SELFTEST_FAILED case=selects-previous-runtime-after-active-exits-before-ready-and-swaps-pointer error=ASSERT_EXACT
+CCOD_PERSISTENCE_SELFTEST_FAILED test=Bootstrap.SelfTest.ps1 exit=1
+```
+
+Running `Bootstrap.SelfTest.ps1` alone twice reproduced
+`previous fallback succeeds expected=[0] actual=[1]`. A corrected zero-wait
+production mutex probe exited 0 and reported:
+
+```text
+CCOD_ACCOUNT_TRANSITION_PROBE outcome=TimedOut
+```
+
+An initial diagnostic probe invocation had a parent-shell quoting error and
+exited 1 before importing the module or touching a mutex; the corrected
+single-quoted probe above is the relevant result.
+
+The installed Supervisor was live, and an independent probe showed that the
+fixed account transition boundary was unavailable. `tests/Validate.ps1 -SkipInstalledPackageCheck`
+was not started because it unconditionally invokes the same Persistence
+aggregate; repeating it could not produce independent evidence. No process was
+stopped and no kernel-name, SID, or safety check was forged to bypass the
+boundary. The existing fully controlled InstalledLifecycle harness and the
+safe pre-Persistence validation components were run instead and passed.
+
+## Scope
+
+Changed implementation/test files:
+
+- `.github/workflows/ci.yml`
+- `.github/workflows/release.yml`
+- `build/TrayHostBuild.psm1`
+- `tests/persistence/ReleaseWorkflow.SelfTest.ps1`
+- `tests/persistence/TrayHostBuild.SelfTest.ps1`
+
+This task did not modify production TrayHost wire fields, message types,
+sequence/authentication rules, CLI selectors, child-session behavior, receipt
+storage, installer behavior, version metadata, README/CHANGELOG content, or
+Task 5 static manifest/version binding.
+
+No real UI, Codex process, installer, installation, release, push, package
+signing, or public operation was started. Tests used temporary artifacts and
+the Task 3 fake-native production trace only.
+
+## Remaining risks
+
+- Full `PersistenceSelfTest.ps1` and therefore `Validate.ps1` still require a
+ fresh run when the real account transition mutex is safely quiescent. This
+ task did not stop the live Supervisor to manufacture a green aggregate.
+- The production trace proves the shared authenticated child-session path with
+ a fake native platform; installed-machine notification-area and Defender
+ validation remain later release acceptance boundaries.
+- Version, Windows manifest identity, README/CHANGELOG, and final asset
+ acceptance are Task 5 scope and were intentionally not changed here.
From f0e388ee6090d1be8d119c78033a6f245764a718 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:06:53 +0800
Subject: [PATCH 043/187] fix: reject bypassed tray trace gates
---
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 66 ++++++++++++++++---
1 file changed, 58 insertions(+), 8 deletions(-)
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 11c7998..fed9492 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -5,7 +5,7 @@ $repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$defenderPath = Join-Path $repositoryRoot 'tools\Test-ReleaseDefender.ps1'
function ConvertFrom-CcodWorkflowScalar {
- param([Parameter(Mandatory)][string]$Value)
+ param([Parameter(Mandatory)][AllowEmptyString()][string]$Value)
$scalar = $Value.Trim()
if ($scalar.Length -ge 2 -and (($scalar[0] -ceq '"' -and $scalar[$scalar.Length - 1] -ceq '"') -or ($scalar[0] -ceq "'" -and $scalar[$scalar.Length - 1] -ceq "'"))) {
return $scalar.Substring(1, $scalar.Length - 2)
@@ -56,29 +56,43 @@ function Get-CcodWorkflowStructure {
}
if (-not $inSteps) { continue }
if ($line -cmatch '^ -(?:\s+(?[A-Za-z][A-Za-z0-9_-]*):\s*(?.*))?\s*$') {
- $currentStep = [pscustomobject]@{ Name = ''; Shell = ''; Run = '' }
+ $currentStep = [pscustomobject]@{ Name = ''; Shell = ''; Run = ''; If = ''; ContinueOnError = '' }
$currentJob.Steps.Add($currentStep)
$key = [string]$Matches.key
$value = [string]$Matches.value
- if ($key -in @('name', 'shell', 'run')) {
+ if ($key -in @('name', 'shell', 'run', 'if', 'continue-on-error')) {
if ($key -ceq 'run' -and $value.Trim() -in @('|', '|-', '|+')) {
$runBlock = $true
$runLines.Clear()
} else {
- $currentStep.$key = ConvertFrom-CcodWorkflowScalar $value
+ $property = switch ($key) {
+ 'name' { 'Name' }
+ 'shell' { 'Shell' }
+ 'run' { 'Run' }
+ 'if' { 'If' }
+ 'continue-on-error' { 'ContinueOnError' }
+ }
+ $currentStep.$property = ConvertFrom-CcodWorkflowScalar $value
}
}
continue
}
if ($null -eq $currentStep) { continue }
- if ($line -cmatch '^ (?shell|run):\s*(?.*)$') {
+ if ($line -cmatch '^ (?name|shell|run|if|continue-on-error):\s*(?.*)$') {
$key = $Matches.key
$value = $Matches.value.Trim()
if ($key -ceq 'run' -and $value -in @('|', '|-', '|+')) {
$runBlock = $true
$runLines.Clear()
} else {
- $currentStep.$key = ConvertFrom-CcodWorkflowScalar $value
+ $property = switch ($key) {
+ 'name' { 'Name' }
+ 'shell' { 'Shell' }
+ 'run' { 'Run' }
+ 'if' { 'If' }
+ 'continue-on-error' { 'ContinueOnError' }
+ }
+ $currentStep.$property = ConvertFrom-CcodWorkflowScalar $value
}
}
}
@@ -133,7 +147,7 @@ function Assert-CcodAuthenticatedTraceWorkflowContract {
$allTraceSteps = @($workflow.Jobs | ForEach-Object { @($_.Steps) } | Where-Object { $_.Name -ceq $traceName })
$allTraceInvocationSteps = @($workflow.Jobs | ForEach-Object { @($_.Steps) } | Where-Object { Test-CcodWorkflowStepInvokesProductionTrace -Step $_ })
$traceSteps = @($jobs[0].Steps | Where-Object { $_.Name -ceq $traceName })
- if ($allTraceSteps.Count -ne 1 -or $allTraceInvocationSteps.Count -ne 1 -or $traceSteps.Count -ne 1 -or $traceSteps[0].Shell -cne 'pwsh' -or $traceSteps[0].Run -cne $traceRun) {
+ if ($allTraceSteps.Count -ne 1 -or $allTraceInvocationSteps.Count -ne 1 -or $traceSteps.Count -ne 1 -or $traceSteps[0].Shell -cne 'pwsh' -or $traceSteps[0].Run -cne $traceRun -or -not [string]::IsNullOrEmpty([string]$traceSteps[0].If) -or -not [string]::IsNullOrEmpty([string]$traceSteps[0].ContinueOnError)) {
throw 'CCOD_RELEASE_TRACE_GATE_INVALID'
}
if ($target.RequireBuildOrder) {
@@ -1534,7 +1548,7 @@ Invoke-CcodTest 'CI and release build jobs uniquely gate asset production on the
-ReleasePath (Join-Path $repositoryRoot '.github\workflows\release.yml')
}
-Invoke-CcodTest 'authenticated trace workflow gate rejects comments wrong jobs duplicates and post-build placement' {
+Invoke-CcodTest 'authenticated trace workflow gate rejects bypass modifiers comments wrong jobs duplicates and post-build placement' {
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-trace-workflow-fixtures-' + [guid]::NewGuid().ToString('N'))
try {
[IO.Directory]::CreateDirectory($root) | Out-Null
@@ -1571,7 +1585,43 @@ jobs:
[IO.File]::WriteAllText($ciPath, $validCi, [Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllText($releasePath, $validRelease, [Text.UTF8Encoding]::new($false))
Assert-CcodAuthenticatedTraceWorkflowContract -CiPath $ciPath -ReleasePath $releasePath
+ $emptyModifierCi = @'
+name: fixture CI
+jobs:
+ validate:
+ steps:
+ - name: Run authenticated TrayHost production trace
+ if:
+ continue-on-error:
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+'@
+ [IO.File]::WriteAllText($ciPath, $emptyModifierCi, [Text.UTF8Encoding]::new($false))
+ Assert-CcodAuthenticatedTraceWorkflowContract -CiPath $ciPath -ReleasePath $releasePath
$invalidFixtures = @(
+ [pscustomobject]@{ Name = 'conditional CI trace step'; Ci = @'
+name: fixture CI
+jobs:
+ validate:
+ steps:
+ - name: Run authenticated TrayHost production trace
+ if: ${{ false }}
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+'@; Release = $validRelease },
+ [pscustomobject]@{ Name = 'continue-on-error release trace step'; Ci = $validCi; Release = @'
+name: fixture release
+jobs:
+ build:
+ steps:
+ - name: Run authenticated TrayHost production trace
+ continue-on-error: true
+ shell: pwsh
+ run: ./tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+ - name: Build assets
+ shell: pwsh
+ run: ./build/build.ps1 -Version 2.5.21
+'@ },
[pscustomobject]@{ Name = 'comment-only CI decoy'; Ci = @'
name: fixture CI
jobs:
From 1d6bd1fa26320201ce80b7bd308f6ff0b17f18d3 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:08:10 +0800
Subject: [PATCH 044/187] docs: record tray trace gate bypass fix
---
.../task-4-report.md | 59 +++++++++++++++++--
1 file changed, 53 insertions(+), 6 deletions(-)
diff --git a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md
index 4481998..4aba45b 100644
--- a/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-async-tray-receipts/task-4-report.md
@@ -20,10 +20,11 @@ CLI, authentication, installer, version metadata, or installed runtime.
hashes the enumerated current sources first, stores them in an ordinal
dictionary, and validates records against that bounded set.
-Implementation commit:
+Implementation commits:
```text
1743de6 test: gate release on tray receipt trace
+f0e388e fix: reject bypassed tray trace gates
```
## TDD RED evidence
@@ -71,9 +72,11 @@ exited 0 and all four provenance mutations were rejected.
- `ReleaseWorkflow.SelfTest.ps1` parses job and step indentation rather than
searching the whole file. It parses each PowerShell `run` body into an AST,
counts the real trace/build commands, and compares their step indexes.
-- Controlled negative YAML fixtures reject a comment-only decoy, a trace in
- `publish`, a post-build trace, an anonymous pre-build command, duplicate
- named steps, and a duplicate trace invocation under a different name.
+- Controlled negative YAML fixtures reject nonempty `if` and
+ `continue-on-error` modifiers, a comment-only decoy, a trace in `publish`, a
+ post-build trace, an anonymous pre-build command, duplicate named steps, and
+ a duplicate trace invocation under a different name. Explicitly empty or
+ absent execution modifiers remain acceptable.
The checked-in workflows each had one trace name and one exact trace run value
at final review.
@@ -103,7 +106,7 @@ SHA-256 values.
| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` | RED 1: exit 1, missing workflow gate (`CCOD_RELEASE_TRACE_GATE_INVALID`). |
| same ReleaseWorkflow command after workflow change | exit 0. |
| same ReleaseWorkflow command after provenance test, before verifier change | RED 2: exit 1, missing source record was wrongly accepted (`ASSERT_THROWS`). |
-| final same ReleaseWorkflow command | exit 0; release workflow self-tests passed, including structural decoys and provenance mutations. |
+| final same ReleaseWorkflow command | exit 0; release workflow self-tests passed, including execution-modifier bypasses, structural decoys, and provenance mutations. |
| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1` | exit 0; six build/provenance cases passed against a real compiled artifact. |
| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly` | exit 0; `TrayHost production child-session trace passed: 3`. |
| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/InstalledLifecycleHarness.SelfTest.ps1` | exit 0; 20 controlled adapter/temp-root scenarios passed. |
@@ -113,7 +116,51 @@ SHA-256 values.
| `node.exe tests/PackageCheckerSelfTest.mjs` | exit 0. |
| `git diff --check` | exit 0; only the repository's existing LF-to-CRLF warnings. |
-### Full Persistence/Validate isolation boundary
+## Fix round 1/5: execution modifiers cannot bypass the trace gate
+
+Independent review found that the first workflow structure retained only
+`Name`, `Shell`, and `Run`. A required trace step could therefore keep every
+checked field while adding `if: ${{ false }}` or
+`continue-on-error: true`; the former skips the trace and the latter tolerates
+its failure.
+
+Two deterministic temporary YAML fixtures were added before changing the
+parser or contract. Running the required command:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1
+```
+
+exited 1 after the real workflow and all preceding release/provenance checks
+passed:
+
+```text
+CCOD_SELFTEST_FAILED case=authenticated-trace-workflow-gate-rejects-comments-wrong-jobs-duplicates-and-post-build-placement error=ASSERT_THROWS
+ASSERT_THROWS: expected CCOD_RELEASE_TRACE_GATE_INVALID
+```
+
+This was the expected RED: the old contract accepted the first conditional
+trace fixture. The step model now retains `If` and `ContinueOnError` from both
+inline and following YAML keys. The exact required trace step must have both
+values empty; any nonempty literal or expression fails with
+`CCOD_RELEASE_TRACE_GATE_INVALID`. A positive fixture proves explicitly empty
+modifiers remain accepted, while the existing valid workflow proves absence
+remains accepted.
+
+Fresh fix-round verification on the final implementation tree:
+
+| Command | Result |
+| --- | --- |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/ReleaseWorkflow.SelfTest.ps1` | exit 0; both execution-modifier bypass fixtures rejected and all release checks passed. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/persistence/TrayHostBuild.SelfTest.ps1` | exit 0; six build/provenance cases passed. |
+| `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/trayhost/Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly` | exit 0; production child-session trace passed 3 cases. |
+
+Fix-round scope was limited to
+`tests/persistence/ReleaseWorkflow.SelfTest.ps1`; no workflow production step,
+TrayHost CLI, wire, authentication, build/provenance implementation, live
+Supervisor, or controlled isolation harness was changed or invoked.
+
+## Full Persistence/Validate isolation boundary
The direct, non-npm aggregate command was attempted once:
From 49f88e2bd511a0f98ce93f48967e4cf3a333f93b Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:42:25 +0800
Subject: [PATCH 045/187] release: prepare CodexRemote-fix v2.5.22
---
.github/workflows/release.yml | 11 +-
CHANGELOG.md | 16 +++
README.md | 17 +--
README.zh-CN.md | 14 +-
build/TrayHostBuild.psm1 | 71 +++++++++-
package.json | 2 +-
src/portable/AssemblyInfo.cs | 4 +-
src/portable/CodexRemote.Portable.manifest | 2 +-
src/trayhost/AssemblyInfo.cs | 4 +-
src/trayhost/CodexRemote.TrayHost.manifest | 2 +-
.../persistence/InstallLifecycle.SelfTest.ps1 | 4 +-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 127 +++++++++++------
tests/persistence/TrayHostBuild.SelfTest.ps1 | 130 +++++++++++++++---
tools/New-GitHubReleaseNotes.ps1 | 48 +++++++
14 files changed, 353 insertions(+), 99 deletions(-)
create mode 100644 tools/New-GitHubReleaseNotes.ps1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 16dfd70..deaf09f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -134,17 +134,8 @@ jobs:
throw "Release tag output is invalid: '$tag'."
}
$version = $tag.Substring(1)
- $changelog = Get-Content -LiteralPath (Join-Path $PWD 'CHANGELOG.md') -Raw -Encoding UTF8
- $releaseSection = [regex]::Match($changelog, '(?ms)^## ' + [regex]::Escape($tag) + '\r?\n(.*?)(?=^## |\z)')
- if (-not $releaseSection.Success) {
- throw "CHANGELOG.md has no release section for $tag"
- }
- $englishSection = [regex]::Match($releaseSection.Groups[1].Value, '(?ms)^### English\r?\n(.*?)(?=^### |\z)')
- if (-not $englishSection.Success) {
- throw "CHANGELOG.md has no English release section for $tag"
- }
$notesPath = Join-Path $env:RUNNER_TEMP ("CodexRemote-fix-$tag-release-notes.md")
- Set-Content -LiteralPath $notesPath -Value ("# CodexRemote-fix $version`n`n" + $englishSection.Groups[1].Value.Trim() + "`n") -Encoding UTF8
+ & (Join-Path $PWD 'tools\New-GitHubReleaseNotes.ps1') -ChangelogPath (Join-Path $PWD 'CHANGELOG.md') -Tag $tag -OutputPath $notesPath | Out-Null
& gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets 2>$null | Out-Null
$exists = $LASTEXITCODE -eq 0
$existingAssets = @()
diff --git a/CHANGELOG.md b/CHANGELOG.md
index efe008c..78dac76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,22 @@ This file keeps the release record. GitHub Release bodies are generated from the
No unreleased changes.
+## v2.5.22
+
+### English
+
+- Bound Setup activation to the requested version and a hash-verified installer payload manifest, then revalidated the active runtime before reporting Ready.
+- Restricted Setup compilation to one generated, inventoried input and rejected unapproved preprocessor constructs before ISCC runs.
+- Added bounded same-root process-tree rereads while preserving strict root and per-process identity checks during close and remote rebind.
+- Correlated native tray actions with the acknowledged presentation revision and durable, bounded terminal diagnostics; stale actions now produce a sanitized support code.
+
+### 简体中文
+
+- 将 Setup 激活绑定到请求版本和经哈希校验的安装载荷 manifest,并在报告 Ready 前重新校验活动 runtime。
+- Setup 只编译一份已清点目录的生成输入;任何未获准的预处理构造都会在 ISCC 运行前被拒绝。
+- 为同一根进程加入有界 process-tree 重读,同时在关闭和远程重绑定时继续严格校验根进程与每个成员的身份。
+- 将原生托盘操作绑定到已确认的 presentation revision 和持久、有界的终态诊断;过期操作会记录清理后的支持代码。
+
## v2.5.21
### English
diff --git a/README.md b/README.md
index 7728700..254b74a 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
> [!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.
+> **Release status:** v2.5.22 is the current stable release. The setup installer and EXE-based portable ZIP are version- and manifest-bound release assets.
CodexRemote-fix enables the UI that ships with Codex Desktop for Windows but is hidden by a runtime defect:
@@ -50,7 +50,7 @@ that existing page and leaves the Codex UI, account authorization, and enrollmen
## Quick start
-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.
+1. Download either `CodexRemote-fix-2.5.22-setup.exe` (recommended installer) or `CodexRemote-fix-2.5.22-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.
@@ -59,7 +59,8 @@ that existing page and leaves the Codex UI, account authorization, and enrollmen
The current portable bundle, checksum, release manifest, and payload manifest are always published on the
[Releases](https://github.com/naipi11/CodexRemote-fix/releases) page.
-Before replacing a portable build, use its installed `Uninstall-CodexControlOtherDevices.ps1`; settings and the DPAPI\r\ndevice-key store stay in place.
+Before replacing a portable build, use its installed `Uninstall-CodexControlOtherDevices.ps1`;
+settings and the DPAPI device-key store stay in place.
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.
@@ -85,12 +86,12 @@ The tray reports two independent, truthful status lines instead of inferring rea
## Releases
-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:
+Every tagged release ships a Windows setup installer and an EXE-entry portable ZIP with separate SHA-256 checksums and release manifests. The portable distribution publishes its payload manifest as an asset; Setup embeds and hash-binds its versioned `installer-payload-manifest.json`. For v2.5.22, download:
-- `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`
+- `CodexRemote-fix-2.5.22-setup.exe` and its checksum
+- `CodexRemote-fix-2.5.22-windows-x64.zip` and its checksum
+- `CodexRemote-fix-2.5.22-payload-manifest.json`
+- `CodexRemote-fix-2.5.22-release-manifest.json` and `CodexRemote-fix-2.5.22-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 c8db449..7d2a952 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -16,7 +16,7 @@
> [!NOTE]
-> **发布状态:** v2.5.21 是当前稳定版。安装包、以 EXE 启动的便携 ZIP、托盘操作、重启并修复流程和常驻远程连接均纳入发布验证门禁。
+> **发布状态:** v2.5.22 是当前稳定版。安装包与以 EXE 启动的便携 ZIP 都是绑定版本和 manifest 的发布资产。
CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、但因运行时缺陷被隐藏的:
@@ -46,7 +46,7 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、
## 快速开始
-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`。
+1. 从 [Releases](https://github.com/naipi11/CodexRemote-fix/releases) 下载 `CodexRemote-fix-2.5.22-setup.exe`(推荐安装包)或便携 ZIP `CodexRemote-fix-2.5.22-windows-x64.zip`;下载并核对对应的 `.sha256.txt`。
2. 选择安装包时直接运行并跟随向导;选择便携 ZIP 时解压到新空目录,然后双击 `CodexRemote-fix.exe`。
两条路径都会校验载荷、执行 Microsoft Defender 自定义扫描,并保留 DPAPI 设备密钥。
@@ -81,12 +81,12 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、
## 发布(Releases)
-每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,各自带 SHA-256 校验文件、release manifest,并共享 payload manifest。`.github/workflows/release.yml` 会在 tag 上自动构建两者,因此 v2.5.21 提供:
+每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,并分别提供 SHA-256 校验文件与 release manifest。便携发行版会外发自己的 payload manifest;Setup 则内嵌并以哈希绑定版本化的 `installer-payload-manifest.json`。v2.5.22 提供:
-- `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`
+- `CodexRemote-fix-2.5.22-setup.exe` 及其校验文件
+- `CodexRemote-fix-2.5.22-windows-x64.zip` 及其校验文件
+- `CodexRemote-fix-2.5.22-payload-manifest.json`
+- `CodexRemote-fix-2.5.22-release-manifest.json` 和 `CodexRemote-fix-2.5.22-setup-release-manifest.json`
GitHub Release 正文只发布英文更新说明;本 README 继续保留中英文使用说明。
完整历史见 [CHANGELOG.md](CHANGELOG.md)。
diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1
index 7609bfd..9215fb4 100644
--- a/build/TrayHostBuild.psm1
+++ b/build/TrayHostBuild.psm1
@@ -35,6 +35,53 @@ function Test-CcodTrayHostOutputDirectory {
if([string]::IsNullOrWhiteSpace($full) -or $full -eq [IO.Path]::GetPathRoot($full).TrimEnd('\')){throw 'CCOD_TRAYHOST_OUTPUT_PATH_INVALID'}
}
+function Assert-CcodNativeVersionContract {
+ param(
+ [Parameter(Mandatory)][string]$RepositoryRoot,
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][ValidateSet('trayhost','portable')][string]$Component
+ )
+ $definition=if($Component -ceq 'trayhost'){
+ [pscustomobject]@{ManifestName='CodexRemote.TrayHost.manifest';IdentityName='CodexRemote.fix.TrayHost';MissingError='CCOD_TRAYHOST_SOURCE_MISSING';VersionError='CCOD_TRAYHOST_VERSION_MISMATCH'}
+ }else{
+ [pscustomobject]@{ManifestName='CodexRemote.Portable.manifest';IdentityName='CodexRemote.fix.Portable';MissingError='CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING';VersionError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH'}
+ }
+ $repo=[IO.Path]::GetFullPath($RepositoryRoot)
+ $sourceRoot=Join-Path $repo ('src\'+$Component)
+ $packagePath=Join-Path $repo 'package.json'
+ $assemblyInfoPath=Join-Path $sourceRoot 'AssemblyInfo.cs'
+ $manifestPath=Join-Path $sourceRoot $definition.ManifestName
+ foreach($required in @($packagePath,$assemblyInfoPath,$manifestPath)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw $definition.MissingError}}
+ try{
+ $package=Get-Content -LiteralPath $packagePath -Raw|ConvertFrom-Json
+ if([string]$package.version -cne $Version){throw $definition.VersionError}
+ $expectedNativeVersion="$Version.0"
+ $assemblyInfo=[IO.File]::ReadAllText($assemblyInfoPath,[Text.UTF8Encoding]::new($false))
+ $assemblyVersions=@([regex]::Matches($assemblyInfo,'(?m)^\s*\[assembly:\s*AssemblyVersion\("(?[^"]+)"\)\]\s*$'))
+ $fileVersions=@([regex]::Matches($assemblyInfo,'(?m)^\s*\[assembly:\s*AssemblyFileVersion\("(?[^"]+)"\)\]\s*$'))
+ if($assemblyVersions.Count -ne 1 -or $fileVersions.Count -ne 1 -or
+ [string]$assemblyVersions[0].Groups['version'].Value -cne $expectedNativeVersion -or
+ [string]$fileVersions[0].Groups['version'].Value -cne $expectedNativeVersion){throw $definition.VersionError}
+ [xml]$manifest=[IO.File]::ReadAllText($manifestPath,[Text.UTF8Encoding]::new($false))
+ $namespace=[Xml.XmlNamespaceManager]::new($manifest.NameTable)
+ $namespace.AddNamespace('asmv1','urn:schemas-microsoft-com:asm.v1')
+ $identities=@($manifest.SelectNodes('/asmv1:assembly/asmv1:assemblyIdentity',$namespace))
+ if($identities.Count -ne 1 -or [string]$identities[0].version -cne $expectedNativeVersion -or
+ [string]$identities[0].name -cne [string]$definition.IdentityName -or [string]$identities[0].type -cne 'win32'){throw $definition.VersionError}
+ return [pscustomobject][ordered]@{SourceRoot=$sourceRoot;AssemblyInfoPath=$assemblyInfoPath;ManifestPath=$manifestPath;NativeVersion=$expectedNativeVersion}
+ }catch{
+ if(([string]$_.FullyQualifiedErrorId -split ',')[0] -like "$($definition.MissingError)*"){throw}
+ throw $definition.VersionError
+ }
+}
+
+function Assert-CcodNativePeVersion {
+ param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ErrorId)
+ $expected="$Version.0"
+ try{$versionInfo=[Diagnostics.FileVersionInfo]::GetVersionInfo([IO.Path]::GetFullPath($Path))}catch{throw $ErrorId}
+ if([string]$versionInfo.FileVersion -cne $expected -or [string]$versionInfo.ProductVersion -cne $expected){throw $ErrorId}
+}
+
function Invoke-CcodTrayHostBuild {
[CmdletBinding()]
param(
@@ -46,11 +93,12 @@ function Invoke-CcodTrayHostBuild {
)
if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_TRAYHOST_VERSION_INVALID'}
$repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($OutputDirectory);Test-CcodTrayHostOutputDirectory $out
+ $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component trayhost
if([string]::IsNullOrWhiteSpace($GitCommit)){$GitCommit=Get-CcodTrayHostGitCommit -RepositoryRoot $repo}else{$GitCommit=$GitCommit.ToLowerInvariant()}
if($GitCommit -cnotmatch '^[0-9a-f]{40}$'){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'}
if([string]::IsNullOrWhiteSpace($BuildTimestampUtc)){$BuildTimestampUtc=[datetime]::UtcNow.ToString('o',[Globalization.CultureInfo]::InvariantCulture)}
if(-not (Test-CcodTrayHostCanonicalUtc $BuildTimestampUtc)){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'}
- $sourceRoot=Join-Path $repo 'src\trayhost';$manifest=Join-Path $sourceRoot 'CodexRemote.TrayHost.manifest';$config=Join-Path $sourceRoot 'CodexRemote.TrayHost.exe.config';$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
+ $sourceRoot=$versionContract.SourceRoot;$manifest=$versionContract.ManifestPath;$config=Join-Path $sourceRoot 'CodexRemote.TrayHost.exe.config';$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
foreach($required in @($manifest,$config,$icon)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_TRAYHOST_SOURCE_MISSING'}}
$sources=@(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File|Sort-Object Name)
if($sources.Count -lt 10){throw 'CCOD_TRAYHOST_SOURCE_INCOMPLETE'}
@@ -65,6 +113,7 @@ function Invoke-CcodTrayHostBuild {
$compilerArgs+=@($sources|ForEach-Object FullName)
& $compiler @compilerArgs
if($LASTEXITCODE -ne 0 -or -not(Test-Path -LiteralPath $exe -PathType Leaf)){throw 'CCOD_TRAYHOST_COMPILE_FAILED'}
+ Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID'
$configOut=Join-Path $work 'CodexRemote.TrayHost.exe.config';Copy-Item -LiteralPath $config -Destination $configOut -Force
$stdoutPath=Join-Path $work 'stdout.txt';$stderrPath=Join-Path $work 'stderr.txt'
$smoke=Start-Process -FilePath $exe -ArgumentList '--headless-smoke' -Wait -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath
@@ -83,15 +132,23 @@ function Invoke-CcodTrayHostBuild {
function Test-CcodTrayHostArtifact {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$RepositoryRoot,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ArtifactDirectory,[string]$ExpectedGitCommit)
+ if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_TRAYHOST_VERSION_INVALID'}
$repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($ArtifactDirectory);Test-CcodTrayHostOutputDirectory $out
+ $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component trayhost
$exe=Join-Path $out 'CodexRemote.TrayHost.exe';$config=Join-Path $out 'CodexRemote.TrayHost.exe.config';$provenancePath=Join-Path $out 'trayhost-build-provenance.json'
foreach($path in @($exe,$config,$provenancePath)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_TRAYHOST_ARTIFACT_MISSING'}}
$provenance=Get-Content -LiteralPath $provenancePath -Raw|ConvertFrom-Json
$commit=$provenance.PSObject.Properties['gitCommit'];$timestamp=$provenance.PSObject.Properties['buildTimestampUtc']
if([int]$provenance.schemaVersion -ne 1 -or [string]$provenance.version -cne $Version -or [string]$provenance.targetFramework -cne 'net48' -or $null -eq $commit -or $null -eq $timestamp -or $commit.Value -isnot [string] -or $commit.Value -cnotmatch '^[0-9a-f]{40}$' -or $timestamp.Value -isnot [string] -or -not(Test-CcodTrayHostCanonicalUtc $timestamp.Value) -or (-not [string]::IsNullOrWhiteSpace($ExpectedGitCommit) -and $commit.Value -cne $ExpectedGitCommit)){throw 'CCOD_TRAYHOST_PROVENANCE_INVALID'}
- $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
- if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon)){throw 'CCOD_TRAYHOST_ARTIFACT_TAMPERED'}
- $sourceRoot=Join-Path $repo 'src\trayhost'
+ $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico';$sourceConfig=Join-Path $versionContract.SourceRoot 'CodexRemote.TrayHost.exe.config'
+ foreach($required in @($icon,$sourceConfig)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_TRAYHOST_SOURCE_MISSING'}}
+ if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or
+ [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or
+ [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon) -or
+ [string]$provenance.manifestSha256 -cne (Get-CcodTrayHostHash $versionContract.ManifestPath) -or
+ [string]$provenance.configSha256 -cne (Get-CcodTrayHostHash $sourceConfig)){throw 'CCOD_TRAYHOST_ARTIFACT_TAMPERED'}
+ Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID'
+ $sourceRoot=$versionContract.SourceRoot
$currentSources=@(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File|Sort-Object Name)
$sourceFilesProperty=$provenance.PSObject.Properties['sourceFiles']
$sourceRecords=if($null -eq $sourceFilesProperty){@()}else{@($sourceFilesProperty.Value)}
@@ -121,12 +178,13 @@ function Invoke-CcodPortableLauncherBuild {
)
if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_PORTABLE_LAUNCHER_VERSION_INVALID'}
$repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($OutputDirectory);Test-CcodTrayHostOutputDirectory $out
+ $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component portable
if([string]::IsNullOrWhiteSpace($GitCommit)){$GitCommit=Get-CcodTrayHostGitCommit -RepositoryRoot $repo}else{$GitCommit=$GitCommit.ToLowerInvariant()}
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';$assemblyInfo=Join-Path $sourceRoot 'AssemblyInfo.cs';
- $manifest=Join-Path $sourceRoot 'CodexRemote.Portable.manifest';$config=Join-Path $sourceRoot 'CodexRemote.Portable.exe.config';
+ $sourceRoot=$versionContract.SourceRoot;$launcher=Join-Path $sourceRoot 'PortableLauncher.cs';$assemblyInfo=$versionContract.AssemblyInfoPath;
+ $manifest=$versionContract.ManifestPath;$config=Join-Path $sourceRoot 'CodexRemote.Portable.exe.config';
$icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico'
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
@@ -139,6 +197,7 @@ function Invoke-CcodPortableLauncherBuild {
$compilerArgs+=@($launcher,$assemblyInfo)
& $compiler @compilerArgs
if($LASTEXITCODE -ne 0 -or -not(Test-Path -LiteralPath $exe -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_COMPILE_FAILED'}
+ Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_VERSION_INVALID'
$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)},[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
diff --git a/package.json b/package.json
index 6edd7f3..cc8df14 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codexremote-fix",
- "version": "2.5.21",
+ "version": "2.5.22",
"private": true,
"description": "CodexRemote-fix persistent current-user tray supervisor for Windows",
"license": "MIT",
diff --git a/src/portable/AssemblyInfo.cs b/src/portable/AssemblyInfo.cs
index 79463e3..06383d5 100644
--- a/src/portable/AssemblyInfo.cs
+++ b/src/portable/AssemblyInfo.cs
@@ -5,6 +5,6 @@
[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: AssemblyVersion("2.5.22.0")]
+[assembly: AssemblyFileVersion("2.5.22.0")]
[assembly: ComVisible(false)]
diff --git a/src/portable/CodexRemote.Portable.manifest b/src/portable/CodexRemote.Portable.manifest
index f97d0fb..915a9e1 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 9055236..8bfb17a 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.21.0")]
-[assembly: AssemblyFileVersion("2.5.21.0")]
+[assembly: AssemblyVersion("2.5.22.0")]
+[assembly: AssemblyFileVersion("2.5.22.0")]
[assembly: ComVisible(false)]
diff --git a/src/trayhost/CodexRemote.TrayHost.manifest b/src/trayhost/CodexRemote.TrayHost.manifest
index 34dea1e..1e54293 100644
--- a/src/trayhost/CodexRemote.TrayHost.manifest
+++ b/src/trayhost/CodexRemote.TrayHost.manifest
@@ -1,6 +1,6 @@
-
+
diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1
index 3f3013a..a7e7c94 100644
--- a/tests/persistence/InstallLifecycle.SelfTest.ps1
+++ b/tests/persistence/InstallLifecycle.SelfTest.ps1
@@ -2585,9 +2585,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.21 release artifact contract' {
+$results += Invoke-CcodTest 'portable builder publishes the exact CodexRemote-fix 2.5.22 release artifact contract' {
$package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json
- Assert-CcodEqual '2.5.21' ([string]$package.version) 'package version is exactly 2.5.21'
+ Assert-CcodEqual '2.5.22' ([string]$package.version) 'package version is exactly 2.5.22'
$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'
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index fed9492..9394583 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -1494,34 +1494,15 @@ Invoke-CcodTest 'TrayHost artifact validation requires the exact compiled source
Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force
$artifact = Join-Path ([IO.Path]::GetTempPath()) ('ccod-trayhost-provenance-fixture-' + [guid]::NewGuid().ToString('N'))
try {
- [IO.Directory]::CreateDirectory($artifact) | Out-Null
- $executable = Join-Path $artifact 'CodexRemote.TrayHost.exe'
- $config = Join-Path $artifact 'CodexRemote.TrayHost.exe.config'
+ $commit = 'c' * 40
+ Invoke-CcodTrayHostBuild -RepositoryRoot $repositoryRoot -Version '2.5.22' -OutputDirectory $artifact -GitCommit $commit -BuildTimestampUtc '2026-08-28T00:00:00.0000000Z' | Out-Null
$provenancePath = Join-Path $artifact 'trayhost-build-provenance.json'
- [IO.File]::WriteAllBytes($executable, [byte[]](7, 5, 2, 2, 1))
- [IO.File]::WriteAllText($config, '', [Text.UTF8Encoding]::new($false))
- $sourceRoot = Join-Path $repositoryRoot 'src\trayhost'
- $sourceRecords = @(Get-ChildItem -LiteralPath $sourceRoot -Filter '*.cs' -File | Sort-Object Name | ForEach-Object {
- [ordered]@{ name = $_.Name; sha256 = Get-CcodTestFileSha256 -Path $_.FullName }
- })
+ $baselineJson = [IO.File]::ReadAllText($provenancePath,[Text.UTF8Encoding]::new($false))
+ $baseline = $baselineJson | ConvertFrom-Json
+ $sourceRecords = @($baseline.sourceFiles)
Assert-CcodTrue (@($sourceRecords.name) -ccontains 'TrayHostChildSession.cs') 'release provenance fixture includes the shared production child session'
Assert-CcodTrue (@($sourceRecords.name) -ccontains 'WindowsTrayHostRuntime.cs') 'release provenance fixture includes the production Windows runtime adapter'
- $commit = 'c' * 40
- $baseline = [ordered]@{
- schemaVersion = 1
- product = 'CodexRemote-fix'
- version = '2.5.21'
- gitCommit = $commit
- buildTimestampUtc = '2026-08-28T00:00:00.0000000Z'
- targetFramework = 'net48'
- sourceFiles = $sourceRecords
- iconSha256 = Get-CcodTestFileSha256 -Path (Join-Path $repositoryRoot 'assets\codexremote-fix\codexremote-fix.ico')
- artifactSha256 = Get-CcodTestFileSha256 -Path $executable
- configArtifactSha256 = Get-CcodTestFileSha256 -Path $config
- }
- $baselineJson = $baseline | ConvertTo-Json -Depth 8
- [IO.File]::WriteAllText($provenancePath, $baselineJson, [Text.UTF8Encoding]::new($false))
- Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null
+ Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null
$mutations = @(
[pscustomobject]@{ Name = 'missing shared child session'; Apply = { param($record) $record.sourceFiles = @($record.sourceFiles | Where-Object { $_.name -cne 'TrayHostChildSession.cs' }) } },
[pscustomobject]@{ Name = 'duplicate shared child session'; Apply = { param($record) $child = @($record.sourceFiles | Where-Object { $_.name -ceq 'TrayHostChildSession.cs' })[0]; $record.sourceFiles = @($record.sourceFiles | Where-Object { $_.name -cne 'WindowsTrayHostRuntime.cs' }) + @([pscustomobject]@{ name = $child.name; sha256 = $child.sha256 }) } },
@@ -1534,7 +1515,7 @@ Invoke-CcodTest 'TrayHost artifact validation requires the exact compiled source
& $applyMutation $mutated
[IO.File]::WriteAllText($provenancePath, ($mutated | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false))
Assert-CcodThrows {
- Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.21' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null
+ Test-CcodTrayHostArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $commit | Out-Null
} 'CCOD_TRAYHOST_SOURCE_TAMPERED'
}
} finally {
@@ -1718,6 +1699,7 @@ Invoke-CcodTest 'package scripts build provenance and workflows retain the relea
Assert-CcodTrue ($release -match 'download-artifact') 'release promotion downloads a previously built candidate'
Assert-CcodTrue ($release -match 'test:release-contract') 'release promotion checks the release contract'
Assert-CcodTrue ($release -match 'release-manifest') 'release promotion uploads the bound release manifest'
+ Assert-CcodTrue ($release -match 'New-GitHubReleaseNotes\.ps1') 'release publication uses the behavior-tested English notes extractor'
Assert-CcodTrue ($release -match 'gh release download') 'existing release assets are downloaded before any publication decision'
Assert-CcodTrue (-not ($release -match 'gh release upload[^\r\n]*--clobber')) 'release publication never overwrites an existing asset'
Assert-CcodTrue ($release -match 'Read back published GitHub release assets') 'release publication re-downloads every uploaded asset for hash read-back'
@@ -1726,31 +1708,96 @@ Invoke-CcodTest 'package scripts build provenance and workflows retain the relea
}
$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' {
+Invoke-CcodTest 'release notes extraction emits only the target release English section' {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-release-notes-fixture-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $changelogPath = Join-Path $root 'CHANGELOG.md'
+ $notesPath = Join-Path $root 'notes.md'
+ $fixture = @'
+# Fixture release notes
+
+## Unreleased
+
+### English
+
+- UNRELEASED_DECOY
+
+## v2.5.23
+
+### English
+
+- NEWER_DECOY
+
+## v2.5.22
+
+### English
+
+- Target English note.
+- Second target English note.
+
+### 简体中文
+
+- CHINESE_DECOY
+
+## v2.5.21
+
+### English
+
+- OLDER_DECOY
+'@
+ [IO.File]::WriteAllText($changelogPath,$fixture.Replace("`n","`r`n"),[Text.UTF8Encoding]::new($false))
+ & (Join-Path $repositoryRoot 'tools\New-GitHubReleaseNotes.ps1') -ChangelogPath $changelogPath -Tag 'v2.5.22' -OutputPath $notesPath | Out-Null
+ $actual = [IO.File]::ReadAllText($notesPath,[Text.UTF8Encoding]::new($false))
+ Assert-CcodEqual "# CodexRemote-fix 2.5.22`n`n- Target English note.`n- Second target English note.`n" $actual 'release body contains exactly the current target English notes'
+ foreach ($decoy in @('UNRELEASED_DECOY','NEWER_DECOY','CHINESE_DECOY','OLDER_DECOY')) {
+ Assert-CcodTrue (-not $actual.Contains($decoy)) "release body excludes $decoy"
+ }
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
+ }
+}
+
+Invoke-CcodTest '2.5.22 source metadata and documentation match the release contract' {
$package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json
- Assert-CcodEqual '2.5.21' ([string]$package.version) 'package metadata is the 2.5.21 release'
+ Assert-CcodEqual '2.5.22' ([string]$package.version) 'package metadata is the 2.5.22 release'
+ foreach ($nativeComponent in @('trayhost','portable')) {
+ $assemblyInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot ("src\{0}\AssemblyInfo.cs" -f $nativeComponent)) -Raw
+ Assert-CcodTrue ($assemblyInfo -cmatch 'AssemblyVersion\("2\.5\.22\.0"\)') "$nativeComponent assembly version is 2.5.22.0"
+ Assert-CcodTrue ($assemblyInfo -cmatch 'AssemblyFileVersion\("2\.5\.22\.0"\)') "$nativeComponent file version is 2.5.22.0"
+ }
+ $trayHostManifest = Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\trayhost\CodexRemote.TrayHost.manifest') -Raw
+ Assert-CcodTrue ($trayHostManifest -cmatch '.*?)(?=^## |\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'
+ $releaseSection = [regex]::Match($changelog, '(?ms)^## v2\.5\.22\s*\r?\n(?.*?)(?=^## |\z)')
+ Assert-CcodTrue $releaseSection.Success 'v2.5.22 release section exists'
+ Assert-CcodTrue ($releaseSection.Groups['body'].Value -match '(?m)^### English\s*$') 'v2.5.22 changelog has concise English release notes'
+ $englishSection = [regex]::Match($releaseSection.Groups['body'].Value,'(?ms)^### English\s*\r?\n(?.*?)(?=^### |\z)')
+ $englishBullets = @($englishSection.Groups['body'].Value -split '\r?\n' | Where-Object { $_ -cmatch '^- ' })
+ Assert-CcodTrue ($englishSection.Success -and $englishBullets.Count -ge 3 -and $englishBullets.Count -le 5) 'v2.5.22 English release notes contain three to five bullets'
$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 ($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 ($readme.Contains('v2.5.22 is the current stable release')) 'English README marks v2.5.22 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-2.5.22-setup.exe')) 'English Quick Start names the setup installer'
+ Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.22-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'
+ Assert-CcodTrue (-not $readme.Contains('\r\n')) 'English README contains no literal CRLF escape text'
+ Assert-CcodTrue ($readme.Contains('The portable distribution publishes its payload manifest as an asset')) 'English README identifies the externally published portable payload manifest'
+ Assert-CcodTrue ($readme.Contains('Setup embeds and hash-binds its versioned `installer-payload-manifest.json`')) 'English README identifies the embedded hash-bound setup payload manifest'
+ Assert-CcodTrue (-not $readme.Contains('shared payload manifest')) 'English README does not claim setup and portable share one payload manifest'
$readmeZh = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.zh-CN.md') -Raw -Encoding UTF8
- Assert-CcodTrue ($readmeZh -match 'v2\.5\.21 \u662F\u5F53\u524D\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.21 stable'
+ Assert-CcodTrue ($readmeZh -match 'v2\.5\.22 \u662F\u5F53\u524D\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.22 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-2.5.22-setup.exe')) 'Chinese Quick Start names the setup installer'
+ Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.22-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'
+ Assert-CcodTrue ($readmeZh -match '\u4FBF\u643A\u53D1\u884C\u7248\u4F1A\u5916\u53D1\u81EA\u5DF1\u7684 payload manifest') 'Chinese README identifies the externally published portable payload manifest'
+ Assert-CcodTrue ($readmeZh -match 'Setup \u5219\u5185\u5D4C\u5E76\u4EE5\u54C8\u5E0C\u7ED1\u5B9A\u7248\u672C\u5316\u7684 `installer-payload-manifest\.json`') 'Chinese README identifies the embedded hash-bound setup payload manifest'
$technical = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\TECHNICAL.md') -Raw
Assert-CcodTrue ($technical.Contains('PortableUninstallFinalizer.ps1')) 'technical documentation records the staged portable finalizer'
$security = Get-Content -LiteralPath (Join-Path $repositoryRoot 'SECURITY.md') -Raw
diff --git a/tests/persistence/TrayHostBuild.SelfTest.ps1 b/tests/persistence/TrayHostBuild.SelfTest.ps1
index ad74661..d78ed82 100644
--- a/tests/persistence/TrayHostBuild.SelfTest.ps1
+++ b/tests/persistence/TrayHostBuild.SelfTest.ps1
@@ -4,19 +4,111 @@ $ErrorActionPreference='Stop'
$repositoryRoot=Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$lockPath=Join-Path $repositoryRoot 'build\trayhost-packages.lock.json'
-Invoke-CcodTest '2.5.21 source metadata binds the package and native assembly identities' {
+Invoke-CcodTest 'TrayHost artifact validation binds the manifest and embedded PE version to the requested release' {
+ Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force
+ $package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json
+ $version = [string]$package.version
+ $nativeVersion = "$version.0"
+ $artifact = Join-Path $env:TEMP ('ccod-native-version-artifact-' + [Guid]::NewGuid().ToString('N'))
+ $fixtureRoot = Join-Path $env:TEMP ('ccod-native-version-repository-' + [Guid]::NewGuid().ToString('N'))
+ try {
+ Invoke-CcodTrayHostBuild -RepositoryRoot $repositoryRoot -Version $version -OutputDirectory $artifact -GitCommit ('b' * 40) -BuildTimestampUtc '2026-08-28T00:00:00.0000000Z' | Out-Null
+ [IO.Directory]::CreateDirectory((Join-Path $fixtureRoot 'src')) | Out-Null
+ Copy-Item -LiteralPath (Join-Path $repositoryRoot 'src\trayhost') -Destination (Join-Path $fixtureRoot 'src\trayhost') -Recurse
+ [IO.Directory]::CreateDirectory((Join-Path $fixtureRoot 'assets\codexremote-fix')) | Out-Null
+ Copy-Item -LiteralPath (Join-Path $repositoryRoot 'assets\codexremote-fix\codexremote-fix.ico') -Destination (Join-Path $fixtureRoot 'assets\codexremote-fix\codexremote-fix.ico')
+ Copy-Item -LiteralPath (Join-Path $repositoryRoot 'package.json') -Destination (Join-Path $fixtureRoot 'package.json')
+
+ $manifestPath = Join-Path $fixtureRoot 'src\trayhost\CodexRemote.TrayHost.manifest'
+ $manifestBaseline = [IO.File]::ReadAllText($manifestPath,[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($manifestPath,$manifestBaseline.Replace('',''),[Text.UTF8Encoding]::new($false))
+ $manifestHashError = ''
+ try { Test-CcodTrayHostArtifact -RepositoryRoot $fixtureRoot -Version $version -ArtifactDirectory $artifact -ExpectedGitCommit ('b' * 40) | Out-Null }
+ catch { $manifestHashError = ([string]$_.FullyQualifiedErrorId -split ',')[0] }
+
+ [IO.File]::WriteAllText($manifestPath,$manifestBaseline.Replace($nativeVersion,'9.9.9.0'),[Text.UTF8Encoding]::new($false))
+ $manifestError = ''
+ try { Test-CcodTrayHostArtifact -RepositoryRoot $fixtureRoot -Version $version -ArtifactDirectory $artifact -ExpectedGitCommit ('b' * 40) | Out-Null }
+ catch { $manifestError = ([string]$_.FullyQualifiedErrorId -split ',')[0] }
+
+ [IO.File]::WriteAllText($manifestPath,$manifestBaseline.Replace($nativeVersion,'9.9.9.0'),[Text.UTF8Encoding]::new($false))
+ $assemblyPath = Join-Path $fixtureRoot 'src\trayhost\AssemblyInfo.cs'
+ $assembly = [IO.File]::ReadAllText($assemblyPath,[Text.UTF8Encoding]::new($false)).Replace($nativeVersion,'9.9.9.0')
+ [IO.File]::WriteAllText($assemblyPath,$assembly,[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $fixtureRoot 'package.json'),'{"version":"9.9.9"}',[Text.UTF8Encoding]::new($false))
+ $provenancePath = Join-Path $artifact 'trayhost-build-provenance.json'
+ $provenance = Get-Content -LiteralPath $provenancePath -Raw | ConvertFrom-Json
+ $provenance.version = '9.9.9'
+ $assemblyRecord = @($provenance.sourceFiles | Where-Object { $_.name -ceq 'AssemblyInfo.cs' })[0]
+ $assemblyRecord.sha256 = Get-CcodTestFileSha256 -Path $assemblyPath
+ $provenance.manifestSha256 = Get-CcodTestFileSha256 -Path $manifestPath
+ [IO.File]::WriteAllText($provenancePath,($provenance | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ $peError = ''
+ try { Test-CcodTrayHostArtifact -RepositoryRoot $fixtureRoot -Version '9.9.9' -ArtifactDirectory $artifact -ExpectedGitCommit ('b' * 40) | Out-Null }
+ catch { $peError = ([string]$_.FullyQualifiedErrorId -split ',')[0] }
+
+ Assert-CcodEqual 'CCOD_TRAYHOST_ARTIFACT_TAMPERED|CCOD_TRAYHOST_VERSION_MISMATCH|CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID' "$manifestHashError|$manifestError|$peError" 'validator binds manifest bytes and rejects static or PE version drift'
+ } finally {
+ if (Test-Path -LiteralPath $artifact) { Remove-Item -LiteralPath $artifact -Recurse -Force -ErrorAction SilentlyContinue }
+ if (Test-Path -LiteralPath $fixtureRoot) { Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue }
+ }
+}
+
+Invoke-CcodTest 'native build entrypoints reject mismatched package AssemblyInfo and manifest versions before compilation' {
+ Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force
+ $root = Join-Path $env:TEMP ('ccod-native-version-contract-' + [Guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $cases = @(
+ [pscustomobject]@{ Component='trayhost'; PackageVersion='9.9.9'; AssemblyVersion='2.5.22.0'; FileVersion='2.5.22.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_TRAYHOST_VERSION_MISMATCH' },
+ [pscustomobject]@{ Component='trayhost'; PackageVersion='2.5.22'; AssemblyVersion='9.9.9.0'; FileVersion='2.5.22.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_TRAYHOST_VERSION_MISMATCH' },
+ [pscustomobject]@{ Component='trayhost'; PackageVersion='2.5.22'; AssemblyVersion='2.5.22.0'; FileVersion='9.9.9.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_TRAYHOST_VERSION_MISMATCH' },
+ [pscustomobject]@{ Component='trayhost'; PackageVersion='2.5.22'; AssemblyVersion='2.5.22.0'; FileVersion='2.5.22.0'; ManifestVersion='9.9.9.0'; ExpectedError='CCOD_TRAYHOST_VERSION_MISMATCH' },
+ [pscustomobject]@{ Component='portable'; PackageVersion='9.9.9'; AssemblyVersion='2.5.22.0'; FileVersion='2.5.22.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH' },
+ [pscustomobject]@{ Component='portable'; PackageVersion='2.5.22'; AssemblyVersion='9.9.9.0'; FileVersion='2.5.22.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH' },
+ [pscustomobject]@{ Component='portable'; PackageVersion='2.5.22'; AssemblyVersion='2.5.22.0'; FileVersion='9.9.9.0'; ManifestVersion='2.5.22.0'; ExpectedError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH' },
+ [pscustomobject]@{ Component='portable'; PackageVersion='2.5.22'; AssemblyVersion='2.5.22.0'; FileVersion='2.5.22.0'; ManifestVersion='9.9.9.0'; ExpectedError='CCOD_PORTABLE_LAUNCHER_VERSION_MISMATCH' }
+ )
+ foreach ($case in $cases) {
+ [IO.File]::WriteAllText((Join-Path $root 'package.json'),("{{`"version`":`"{0}`"}}" -f $case.PackageVersion),[Text.UTF8Encoding]::new($false))
+ $sourceRoot = Join-Path $root ('src\' + $case.Component)
+ [IO.Directory]::CreateDirectory($sourceRoot) | Out-Null
+ $assemblyInfo = "using System.Reflection;`r`n[assembly: AssemblyVersion(`"$($case.AssemblyVersion)`")]`r`n[assembly: AssemblyFileVersion(`"$($case.FileVersion)`")]`r`n"
+ [IO.File]::WriteAllText((Join-Path $sourceRoot 'AssemblyInfo.cs'),$assemblyInfo,[Text.UTF8Encoding]::new($false))
+ $manifestName = if ($case.Component -ceq 'trayhost') { 'CodexRemote.TrayHost.manifest' } else { 'CodexRemote.Portable.manifest' }
+ $identityName = if ($case.Component -ceq 'trayhost') { 'CodexRemote.fix.TrayHost' } else { 'CodexRemote.fix.Portable' }
+ $manifest = ""
+ [IO.File]::WriteAllText((Join-Path $sourceRoot $manifestName),$manifest,[Text.UTF8Encoding]::new($false))
+ $actualError = ''
+ try {
+ if ($case.Component -ceq 'trayhost') {
+ Invoke-CcodTrayHostBuild -RepositoryRoot $root -Version '2.5.22' -OutputDirectory (Join-Path $root 'trayhost-out') -GitCommit ('a' * 40) -BuildTimestampUtc '2026-08-28T00:00:00.0000000Z' | Out-Null
+ } else {
+ Invoke-CcodPortableLauncherBuild -RepositoryRoot $root -Version '2.5.22' -OutputDirectory (Join-Path $root 'portable-out') -GitCommit ('a' * 40) -BuildTimestampUtc '2026-08-28T00:00:00.0000000Z' | Out-Null
+ }
+ } catch {
+ $actualError = ([string]$_.FullyQualifiedErrorId -split ',')[0]
+ }
+ Assert-CcodEqual ([string]$case.ExpectedError) $actualError "$($case.Component) rejects $($case.PackageVersion)/$($case.AssemblyVersion)/$($case.FileVersion)/$($case.ManifestVersion) before resolving compiler inputs"
+ }
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
+ }
+}
+
+Invoke-CcodTest '2.5.22 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.21' ([string]$package.version) 'package version is the 2.5.21 release version'
+ Assert-CcodEqual '2.5.22' ([string]$package.version) 'package version is the 2.5.22 release version'
$assemblyInfo=Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\trayhost\AssemblyInfo.cs') -Raw
- 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'
+ Assert-CcodTrue ($assemblyInfo -match 'AssemblyVersion\("2\.5\.22\.0"\)') 'TrayHost assembly version is 2.5.22.0'
+ Assert-CcodTrue ($assemblyInfo -match 'AssemblyFileVersion\("2\.5\.22\.0"\)') 'TrayHost file version is 2.5.22.0'
$manifest=Get-Content -LiteralPath (Join-Path $repositoryRoot 'src\trayhost\CodexRemote.TrayHost.manifest') -Raw
- Assert-CcodTrue ($manifest -match '.*?)(?=^## |\z)'
+$releaseSections = @([regex]::Matches($text,$releasePattern))
+if ($releaseSections.Count -ne 1) {
+ throw 'CCOD_RELEASE_NOTES_RELEASE_SECTION_INVALID'
+}
+$englishSections = @([regex]::Matches($releaseSections[0].Groups['body'].Value,'(?ms)^### English[ \t]*\r?\n(?.*?)(?=^### |\z)'))
+if ($englishSections.Count -ne 1) {
+ throw 'CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID'
+}
+$english = $englishSections[0].Groups['body'].Value.Trim().Replace("`r`n","`n").Replace("`r","`n")
+if ([string]::IsNullOrWhiteSpace($english)) {
+ throw 'CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID'
+}
+
+$version = $Tag.Substring(1)
+$notes = "# CodexRemote-fix $version`n`n$english`n"
+[IO.File]::WriteAllText($output,$notes,[Text.UTF8Encoding]::new($false))
+
+[pscustomobject][ordered]@{
+ Tag = $Tag
+ Version = $version
+ OutputPath = $output
+}
From 9799d4fd2f0b9a17eb7ee2fd6738dcb7edbfedb0 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:45:10 +0800
Subject: [PATCH 046/187] docs: record v2.5.22 release validation
---
.../task-5-report.md | 212 ++++++++++++++++++
README.md | 2 +-
README.zh-CN.md | 2 +-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 4 +-
4 files changed, 216 insertions(+), 4 deletions(-)
create mode 100644 .superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
new file mode 100644
index 0000000..9bc617f
--- /dev/null
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
@@ -0,0 +1,212 @@
+# Task 5 report — v2.5.22 release metadata, documentation, and contract
+
+## Scope and safety boundary
+
+- Worktree: `C:\Users\33384\Documents\Codex-Control-other-devices-Windows\.worktrees\codex-v2522-install-runtime-reliability`
+- Starting reviewed HEAD: `1d6bd1f` (`docs: record tray trace gate bypass fix`)
+- Implemented only release metadata, native build/artifact version verification,
+ release-coupled tests, stable README release references, CHANGELOG notes, and
+ deterministic GitHub release-note extraction.
+- Did not install or run the installer, start a real Codex/UI process, stop the
+ real Supervisor/TrayHost, publish, push, sign, tag, or write WindowsApps/DPAPI.
+
+## TDD evidence
+
+### Release version contract RED
+
+After changing only the ReleaseWorkflow contract to require v2.5.22:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 1
+ASSERT_EQUAL: package metadata is the 2.5.22 release
+expected=[2.5.22] actual=[2.5.21]
+```
+
+All preceding release gates, including real ISCC compilation, had passed. This
+proved the new contract failed for the intended missing release metadata.
+
+### Native static-metadata gate RED
+
+The next test called the real TrayHost/portable build entrypoints against
+controlled package, AssemblyInfo, and manifest mismatches before production
+changes:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\TrayHostBuild.SelfTest.ps1
+exit 1
+expected=[CCOD_TRAYHOST_VERSION_MISMATCH]
+actual=[CCOD_TRAYHOST_SOURCE_MISSING]
+```
+
+This proved a requested version mismatch was not rejected before compiler input
+resolution.
+
+### Artifact manifest and PE version RED
+
+A real compiled TrayHost artifact was validated against a copied repository
+contract. The test changed the manifest and then aligned package/static
+metadata/provenance to a false `9.9.9` while retaining the original PE:
+
+```text
+exit 1
+expected=[CCOD_TRAYHOST_VERSION_MISMATCH|CCOD_TRAYHOST_ARTIFACT_VERSION_INVALID]
+actual=[|]
+```
+
+The old validator accepted both mutations. The final mutation coverage also
+changes valid-version manifest bytes without changing the identity, proving the
+manifest SHA binding independently.
+
+### Release-note extraction RED/GREEN
+
+The behavior test first failed because the workflow extractor did not exist.
+After the tool was added, a CRLF fixture produced a second intentional RED
+because the target English section retained CRLF bytes. The final extractor
+normalizes output to LF and emits only the exact target English section; the
+test excludes Unreleased, newer-version, Chinese, and older-version decoys.
+
+## Implementation
+
+### Version sources
+
+- `package.json`: `2.5.22`
+- TrayHost AssemblyVersion/FileVersion and Windows manifest: `2.5.22.0`
+- Portable launcher AssemblyVersion/FileVersion and Windows manifest:
+ `2.5.22.0`
+
+Historical CHANGELOG entries and non-release presentation/runtime fixtures
+remain unchanged. In particular, the TrayHost native `2.5.21` strings are
+acknowledged-presentation test inputs, `InstalledLifecycleHarness` uses
+`2.5.21-new` runtime fixtures, and ReleaseWorkflow retains an intentional old
+payload/structural workflow fixture.
+
+### Fail-closed native build and artifact verification
+
+`build/TrayHostBuild.psm1` now:
+
+- requires requested `-Version` to match `package.json`;
+- requires exactly one matching AssemblyVersion and AssemblyFileVersion;
+- parses the Windows assembly manifest and requires the exact identity name,
+ `win32` type, and `.0` value;
+- applies that contract to both TrayHost and portable build entrypoints before
+ compiler/reference resolution;
+- reads the compiled PE FileVersion and ProductVersion and requires exact
+ `.0` values before publishing either artifact;
+- makes `Test-CcodTrayHostArtifact` repeat the static contract, bind the source
+ manifest/config hashes from provenance, and reject PE version drift.
+
+Regression cases independently mutate package version, AssemblyVersion,
+AssemblyFileVersion, manifest version, manifest bytes, provenance/static
+version, and PE version relationship.
+
+### Documentation and release notes
+
+- README/README.zh-CN now name v2.5.22 only in the stable status, Quick Start,
+ and release-asset descriptions; no What's new block or internal error code
+ was added.
+- Removed the English README's literal `\r\n` text.
+- Corrected the asset contract: portable publishes its payload manifest as an
+ external asset; Setup embeds and hash-binds its versioned
+ `installer-payload.manifest.json`.
+- CHANGELOG has four concise v2.5.22 English bullets followed by Chinese notes.
+- `.github/workflows/release.yml` calls the behavior-tested
+ `tools/New-GitHubReleaseNotes.ps1`, so the GitHub body contains only the
+ requested release's English section.
+
+## Verification before primary commit
+
+```text
+TrayHostBuild.SelfTest.ps1
+ exit 0; 8 cases
+
+ReleaseWorkflow.SelfTest.ps1
+ exit 0; all cases, including real ISCC, real TrayHost PE validation,
+ CRLF-to-LF English-only release notes, and v2.5.22 documentation contract
+
+Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+ exit 0; 3 production child-session trace cases
+
+Invoke-TrayHostSelfTest.ps1 -NativeOnly
+ exit 0; 25 native fake-platform cases
+
+PowerShell AST parse
+ exit 0; TrayHostBuild module, three release-coupled test scripts,
+ and New-GitHubReleaseNotes.ps1
+
+version audit
+ package=2.5.22
+ tray Assembly/File/manifest=2.5.22.0
+ portable Assembly/File/manifest=2.5.22.0
+```
+
+### InstallLifecycle environment boundary
+
+The full requested test was run without stopping the real Supervisor:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+exit 1 after approximately 67 seconds
+case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal
+error=CCOD_LIFECYCLE_LEASE_TIMEOUT
+message=Lifecycle ownership mutex timed out
+```
+
+That failing case begins at line 1876. The v2.5.22 release-coupled builder case
+begins at line 2588, so it was not reached and is not reported as passing. No
+mutex bypass or real Supervisor stop was attempted. `npm test` / aggregate
+Validate were not run because they would recurse into the same known live
+mutex boundary, contrary to the task's safety constraint.
+
+## Clean release build
+
+The primary release commit was created as:
+
+```text
+49f88e2 release: prepare CodexRemote-fix v2.5.22
+```
+
+From that clean HEAD, the normal release workflow completed:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File build\build.ps1 -Version 2.5.22
+exit 0
+```
+
+Generated assets remained under `build/dist` only:
+
+```text
+CodexRemote-fix-2.5.22-windows-x64.zip
+ 55d0b0e383faa208f0795dbeb6b2ae01051807fdd2d40ddea115951ae6fae02b
+CodexRemote-fix-2.5.22-setup.exe
+ 298dd4c821b83df3bcca32ffe0193bcaa55eff1ddcdf3115680198c635d668a0
+CodexRemote-fix-2.5.22-payload-manifest.json
+ 08cbbd5eb0093b15d461f42912e1395cf2c90da0c1da503b41b9bda1293b2492
+CodexRemote-fix-2.5.22-release-manifest.json
+ e313e9943888f3b346d20f7d6627fbf60254de40c6518d47e7c3ba46bd6fd18e
+CodexRemote-fix-2.5.22-setup-release-manifest.json
+ b7a95f58b0cb3178a3a2e7d208b5ba931c2fe7d391970ec297c1b8c70db17579
+CodexRemote-fix-2.5.22-trayhost-provenance.json
+ 95bc8535ba54b4a0bfc4356fd4162e71800b599b0a1b42476c0ce9912ab0ac40
+```
+
+Both release manifests passed `Test-CcodReleaseAssetManifest` for expected
+version `2.5.22`; portable reported distribution `portable-zip`. Both manifests
+and TrayHost provenance bound commit
+`49f88e2bd511a0f98ce93f48967e4cf3a333f93b`. The generated TrayHost and
+portable PE FileVersion/ProductVersion values were all `2.5.22.0`.
+
+The installer was compiled but never executed. No asset was installed,
+published, pushed, signed, or tagged. A post-build documentation precision fix
+corrected the actual embedded filename from the design spelling
+`installer-payload-manifest.json` to the implementation spelling
+`installer-payload.manifest.json`; therefore these local artifacts are retained
+as build evidence and must not be treated as immutable publish candidates for
+the follow-up documentation HEAD without a fresh clean rebuild.
+
+## Remaining acceptance boundary
+
+- A real setup install/upgrade, reboot, remote connection, About/language/logs,
+ repair, and Defender outcome remain outside this Task 5 local build scope.
+- Independent parent review remains required before any tag, push, publish,
+ signing, or installation decision.
diff --git a/README.md b/README.md
index 254b74a..dd815b0 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,7 @@ The tray reports two independent, truthful status lines instead of inferring rea
## Releases
-Every tagged release ships a Windows setup installer and an EXE-entry portable ZIP with separate SHA-256 checksums and release manifests. The portable distribution publishes its payload manifest as an asset; Setup embeds and hash-binds its versioned `installer-payload-manifest.json`. For v2.5.22, download:
+Every tagged release ships a Windows setup installer and an EXE-entry portable ZIP with separate SHA-256 checksums and release manifests. The portable distribution publishes its payload manifest as an asset; Setup embeds and hash-binds its versioned `installer-payload.manifest.json`. For v2.5.22, download:
- `CodexRemote-fix-2.5.22-setup.exe` and its checksum
- `CodexRemote-fix-2.5.22-windows-x64.zip` and its checksum
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 7d2a952..7acbcf6 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -81,7 +81,7 @@ CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、
## 发布(Releases)
-每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,并分别提供 SHA-256 校验文件与 release manifest。便携发行版会外发自己的 payload manifest;Setup 则内嵌并以哈希绑定版本化的 `installer-payload-manifest.json`。v2.5.22 提供:
+每个带 tag 的发布都会附带 Windows 安装包和以 EXE 启动的便携 ZIP,并分别提供 SHA-256 校验文件与 release manifest。便携发行版会外发自己的 payload manifest;Setup 则内嵌并以哈希绑定版本化的 `installer-payload.manifest.json`。v2.5.22 提供:
- `CodexRemote-fix-2.5.22-setup.exe` 及其校验文件
- `CodexRemote-fix-2.5.22-windows-x64.zip` 及其校验文件
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 9394583..5fdf4cd 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -1787,7 +1787,7 @@ Invoke-CcodTest '2.5.22 source metadata and documentation match the release cont
Assert-CcodTrue ($quickStart.Contains('Microsoft Defender')) 'English Quick Start documents the local Defender gate'
Assert-CcodTrue (-not $readme.Contains('\r\n')) 'English README contains no literal CRLF escape text'
Assert-CcodTrue ($readme.Contains('The portable distribution publishes its payload manifest as an asset')) 'English README identifies the externally published portable payload manifest'
- Assert-CcodTrue ($readme.Contains('Setup embeds and hash-binds its versioned `installer-payload-manifest.json`')) 'English README identifies the embedded hash-bound setup payload manifest'
+ Assert-CcodTrue ($readme.Contains('Setup embeds and hash-binds its versioned `installer-payload.manifest.json`')) 'English README identifies the embedded hash-bound setup payload manifest'
Assert-CcodTrue (-not $readme.Contains('shared payload manifest')) 'English README does not claim setup and portable share one payload manifest'
$readmeZh = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.zh-CN.md') -Raw -Encoding UTF8
Assert-CcodTrue ($readmeZh -match 'v2\.5\.22 \u662F\u5F53\u524D\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.22 stable'
@@ -1797,7 +1797,7 @@ Invoke-CcodTest '2.5.22 source metadata and documentation match the release cont
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'
Assert-CcodTrue ($readmeZh -match '\u4FBF\u643A\u53D1\u884C\u7248\u4F1A\u5916\u53D1\u81EA\u5DF1\u7684 payload manifest') 'Chinese README identifies the externally published portable payload manifest'
- Assert-CcodTrue ($readmeZh -match 'Setup \u5219\u5185\u5D4C\u5E76\u4EE5\u54C8\u5E0C\u7ED1\u5B9A\u7248\u672C\u5316\u7684 `installer-payload-manifest\.json`') 'Chinese README identifies the embedded hash-bound setup payload manifest'
+ Assert-CcodTrue ($readmeZh -match 'Setup \u5219\u5185\u5D4C\u5E76\u4EE5\u54C8\u5E0C\u7ED1\u5B9A\u7248\u672C\u5316\u7684 `installer-payload\.manifest\.json`') 'Chinese README identifies the embedded hash-bound setup payload manifest'
$technical = Get-Content -LiteralPath (Join-Path $repositoryRoot 'docs\TECHNICAL.md') -Raw
Assert-CcodTrue ($technical.Contains('PortableUninstallFinalizer.ps1')) 'technical documentation records the staged portable finalizer'
$security = Get-Content -LiteralPath (Join-Path $repositoryRoot 'SECURITY.md') -Raw
From 6204a687816bdeea25f072363aadf62c23fe5da3 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:59:16 +0800
Subject: [PATCH 047/187] fix: verify portable release artifacts
---
.../task-5-report.md | 64 +++++++++++++++++++
build/TrayHostBuild.psm1 | 42 +++++++++++-
build/build.ps1 | 5 +-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 7 +-
tests/persistence/TrayHostBuild.SelfTest.ps1 | 44 +++++++++++++
tools/New-GitHubReleaseNotes.ps1 | 2 +-
6 files changed, 159 insertions(+), 5 deletions(-)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
index 9bc617f..7e60ba7 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
@@ -210,3 +210,67 @@ the follow-up documentation HEAD without a fresh clean rebuild.
repair, and Defender outcome remain outside this Task 5 local build scope.
- Independent parent review remains required before any tag, push, publish,
signing, or installation decision.
+
+## Fix round 1 — portable artifact verification and release-note body
+
+Independent review found two Important gaps: the freshly compiled portable
+launcher was copied into the payload without an artifact validator analogous to
+TrayHost, and the GitHub notes extractor synthesized an H1 instead of returning
+only the selected English section body.
+
+### RED evidence
+
+The portable regression was added before production code and called the wished-
+for validator against a real compiled launcher:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\TrayHostBuild.SelfTest.ps1
+exit 1
+Test-CcodPortableLauncherArtifact is not recognized
+```
+
+The release-note expectation was independently changed to the exact English
+body with one LF, producing:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 1
+expected=[- Target English note.\n- Second target English note.\n]
+actual=[# CodexRemote-fix 2.5.22\n\n- Target English note.\n- Second target English note.\n]
+```
+
+### Implementation
+
+`Test-CcodPortableLauncherArtifact` now revalidates:
+
+- requested version against package, portable AssemblyInfo, and the portable
+ Windows manifest;
+- PE FileVersion and ProductVersion;
+- schema/product/version/framework/reference-pack, exact commit, and canonical
+ timestamp provenance;
+- exact `PortableLauncher.cs` and `AssemblyInfo.cs` source name/hash set;
+- icon, manifest, source config, executable, and artifact config hashes.
+
+`build/build.ps1` invokes that validator immediately after portable compilation
+and before the first portable EXE/config/provenance copy into the payload. The
+tamper regression changes commit, timestamp, every required hash class, source
+hashes, and a fully aligned false static/provenance version whose PE remains
+2.5.22.0.
+
+`New-GitHubReleaseNotes.ps1` now writes only the normalized target English body
+with exactly one trailing LF. The GitHub release title remains the workflow's
+separate `gh release create/edit --title` responsibility.
+
+### GREEN evidence and boundary
+
+```text
+TrayHostBuild.SelfTest.ps1
+ exit 0; 8 cases including portable validator tamper coverage
+
+ReleaseWorkflow.SelfTest.ps1
+ exit 0; English-body-only extraction and build-before-copy validator order
+```
+
+No formal release build, installer execution, install, publish, push, tag,
+signing, or real Codex/UI operation was performed in this fix round. The final
+fresh clean release build remains gated on scoped independent re-review.
diff --git a/build/TrayHostBuild.psm1 b/build/TrayHostBuild.psm1
index 9215fb4..f767edf 100644
--- a/build/TrayHostBuild.psm1
+++ b/build/TrayHostBuild.psm1
@@ -210,4 +210,44 @@ function Invoke-CcodPortableLauncherBuild {
}finally{if(Test-Path -LiteralPath $work){Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue}}
}
-Export-ModuleMember -Function Invoke-CcodTrayHostBuild,Test-CcodTrayHostArtifact,Invoke-CcodPortableLauncherBuild
+function Test-CcodPortableLauncherArtifact {
+ [CmdletBinding()]
+ param([Parameter(Mandatory)][string]$RepositoryRoot,[Parameter(Mandatory)][string]$Version,[Parameter(Mandatory)][string]$ArtifactDirectory,[string]$ExpectedGitCommit)
+ if($Version -notmatch '^\d+\.\d+\.\d+$'){throw 'CCOD_PORTABLE_LAUNCHER_VERSION_INVALID'}
+ $repo=[IO.Path]::GetFullPath($RepositoryRoot);$out=[IO.Path]::GetFullPath($ArtifactDirectory);Test-CcodTrayHostOutputDirectory $out
+ $versionContract=Assert-CcodNativeVersionContract -RepositoryRoot $repo -Version $Version -Component portable
+ $exe=Join-Path $out 'CodexRemote.Portable.exe';$config=Join-Path $out 'CodexRemote.Portable.exe.config';$provenancePath=Join-Path $out 'portable-launcher-provenance.json'
+ foreach($path in @($exe,$config,$provenancePath)){if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_MISSING'}}
+ try{$provenance=Get-Content -LiteralPath $provenancePath -Raw|ConvertFrom-Json}catch{throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'}
+ $commit=$provenance.PSObject.Properties['gitCommit'];$timestamp=$provenance.PSObject.Properties['buildTimestampUtc'];$compiler=$provenance.PSObject.Properties['compiler']
+ if([int]$provenance.schemaVersion -ne 1 -or [string]$provenance.product -cne 'CodexRemote-fix' -or
+ [string]$provenance.version -cne $Version -or [string]$provenance.targetFramework -cne 'net48' -or
+ [string]$provenance.referenceRoot -cne 'locked-net48' -or $null -eq $commit -or $null -eq $timestamp -or $null -eq $compiler -or
+ $commit.Value -isnot [string] -or $commit.Value -cnotmatch '^[0-9a-f]{40}$' -or
+ $timestamp.Value -isnot [string] -or -not(Test-CcodTrayHostCanonicalUtc $timestamp.Value) -or
+ (-not [string]::IsNullOrWhiteSpace($ExpectedGitCommit) -and $commit.Value -cne $ExpectedGitCommit) -or
+ [string]$compiler.Value.name -cne 'csc.exe' -or [string]$compiler.Value.sha256 -cnotmatch '^[0-9a-f]{64}$'){throw 'CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'}
+ $icon=Join-Path $repo 'assets\codexremote-fix\codexremote-fix.ico';$sourceConfig=Join-Path $versionContract.SourceRoot 'CodexRemote.Portable.exe.config'
+ foreach($required in @($icon,$sourceConfig)){if(-not(Test-Path -LiteralPath $required -PathType Leaf)){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_MISSING'}}
+ if([string]$provenance.artifactSha256 -cne (Get-CcodTrayHostHash $exe) -or
+ [string]$provenance.configArtifactSha256 -cne (Get-CcodTrayHostHash $config) -or
+ [string]$provenance.iconSha256 -cne (Get-CcodTrayHostHash $icon) -or
+ [string]$provenance.manifestSha256 -cne (Get-CcodTrayHostHash $versionContract.ManifestPath) -or
+ [string]$provenance.configSha256 -cne (Get-CcodTrayHostHash $sourceConfig)){throw 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'}
+ $expectedSources=[Collections.Generic.Dictionary[string,string]]::new([StringComparer]::Ordinal)
+ foreach($name in @('AssemblyInfo.cs','PortableLauncher.cs')){$expectedSources.Add($name,(Get-CcodTrayHostHash (Join-Path $versionContract.SourceRoot $name)))}
+ $sourceFilesProperty=$provenance.PSObject.Properties['sourceFiles'];$sourceRecords=if($null -eq $sourceFilesProperty){@()}else{@($sourceFilesProperty.Value)}
+ if($sourceRecords.Count -ne $expectedSources.Count){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'}
+ $seenSources=[Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ foreach($record in $sourceRecords){
+ if($null -eq $record){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'}
+ $nameProperty=$record.PSObject.Properties['name'];$hashProperty=$record.PSObject.Properties['sha256']
+ if($null -eq $nameProperty -or $null -eq $hashProperty -or $nameProperty.Value -isnot [string] -or $hashProperty.Value -isnot [string]){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'}
+ $name=[string]$nameProperty.Value;$hash=[string]$hashProperty.Value
+ if(-not $seenSources.Add($name) -or -not $expectedSources.ContainsKey($name) -or $hash -cnotmatch '^[0-9a-f]{64}$' -or $hash -cne $expectedSources[$name]){throw 'CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'}
+ }
+ Assert-CcodNativePeVersion -Path $exe -Version $Version -ErrorId 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_VERSION_INVALID'
+ return [pscustomobject][ordered]@{Valid=$true;Executable=$exe;Version=$Version;GitCommit=[string]$commit.Value;Sha256=(Get-CcodTrayHostHash $exe)}
+}
+
+Export-ModuleMember -Function Invoke-CcodTrayHostBuild,Test-CcodTrayHostArtifact,Invoke-CcodPortableLauncherBuild,Test-CcodPortableLauncherArtifact
diff --git a/build/build.ps1 b/build/build.ps1
index 2f53eb1..8b900c8 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -321,7 +321,9 @@ try {
$relative = $runtimeFile.FullName.Substring($runtimeRoot.TrimEnd('\').Length + 1).Replace('\','/')
Copy-CcodBuildPayloadFile -Source $runtimeFile.FullName -PayloadRoot $payloadRoot -Relative ('src/runtime/' + $relative)
}
- Invoke-CcodPortableLauncherBuild -RepositoryRoot $repoRoot -Version $Version -OutputDirectory (Join-Path $PSScriptRoot 'generated\portable') -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc | Out-Null
+ $portableArtifact = Join-Path $PSScriptRoot 'generated\portable'
+ Invoke-CcodPortableLauncherBuild -RepositoryRoot $repoRoot -Version $Version -OutputDirectory $portableArtifact -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc | Out-Null
+ Test-CcodPortableLauncherArtifact -RepositoryRoot $repoRoot -Version $Version -ArtifactDirectory $portableArtifact -ExpectedGitCommit $gitCommit | Out-Null
foreach ($trayHostFile in @('CodexRemote.TrayHost.exe','CodexRemote.TrayHost.exe.config','trayhost-build-provenance.json')) {
Copy-CcodBuildPayloadFile -Source (Join-Path $trayHostArtifact $trayHostFile) -PayloadRoot $payloadRoot -Relative ('bin/' + $trayHostFile)
}
@@ -425,7 +427,6 @@ $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'
-$portableArtifact = Join-Path $PSScriptRoot 'generated\portable'
$isccArguments = @(
"/DProjectVersion=$Version",
"/DTrayHostArtifactDirectory=$trayHostArtifact",
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 5fdf4cd..91a6664 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -1749,7 +1749,7 @@ Invoke-CcodTest 'release notes extraction emits only the target release English
[IO.File]::WriteAllText($changelogPath,$fixture.Replace("`n","`r`n"),[Text.UTF8Encoding]::new($false))
& (Join-Path $repositoryRoot 'tools\New-GitHubReleaseNotes.ps1') -ChangelogPath $changelogPath -Tag 'v2.5.22' -OutputPath $notesPath | Out-Null
$actual = [IO.File]::ReadAllText($notesPath,[Text.UTF8Encoding]::new($false))
- Assert-CcodEqual "# CodexRemote-fix 2.5.22`n`n- Target English note.`n- Second target English note.`n" $actual 'release body contains exactly the current target English notes'
+ Assert-CcodEqual "- Target English note.`n- Second target English note.`n" $actual 'release body contains exactly the current target English notes'
foreach ($decoy in @('UNRELEASED_DECOY','NEWER_DECOY','CHINESE_DECOY','OLDER_DECOY')) {
Assert-CcodTrue (-not $actual.Contains($decoy)) "release body excludes $decoy"
}
@@ -1758,6 +1758,11 @@ Invoke-CcodTest 'release notes extraction emits only the target release English
}
}
+Invoke-CcodTest 'release build validates the portable launcher artifact before payload copy' {
+ $build = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\build.ps1') -Raw
+ Assert-CcodTrue ($build -cmatch '(?s)Invoke-CcodPortableLauncherBuild.*?Test-CcodPortableLauncherArtifact.*?foreach \(\$portableFile in') 'portable build validates the generated artifact before copying it into the payload'
+}
+
Invoke-CcodTest '2.5.22 source metadata and documentation match the release contract' {
$package = Get-Content -LiteralPath (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json
Assert-CcodEqual '2.5.22' ([string]$package.version) 'package metadata is the 2.5.22 release'
diff --git a/tests/persistence/TrayHostBuild.SelfTest.ps1 b/tests/persistence/TrayHostBuild.SelfTest.ps1
index d78ed82..5fe0072 100644
--- a/tests/persistence/TrayHostBuild.SelfTest.ps1
+++ b/tests/persistence/TrayHostBuild.SelfTest.ps1
@@ -205,6 +205,7 @@ Invoke-CcodTest 'TrayHost build embeds the product ICO instead of relying on a r
Invoke-CcodTest 'portable launcher build emits a tamper-bound double-click entrypoint' {
Import-Module (Join-Path $repositoryRoot 'build\TrayHostBuild.psm1') -Force
$artifact = Join-Path $env:TEMP ('ccod-portable-launcher-artifact-' + [Guid]::NewGuid().ToString('N'))
+ $fixtureRoot = Join-Path $env:TEMP ('ccod-portable-launcher-repository-' + [Guid]::NewGuid().ToString('N'))
try {
$result = Invoke-CcodPortableLauncherBuild -RepositoryRoot $repositoryRoot -Version '2.5.22' -OutputDirectory $artifact
Assert-CcodTrue (Test-Path -LiteralPath (Join-Path $artifact 'CodexRemote.Portable.exe') -PathType Leaf) 'portable launcher executable exists'
@@ -219,8 +220,51 @@ Invoke-CcodTest 'portable launcher build emits a tamper-bound double-click entry
Assert-CcodTrue (@($provenance.sourceFiles.name) -ccontains 'AssemblyInfo.cs') 'portable launcher provenance includes assembly metadata'
$validated = (Get-FileHash -LiteralPath (Join-Path $artifact 'CodexRemote.Portable.exe') -Algorithm SHA256).Hash.ToLowerInvariant()
Assert-CcodEqual $validated ([string]$provenance.artifactSha256) 'portable launcher artifact binds its executable hash'
+ $currentCommit = (& git -C $repositoryRoot rev-parse HEAD).Trim().ToLowerInvariant()
+ $artifactValidation = Test-CcodPortableLauncherArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit
+ Assert-CcodEqual $currentCommit ([string]$artifactValidation.GitCommit) 'portable artifact validator binds the expected source commit'
+
+ $provenancePath = Join-Path $artifact 'portable-launcher-provenance.json'
+ $baselineJson = [IO.File]::ReadAllText($provenancePath,[Text.UTF8Encoding]::new($false))
+ $mutations = @(
+ [pscustomobject]@{ Name='commit'; Error='CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'; Apply={param($record)$record.gitCommit='d'*40} },
+ [pscustomobject]@{ Name='timestamp'; Error='CCOD_PORTABLE_LAUNCHER_PROVENANCE_INVALID'; Apply={param($record)$record.buildTimestampUtc='not-canonical'} },
+ [pscustomobject]@{ Name='source hash'; Error='CCOD_PORTABLE_LAUNCHER_SOURCE_TAMPERED'; Apply={param($record)$record.sourceFiles[0].sha256='0'*64} },
+ [pscustomobject]@{ Name='icon hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.iconSha256='0'*64} },
+ [pscustomobject]@{ Name='manifest hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.manifestSha256='0'*64} },
+ [pscustomobject]@{ Name='source config hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.configSha256='0'*64} },
+ [pscustomobject]@{ Name='executable hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.artifactSha256='0'*64} },
+ [pscustomobject]@{ Name='artifact config hash'; Error='CCOD_PORTABLE_LAUNCHER_ARTIFACT_TAMPERED'; Apply={param($record)$record.configArtifactSha256='0'*64} }
+ )
+ foreach ($mutationCase in $mutations) {
+ $mutated = $baselineJson | ConvertFrom-Json
+ & ([scriptblock]$mutationCase.Apply) $mutated
+ [IO.File]::WriteAllText($provenancePath,($mutated | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ try {
+ Assert-CcodThrows { Test-CcodPortableLauncherArtifact -RepositoryRoot $repositoryRoot -Version '2.5.22' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit | Out-Null } ([string]$mutationCase.Error)
+ } finally {
+ [IO.File]::WriteAllText($provenancePath,$baselineJson,[Text.UTF8Encoding]::new($false))
+ }
+ }
+
+ [IO.Directory]::CreateDirectory((Join-Path $fixtureRoot 'src')) | Out-Null
+ Copy-Item -LiteralPath (Join-Path $repositoryRoot 'src\portable') -Destination (Join-Path $fixtureRoot 'src\portable') -Recurse
+ [IO.Directory]::CreateDirectory((Join-Path $fixtureRoot 'assets\codexremote-fix')) | Out-Null
+ Copy-Item -LiteralPath (Join-Path $repositoryRoot 'assets\codexremote-fix\codexremote-fix.ico') -Destination (Join-Path $fixtureRoot 'assets\codexremote-fix\codexremote-fix.ico')
+ [IO.File]::WriteAllText((Join-Path $fixtureRoot 'package.json'),'{"version":"9.9.9"}',[Text.UTF8Encoding]::new($false))
+ $assemblyPath = Join-Path $fixtureRoot 'src\portable\AssemblyInfo.cs'
+ [IO.File]::WriteAllText($assemblyPath,([IO.File]::ReadAllText($assemblyPath,[Text.UTF8Encoding]::new($false)).Replace('2.5.22.0','9.9.9.0')),[Text.UTF8Encoding]::new($false))
+ $manifestPath = Join-Path $fixtureRoot 'src\portable\CodexRemote.Portable.manifest'
+ [IO.File]::WriteAllText($manifestPath,([IO.File]::ReadAllText($manifestPath,[Text.UTF8Encoding]::new($false)).Replace('2.5.22.0','9.9.9.0')),[Text.UTF8Encoding]::new($false))
+ $falseVersion = $baselineJson | ConvertFrom-Json
+ $falseVersion.version = '9.9.9'
+ @($falseVersion.sourceFiles | Where-Object { $_.name -ceq 'AssemblyInfo.cs' })[0].sha256 = Get-CcodTestFileSha256 -Path $assemblyPath
+ $falseVersion.manifestSha256 = Get-CcodTestFileSha256 -Path $manifestPath
+ [IO.File]::WriteAllText($provenancePath,($falseVersion | ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ Assert-CcodThrows { Test-CcodPortableLauncherArtifact -RepositoryRoot $fixtureRoot -Version '9.9.9' -ArtifactDirectory $artifact -ExpectedGitCommit $currentCommit | Out-Null } 'CCOD_PORTABLE_LAUNCHER_ARTIFACT_VERSION_INVALID'
} finally {
if (Test-Path -LiteralPath $artifact) { Remove-Item -LiteralPath $artifact -Recurse -Force -ErrorAction SilentlyContinue }
+ if (Test-Path -LiteralPath $fixtureRoot) { Remove-Item -LiteralPath $fixtureRoot -Recurse -Force -ErrorAction SilentlyContinue }
}
}
diff --git a/tools/New-GitHubReleaseNotes.ps1 b/tools/New-GitHubReleaseNotes.ps1
index 3856a76..62cc90a 100644
--- a/tools/New-GitHubReleaseNotes.ps1
+++ b/tools/New-GitHubReleaseNotes.ps1
@@ -38,7 +38,7 @@ if ([string]::IsNullOrWhiteSpace($english)) {
}
$version = $Tag.Substring(1)
-$notes = "# CodexRemote-fix $version`n`n$english`n"
+$notes = "$english`n"
[IO.File]::WriteAllText($output,$notes,[Text.UTF8Encoding]::new($false))
[pscustomobject][ordered]@{
From f437d501feacc6ac7bf769842ace1a4be374ede5 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 14:13:44 +0800
Subject: [PATCH 048/187] test: normalize release notes fixture line endings
---
.../task-5-report.md | 34 +++++++++++++++++++
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 3 +-
2 files changed, 36 insertions(+), 1 deletion(-)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
index 7e60ba7..72b3607 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
@@ -274,3 +274,37 @@ ReleaseWorkflow.SelfTest.ps1
No formal release build, installer execution, install, publish, push, tag,
signing, or real Codex/UI operation was performed in this fix round. The final
fresh clean release build remains gated on scoped independent re-review.
+
+## Fix round 2 — CRLF-stable release-note fixture
+
+Final-candidate validation on a clean CRLF checkout exposed a test portability
+defect. The fixture already contained CRLF, but the test blindly replaced every
+LF with CRLF, producing CRCRLF. The real extractor then correctly rejected the
+malformed heading boundary:
+
+```text
+ReleaseWorkflow.SelfTest.ps1
+exit 1
+CCOD_RELEASE_NOTES_RELEASE_SECTION_INVALID
+```
+
+A focused local reproduction independently confirmed the old expression:
+
+```text
+CRCRLF_REPRODUCED=True
+```
+
+The fixture now normalizes CRLF and lone CR to LF before converting LF to CRLF.
+This preserves the intended CRLF-input coverage on LF, CRLF, and mixed-line-
+ending checkouts without changing production extraction behavior.
+
+Fresh GREEN evidence:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 0
+Release workflow self-tests passed.
+```
+
+No build, installer execution, install, publish, push, tag, or signing occurred
+in this fix round.
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 91a6664..91dd6eb 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -1746,7 +1746,8 @@ Invoke-CcodTest 'release notes extraction emits only the target release English
- OLDER_DECOY
'@
- [IO.File]::WriteAllText($changelogPath,$fixture.Replace("`n","`r`n"),[Text.UTF8Encoding]::new($false))
+ $fixtureCrlf = $fixture.Replace("`r`n","`n").Replace("`r","`n").Replace("`n","`r`n")
+ [IO.File]::WriteAllText($changelogPath,$fixtureCrlf,[Text.UTF8Encoding]::new($false))
& (Join-Path $repositoryRoot 'tools\New-GitHubReleaseNotes.ps1') -ChangelogPath $changelogPath -Tag 'v2.5.22' -OutputPath $notesPath | Out-Null
$actual = [IO.File]::ReadAllText($notesPath,[Text.UTF8Encoding]::new($false))
Assert-CcodEqual "- Target English note.`n- Second target English note.`n" $actual 'release body contains exactly the current target English notes'
From 31fd365612db30a19398668c0a7b119555ddb5dd Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 14:34:48 +0800
Subject: [PATCH 049/187] test: update v2.5.22 release branding contract
---
.../task-5-report.md | 32 +++++++++++++++++++
.../persistence/InstallLifecycle.SelfTest.ps1 | 25 +++++++++++----
2 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
index 72b3607..f883720 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
@@ -308,3 +308,35 @@ Release workflow self-tests passed.
No build, installer execution, install, publish, push, tag, or signing occurred
in this fix round.
+
+## Fix round 3 — current portable branding regression
+
+The final controlled isolated `npm test` reached
+`InstallLifecycle.SelfTest.ps1:2942` and produced the expected RED in
+`README and release workflow publish current portable-release branding`. The
+release-coupled regression still required v2.5.21 setup/ZIP names at lines
+2949/2950/2957/2958 and the retired inline
+`englishSection = [regex]::Match` implementation at lines 2971/2972.
+
+The regression now requires the v2.5.22 setup and portable asset names in both
+Quick Start sections. It preserves the public GitHub release title assertion,
+requires workflow use of `tools\New-GitHubReleaseNotes.ps1`, and rejects a
+second inline English extractor. The missing-English boundary is exercised
+against the real tool with a controlled changelog and requires
+`CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID` with no notes output.
+
+Local bounded verification:
+
+```text
+PowerShell AST parse of InstallLifecycle.SelfTest.ps1
+ exit 0
+
+git diff --check
+ exit 0
+```
+
+The local full InstallLifecycle suite was not represented as GREEN because the
+normal desktop environment retains the earlier real-Supervisor lifecycle mutex
+boundary. The parent will rerun the controlled isolated aggregate after scoped
+re-review. No build, installer execution, install, publish, push, tag, release,
+or signing occurred in this fix round.
diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1
index a7e7c94..538a5bd 100644
--- a/tests/persistence/InstallLifecycle.SelfTest.ps1
+++ b/tests/persistence/InstallLifecycle.SelfTest.ps1
@@ -2946,16 +2946,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\.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-2\.5\.22-setup\.exe') 'English Quick Start names the setup installer'
+ Assert-CcodTrue ($quickStart -cmatch 'CodexRemote-fix-2\.5\.22-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\.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-2\.5\.22-setup\.exe') 'Chinese Quick Start names the setup installer'
+ Assert-CcodTrue ($quickStartChinese -cmatch 'CodexRemote-fix-2\.5\.22-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'
@@ -2968,8 +2968,21 @@ $results += Invoke-CcodTest 'README and release workflow publish current portabl
Assert-CcodTrue ($workflow -cmatch '(?m)^name: CodexRemote-fix release\r?$') 'release workflow uses public product branding'
Assert-CcodTrue ($workflow -cmatch '(?m)^\s+name: CodexRemote-fix portable bundle\r?$') 'uploaded artifact uses public portable bundle branding'
Assert-CcodTrue ($workflow -cmatch '--title "CodexRemote-fix \$version"') 'GitHub release title uses public product branding'
- Assert-CcodTrue ($workflow -cmatch 'englishSection = \[regex\]::Match') 'GitHub release notes extract the English changelog section only'
- Assert-CcodTrue ($workflow -cmatch 'has no English release section') 'release fails clearly when the English changelog section is missing'
+ Assert-CcodTrue ($workflow -cmatch 'tools\\New-GitHubReleaseNotes\.ps1''\) -ChangelogPath .*? -Tag \$tag -OutputPath \$notesPath') 'GitHub release notes use the behavior-tested target-English extractor'
+ Assert-CcodTrue ($workflow -cnotmatch 'englishSection\s*=\s*\[regex\]::Match') 'release workflow does not retain a second inline English extractor'
+ $notesTool = Join-Path $repositoryRoot 'tools\New-GitHubReleaseNotes.ps1'
+ Assert-CcodTrue (Test-Path -LiteralPath $notesTool -PathType Leaf) 'behavior-tested GitHub release notes extractor exists'
+ $notesFixtureRoot = Join-Path ([IO.Path]::GetTempPath()) ('ccod-install-release-notes-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($notesFixtureRoot) | Out-Null
+ $missingEnglishChangelog = Join-Path $notesFixtureRoot 'CHANGELOG.md'
+ $notesOutput = Join-Path $notesFixtureRoot 'notes.md'
+ [IO.File]::WriteAllText($missingEnglishChangelog,"## v2.5.22`r`n`r`n### Simplified Chinese`r`n`r`n- fixture`r`n",[Text.UTF8Encoding]::new($false))
+ Assert-CcodThrows { & $notesTool -ChangelogPath $missingEnglishChangelog -Tag 'v2.5.22' -OutputPath $notesOutput | Out-Null } 'CCOD_RELEASE_NOTES_ENGLISH_SECTION_INVALID'
+ Assert-CcodTrue (-not (Test-Path -LiteralPath $notesOutput)) 'missing English notes cannot create a GitHub release body'
+ } finally {
+ if (Test-Path -LiteralPath $notesFixtureRoot) { Remove-Item -LiteralPath $notesFixtureRoot -Recurse -Force -ErrorAction SilentlyContinue }
+ }
}
$results += Invoke-CcodTest 'installer carries the Inno contract needed by its self-validation' {
From cb36d0ba202724c6c9bb223d94dc3ce838f45f9e Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:08:58 +0800
Subject: [PATCH 050/187] test: expose activation payload byte swap
---
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 155 +++++++++++++++++-
1 file changed, 153 insertions(+), 2 deletions(-)
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 91dd6eb..3fa3cea 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -1035,8 +1035,39 @@ function New-CcodActivationPayloadFixture {
$payloadRoot = Join-Path $appRoot "payload\$Version"
[IO.Directory]::CreateDirectory((Join-Path $payloadRoot 'src\persistence\modules')) | Out-Null
[IO.File]::WriteAllText((Join-Path $payloadRoot 'package.json'),([ordered]@{name='fixture';version=$Version}|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
- [IO.File]::WriteAllText((Join-Path $payloadRoot 'Install-CodexControlOtherDevices.ps1'),'exit 0',[Text.UTF8Encoding]::new($false))
- [IO.File]::WriteAllText((Join-Path $payloadRoot 'src\persistence\modules\InstallLifecycle.psm1'),'function Get-CcodLifecyclePayloadManifestFiles { @() }',[Text.UTF8Encoding]::new($false))
+ $installSource = @'
+param(
+ [string]$InstallRoot,
+ [switch]$EnableCandidateCompatibleUpdates,
+ [string]$ActivationId,
+ [string]$ExpectedVersion,
+ [string]$PayloadManifestPath,
+ [string]$ExpectedPayloadManifestSha256
+)
+$manifest = [IO.File]::ReadAllText($PayloadManifestPath,[Text.Encoding]::UTF8) | ConvertFrom-Json
+Import-Module (Join-Path $PSScriptRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force
+Invoke-CcodFixtureInstall -InstallRoot $InstallRoot -ActivationId $ActivationId -ManifestVersion ([string]$manifest.projectVersion)
+exit 0
+'@
+ [IO.File]::WriteAllText((Join-Path $payloadRoot 'Install-CodexControlOtherDevices.ps1'),$installSource,[Text.UTF8Encoding]::new($false))
+ $lifecycleSource = @'
+function Invoke-CcodFixtureInstall {
+ param([string]$InstallRoot,[string]$ActivationId,[string]$ManifestVersion)
+ [IO.Directory]::CreateDirectory((Join-Path $InstallRoot 'state')) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $InstallRoot 'activation-byte-marker.txt'),('original:' + $ManifestVersion),[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),'{"activeRuntime":"runtime-fixture"}',[Text.UTF8Encoding]::new($false))
+ $receipt=[ordered]@{schemaVersion=1;activationId=$ActivationId;phase='Ready';runtimeId='runtime-fixture';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))
+}
+Export-ModuleMember -Function Invoke-CcodFixtureInstall
+'@
+ [IO.File]::WriteAllText((Join-Path $payloadRoot 'src\persistence\modules\InstallLifecycle.psm1'),$lifecycleSource,[Text.UTF8Encoding]::new($false))
+ $runtimeManifestSource = @"
+function Read-CcodActiveRuntime { param([string]`$InstallRoot) Get-Content -LiteralPath (Join-Path `$InstallRoot 'active.json') -Raw | ConvertFrom-Json }
+function Test-CcodRuntimeManifest { param([string]`$RuntimeDirectory,[string]`$ExpectedRuntimeId) [pscustomobject]@{Valid=`$true;Manifest=[pscustomobject]@{projectVersion='$Version'}} }
+Export-ModuleMember -Function Read-CcodActiveRuntime,Test-CcodRuntimeManifest
+"@
+ [IO.File]::WriteAllText((Join-Path $payloadRoot 'src\persistence\modules\RuntimeManifest.psm1'),$runtimeManifestSource,[Text.UTF8Encoding]::new($false))
$records = [Collections.Generic.List[object]]::new()
foreach ($file in @(Get-ChildItem -LiteralPath $payloadRoot -File -Force -Recurse)) {
$relative = $file.FullName.Substring($payloadRoot.TrimEnd('\').Length + 1).Replace('\','/')
@@ -1049,6 +1080,48 @@ function New-CcodActivationPayloadFixture {
return [pscustomobject]@{AppRoot=$appRoot;PayloadRoot=$payloadRoot;ManifestPath=$manifestPath;ManifestSha256=Get-CcodTestFileSha256 -Path $manifestPath}
}
+function New-CcodActivationBarrierHost {
+ param(
+ [Parameter(Mandatory)][string]$OutputDirectory,
+ [Parameter(Mandatory)][string]$ReadyPath,
+ [Parameter(Mandatory)][string]$ContinuePath,
+ [Parameter(Mandatory)][string]$RealPowerShellPath
+ )
+
+ [IO.Directory]::CreateDirectory($OutputDirectory) | Out-Null
+ $outputPath = Join-Path $OutputDirectory 'powershell.exe'
+ $source = @"
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Text;
+using System.Threading;
+public static class Program {
+ private static string Quote(string value) {
+ var result = new StringBuilder(); result.Append((char)34); int slashes = 0;
+ foreach (char character in value) {
+ if (character == (char)92) { slashes++; continue; }
+ if (character == (char)34) { result.Append(new string((char)92, (slashes * 2) + 1)); result.Append((char)34); slashes = 0; continue; }
+ if (slashes > 0) { result.Append(new string((char)92, slashes)); slashes = 0; }
+ result.Append(character);
+ }
+ if (slashes > 0) result.Append(new string((char)92, slashes * 2));
+ result.Append((char)34); return result.ToString();
+ }
+ public static int Main(string[] args) {
+ File.WriteAllText(@"$($ReadyPath.Replace('"','""'))", "ready", new UTF8Encoding(false));
+ var deadline = DateTime.UtcNow.AddSeconds(30);
+ while (!File.Exists(@"$($ContinuePath.Replace('"','""'))")) { if (DateTime.UtcNow >= deadline) return 124; Thread.Sleep(10); }
+ var info = new ProcessStartInfo(@"$($RealPowerShellPath.Replace('"','""'))", String.Join(" ", Array.ConvertAll(args, Quote)));
+ info.UseShellExecute = false; info.CreateNoWindow = true;
+ using (var process = Process.Start(info)) { process.WaitForExit(); return process.ExitCode; }
+ }
+}
+"@
+ Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $outputPath -OutputType ConsoleApplication
+ return $outputPath
+}
+
function Invoke-CcodActivationVerifierFixture {
param([Parameter(Mandatory)]$Fixture,[Parameter(Mandatory)][string]$ExpectedManifestSha256,[string]$PayloadRoot)
@@ -1141,6 +1214,84 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload
Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '-ExpectedVersion\s+\$ExpectedVersion' -and $installer -cmatch '-PayloadManifestPath\s+\$PayloadManifestPath') 'installer forwards the immutable payload contract to lifecycle activation'
}
+# Production mutation caught: reopening mutable payload paths after the parent accepted their manifest bytes.
+Invoke-CcodTest 'activation executes only the verified manifest installer and module bytes across the child-launch barrier' {
+ $fixture = New-CcodActivationPayloadFixture
+ $barrierRoot = Join-Path $fixture.AppRoot 'barrier-host'
+ $readyPath = Join-Path $fixture.AppRoot 'child-launch.ready'
+ $continuePath = Join-Path $fixture.AppRoot 'child-launch.continue'
+ $stdoutPath = Join-Path $fixture.AppRoot 'activation.stdout'
+ $stderrPath = Join-Path $fixture.AppRoot 'activation.stderr'
+ $process = $null
+ try {
+ $realPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
+ $null = New-CcodActivationBarrierHost -OutputDirectory $barrierRoot -ReadyPath $readyPath -ContinuePath $continuePath -RealPowerShellPath $realPowerShell
+ $activationId = '77777777-6666-5555-4444-333333333333'
+ $arguments = @(
+ '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',(Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'),
+ '-AppRoot',$fixture.AppRoot,'-InstallRoot',$fixture.AppRoot,'-PayloadRoot',$fixture.PayloadRoot,
+ '-ExpectedVersion','2.5.22','-ExpectedPayloadManifestSha256',$fixture.ManifestSha256,'-ActivationId',$activationId,
+ '-FirstReceiptTimeoutMilliseconds','30000','-ActivationTimeoutMilliseconds','30000'
+ )
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
+ $startInfo.FileName = $realPowerShell
+ $startInfo.Arguments = (($arguments | ForEach-Object { '"' + ([string]$_).Replace('\','\').Replace('"','\"') + '"' }) -join ' ')
+ $startInfo.UseShellExecute = $false
+ $startInfo.CreateNoWindow = $true
+ $startInfo.RedirectStandardOutput = $true
+ $startInfo.RedirectStandardError = $true
+ $startInfo.EnvironmentVariables['PATH'] = $barrierRoot + ';' + $env:PATH
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $startInfo
+ Assert-CcodTrue $process.Start() 'activation fixture process starts'
+
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
+ while (-not [IO.File]::Exists($readyPath) -and [DateTime]::UtcNow -lt $deadline -and -not $process.HasExited) {
+ [Threading.Thread]::Sleep(10)
+ }
+ Assert-CcodTrue ([IO.File]::Exists($readyPath)) 'barrier proves parent payload verification completed before child execution'
+
+ $replacementInstallerMarker = Join-Path $fixture.AppRoot 'replacement-installer-executed.txt'
+ $replacementInstaller = @"
+param([string]`$InstallRoot,[switch]`$EnableCandidateCompatibleUpdates,[string]`$ActivationId,[string]`$ExpectedVersion,[string]`$PayloadManifestPath,[string]`$ExpectedPayloadManifestSha256)
+[IO.File]::WriteAllText('$($replacementInstallerMarker.Replace("'","''"))','executed',[Text.UTF8Encoding]::new(`$false))
+`$manifest=[IO.File]::ReadAllText(`$PayloadManifestPath,[Text.Encoding]::UTF8)|ConvertFrom-Json
+Import-Module (Join-Path `$PSScriptRoot 'src\persistence\modules\InstallLifecycle.psm1') -Force
+Invoke-CcodFixtureInstall -InstallRoot `$InstallRoot -ActivationId `$ActivationId -ManifestVersion ([string]`$manifest.projectVersion)
+exit 0
+"@
+ [IO.File]::WriteAllText((Join-Path $fixture.PayloadRoot 'Install-CodexControlOtherDevices.ps1'),$replacementInstaller,[Text.UTF8Encoding]::new($false))
+ $replacementModule = @'
+function Invoke-CcodFixtureInstall {
+ param([string]$InstallRoot,[string]$ActivationId,[string]$ManifestVersion)
+ [IO.Directory]::CreateDirectory((Join-Path $InstallRoot 'state')) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $InstallRoot 'activation-byte-marker.txt'),('replacement:' + $ManifestVersion),[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $InstallRoot 'active.json'),'{"activeRuntime":"runtime-fixture"}',[Text.UTF8Encoding]::new($false))
+ $receipt=[ordered]@{schemaVersion=1;activationId=$ActivationId;phase='Ready';runtimeId='runtime-fixture';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))
+}
+Export-ModuleMember -Function Invoke-CcodFixtureInstall
+'@
+ [IO.File]::WriteAllText((Join-Path $fixture.PayloadRoot 'src\persistence\modules\InstallLifecycle.psm1'),$replacementModule,[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($fixture.ManifestPath,'{"schemaVersion":1,"projectVersion":"9.9.9","files":[]}',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($continuePath,'continue',[Text.UTF8Encoding]::new($false))
+
+ Assert-CcodTrue $process.WaitForExit(30000) 'activation fixture finishes within the bounded child deadline'
+ $stdout = $process.StandardOutput.ReadToEnd()
+ $stderr = $process.StandardError.ReadToEnd()
+ Assert-CcodEqual 0 ([int]$process.ExitCode) "verified fixture activation succeeds: $stdout $stderr"
+ Assert-CcodEqual 'original:2.5.22' ([IO.File]::ReadAllText((Join-Path $fixture.AppRoot 'activation-byte-marker.txt'),[Text.UTF8Encoding]::new($false))) 'child executes and parses only the original verified bytes'
+ Assert-CcodTrue (-not [IO.File]::Exists($replacementInstallerMarker)) 'post-verification installer replacement never executes'
+ Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $fixture.AppRoot 'active.json.tmp'))) 'rejection or success leaves no uncommitted pointer sidecar'
+ } finally {
+ if ($null -ne $process) {
+ try { if (-not $process.HasExited) { $process.Kill(); [void]$process.WaitForExit(5000) } } catch { }
+ $process.Dispose()
+ }
+ if (Test-Path -LiteralPath $fixture.AppRoot) { Remove-Item -LiteralPath $fixture.AppRoot -Recurse -Force -ErrorAction SilentlyContinue }
+ }
+}
+
Invoke-CcodTest 'activation accepts a compile-bound installer manifest hash before payload verification' {
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-hash-interface-' + [guid]::NewGuid().ToString('N'))
try {
From ed782ecadc005b60822d9fac745a2292ffab6e24 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:23:42 +0800
Subject: [PATCH 051/187] fix: seal activation bytes and reject linked leaves
---
Activate-CcodRemoteFix.ps1 | 223 +++++++++++++-----
Install-CodexControlOtherDevices.ps1 | 169 ++++++++++++-
build/CodexControlOtherDevices.iss | 97 ++++++++
src/persistence/modules/InstallLifecycle.psm1 | 168 +++++++++++--
.../persistence/InstallLifecycle.SelfTest.ps1 | 66 +++++-
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 65 ++++-
6 files changed, 686 insertions(+), 102 deletions(-)
diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1
index bebd91a..536f0df 100644
--- a/Activate-CcodRemoteFix.ps1
+++ b/Activate-CcodRemoteFix.ps1
@@ -54,19 +54,38 @@ function Write-CcodActivationRecord {
} catch { }
}
-function Get-CcodActivationFileSha256 {
- param([Parameter(Mandatory)][string]$Path)
+function Get-CcodActivationBytesSha256 {
+ param([Parameter(Mandatory)][byte[]]$Bytes)
$sha = [Security.Cryptography.SHA256]::Create()
- $stream = $null
try {
- $stream = [IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
- return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()
+ return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant()
} finally {
- if ($null -ne $stream) { $stream.Dispose() }
$sha.Dispose()
}
}
+function Open-CcodActivationLockedBytes {
+ param(
+ [Parameter(Mandatory)][string]$Path,
+ [ValidateRange(1,268435456)][int64]$MaximumBytes = 268435456
+ )
+
+ $full = [IO.Path]::GetFullPath($Path)
+ $stream = [IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ try {
+ if ($stream.Length -lt 0 -or $stream.Length -gt $MaximumBytes) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $memory = [IO.MemoryStream]::new()
+ try {
+ $stream.CopyTo($memory)
+ $bytes = $memory.ToArray()
+ } finally { $memory.Dispose() }
+ return [pscustomobject]@{ Path=$full; Stream=$stream; Bytes=$bytes }
+ } catch {
+ $stream.Dispose()
+ throw
+ }
+}
+
function Assert-CcodActivationNonReparsePath {
param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path)
$canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
@@ -99,7 +118,32 @@ function Test-CcodActivationAlternateDataStreams {
}
}
-function Assert-CcodActivationPayload {
+function Remove-CcodActivationPayloadStage {
+ param([Parameter(Mandatory)][string]$AppRoot,[Parameter(Mandatory)][string]$StageRoot)
+ if (-not [IO.Directory]::Exists($StageRoot)) { return }
+ $app = [IO.Path]::GetFullPath($AppRoot).TrimEnd('\')
+ $stage = [IO.Path]::GetFullPath($StageRoot).TrimEnd('\')
+ $prefix = $app + '\'
+ if (-not $stage.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase) -or
+ [IO.Path]::GetFileName($stage) -cnotmatch '^\.activation-payload-stage-[0-9a-f]{32}$') { return }
+ foreach ($item in @(Get-ChildItem -LiteralPath $stage -Force -Recurse -ErrorAction SilentlyContinue)) {
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return }
+ }
+ $stageItem = Get-Item -LiteralPath $stage -Force -ErrorAction SilentlyContinue
+ if ($null -eq $stageItem -or ($stageItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { return }
+ Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+function Close-CcodActivationPayloadSeal {
+ param($Seal)
+ if ($null -eq $Seal) { return }
+ foreach ($stream in @($Seal.Streams)) {
+ if ($null -ne $stream) { try { $stream.Dispose() } catch { } }
+ }
+ Remove-CcodActivationPayloadStage -AppRoot $Seal.AppRoot -StageRoot $Seal.StageRoot
+}
+
+function New-CcodActivationPayloadSeal {
param(
[Parameter(Mandatory)][string]$AppRoot,
[Parameter(Mandatory)][string]$Root,
@@ -122,59 +166,99 @@ function Assert-CcodActivationPayload {
throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
}
[void](Assert-CcodActivationNonReparsePath -Root $payload -Path $manifestPath)
- $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop
- if ($manifestItem.PSIsContainer -or $manifestItem.Length -le 0 -or $manifestItem.Length -gt 4194304 -or
- (Test-CcodActivationAlternateDataStreams -Path $manifestPath) -or
- (Get-CcodActivationFileSha256 -Path $manifestPath) -cne $ExpectedManifestSha256) {
- throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
- }
+ $sourceLocks = [Collections.Generic.List[object]]::new()
+ $stageLocks = [Collections.Generic.List[object]]::new()
+ $stageRoot = Join-Path $app ('.activation-payload-stage-' + [guid]::NewGuid().ToString('N'))
try {
- $manifest = [IO.File]::ReadAllText($manifestPath,[Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop
- } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- $properties = @($manifest.PSObject.Properties.Name)
- $expectedProperties = @('schemaVersion','projectVersion','files')
- if ($manifest -isnot [pscustomobject] -or $properties.Count -ne $expectedProperties.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- for ($index=0;$index-lt$expectedProperties.Count;$index++) {
- if ($properties[$index] -cne $expectedProperties[$index]) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- }
- if ($manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or $manifest.projectVersion -isnot [string] -or $manifest.projectVersion -cne $Version) {
- throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
- }
- $records = @($manifest.files)
- if ($records.Count -eq 0) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
- $previous = $null
- foreach ($record in $records) {
- $recordProperties = @($record.PSObject.Properties.Name)
- if ($record -isnot [pscustomobject] -or $recordProperties.Count -ne 3 -or $recordProperties[0] -cne 'path' -or $recordProperties[1] -cne 'length' -or $recordProperties[2] -cne 'sha256' -or
- $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or
- $record.length -isnot [ValueType] -or [decimal]$record.length -ne [decimal][int64]$record.length -or [int64]$record.length -lt 0 -or
- $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- if (($null-ne$previous -and [StringComparer]::Ordinal.Compare([string]$previous,[string]$record.path)-ge 0) -or -not $seen.Add([string]$record.path)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- $previous = [string]$record.path
- $source = [IO.Path]::GetFullPath((Join-Path $payload ($record.path.Replace('/','\'))))
- [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $source)
- $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop
- if ($item.PSIsContainer -or (Test-CcodActivationAlternateDataStreams -Path $source) -or [int64]$item.Length -ne [int64]$record.length -or
- (Get-CcodActivationFileSha256 -Path $source) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
- }
- $actualFileList = [Collections.Generic.List[string]]::new()
- foreach ($actualItem in @(Get-ChildItem -LiteralPath $payload -Force -Recurse -ErrorAction Stop)) {
- if (($actualItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
- if ($actualItem.PSIsContainer -or $actualItem.FullName.Equals($manifestPath,[StringComparison]::OrdinalIgnoreCase)) { continue }
- $actualFileList.Add($actualItem.FullName.Substring($payload.Length + 1).Replace('\','/'))
- }
- $actualFiles = @($actualFileList)
- if ($actualFiles.Count -ne $records.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- foreach ($actual in $actualFiles) {
- if (-not $seen.Contains([string]$actual)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- }
- try { $package = [IO.File]::ReadAllText($packagePath,[Text.Encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop }
- catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
- if ($package.version -isnot [string] -or [string]$package.version -cne $Version) {
- throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
+ $manifestLock = Open-CcodActivationLockedBytes -Path $manifestPath -MaximumBytes 4194304
+ $sourceLocks.Add($manifestLock)
+ $manifestItem = Get-Item -LiteralPath $manifestPath -Force -ErrorAction Stop
+ if ($manifestItem.PSIsContainer -or $manifestLock.Bytes.Length -le 0 -or
+ (Test-CcodActivationAlternateDataStreams -Path $manifestPath) -or
+ (Get-CcodActivationBytesSha256 -Bytes $manifestLock.Bytes) -cne $ExpectedManifestSha256) {
+ throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID'
+ }
+ try {
+ $manifestText = [Text.UTF8Encoding]::new($false,$true).GetString($manifestLock.Bytes)
+ $manifest = $manifestText | ConvertFrom-Json -ErrorAction Stop
+ } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $properties = @($manifest.PSObject.Properties.Name)
+ $expectedProperties = @('schemaVersion','projectVersion','files')
+ if ($manifest -isnot [pscustomobject] -or $properties.Count -ne $expectedProperties.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ for ($index=0;$index-lt$expectedProperties.Count;$index++) {
+ if ($properties[$index] -cne $expectedProperties[$index]) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ }
+ if ($manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or $manifest.projectVersion -isnot [string] -or $manifest.projectVersion -cne $Version) {
+ throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
+ }
+ $records = @($manifest.files)
+ if ($records.Count -eq 0) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ $snapshots = [Collections.Generic.List[object]]::new()
+ $previous = $null
+ foreach ($record in $records) {
+ $recordProperties = @($record.PSObject.Properties.Name)
+ if ($record -isnot [pscustomobject] -or $recordProperties.Count -ne 3 -or $recordProperties[0] -cne 'path' -or $recordProperties[1] -cne 'length' -or $recordProperties[2] -cne 'sha256' -or
+ $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or
+ $record.length -isnot [ValueType] -or [decimal]$record.length -ne [decimal][int64]$record.length -or [int64]$record.length -lt 0 -or
+ $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ if (($null-ne$previous -and [StringComparer]::Ordinal.Compare([string]$previous,[string]$record.path)-ge 0) -or -not $seen.Add([string]$record.path)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $previous = [string]$record.path
+ $source = [IO.Path]::GetFullPath((Join-Path $payload ($record.path.Replace('/','\'))))
+ [void](Assert-CcodActivationNonReparsePath -Root $payload -Path $source)
+ $locked = Open-CcodActivationLockedBytes -Path $source
+ $sourceLocks.Add($locked)
+ $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ (Test-CcodActivationAlternateDataStreams -Path $source) -or [int64]$locked.Bytes.Length -ne [int64]$record.length -or
+ (Get-CcodActivationBytesSha256 -Bytes $locked.Bytes) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
+ $snapshots.Add([pscustomobject]@{ Relative=[string]$record.path; Bytes=$locked.Bytes; Sha256=[string]$record.sha256 })
+ }
+ foreach ($required in @('package.json','Install-CodexControlOtherDevices.ps1','src/persistence/modules/InstallLifecycle.psm1','src/persistence/modules/RuntimeManifest.psm1')) {
+ if (-not $seen.Contains($required)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ }
+ $actualFileList = [Collections.Generic.List[string]]::new()
+ foreach ($actualItem in @(Get-ChildItem -LiteralPath $payload -Force -Recurse -ErrorAction Stop)) {
+ if (($actualItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($actualItem.PSIsContainer -or $actualItem.FullName.Equals($manifestPath,[StringComparison]::OrdinalIgnoreCase)) { continue }
+ $actualFileList.Add($actualItem.FullName.Substring($payload.Length + 1).Replace('\','/'))
+ }
+ $actualFiles = @($actualFileList)
+ if ($actualFiles.Count -ne $records.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ foreach ($actual in $actualFiles) { if (-not $seen.Contains([string]$actual)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } }
+ $packageSnapshot = @($snapshots | Where-Object { $_.Relative -ceq 'package.json' })
+ try { $package = [Text.UTF8Encoding]::new($false,$true).GetString([byte[]]$packageSnapshot[0].Bytes) | ConvertFrom-Json -ErrorAction Stop }
+ catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ if ($packageSnapshot.Count -ne 1 -or $package.version -isnot [string] -or [string]$package.version -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+
+ [IO.Directory]::CreateDirectory($stageRoot) | Out-Null
+ [void](Assert-CcodActivationNonReparsePath -Root $app -Path $stageRoot)
+ foreach ($snapshot in @($snapshots) + @([pscustomobject]@{Relative='installer-payload.manifest.json';Bytes=$manifestLock.Bytes;Sha256=$ExpectedManifestSha256})) {
+ $destination = [IO.Path]::GetFullPath((Join-Path $stageRoot ([string]$snapshot.Relative).Replace('/','\')))
+ $stagePrefix = $stageRoot.TrimEnd('\') + '\'
+ if (-not $destination.StartsWith($stagePrefix,[StringComparison]::OrdinalIgnoreCase)) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
+ [IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) | Out-Null
+ $writer = [IO.File]::Open($destination,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
+ try { $writer.Write([byte[]]$snapshot.Bytes,0,([byte[]]$snapshot.Bytes).Length); $writer.Flush($true) } finally { $writer.Dispose() }
+ [void](Assert-CcodActivationNonReparsePath -Root $stageRoot -Path $destination)
+ $stageLock = Open-CcodActivationLockedBytes -Path $destination
+ $stageLocks.Add($stageLock)
+ if ((Get-CcodActivationBytesSha256 -Bytes $stageLock.Bytes) -cne [string]$snapshot.Sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
+ }
+ return [pscustomobject]@{
+ AppRoot=$app
+ StageRoot=[IO.Path]::GetFullPath($stageRoot)
+ ManifestPath=Join-Path $stageRoot 'installer-payload.manifest.json'
+ ManifestBytes=$manifestLock.Bytes
+ Streams=@($stageLocks | ForEach-Object { $_.Stream })
+ }
+ } catch {
+ foreach ($locked in @($stageLocks)) { try { $locked.Stream.Dispose() } catch { } }
+ Remove-CcodActivationPayloadStage -AppRoot $app -StageRoot $stageRoot
+ throw
+ } finally {
+ foreach ($locked in @($sourceLocks)) { try { $locked.Stream.Dispose() } catch { } }
}
- return $manifestPath
}
function Assert-CcodActivatedRuntimeVersion {
@@ -306,6 +390,7 @@ function Invoke-CcodOwnedInstallWorker {
param(
[Parameter(Mandatory)][string]$PowerShellPath,
[Parameter(Mandatory)][string]$InstallScript,
+ [Parameter(Mandatory)][string]$VerifiedPayloadRoot,
[Parameter(Mandatory)][string]$StateRoot,
[Parameter(Mandatory)][int]$FirstReceiptTimeout,
[Parameter(Mandatory)][int]$ActivationTimeout
@@ -328,7 +413,11 @@ function Invoke-CcodOwnedInstallWorker {
$ActivationId
)
if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
- $arguments += @('-ExpectedVersion',$ExpectedVersion,'-PayloadManifestPath',(Join-Path $PayloadRoot 'installer-payload.manifest.json'))
+ $arguments += @(
+ '-ExpectedVersion',$ExpectedVersion,
+ '-PayloadManifestPath',(Join-Path $VerifiedPayloadRoot 'installer-payload.manifest.json'),
+ '-ExpectedPayloadManifestSha256',$ExpectedPayloadManifestSha256
+ )
}
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $PowerShellPath
@@ -442,8 +531,10 @@ function Show-CcodActivationFailure {
if ($ValidateReceiptOnly -and $ValidateReceiptWithTimeout) { Write-Error 'CCOD_ACTIVATION_VALIDATOR_MODE_INVALID' -ErrorAction Continue; exit 3 }
if ([string]::IsNullOrWhiteSpace($PayloadRoot)) { $PayloadRoot = $AppRoot }
$PayloadRoot = [IO.Path]::GetFullPath($PayloadRoot)
+$payloadSeal = $null
+try {
if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
- try { $null = Assert-CcodActivationPayload -AppRoot $AppRoot -Root $PayloadRoot -Version $ExpectedVersion -ExpectedManifestSha256 $ExpectedPayloadManifestSha256 }
+ try { $payloadSeal = New-CcodActivationPayloadSeal -AppRoot $AppRoot -Root $PayloadRoot -Version $ExpectedVersion -ExpectedManifestSha256 $ExpectedPayloadManifestSha256 }
catch {
$candidate = ([string]$_.FullyQualifiedErrorId -split ',')[0]
$code = if ($candidate -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $candidate } elseif ($_.Exception.Message -cmatch '^CCOD_[A-Z0-9_]{1,96}$') { $Matches[0] } else { 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
@@ -466,7 +557,7 @@ if ($ValidateReceiptOnly) {
$terminalReceipt = Read-CcodTerminalActivationReceipt -Root ([IO.Path]::GetFullPath($InstallRoot)) -ExpectedActivationId $ActivationId
if ($terminalReceipt.phase -ceq 'Ready') {
if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
- Assert-CcodActivatedRuntimeVersion -Root ([IO.Path]::GetFullPath($InstallRoot)) -RuntimeId ([string]$terminalReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $PayloadRoot 'src\persistence\modules\RuntimeManifest.psm1')
+ Assert-CcodActivatedRuntimeVersion -Root ([IO.Path]::GetFullPath($InstallRoot)) -RuntimeId ([string]$terminalReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $payloadSeal.StageRoot 'src\persistence\modules\RuntimeManifest.psm1')
}
exit 0
}
@@ -483,11 +574,12 @@ $clock = [Diagnostics.Stopwatch]::StartNew()
try {
$root = [IO.Path]::GetFullPath($AppRoot)
$stateRoot = [IO.Path]::GetFullPath($InstallRoot)
- $installScript = Join-Path $PayloadRoot 'Install-CodexControlOtherDevices.ps1'
+ $executionPayloadRoot = if ($null -ne $payloadSeal) { [string]$payloadSeal.StageRoot } else { $PayloadRoot }
+ $installScript = Join-Path $executionPayloadRoot 'Install-CodexControlOtherDevices.ps1'
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
- $installExitCode = Invoke-CcodOwnedInstallWorker -PowerShellPath $powershell -InstallScript $installScript -StateRoot $stateRoot -FirstReceiptTimeout $FirstReceiptTimeoutMilliseconds -ActivationTimeout $ActivationTimeoutMilliseconds
+ $installExitCode = Invoke-CcodOwnedInstallWorker -PowerShellPath $powershell -InstallScript $installScript -VerifiedPayloadRoot $executionPayloadRoot -StateRoot $stateRoot -FirstReceiptTimeout $FirstReceiptTimeoutMilliseconds -ActivationTimeout $ActivationTimeoutMilliseconds
if ($installExitCode -ne 0) {
$reportedErrorCode = $null
try {
@@ -499,7 +591,7 @@ try {
}
$activationReceipt = Read-CcodFinalActivationReceipt -Root $stateRoot -ExpectedActivationId $ActivationId
if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
- Assert-CcodActivatedRuntimeVersion -Root $stateRoot -RuntimeId ([string]$activationReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $PayloadRoot 'src\persistence\modules\RuntimeManifest.psm1')
+ Assert-CcodActivatedRuntimeVersion -Root $stateRoot -RuntimeId ([string]$activationReceipt.runtimeId) -Version $ExpectedVersion -RuntimeModulePath (Join-Path $payloadSeal.StageRoot 'src\persistence\modules\RuntimeManifest.psm1')
}
Write-CcodActivationRecord -Code 'RUNTIME_ACTIVATED' -DurationMilliseconds $clock.ElapsedMilliseconds
} catch {
@@ -530,3 +622,6 @@ if ($Prompt) {
Write-CcodActivationRecord -Code 'COMPLETED' -DurationMilliseconds $clock.ElapsedMilliseconds
exit 0
+} finally {
+ Close-CcodActivationPayloadSeal -Seal $payloadSeal
+}
diff --git a/Install-CodexControlOtherDevices.ps1 b/Install-CodexControlOtherDevices.ps1
index 4960ea0..bc8cf47 100644
--- a/Install-CodexControlOtherDevices.ps1
+++ b/Install-CodexControlOtherDevices.ps1
@@ -6,11 +6,135 @@ param(
[switch]$DoNotStart,
[string]$ActivationId,
[string]$ExpectedVersion,
- [string]$PayloadManifestPath
+ [string]$PayloadManifestPath,
+ [string]$ExpectedPayloadManifestSha256
)
$ErrorActionPreference = 'Stop'
+function Get-CcodInstallerBytesSha256 {
+ param([Parameter(Mandatory)][byte[]]$Bytes)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant() }
+ finally { $sha.Dispose() }
+}
+
+function Open-CcodInstallerLockedBytes {
+ param([Parameter(Mandatory)][string]$Path,[int64]$MaximumBytes = 268435456)
+ $full = [IO.Path]::GetFullPath($Path)
+ $stream = [IO.File]::Open($full,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ try {
+ if ($stream.Length -lt 0 -or $stream.Length -gt $MaximumBytes) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $memory = [IO.MemoryStream]::new()
+ try { $stream.CopyTo($memory); $bytes = $memory.ToArray() } finally { $memory.Dispose() }
+ return [pscustomobject]@{Path=$full;Stream=$stream;Bytes=$bytes}
+ } catch { $stream.Dispose(); throw }
+}
+
+function Assert-CcodInstallerPayloadPath {
+ param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path)
+ $canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
+ $candidate = [IO.Path]::GetFullPath($Path)
+ $prefix = $canonicalRoot + '\'
+ if (-not ($candidate.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase) -or $candidate.StartsWith($prefix,[StringComparison]::OrdinalIgnoreCase))) {
+ throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID'
+ }
+ $cursor = $candidate
+ while ($true) {
+ $item = Get-Item -LiteralPath $cursor -Force -ErrorAction Stop
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($cursor.Equals($canonicalRoot,[StringComparison]::OrdinalIgnoreCase)) { break }
+ $cursor = Split-Path $cursor -Parent
+ }
+ return $candidate
+}
+
+function Test-CcodInstallerAlternateDataStreams {
+ param([Parameter(Mandatory)][string]$Path)
+ try {
+ foreach ($stream in @(Get-Item -LiteralPath $Path -Stream * -ErrorAction Stop)) {
+ if ([string]$stream.Stream -cnotin @(':$DATA','::$DATA','$DATA')) { return $true }
+ }
+ return $false
+ } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+}
+
+function Open-CcodInstallerPayloadSeal {
+ param(
+ [Parameter(Mandatory)][string]$SourceRoot,
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$ManifestPath,
+ [Parameter(Mandatory)][string]$ExpectedManifestSha256
+ )
+ if ($Version -cnotmatch '^\d+\.\d+\.\d+$' -or $ExpectedManifestSha256 -cnotmatch '^[0-9a-f]{64}$') { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ $root = [IO.Path]::GetFullPath($SourceRoot).TrimEnd('\')
+ [void](Assert-CcodInstallerPayloadPath -Root $root -Path $root)
+ $manifestFile = Assert-CcodInstallerPayloadPath -Root $root -Path $ManifestPath
+ $locks = [Collections.Generic.List[object]]::new()
+ try {
+ $manifestLock = Open-CcodInstallerLockedBytes -Path $manifestFile -MaximumBytes 4194304
+ $locks.Add($manifestLock)
+ if ((Test-CcodInstallerAlternateDataStreams -Path $manifestFile) -or
+ (Get-CcodInstallerBytesSha256 -Bytes $manifestLock.Bytes) -cne $ExpectedManifestSha256) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ try {
+ $manifestText = [Text.UTF8Encoding]::new($false,$true).GetString($manifestLock.Bytes)
+ $manifest = $manifestText | ConvertFrom-Json -ErrorAction Stop
+ } catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $fields = @($manifest.PSObject.Properties.Name)
+ if ($manifest -isnot [pscustomobject] -or ($fields -join ',') -cne 'schemaVersion,projectVersion,files' -or
+ $manifest.schemaVersion -isnot [int] -or $manifest.schemaVersion -ne 1 -or
+ $manifest.projectVersion -isnot [string] -or $manifest.projectVersion -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ $records = @($manifest.files)
+ if ($records.Count -eq 0) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
+ $previous = $null
+ $packageBytes = $null
+ foreach ($record in $records) {
+ $recordFields = @($record.PSObject.Properties.Name)
+ if ($record -isnot [pscustomobject] -or ($recordFields -join ',') -cne 'path,length,sha256' -or
+ $record.path -isnot [string] -or $record.path -cnotmatch '^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$' -or
+ $record.path.Contains('//') -or $record.path.Contains('..') -or $record.path.Contains(':') -or $record.path.Contains('\') -or
+ $record.length -isnot [ValueType] -or [decimal]$record.length -ne [decimal][int64]$record.length -or [int64]$record.length -lt 0 -or
+ $record.sha256 -isnot [string] -or $record.sha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ ($null -ne $previous -and [StringComparer]::Ordinal.Compare([string]$previous,[string]$record.path) -ge 0) -or
+ -not $seen.Add([string]$record.path)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ $previous = [string]$record.path
+ $source = Assert-CcodInstallerPayloadPath -Root $root -Path (Join-Path $root ([string]$record.path).Replace('/','\'))
+ $locked = Open-CcodInstallerLockedBytes -Path $source
+ $locks.Add($locked)
+ $item = Get-Item -LiteralPath $source -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ (Test-CcodInstallerAlternateDataStreams -Path $source) -or [int64]$locked.Bytes.Length -ne [int64]$record.length -or
+ (Get-CcodInstallerBytesSha256 -Bytes $locked.Bytes) -cne [string]$record.sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
+ if ([string]$record.path -ceq 'package.json') { $packageBytes = $locked.Bytes }
+ }
+ foreach ($required in @('package.json','Install-CodexControlOtherDevices.ps1','src/persistence/modules/InstallLifecycle.psm1','src/persistence/modules/RuntimeManifest.psm1')) {
+ if (-not $seen.Contains($required)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ }
+ $actual = [Collections.Generic.List[string]]::new()
+ foreach ($item in @(Get-ChildItem -LiteralPath $root -Force -Recurse -ErrorAction Stop)) {
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'CCOD_INSTALL_SOURCE_REPARSE' }
+ if ($item.PSIsContainer -or $item.FullName.Equals($manifestFile,[StringComparison]::OrdinalIgnoreCase)) { continue }
+ $actual.Add($item.FullName.Substring($root.Length + 1).Replace('\','/'))
+ }
+ if ($actual.Count -ne $records.Count) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ foreach ($relative in $actual) { if (-not $seen.Contains($relative)) { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' } }
+ try { $package = [Text.UTF8Encoding]::new($false,$true).GetString([byte[]]$packageBytes) | ConvertFrom-Json -ErrorAction Stop }
+ catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
+ if ($package.version -isnot [string] -or [string]$package.version -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
+ return [pscustomobject]@{ManifestBytes=$manifestLock.Bytes;Locks=@($locks | ForEach-Object {$_.Stream})}
+ } catch {
+ foreach ($locked in @($locks)) { try { $locked.Stream.Dispose() } catch { } }
+ throw
+ }
+}
+
+function Close-CcodInstallerPayloadSeal {
+ param($Seal)
+ if ($null -eq $Seal) { return }
+ foreach ($stream in @($Seal.Locks)) { if ($null -ne $stream) { try { $stream.Dispose() } catch { } } }
+}
+
function Resolve-CcodInstallerModule {
param([Parameter(Mandatory)][string]$CheckoutRoot)
@@ -26,22 +150,41 @@ function Resolve-CcodInstallerModule {
return $modulePath
}
-$script:InstallerModule = Resolve-CcodInstallerModule -CheckoutRoot $PSScriptRoot
-Import-Module $script:InstallerModule -Force
-
if ([string]::IsNullOrWhiteSpace($InstallRoot)) {
$InstallRoot = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'CodexControlOtherDevices'
}
-$receipt = Invoke-CcodInstall `
- -SourceRoot $PSScriptRoot `
- -InstallRoot $InstallRoot `
- -EnableCandidateCompatibleUpdates:([bool]$EnableCandidateCompatibleUpdates) `
- -RepairState:([bool]$RepairState) `
- -DoNotStart:([bool]$DoNotStart) `
- -ActivationId $ActivationId `
- -ExpectedVersion $ExpectedVersion `
- -PayloadManifestPath $PayloadManifestPath
+$payloadBound = -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -or -not [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or -not [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256)
+$payloadSeal = $null
+$installerModule = $null
+try {
+ if ($payloadBound) {
+ if ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256)) {
+ throw 'CCOD_INSTALL_INPUT_INVALID'
+ }
+ $payloadSeal = Open-CcodInstallerPayloadSeal -SourceRoot $PSScriptRoot -Version $ExpectedVersion -ManifestPath $PayloadManifestPath -ExpectedManifestSha256 $ExpectedPayloadManifestSha256
+ }
+ $script:InstallerModule = Resolve-CcodInstallerModule -CheckoutRoot $PSScriptRoot
+ $installerModule = Import-Module $script:InstallerModule -Force -PassThru
+ $invoke = @{
+ SourceRoot = $PSScriptRoot
+ InstallRoot = $InstallRoot
+ EnableCandidateCompatibleUpdates = [bool]$EnableCandidateCompatibleUpdates
+ RepairState = [bool]$RepairState
+ DoNotStart = [bool]$DoNotStart
+ ActivationId = $ActivationId
+ }
+ if ($payloadBound) {
+ $invoke.ExpectedVersion = $ExpectedVersion
+ $invoke.PayloadManifestPath = $PayloadManifestPath
+ $invoke.ExpectedPayloadManifestSha256 = $ExpectedPayloadManifestSha256
+ $invoke.PayloadManifestBytesBase64 = [Convert]::ToBase64String([byte[]]$payloadSeal.ManifestBytes)
+ }
+ $receipt = Invoke-CcodInstall @invoke
+} finally {
+ if ($null -ne $installerModule) { Remove-Module -Name $installerModule.Name -Force -ErrorAction SilentlyContinue }
+ Close-CcodInstallerPayloadSeal -Seal $payloadSeal
+}
Write-Host ''
Write-Host 'CodexRemote-fix - install result' -ForegroundColor Cyan
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index ff42b79..b99a09d 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -106,11 +106,39 @@ const
VALIDATION_LAUNCH_BUDGET_MILLISECONDS = 5000;
CCOD_FILE_ATTRIBUTE_DIRECTORY = $00000010;
CCOD_FILE_ATTRIBUTE_REPARSE_POINT = $00000400;
+ CCOD_FILE_READ_ATTRIBUTES = $00000080;
+ CCOD_FILE_SHARE_READ = $00000001;
+ CCOD_FILE_SHARE_WRITE = $00000002;
+ CCOD_FILE_SHARE_DELETE = $00000004;
+ CCOD_OPEN_EXISTING = 3;
+ CCOD_FILE_FLAG_OPEN_REPARSE_POINT = $00200000;
CCOD_INVALID_FILE_ATTRIBUTES = $FFFFFFFF;
+ CCOD_INVALID_HANDLE_VALUE = -1;
+ CCOD_ERROR_HANDLE_EOF = 38;
type
TActivationPhase = (apNone, apStoppingPreviousRuntime, apInstallingRuntime,
apActivatingRuntime, apStartingProtection, apReady, apFailed);
+ TCcodFileTime = record
+ LowDateTime: Cardinal;
+ HighDateTime: Cardinal;
+ end;
+ TCcodByHandleFileInformation = record
+ FileAttributes: Cardinal;
+ CreationTime: TCcodFileTime;
+ LastAccessTime: TCcodFileTime;
+ LastWriteTime: TCcodFileTime;
+ VolumeSerialNumber: Cardinal;
+ FileSizeHigh: Cardinal;
+ FileSizeLow: Cardinal;
+ NumberOfLinks: Cardinal;
+ FileIndexHigh: Cardinal;
+ FileIndexLow: Cardinal;
+ end;
+ TCcodFindStreamData = record
+ StreamSize: Int64;
+ StreamNameBuffer: array[0..591] of Byte;
+ end;
function GetTickCount64(): Int64;
external 'GetTickCount64@kernel32.dll stdcall';
@@ -120,6 +148,25 @@ function StringFromGUID2(var Guid: TGUID; GuidString: String; MaxCharacters: Int
external 'StringFromGUID2@ole32.dll stdcall';
function GetFileAttributesW(const FileName: String): Cardinal;
external 'GetFileAttributesW@kernel32.dll stdcall';
+function CreateFileW(const FileName: String; DesiredAccess, ShareMode,
+ SecurityAttributes, CreationDisposition, FlagsAndAttributes,
+ TemplateFile: Cardinal): Integer;
+ external 'CreateFileW@kernel32.dll stdcall';
+function GetFileInformationByHandle(FileHandle: Integer;
+ var Information: TCcodByHandleFileInformation): Boolean;
+ external 'GetFileInformationByHandle@kernel32.dll stdcall';
+function CloseHandle(Handle: Integer): Boolean;
+ external 'CloseHandle@kernel32.dll stdcall';
+function FindFirstStreamW(const FileName: String; InfoLevel: Integer;
+ var StreamData: TCcodFindStreamData; Flags: Cardinal): Integer;
+ external 'FindFirstStreamW@kernel32.dll stdcall';
+function FindNextStreamW(FindHandle: Integer;
+ var StreamData: TCcodFindStreamData): Boolean;
+ external 'FindNextStreamW@kernel32.dll stdcall';
+function CcodFindClose(FindHandle: Integer): Boolean;
+ external 'FindClose@kernel32.dll stdcall';
+function GetLastError(): Cardinal;
+ external 'GetLastError@kernel32.dll stdcall';
function IsSafeExistingPayloadDirectory(const DirectoryName: String): Boolean;
var
@@ -131,6 +178,50 @@ begin
((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0));
end;
+function HasOnlyDefaultDataStream(const FileName: String): Boolean;
+var
+ FindHandle: Integer;
+ StreamData: TCcodFindStreamData;
+ ErrorCode: Cardinal;
+begin
+ Result := False;
+ FindHandle := FindFirstStreamW(FileName, 0, StreamData, 0);
+ if FindHandle = CCOD_INVALID_HANDLE_VALUE then Exit;
+ try
+ if FindNextStreamW(FindHandle, StreamData) then Exit;
+ ErrorCode := GetLastError();
+ Result := ErrorCode = CCOD_ERROR_HANDLE_EOF;
+ finally
+ CcodFindClose(FindHandle);
+ end;
+end;
+
+function IsSafeExistingSetupLeaf(const FileName: String): Boolean;
+var
+ Attributes: Cardinal;
+ FileHandle: Integer;
+ Information: TCcodByHandleFileInformation;
+begin
+ Result := False;
+ Attributes := GetFileAttributesW(FileName);
+ if (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or
+ ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) or
+ ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then Exit;
+ FileHandle := CreateFileW(FileName, CCOD_FILE_READ_ATTRIBUTES,
+ CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE or CCOD_FILE_SHARE_DELETE,
+ 0, CCOD_OPEN_EXISTING, CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0);
+ if FileHandle = CCOD_INVALID_HANDLE_VALUE then Exit;
+ try
+ if not GetFileInformationByHandle(FileHandle, Information) then Exit;
+ Result := (Information.NumberOfLinks = 1) and
+ ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) and
+ ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) = 0) and
+ HasOnlyDefaultDataStream(FileName);
+ finally
+ CloseHandle(FileHandle);
+ end;
+end;
+
function IsSafeExistingSetupTree(const DirectoryName: String): Boolean;
var
Attributes: Cardinal;
@@ -159,6 +250,12 @@ begin
Result := False;
Exit;
end;
+ if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) and
+ (not IsSafeExistingSetupLeaf(ChildPath)) then
+ begin
+ Result := False;
+ Exit;
+ end;
end;
until not FindNext(FindRec);
finally
diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1
index 329f1ed..e2a7e12 100644
--- a/src/persistence/modules/InstallLifecycle.psm1
+++ b/src/persistence/modules/InstallLifecycle.psm1
@@ -194,6 +194,7 @@ function Assert-CcodLifecycleInstallTreeSafe {
Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install tree contains a reparse point or escaped entry' $full
}
if ($item.PSIsContainer) { $pending.Push($full) }
+ else { [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $full) }
}
}
return $tree
@@ -420,11 +421,126 @@ function Get-CcodLifecycleSourceFiles {
return @($files)
}
+function Get-CcodLifecycleFileLinkCount {
+ param([Parameter(Mandatory)][string]$Path)
+
+ try {
+ if ($null -eq ('CcodLifecycleFileIdentityNative' -as [type])) {
+ Add-Type -TypeDefinition @'
+using System;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+
+public static class CcodLifecycleFileIdentityNative
+{
+ [StructLayout(LayoutKind.Sequential)]
+ public struct FILETIME { public uint Low; public uint High; }
+
+ [StructLayout(LayoutKind.Sequential)]
+ public struct BY_HANDLE_FILE_INFORMATION
+ {
+ public uint FileAttributes;
+ public FILETIME CreationTime;
+ public FILETIME LastAccessTime;
+ public FILETIME LastWriteTime;
+ public uint VolumeSerialNumber;
+ public uint FileSizeHigh;
+ public uint FileSizeLow;
+ public uint NumberOfLinks;
+ public uint FileIndexHigh;
+ public uint FileIndexLow;
+ }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information);
+
+ public static uint GetLinkCount(string path)
+ {
+ const uint FILE_READ_ATTRIBUTES = 0x80;
+ const uint FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, FILE_SHARE_DELETE = 4;
+ const uint OPEN_EXISTING = 3, FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
+ using (SafeFileHandle handle = CreateFileW(path, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+ IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero))
+ {
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
+ BY_HANDLE_FILE_INFORMATION information;
+ if (!GetFileInformationByHandle(handle, out information)) throw new Win32Exception(Marshal.GetLastWin32Error());
+ return information.NumberOfLinks;
+ }
+ }
+}
+'@
+ }
+ return [uint32][CcodLifecycleFileIdentityNative]::GetLinkCount([IO.Path]::GetFullPath($Path))
+ } catch {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_UNSAFE_LEAF' 'Unable to prove the install leaf link identity' $Path
+ }
+}
+
+function Assert-CcodLifecycleInstallLeafSafe {
+ param(
+ [Parameter(Mandatory)][string]$InstallRoot,
+ [Parameter(Mandatory)][string]$Path,
+ [switch]$AllowMissing
+ )
+
+ $candidate = Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $Path
+ if (-not [IO.File]::Exists($candidate)) {
+ if ([IO.Directory]::Exists($candidate) -or -not $AllowMissing) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_UNSAFE_LEAF' 'Install leaf is missing or not a regular file' $candidate
+ }
+ return $candidate
+ }
+ $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop
+ $hasAds = $true
+ try { $hasAds = Test-CcodLifecycleAlternateDataStreams -Path $candidate } catch {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_UNSAFE_LEAF' 'Install leaf alternate streams could not be proven absent' $candidate
+ }
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
+ $hasAds -or (Get-CcodLifecycleFileLinkCount -Path $candidate) -ne 1) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_UNSAFE_LEAF' 'Install leaf must be a regular non-reparse single-link file without alternate streams' $candidate
+ }
+ return $candidate
+}
+
+function Copy-CcodLifecycleFileAtomically {
+ param([Parameter(Mandatory)][string]$Source,[Parameter(Mandatory)][string]$Destination)
+
+ $destination = [IO.Path]::GetFullPath($Destination)
+ $parent = Split-Path $destination -Parent
+ [IO.Directory]::CreateDirectory($parent) | Out-Null
+ $temporary = Join-Path $parent ('.ccod-copy-' + [guid]::NewGuid().ToString('N') + '.tmp')
+ try {
+ [IO.File]::Copy([IO.Path]::GetFullPath($Source),$temporary,$false)
+ if ([IO.File]::Exists($destination)) {
+ [IO.File]::Replace($temporary,$destination,$null,$true)
+ } else {
+ [IO.File]::Move($temporary,$destination)
+ }
+ } finally {
+ if ([IO.File]::Exists($temporary)) { try { [IO.File]::Delete($temporary) } catch { } }
+ }
+}
+
+function Get-CcodLifecycleBytesSha256 {
+ param([Parameter(Mandatory)][byte[]]$Bytes)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() }
+ finally { $sha.Dispose() }
+}
+
function Get-CcodLifecyclePayloadManifestFiles {
param(
[Parameter(Mandatory)][string]$SourceRoot,
[Parameter(Mandatory)][string]$ExpectedVersion,
- [Parameter(Mandatory)][string]$PayloadManifestPath
+ [Parameter(Mandatory)][string]$PayloadManifestPath,
+ [Parameter(Mandatory)][string]$ExpectedPayloadManifestSha256,
+ [Parameter(Mandatory)][string]$PayloadManifestBytesBase64
)
if ($ExpectedVersion -cnotmatch '^\d+\.\d+\.\d+$') {
@@ -448,7 +564,16 @@ function Get-CcodLifecyclePayloadManifestFiles {
Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest escaped the source root' $manifestPath
}
}
- try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -ErrorAction Stop }
+ if ($ExpectedPayloadManifestSha256 -cnotmatch '^[0-9a-f]{64}$' -or [string]::IsNullOrWhiteSpace($PayloadManifestBytesBase64)) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest byte binding is missing or invalid' $manifestPath
+ }
+ try {
+ $manifestBytes = [Convert]::FromBase64String($PayloadManifestBytesBase64)
+ if ($manifestBytes.Length -le 0 -or $manifestBytes.Length -gt 4194304 -or
+ (Get-CcodLifecycleBytesSha256 -Bytes $manifestBytes) -cne $ExpectedPayloadManifestSha256) { throw 'manifest byte binding' }
+ $manifestText = [Text.UTF8Encoding]::new($false,$true).GetString($manifestBytes)
+ $manifest = $manifestText | ConvertFrom-Json -ErrorAction Stop
+ }
catch { Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest is not valid JSON' $manifestPath }
if ($manifest -isnot [pscustomobject]) {
Throw-CcodLifecycleError 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' 'Payload manifest must be one JSON object' $manifestPath
@@ -570,9 +695,9 @@ function Copy-CcodLifecycleStaging {
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destinationParent)
[IO.Directory]::CreateDirectory($destinationParent) | Out-Null
[void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destinationParent)
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination)
+ [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $destination -AllowMissing)
& $Adapters.CopyFile $file.Source $destination
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $destination)
+ [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $destination)
}
foreach ($file in $Files) {
$destination = [IO.Path]::GetFullPath((Join-Path $stagingDirectory $file.Relative))
@@ -586,18 +711,18 @@ function Copy-CcodLifecycleStaging {
if (-not [IO.File]::Exists($bootstrapSource)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INCOMPLETE' 'The verified staging bootstrap script is missing' $bootstrapSource
}
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath)
+ [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $bootstrapPath -AllowMissing)
& $Adapters.CopyFile $bootstrapSource $bootstrapPath
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath)
+ [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $bootstrapPath)
if ((Get-CcodLifecycleFileSha256 -Path $bootstrapPath) -cne (Get-CcodLifecycleFileSha256 -Path $bootstrapSource)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'The stable bootstrap copy differs from verified staging bytes' $bootstrapPath
}
$copiedStableBootstrap = $true
$uninstallerSource = Join-Path $stagingDirectory 'Uninstall-CodexControlOtherDevices.ps1'
if ([IO.File]::Exists($uninstallerSource)) {
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath)
+ [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $uninstallerPath -AllowMissing)
& $Adapters.CopyFile $uninstallerSource $uninstallerPath
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath)
+ [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $uninstallerPath)
if ((Get-CcodLifecycleFileSha256 -Path $uninstallerPath) -cne (Get-CcodLifecycleFileSha256 -Path $uninstallerSource)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_FILE_HASH_MISMATCH' 'The stable uninstaller copy differs from verified staging bytes' $uninstallerPath
}
@@ -609,10 +734,10 @@ function Copy-CcodLifecycleStaging {
try { [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $InstallRoot -Path $stagingDirectory); Remove-Item -LiteralPath $stagingDirectory -Recurse -Force -ErrorAction Stop } catch { }
}
if ($copiedStableBootstrap -and -not $bootstrapExistedBefore -and [IO.File]::Exists($bootstrapPath)) {
- try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $bootstrapPath); Remove-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $bootstrapPath); Remove-Item -LiteralPath $bootstrapPath -Force -ErrorAction Stop } catch { }
}
if ($copiedStableUninstaller -and -not $uninstallerExistedBefore -and [IO.File]::Exists($uninstallerPath)) {
- try { [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $InstallRoot -Path $uninstallerPath); Remove-Item -LiteralPath $uninstallerPath -Force -ErrorAction Stop } catch { }
+ try { [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $InstallRoot -Path $uninstallerPath); Remove-Item -LiteralPath $uninstallerPath -Force -ErrorAction Stop } catch { }
}
$candidateId = ([string]$_.FullyQualifiedErrorId -split ',')[0]
if ($candidateId -clike 'CCOD_INSTALL_*' -or $candidateId -clike 'CCOD_*') {
@@ -1450,8 +1575,7 @@ function Get-CcodLifecycleAdapters {
}
CopyFile = {
param($Source, $Destination)
- [IO.Directory]::CreateDirectory((Split-Path $Destination -Parent)) | Out-Null
- [IO.File]::Copy($Source, $Destination, $true)
+ Copy-CcodLifecycleFileAtomically -Source $Source -Destination $Destination
}
NewRuntimeManifest = {
param($RuntimeDirectory, $ProjectVersion)
@@ -1750,6 +1874,8 @@ function Invoke-CcodInstall {
[string]$ActivationId,
[string]$ExpectedVersion,
[string]$PayloadManifestPath,
+ [string]$ExpectedPayloadManifestSha256,
+ [string]$PayloadManifestBytesBase64,
[hashtable]$Adapters
)
@@ -1775,12 +1901,18 @@ function Invoke-CcodInstall {
if (-not [IO.Directory]::Exists($sourceRoot)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_MISSING' 'Source checkout does not exist' $sourceRoot
}
- $payloadBound = -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -or -not [string]::IsNullOrWhiteSpace($PayloadManifestPath)
- if ($payloadBound -and ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or [string]::IsNullOrWhiteSpace($PayloadManifestPath))) {
- Throw-CcodLifecycleError 'CCOD_INSTALL_INPUT_INVALID' 'ExpectedVersion and PayloadManifestPath must be supplied together' $null
+ $payloadBound = -not [string]::IsNullOrWhiteSpace($ExpectedVersion) -or
+ -not [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or
+ -not [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256) -or
+ -not [string]::IsNullOrWhiteSpace($PayloadManifestBytesBase64)
+ if ($payloadBound -and ([string]::IsNullOrWhiteSpace($ExpectedVersion) -or
+ [string]::IsNullOrWhiteSpace($PayloadManifestPath) -or
+ [string]::IsNullOrWhiteSpace($ExpectedPayloadManifestSha256) -or
+ [string]::IsNullOrWhiteSpace($PayloadManifestBytesBase64))) {
+ Throw-CcodLifecycleError 'CCOD_INSTALL_INPUT_INVALID' 'ExpectedVersion, manifest path, hash, and exact bytes must be supplied together' $null
}
$files = if ($payloadBound) {
- @(Get-CcodLifecyclePayloadManifestFiles -SourceRoot $sourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $PayloadManifestPath)
+ @(Get-CcodLifecyclePayloadManifestFiles -SourceRoot $sourceRoot -ExpectedVersion $ExpectedVersion -PayloadManifestPath $PayloadManifestPath -ExpectedPayloadManifestSha256 $ExpectedPayloadManifestSha256 -PayloadManifestBytesBase64 $PayloadManifestBytesBase64)
} else { $null }
if (-not (& $adapters.ValidateSource $sourceRoot)) {
Throw-CcodLifecycleError 'CCOD_INSTALL_SOURCE_INVALID' 'Source checkout failed hermetic validation' $sourceRoot
@@ -1858,13 +1990,13 @@ function Invoke-CcodInstall {
$stagingDirectory = Copy-CcodLifecycleStaging -SourceRoot $sourceRoot -InstallRoot $root -Adapters $adapters -Files $files
$manifest = & $adapters.NewRuntimeManifest $stagingDirectory $projectVersion
$stagingManifestPath = Join-Path $stagingDirectory 'manifest.json'
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingManifestPath)
+ [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $stagingManifestPath -AllowMissing)
[IO.File]::WriteAllText(
$stagingManifestPath,
($manifest | ConvertTo-Json -Depth 16),
[Text.UTF8Encoding]::new($false)
)
- [void](Assert-CcodLifecycleInstallPathSafe -InstallRoot $root -Path $stagingManifestPath)
+ [void](Assert-CcodLifecycleInstallLeafSafe -InstallRoot $root -Path $stagingManifestPath)
$runtimeId = [string]$manifest.runtimeId
$validation = Test-CcodRuntimeManifest -RuntimeDirectory $stagingDirectory -ExpectedRuntimeId $runtimeId
if (-not $validation.Valid) {
diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1
index 538a5bd..55e5af8 100644
--- a/tests/persistence/InstallLifecycle.SelfTest.ps1
+++ b/tests/persistence/InstallLifecycle.SelfTest.ps1
@@ -88,6 +88,20 @@ function New-CcodLifecyclePayloadManifest {
return $manifestPath
}
+function Get-CcodLifecyclePayloadBinding {
+ param(
+ [Parameter(Mandatory)][string]$ManifestPath,
+ [Parameter(Mandatory)][string]$ExpectedVersion
+ )
+ $bytes = [IO.File]::ReadAllBytes($ManifestPath)
+ return @{
+ ExpectedVersion = $ExpectedVersion
+ PayloadManifestPath = $ManifestPath
+ ExpectedPayloadManifestSha256 = Get-CcodTestFileSha256 -Path $ManifestPath
+ PayloadManifestBytesBase64 = [Convert]::ToBase64String($bytes)
+ }
+}
+
function New-CcodLifecycleFakeNode {
param([Parameter(Mandatory)][string]$Root)
New-Item -ItemType Directory -Path (Join-Path $Root 'node') -Force | Out-Null
@@ -681,8 +695,9 @@ $results += Invoke-CcodTest 'upgrade rejects a payload version mismatch before a
$before = Read-CcodActiveRuntime -InstallRoot $install
$fake = New-CcodLifecycleFake -NodePath $nodePath
+ $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22'
Assert-CcodThrows {
- Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters | Out-Null
} 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH'
$after = Read-CcodActiveRuntime -InstallRoot $install
@@ -707,7 +722,8 @@ $results += Invoke-CcodTest 'matching payload stages only immutable manifest-lis
[IO.File]::WriteAllText($stalePath,"module.exports = 'stale';`n",[Text.UTF8Encoding]::new($false))
$nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
- $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters
+ $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22'
+ $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters
$runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)"
Assert-CcodTrue (-not (Test-Path -LiteralPath (Join-Path $runtimeRoot 'src\runtime\stale-old-payload.js'))) 'unlisted stale source file is absent from the runtime'
@@ -737,8 +753,9 @@ $results += Invoke-CcodTest 'payload-bound install rereads the active pointer af
[pscustomobject][ordered]@{ SupervisorReady = $true; TrayReady = $true }
}.GetNewClosure()
+ $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22'
Assert-CcodThrows {
- Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters | Out-Null
} 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN'
Assert-CcodTrue ($fake.World.Phases -notcontains 'Ready') 'pointer drift after readiness cannot produce a Ready receipt'
} finally {
@@ -770,7 +787,8 @@ $results += Invoke-CcodTest 'stable bootstrap and uninstaller come from manifest
}.GetNewClosure()
}
- $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters
+ $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22'
+ $receipt = Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters
$runtimeRoot = Join-Path $install "runtime\$($receipt.RuntimeId)"
Assert-CcodEqual (Get-CcodTestFileSha256 -Path (Join-Path $runtimeRoot 'src\persistence\bootstrap.ps1')) (Get-CcodTestFileSha256 -Path (Join-Path $install 'bootstrap.ps1')) 'stable bootstrap equals its verified runtime byte copy'
@@ -861,6 +879,40 @@ $results += Invoke-CcodTest 'stable bootstrap leaf junction is rejected before c
}
}
+# Production mutation caught: treating a non-reparse hard link as a safe overwrite target and changing bytes outside the install root.
+$results += Invoke-CcodTest 'stable bootstrap and public uninstaller reject hard-linked target leaves before outside bytes change' {
+ foreach ($case in @(
+ [pscustomobject]@{Name='bootstrap';Leaf='bootstrap.ps1';AddUninstaller=$false},
+ [pscustomobject]@{Name='uninstaller';Leaf='Uninstall-CodexControlOtherDevices.ps1';AddUninstaller=$true}
+ )) {
+ $source = New-CcodLifecycleTempRoot
+ $install = New-CcodLifecycleTempRoot
+ $outside = New-CcodLifecycleTempRoot
+ $nodeRoot = New-CcodLifecycleTempRoot
+ try {
+ New-CcodLifecycleSourceFixture -Root $source | Out-Null
+ if ($case.AddUninstaller) {
+ [IO.File]::WriteAllText((Join-Path $source 'Uninstall-CodexControlOtherDevices.ps1'),'# verified uninstaller',[Text.UTF8Encoding]::new($false))
+ }
+ [IO.Directory]::CreateDirectory($install) | Out-Null
+ [IO.Directory]::CreateDirectory($outside) | Out-Null
+ $sentinel = Join-Path $outside ($case.Name + '-sentinel.txt')
+ [IO.File]::WriteAllText($sentinel,'outside-original',[Text.UTF8Encoding]::new($false))
+ $targetLeaf = Join-Path $install $case.Leaf
+ New-Item -ItemType HardLink -Path $targetLeaf -Target $sentinel | Out-Null
+ $nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
+
+ Assert-CcodThrows {
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters | Out-Null
+ } 'CCOD_INSTALL_UNSAFE_LEAF'
+ Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText($sentinel,[Text.UTF8Encoding]::new($false))) "$($case.Name) hard-link sentinel remains unchanged"
+ Assert-CcodTrue (-not [IO.File]::Exists((Join-Path $install 'active.json'))) "$($case.Name) hard-link rejection cannot commit an active pointer"
+ } finally {
+ foreach ($path in @($source,$install,$outside,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } }
+ }
+ }
+}
+
$results += Invoke-CcodTest 'runtime manifest leaf junction is rejected before write' {
$source = New-CcodLifecycleTempRoot
$install = New-CcodLifecycleTempRoot
@@ -930,7 +982,8 @@ $results += Invoke-CcodTest 'payload manifest ancestry treats Windows root equal
New-CcodLifecycleSourceFixture -Root $source -Version '2.5.22' | Out-Null
$manifestPath = New-CcodLifecyclePayloadManifest -Root $source -Version '2.5.22'
$nodePath = New-CcodLifecycleFakeNode -Root $nodeRoot
- $receipt = Invoke-CcodInstall -SourceRoot $source.ToUpperInvariant() -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters
+ $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22'
+ $receipt = Invoke-CcodInstall -SourceRoot $source.ToUpperInvariant() -InstallRoot $install @payloadBinding -Adapters (New-CcodLifecycleFake -NodePath $nodePath).Adapters
Assert-CcodEqual 'Installed' $receipt.Outcome 'case-only source-root difference preserves valid Windows payload ancestry'
} finally {
foreach ($path in @($source,$install,$nodeRoot)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
@@ -954,8 +1007,9 @@ $results += Invoke-CcodTest 'payload-bound install rejects a changed runtime man
[IO.File]::WriteAllText($runtimeManifestPath,($runtimeManifest|ConvertTo-Json -Depth 16),[Text.UTF8Encoding]::new($false))
[pscustomobject][ordered]@{SupervisorReady=$true;TrayReady=$true}
}.GetNewClosure()
+ $payloadBinding = Get-CcodLifecyclePayloadBinding -ManifestPath $manifestPath -ExpectedVersion '2.5.22'
Assert-CcodThrows {
- Invoke-CcodInstall -SourceRoot $source -InstallRoot $install -ExpectedVersion '2.5.22' -PayloadManifestPath $manifestPath -Adapters $fake.Adapters | Out-Null
+ Invoke-CcodInstall -SourceRoot $source -InstallRoot $install @payloadBinding -Adapters $fake.Adapters | Out-Null
} 'CCOD_INSTALL_RUNTIME_ACTIVATION_UNPROVEN'
Assert-CcodTrue ($fake.World.Phases -notcontains 'Ready') 'changed selected runtime manifest cannot produce Ready'
} finally {
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 3fa3cea..e75107d 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -1211,7 +1211,7 @@ Invoke-CcodTest 'setup build and activation bind one immutable versioned payload
Assert-CcodTrue ($inno -cmatch 'InstallerPayloadDirectory' -and $inno -cmatch 'DestDir:\s*"\{app\}\\payload\\\{#ProjectVersion\}"') 'Inno copies the immutable build payload into its exact version directory'
Assert-CcodTrue ($inno -cmatch "ExpandConstant\('\{app\}\\payload\\\{#ProjectVersion\}'\)" -and $inno -cmatch '-ExpectedVersion\s+"\{#ProjectVersion\}') 'Inno binds activation to its compiled payload version'
Assert-CcodTrue ($activation -cmatch '\[string\]\$ExpectedVersion' -and $activation -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $activation -cmatch 'installer-payload\.manifest\.json') 'activation accepts and resolves the expected payload contract'
- Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '-ExpectedVersion\s+\$ExpectedVersion' -and $installer -cmatch '-PayloadManifestPath\s+\$PayloadManifestPath') 'installer forwards the immutable payload contract to lifecycle activation'
+ Assert-CcodTrue ($installer -cmatch '\[string\]\$ExpectedVersion' -and $installer -cmatch '\[string\]\$PayloadManifestPath' -and $installer -cmatch '\[string\]\$ExpectedPayloadManifestSha256' -and $installer -cmatch 'Open-CcodInstallerPayloadSeal' -and $installer -cmatch 'PayloadManifestBytesBase64') 'installer revalidates and forwards the exact immutable payload bytes to lifecycle activation'
}
# Production mutation caught: reopening mutable payload paths after the parent accepted their manifest bytes.
@@ -1434,6 +1434,9 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
$inno = Get-Content -LiteralPath (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss') -Raw -Encoding UTF8
$helper = [regex]::Match($inno,'(?ms)^function IsSafeExistingPayloadDirectory\(.*?^end;')
Assert-CcodTrue $helper.Success 'production Inno script exposes the directory predicate used before payload writes'
+ $streamHelper = [regex]::Match($inno,'(?ms)^function HasOnlyDefaultDataStream\(.*?^end;')
+ $leafHelper = [regex]::Match($inno,'(?ms)^function IsSafeExistingSetupLeaf\(.*?^end;')
+ Assert-CcodTrue ($streamHelper.Success -and $leafHelper.Success) 'production Inno script exposes native stream and link-count predicates for existing leaves'
$treeHelper = [regex]::Match($inno,'(?ms)^function IsSafeExistingSetupTree\(.*?^end;')
Assert-CcodTrue $treeHelper.Success 'production Inno script exposes a recursive destination-tree predicate'
$inventoryValidator = [regex]::Match($inno,'(?ms)^function AreCcodExpectedSetupDirectoriesSafe\(.*?^end;')
@@ -1452,6 +1455,9 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
$expectedJunctionRoot = Join-Path $root 'expected-junction-root'
$normalExpectedRoot = Join-Path $root 'normal-expected-root'
$inventoryPayload = Join-Path $root 'inventory-payload'
+ $hardLinkTree = Join-Path $root 'hardlink-tree'
+ $outsideHardLink = Join-Path $root 'outside-hardlink-sentinel.txt'
+ $hardLinkLeaf = Join-Path $hardLinkTree 'existing-leaf.txt'
$fileWriteMarker = Join-Path $expectedFileRoot 'payload-write-marker.txt'
$junctionWriteMarker = Join-Path $expectedJunctionRoot 'payload-write-marker.txt'
$resultPath = Join-Path $root 'result.txt'
@@ -1466,6 +1472,9 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
[IO.Directory]::CreateDirectory((Join-Path $expectedJunctionRoot 'payload\2.5.22\src')) | Out-Null
New-Item -ItemType Junction -Path (Join-Path $expectedJunctionRoot 'payload\2.5.22\src\persistence') -Target $target | Out-Null
[IO.Directory]::CreateDirectory($normalExpectedRoot) | Out-Null
+ [IO.Directory]::CreateDirectory($hardLinkTree) | Out-Null
+ [IO.File]::WriteAllText($outsideHardLink,'outside-original',[Text.UTF8Encoding]::new($false))
+ New-Item -ItemType HardLink -Path $hardLinkLeaf -Target $outsideHardLink | Out-Null
[IO.Directory]::CreateDirectory((Join-Path $inventoryPayload 'src\persistence\modules')) | Out-Null
[IO.File]::WriteAllText((Join-Path $inventoryPayload 'src\persistence\modules\InstallLifecycle.psm1'),'fixture',[Text.UTF8Encoding]::new($false))
$inventoryPath = Join-Path $root 'InventoryFixture.iss'
@@ -1485,10 +1494,60 @@ Uninstallable=no
const
CCOD_FILE_ATTRIBUTE_DIRECTORY = `$00000010;
CCOD_FILE_ATTRIBUTE_REPARSE_POINT = `$00000400;
+ CCOD_FILE_READ_ATTRIBUTES = `$00000080;
+ CCOD_FILE_SHARE_READ = `$00000001;
+ CCOD_FILE_SHARE_WRITE = `$00000002;
+ CCOD_FILE_SHARE_DELETE = `$00000004;
+ CCOD_OPEN_EXISTING = 3;
+ CCOD_FILE_FLAG_OPEN_REPARSE_POINT = `$00200000;
CCOD_INVALID_FILE_ATTRIBUTES = `$FFFFFFFF;
+ CCOD_INVALID_HANDLE_VALUE = -1;
+ CCOD_ERROR_HANDLE_EOF = 38;
+type
+ TCcodFileTime = record
+ LowDateTime: Cardinal;
+ HighDateTime: Cardinal;
+ end;
+ TCcodByHandleFileInformation = record
+ FileAttributes: Cardinal;
+ CreationTime: TCcodFileTime;
+ LastAccessTime: TCcodFileTime;
+ LastWriteTime: TCcodFileTime;
+ VolumeSerialNumber: Cardinal;
+ FileSizeHigh: Cardinal;
+ FileSizeLow: Cardinal;
+ NumberOfLinks: Cardinal;
+ FileIndexHigh: Cardinal;
+ FileIndexLow: Cardinal;
+ end;
+ TCcodFindStreamData = record
+ StreamSize: Int64;
+ StreamNameBuffer: array[0..591] of Byte;
+ end;
function GetFileAttributesW(const FileName: String): Cardinal;
external 'GetFileAttributesW@kernel32.dll stdcall';
+function CreateFileW(const FileName: String; DesiredAccess, ShareMode,
+ SecurityAttributes, CreationDisposition, FlagsAndAttributes,
+ TemplateFile: Cardinal): Integer;
+ external 'CreateFileW@kernel32.dll stdcall';
+function GetFileInformationByHandle(FileHandle: Integer;
+ var Information: TCcodByHandleFileInformation): Boolean;
+ external 'GetFileInformationByHandle@kernel32.dll stdcall';
+function CloseHandle(Handle: Integer): Boolean;
+ external 'CloseHandle@kernel32.dll stdcall';
+function FindFirstStreamW(const FileName: String; InfoLevel: Integer;
+ var StreamData: TCcodFindStreamData; Flags: Cardinal): Integer;
+ external 'FindFirstStreamW@kernel32.dll stdcall';
+function FindNextStreamW(FindHandle: Integer;
+ var StreamData: TCcodFindStreamData): Boolean;
+ external 'FindNextStreamW@kernel32.dll stdcall';
+function CcodFindClose(FindHandle: Integer): Boolean;
+ external 'FindClose@kernel32.dll stdcall';
+function GetLastError(): Cardinal;
+ external 'GetLastError@kernel32.dll stdcall';
$($helper.Value)
+$($streamHelper.Value)
+$($leafHelper.Value)
$($treeHelper.Value)
$inventorySource
$($inventoryValidator.Value)
@@ -1498,11 +1557,14 @@ begin
SaveStringToFile('$($fileWriteMarker.Replace("'","''"))','unsafe write',False);
if AreCcodExpectedSetupDirectoriesSafe('$($expectedJunctionRoot.Replace("'","''"))') then
SaveStringToFile('$($junctionWriteMarker.Replace("'","''"))','unsafe write',False);
+ if IsSafeExistingSetupTree('$($hardLinkTree.Replace("'","''"))') then
+ SaveStringToFile('$($hardLinkLeaf.Replace("'","''"))','unsafe overwrite',False);
if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and
IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and
(not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) and
(not IsSafeExistingSetupTree('$($tree.Replace("'","''"))')) and
(not IsSafeExistingSetupTree('$($fileDirectory.Replace("'","''"))')) and
+ (not IsSafeExistingSetupTree('$($hardLinkTree.Replace("'","''"))')) and
AreCcodExpectedSetupDirectoriesSafe('$($normalExpectedRoot.Replace("'","''"))') and
(not AreCcodExpectedSetupDirectoriesSafe('$($expectedFileRoot.Replace("'","''"))')) and
(not AreCcodExpectedSetupDirectoriesSafe('$($expectedJunctionRoot.Replace("'","''"))')) then
@@ -1519,6 +1581,7 @@ end;
Assert-CcodEqual 'pass' ([IO.File]::ReadAllText($resultPath,[Text.UTF8Encoding]::new($false))) 'production predicate rejects root/nested junctions and file-valued directory paths'
Assert-CcodTrue (-not (Test-Path -LiteralPath $fileWriteMarker)) 'nested file-as-directory is rejected before simulated payload writes'
Assert-CcodTrue (-not (Test-Path -LiteralPath $junctionWriteMarker)) 'nested junction is rejected before simulated payload writes'
+ Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText($outsideHardLink,[Text.UTF8Encoding]::new($false))) 'hard-linked setup leaf is rejected before simulated outside overwrite'
} finally {
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
}
From ca5a5f71f0348d8f6cb61b033405dfcaac6917ef Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:42:46 +0800
Subject: [PATCH 052/187] fix: bind setup artifact provenance
---
CHANGELOG.md | 8 +-
README.md | 2 +-
README.zh-CN.md | 2 +-
build/CodexControlOtherDevices.iss | 14 ++
build/SetupArtifact.psm1 | 185 ++++++++++++++++++
build/build.ps1 | 90 ++++++---
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 89 ++++++++-
tools/New-InstallerDestinationInventory.ps1 | 8 +-
tools/Test-ReleaseDefender.ps1 | 21 +-
9 files changed, 378 insertions(+), 41 deletions(-)
create mode 100644 build/SetupArtifact.psm1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78dac76..e28d9b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,15 +10,15 @@ No unreleased changes.
### English
-- Bound Setup activation to the requested version and a hash-verified installer payload manifest, then revalidated the active runtime before reporting Ready.
-- Restricted Setup compilation to one generated, inventoried input and rejected unapproved preprocessor constructs before ISCC runs.
+- Bound Setup activation to the exact verified manifest, installer, and module bytes across child launch, then revalidated the selected runtime before reporting Ready.
+- Rejected hard-linked install leaves before writes and added independent Setup PE/provenance validation for version, commit, and the activation payload-manifest hash.
- Added bounded same-root process-tree rereads while preserving strict root and per-process identity checks during close and remote rebind.
- Correlated native tray actions with the acknowledged presentation revision and durable, bounded terminal diagnostics; stale actions now produce a sanitized support code.
### 简体中文
-- 将 Setup 激活绑定到请求版本和经哈希校验的安装载荷 manifest,并在报告 Ready 前重新校验活动 runtime。
-- Setup 只编译一份已清点目录的生成输入;任何未获准的预处理构造都会在 ISCC 运行前被拒绝。
+- 将 Setup 激活绑定到跨子进程启动仍保持不变的 manifest、installer 与 module 精确字节,并在报告 Ready 前重新校验所选 runtime。
+- 在写入前拒绝安装目录中的硬链接叶子,并独立校验 Setup PE/构建 provenance 所绑定的版本、commit 与激活 payload-manifest 哈希。
- 为同一根进程加入有界 process-tree 重读,同时在关闭和远程重绑定时继续严格校验根进程与每个成员的身份。
- 将原生托盘操作绑定到已确认的 presentation revision 和持久、有界的终态诊断;过期操作会记录清理后的支持代码。
diff --git a/README.md b/README.md
index dd815b0..76c384a 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
> [!NOTE]
-> **Release status:** v2.5.22 is the current stable release. The setup installer and EXE-based portable ZIP are version- and manifest-bound release assets.
+> **Release status:** v2.5.22 is the current release candidate. Its setup installer and EXE-based portable ZIP are version- and manifest-bound; stable Windows acceptance still requires recorded install, upgrade, reboot, repair, UI, and Defender verification.
CodexRemote-fix enables the UI that ships with Codex Desktop for Windows but is hidden by a runtime defect:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 7acbcf6..ba6c6fe 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -16,7 +16,7 @@
> [!NOTE]
-> **发布状态:** v2.5.22 是当前稳定版。安装包与以 EXE 启动的便携 ZIP 都是绑定版本和 manifest 的发布资产。
+> **发布状态:** v2.5.22 是当前候选发布版。安装包与以 EXE 启动的便携 ZIP 都已绑定版本和 manifest;Windows 稳定版验收仍需记录安装、升级、重启、修复、UI 与 Defender 的实机验证结果。
CodexRemote-fix 在 Windows 版 Codex Desktop 中启用随应用一起打包、但因运行时缺陷被隐藏的:
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index b99a09d..c77f35c 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -11,6 +11,12 @@
#ifndef InstallerPayloadManifestSha256
#error InstallerPayloadManifestSha256 must be supplied by the release builder
#endif
+#ifndef SetupGitCommit
+#error SetupGitCommit must be supplied by the release builder
+#endif
+#ifndef SetupProvenancePath
+#error SetupProvenancePath must be supplied by the release builder
+#endif
AppId={{2B9E9F2E-7A32-4A7E-9C1D-9F5B5C6D7E8F}
AppName=CodexRemote-fix
AppVersion={#ProjectVersion}
@@ -20,6 +26,12 @@ AppPublisherURL=https://github.com/naipi11/CodexRemote-fix
AppSupportURL=https://github.com/naipi11/CodexRemote-fix/issues
AppUpdatesURL=https://github.com/naipi11/CodexRemote-fix/releases
VersionInfoVersion={#ProjectVersion}.0
+VersionInfoTextVersion={#ProjectVersion}.0
+VersionInfoProductName=CodexRemote-fix
+VersionInfoProductTextVersion={#ProjectVersion}.0
+VersionInfoDescription=CCODSETUP {#ProjectVersion}
+VersionInfoCompany={#SetupGitCommit}
+VersionInfoCopyright={#InstallerPayloadManifestSha256}
DefaultDirName={localappdata}\CodexControlOtherDevices-installer
DefaultGroupName=CodexRemote-fix
UsePreviousGroup=no
@@ -43,6 +55,7 @@ MinVersion=10.0.17763
Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
+Source: "{#SetupProvenancePath}"; DestDir: "{app}"; DestName: "setup-provenance.json"; Flags: dontcopy
Source: "..\README.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\LICENSE"; DestDir: "{app}"; Flags: ignoreversion
@@ -51,6 +64,7 @@ Source: "..\SECURITY.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\package.json"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\build\CodexControlOtherDevices.iss"; DestDir: "{app}\build"; Flags: ignoreversion
Source: "..\build\build.ps1"; DestDir: "{app}\build"; Flags: ignoreversion
+Source: "..\build\SetupArtifact.psm1"; DestDir: "{app}\build"; Flags: ignoreversion
Source: "..\build\build-trayhost.ps1"; DestDir: "{app}\build"; Flags: ignoreversion
Source: "..\build\TrayHostBuild.psm1"; DestDir: "{app}\build"; Flags: ignoreversion
Source: "..\build\TrayHostReferencePack.psm1"; DestDir: "{app}\build"; Flags: ignoreversion
diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1
new file mode 100644
index 0000000..58995fe
--- /dev/null
+++ b/build/SetupArtifact.psm1
@@ -0,0 +1,185 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Throw-CcodSetupArtifactError {
+ param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Message,$Target)
+ throw [Management.Automation.ErrorRecord]::new(
+ [InvalidOperationException]::new($Message),
+ $Id,
+ [Management.Automation.ErrorCategory]::InvalidData,
+ $Target
+ )
+}
+
+function Get-CcodSetupArtifactHash {
+ param([Parameter(Mandatory)][string]$Path)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ $stream = $null
+ try {
+ $stream = [IO.File]::Open([IO.Path]::GetFullPath($Path),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read)
+ return [BitConverter]::ToString($sha.ComputeHash($stream)).Replace('-','').ToLowerInvariant()
+ } finally {
+ if ($null -ne $stream) { $stream.Dispose() }
+ $sha.Dispose()
+ }
+}
+
+function Test-CcodSetupCanonicalUtc {
+ param([AllowNull()][string]$Value)
+ if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
+ $parsed = [DateTime]::MinValue
+ return [DateTime]::TryParseExact($Value,'o',[Globalization.CultureInfo]::InvariantCulture,[Globalization.DateTimeStyles]::RoundtripKind,[ref]$parsed) -and
+ $parsed.Kind -eq [DateTimeKind]::Utc -and
+ $parsed.ToUniversalTime().ToString('o',[Globalization.CultureInfo]::InvariantCulture) -ceq $Value
+}
+
+function Get-CcodSetupDescription {
+ param([Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version)
+ return "CCODSETUP $Version"
+}
+
+function Assert-CcodSetupRegularFile {
+ param([Parameter(Mandatory)][string]$Path,[Parameter(Mandatory)][string]$Kind)
+ $full = [IO.Path]::GetFullPath($Path)
+ if (-not [IO.File]::Exists($full)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_ARTIFACT_INVALID' "$Kind is missing" $full }
+ $item = Get-Item -LiteralPath $full -Force -ErrorAction Stop
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_ARTIFACT_INVALID' "$Kind must be a regular non-reparse file" $full
+ }
+ return $full
+}
+
+function Test-CcodSetupArtifact {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$SetupPath,
+ [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPayloadManifestSha256
+ )
+
+ $setup = Assert-CcodSetupRegularFile -Path $SetupPath -Kind 'Setup artifact'
+ $expectedPeVersion = "$ExpectedVersion.0"
+ $expectedDescription = Get-CcodSetupDescription -Version $ExpectedVersion
+ try { $versionInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo($setup) }
+ catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PE_VERSION_INVALID' 'Setup PE version metadata could not be read' $setup }
+ $fileVersion = ([string]$versionInfo.FileVersion).Trim()
+ $productVersion = ([string]$versionInfo.ProductVersion).Trim()
+ $productName = ([string]$versionInfo.ProductName).Trim()
+ $fileDescription = ([string]$versionInfo.FileDescription).Trim()
+ $companyName = ([string]$versionInfo.CompanyName).Trim()
+ $legalCopyright = ([string]$versionInfo.LegalCopyright).Trim()
+ if ($fileVersion -cne $expectedPeVersion -or $productVersion -cne $expectedPeVersion) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PE_VERSION_INVALID' ("Setup PE FileVersion/ProductVersion do not match the release version: file={0}; product={1}; expected={2}" -f [string]$versionInfo.FileVersion,[string]$versionInfo.ProductVersion,$expectedPeVersion) $setup
+ }
+ if ($productName -cne 'CodexRemote-fix' -or $fileDescription -cne $expectedDescription -or
+ $companyName -cne $ExpectedGitCommit -or $legalCopyright -cne $ExpectedPayloadManifestSha256) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PAYLOAD_BINDING_INVALID' ("Setup PE does not bind the expected version, commit, and activation payload manifest hash: product={0}; description={1}; company={2}; copyright={3}" -f $productName,$fileDescription,$companyName,$legalCopyright) $setup
+ }
+ return [pscustomobject][ordered]@{
+ Valid = $true
+ Sha256 = Get-CcodSetupArtifactHash -Path $setup
+ FileVersion = $fileVersion
+ ProductVersion = $productVersion
+ ProductName = $productName
+ FileDescription = $fileDescription
+ CompanyName = $companyName
+ LegalCopyright = $legalCopyright
+ }
+}
+
+function New-CcodSetupBuildProvenance {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$GitCommit,
+ [Parameter(Mandatory)][string]$BuildTimestampUtc,
+ [Parameter(Mandatory)][string]$PayloadManifestPath,
+ [Parameter(Mandatory)][string]$InnoTemplatePath,
+ [Parameter(Mandatory)][string]$DestinationInventoryPath,
+ [Parameter(Mandatory)][string]$CompilerPath,
+ [Parameter(Mandatory)][string]$OutputPath
+ )
+
+ if (-not (Test-CcodSetupCanonicalUtc $BuildTimestampUtc)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance timestamp is not canonical UTC' $BuildTimestampUtc }
+ $payload = Assert-CcodSetupRegularFile -Path $PayloadManifestPath -Kind 'Installer payload manifest'
+ $template = Assert-CcodSetupRegularFile -Path $InnoTemplatePath -Kind 'Inno template'
+ $inventory = Assert-CcodSetupRegularFile -Path $DestinationInventoryPath -Kind 'Destination inventory'
+ $compiler = Assert-CcodSetupRegularFile -Path $CompilerPath -Kind 'Inno compiler'
+ try { $payloadRecord = [IO.File]::ReadAllText($payload,[Text.UTF8Encoding]::new($false)) | ConvertFrom-Json -ErrorAction Stop }
+ catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer payload manifest is invalid JSON' $payload }
+ if ($payloadRecord.schemaVersion -isnot [int] -or $payloadRecord.schemaVersion -ne 1 -or
+ $payloadRecord.projectVersion -isnot [string] -or $payloadRecord.projectVersion -cne $Version -or
+ @($payloadRecord.files).Count -eq 0) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Installer payload manifest metadata is invalid' $payload }
+ $payloadHash = Get-CcodSetupArtifactHash -Path $payload
+ $compilerVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo($compiler)
+ $record = [ordered]@{
+ schemaVersion = 1
+ product = 'CodexRemote-fix'
+ version = $Version
+ gitCommit = $GitCommit
+ buildTimestampUtc = $BuildTimestampUtc
+ payloadManifest = [ordered]@{
+ name = 'installer-payload.manifest.json'
+ length = [int64](Get-Item -LiteralPath $payload -Force).Length
+ sha256 = $payloadHash
+ fileCount = [int]@($payloadRecord.files).Count
+ }
+ buildInputs = [ordered]@{
+ innoTemplateSha256 = Get-CcodSetupArtifactHash -Path $template
+ destinationInventorySha256 = Get-CcodSetupArtifactHash -Path $inventory
+ compilerSha256 = Get-CcodSetupArtifactHash -Path $compiler
+ compilerFileVersion = [string]$compilerVersion.FileVersion
+ }
+ peContract = [ordered]@{
+ fileVersion = "$Version.0"
+ productVersion = "$Version.0"
+ productName = 'CodexRemote-fix'
+ fileDescription = Get-CcodSetupDescription -Version $Version
+ companyName = $GitCommit
+ legalCopyright = $payloadHash
+ }
+ }
+ $output = [IO.Path]::GetFullPath($OutputPath)
+ if ([IO.File]::Exists($output) -or [IO.Directory]::Exists($output)) { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Refusing to overwrite Setup provenance' $output }
+ [IO.File]::WriteAllText($output,(($record | ConvertTo-Json -Depth 8) + [Environment]::NewLine),[Text.UTF8Encoding]::new($false))
+ return [pscustomobject]$record
+}
+
+function Test-CcodSetupBuildProvenance {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)][string]$ProvenancePath,
+ [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit,
+ [Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPayloadManifestSha256,
+ [string]$ExpectedBuildTimestampUtc
+ )
+ $path = Assert-CcodSetupRegularFile -Path $ProvenancePath -Kind 'Setup provenance'
+ try { $raw = [IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)); $record = $raw | ConvertFrom-Json -ErrorAction Stop }
+ catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is invalid JSON' $path }
+ $fields = @('schemaVersion','product','version','gitCommit','buildTimestampUtc','payloadManifest','buildInputs','peContract')
+ if ($record -isnot [pscustomobject] -or ((@($record.PSObject.Properties.Name | Sort-Object) -join '|') -cne (@($fields | Sort-Object) -join '|')) -or
+ $record.schemaVersion -isnot [int] -or $record.schemaVersion -ne 1 -or $record.product -isnot [string] -or $record.product -cne 'CodexRemote-fix' -or
+ $record.version -isnot [string] -or $record.version -cne $ExpectedVersion -or $record.gitCommit -isnot [string] -or $record.gitCommit -cne $ExpectedGitCommit -or
+ -not (Test-CcodSetupCanonicalUtc ([string]$record.buildTimestampUtc)) -or
+ (-not [string]::IsNullOrWhiteSpace($ExpectedBuildTimestampUtc) -and [string]$record.buildTimestampUtc -cne $ExpectedBuildTimestampUtc) -or
+ $record.payloadManifest.sha256 -isnot [string] -or $record.payloadManifest.sha256 -cne $ExpectedPayloadManifestSha256 -or
+ $record.payloadManifest.name -isnot [string] -or $record.payloadManifest.name -cne 'installer-payload.manifest.json' -or
+ [int64]$record.payloadManifest.length -le 0 -or [int]$record.payloadManifest.fileCount -le 0 -or
+ $record.peContract.fileVersion -isnot [string] -or $record.peContract.fileVersion -cne "$ExpectedVersion.0" -or
+ $record.peContract.productVersion -isnot [string] -or $record.peContract.productVersion -cne "$ExpectedVersion.0" -or
+ $record.peContract.productName -isnot [string] -or $record.peContract.productName -cne 'CodexRemote-fix' -or
+ $record.peContract.fileDescription -isnot [string] -or $record.peContract.fileDescription -cne (Get-CcodSetupDescription -Version $ExpectedVersion) -or
+ $record.peContract.companyName -isnot [string] -or $record.peContract.companyName -cne $ExpectedGitCommit -or
+ $record.peContract.legalCopyright -isnot [string] -or $record.peContract.legalCopyright -cne $ExpectedPayloadManifestSha256 -or
+ $record.buildInputs.innoTemplateSha256 -isnot [string] -or $record.buildInputs.innoTemplateSha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ $record.buildInputs.destinationInventorySha256 -isnot [string] -or $record.buildInputs.destinationInventorySha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ $record.buildInputs.compilerSha256 -isnot [string] -or $record.buildInputs.compilerSha256 -cnotmatch '^[0-9a-f]{64}$' -or
+ $record.buildInputs.compilerFileVersion -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$record.buildInputs.compilerFileVersion)) {
+ Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is not exactly bound to the release contract' $path
+ }
+ return $record
+}
+
+Export-ModuleMember -Function Get-CcodSetupArtifactHash,Get-CcodSetupDescription,New-CcodSetupBuildProvenance,Test-CcodSetupBuildProvenance,Test-CcodSetupArtifact
diff --git a/build/build.ps1 b/build/build.ps1
index 8b900c8..c9cdf94 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -130,6 +130,10 @@ function Assert-CcodBuildInnoPreprocessorLines {
'#error InstallerPayloadDirectory must be supplied by the release builder',
'#ifndef InstallerPayloadManifestSha256',
'#error InstallerPayloadManifestSha256 must be supplied by the release builder',
+ '#ifndef SetupGitCommit',
+ '#error SetupGitCommit must be supplied by the release builder',
+ '#ifndef SetupProvenancePath',
+ '#error SetupProvenancePath must be supplied by the release builder',
'#endif'
)
$allowedInlineConstructs = @(
@@ -137,7 +141,9 @@ function Assert-CcodBuildInnoPreprocessorLines {
'{#TrayHostArtifactDirectory}',
'{#PortableArtifactDirectory}',
'{#InstallerPayloadDirectory}',
- '{#InstallerPayloadManifestSha256}'
+ '{#InstallerPayloadManifestSha256}',
+ '{#SetupGitCommit}',
+ '{#SetupProvenancePath}'
)
for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) {
$line = [string]$Lines[$lineIndex]
@@ -249,6 +255,50 @@ function Invoke-CcodBuildInnoCompiler {
}
}
+function Remove-CcodBuildTemporarySetupInput {
+ param(
+ [Parameter(Mandatory)][string]$BuildRoot,
+ [Parameter(Mandatory)][string]$Path,
+ [Parameter(Mandatory)][ValidateSet('PayloadDirectory','DestinationInventory')][string]$Kind
+ )
+ $root = [IO.Path]::GetFullPath($BuildRoot).TrimEnd('\')
+ $candidate = [IO.Path]::GetFullPath($Path)
+ $parent = [IO.Path]::GetFullPath((Split-Path $candidate -Parent)).TrimEnd('\')
+ $leaf = [IO.Path]::GetFileName($candidate)
+ $expectedPattern = if ($Kind -ceq 'PayloadDirectory') { '^\.installer-payload-stage-[0-9a-f]{32}$' } else { '^\.installer-destination-inventory-[0-9a-f]{32}\.iss$' }
+ if (-not $parent.Equals($root,[StringComparison]::OrdinalIgnoreCase) -or $leaf -cnotmatch $expectedPattern) {
+ throw "Refusing to clean an unexpected temporary Setup input: $candidate"
+ }
+ if ($Kind -ceq 'PayloadDirectory') {
+ if (-not [IO.Directory]::Exists($candidate)) { return }
+ $rootItem = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop
+ if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean a reparse Setup payload stage: $candidate" }
+ foreach ($item in @(Get-ChildItem -LiteralPath $candidate -Force -Recurse -ErrorAction Stop)) {
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean a Setup payload stage containing a reparse point: $($item.FullName)" }
+ }
+ Remove-Item -LiteralPath $candidate -Recurse -Force -ErrorAction Stop
+ return
+ }
+ if (-not [IO.File]::Exists($candidate)) { return }
+ $inventory = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop
+ if ($inventory.PSIsContainer -or ($inventory.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing to clean an unsafe Setup destination inventory: $candidate" }
+ Remove-Item -LiteralPath $candidate -Force -ErrorAction Stop
+}
+
+function Invoke-CcodBuildTemporarySetupScope {
+ param(
+ [Parameter(Mandatory)][string]$BuildRoot,
+ [Parameter(Mandatory)][string]$InstallerPayloadDirectory,
+ [Parameter(Mandatory)][string]$DestinationInventoryPath,
+ [Parameter(Mandatory)][scriptblock]$Action
+ )
+ try { & $Action $InstallerPayloadDirectory $DestinationInventoryPath }
+ finally {
+ Remove-CcodBuildTemporarySetupInput -BuildRoot $BuildRoot -Path $InstallerPayloadDirectory -Kind PayloadDirectory
+ Remove-CcodBuildTemporarySetupInput -BuildRoot $BuildRoot -Path $DestinationInventoryPath -Kind DestinationInventory
+ }
+}
+
if ($Library) { return }
$repoRoot = Split-Path $PSScriptRoot -Parent
@@ -280,13 +330,16 @@ $payloadManifestAsset = Join-Path $dist "CodexRemote-fix-$Version-payload-manife
$releaseManifest = Join-Path $dist "CodexRemote-fix-$Version-release-manifest.json"
$setupExe = Join-Path $dist "CodexRemote-fix-$Version-setup.exe"
$setupChecksum = "$setupExe.sha256.txt"
-foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum)) {
+$setupProvenance = Join-Path $dist "CodexRemote-fix-$Version-setup-provenance.json"
+$setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json"
+foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum,$setupProvenance,$setupReleaseManifest)) {
if ([IO.File]::Exists($path) -or [IO.Directory]::Exists($path)) { throw "Refusing to overwrite immutable release output: $path" }
}
$stageRoot = Join-Path $PSScriptRoot ('.portable-stage-' + [guid]::NewGuid().ToString('N'))
$installerPayloadDirectory = Join-Path $PSScriptRoot ('.installer-payload-stage-' + [guid]::NewGuid().ToString('N'))
$installerDestinationInventoryPath = Join-Path $PSScriptRoot ('.installer-destination-inventory-' + [guid]::NewGuid().ToString('N') + '.iss')
+Invoke-CcodBuildTemporarySetupScope -BuildRoot $PSScriptRoot -InstallerPayloadDirectory $installerPayloadDirectory -DestinationInventoryPath $installerDestinationInventoryPath -Action ({
try {
[IO.Directory]::CreateDirectory($stageRoot) | Out-Null
$payloadRoot = Join-Path $stageRoot 'payload'
@@ -427,18 +480,23 @@ $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'
+Import-Module (Join-Path $PSScriptRoot 'SetupArtifact.psm1') -Force
+$setupProvenanceRecord = New-CcodSetupBuildProvenance -Version $Version -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc -PayloadManifestPath $installerPayloadManifestPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -OutputPath $setupProvenance
+Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc | Out-Null
$isccArguments = @(
"/DProjectVersion=$Version",
"/DTrayHostArtifactDirectory=$trayHostArtifact",
"/DPortableArtifactDirectory=$portableArtifact",
"/DInstallerPayloadDirectory=$installerPayloadDirectory",
"/DInstallerPayloadManifestSha256=$installerPayloadManifestSha256",
+ "/DSetupGitCommit=$gitCommit",
+ "/DSetupProvenancePath=$setupProvenance",
"/O$dist\."
)
Invoke-CcodBuildInnoCompiler -TemplatePath $issPath -InventoryPath $installerDestinationInventoryPath -IsccPath $iscc -Arguments $isccArguments -SetupPath $setupExe
-$setupHash = Get-CcodBuildFileSha256 -Path $setupExe
+$setupValidation = Test-CcodSetupArtifact -SetupPath $setupExe -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256
+$setupHash = [string]$setupValidation.Sha256
Write-CcodBuildUtf8 -Path $setupChecksum -Text ("{0} *{1}" -f $setupHash,[IO.Path]::GetFileName($setupExe))
-$setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json"
$setupRecord = [ordered]@{
schemaVersion = 1
product = 'CodexRemote-fix'
@@ -448,7 +506,8 @@ $setupRecord = [ordered]@{
assets = @(
[ordered]@{ name = [IO.Path]::GetFileName($setupExe); sha256 = $setupHash },
[ordered]@{ name = [IO.Path]::GetFileName($setupChecksum); sha256 = Get-CcodBuildFileSha256 -Path $setupChecksum },
- [ordered]@{ name = [IO.Path]::GetFileName($provenance); sha256 = Get-CcodBuildFileSha256 -Path $provenance }
+ [ordered]@{ name = [IO.Path]::GetFileName($provenance); sha256 = Get-CcodBuildFileSha256 -Path $provenance },
+ [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodBuildFileSha256 -Path $setupProvenance }
)
}
Write-CcodBuildUtf8 -Path $setupReleaseManifest -Text (($setupRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine)
@@ -458,24 +517,7 @@ Write-Host ''
Write-Host 'Installer build completed:' -ForegroundColor Green
Write-Host (" Setup: {0}" -f $setupExe)
Write-Host (" SHA-256: {0}" -f $setupChecksum)
+Write-Host (" Provenance: {0}" -f $setupProvenance)
Write-Host (" Manifest: {0}" -f $setupReleaseManifest)
Write-Host ''
-
-if ([IO.Directory]::Exists($installerPayloadDirectory)) {
- $installerPayloadFull = [IO.Path]::GetFullPath($installerPayloadDirectory)
- $buildFull = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\'
- if ($installerPayloadFull.StartsWith($buildFull,[StringComparison]::OrdinalIgnoreCase) -and
- [IO.Path]::GetFileName($installerPayloadFull).StartsWith('.installer-payload-stage-',[StringComparison]::Ordinal) -and
- -not ((Get-Item -LiteralPath $installerPayloadFull -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
- Remove-Item -LiteralPath $installerPayloadFull -Recurse -Force -ErrorAction SilentlyContinue
- }
-}
-if ([IO.File]::Exists($installerDestinationInventoryPath)) {
- $inventoryFull = [IO.Path]::GetFullPath($installerDestinationInventoryPath)
- $buildFull = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + '\'
- if ($inventoryFull.StartsWith($buildFull,[StringComparison]::OrdinalIgnoreCase) -and
- [IO.Path]::GetFileName($inventoryFull).StartsWith('.installer-destination-inventory-',[StringComparison]::Ordinal) -and
- -not ((Get-Item -LiteralPath $inventoryFull -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) {
- Remove-Item -LiteralPath $inventoryFull -Force -ErrorAction SilentlyContinue
- }
-}
+}.GetNewClosure())
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index e75107d..85efd25 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -165,28 +165,52 @@ function New-CcodReleaseFixture {
$root = Join-Path $env:TEMP ('ccod-release-workflow-' + [guid]::NewGuid().ToString('N'))
$null = [IO.Directory]::CreateDirectory($root)
$installer = Join-Path $root 'CodexRemote-fix-2.5.0-setup.exe'
- [IO.File]::WriteAllBytes($installer, [byte[]](9,8,7,6,5,4,3,2,1))
+ $commit = 'a' * 40
+ $payloadHash = 'd' * 64
+ $typeName = 'SetupFixture' + [guid]::NewGuid().ToString('N')
+ $setupSource = @"
+using System.Reflection;
+[assembly: AssemblyVersion("2.5.0.0")]
+[assembly: AssemblyFileVersion("2.5.0.0")]
+[assembly: AssemblyInformationalVersion("2.5.0.0")]
+[assembly: AssemblyProduct("CodexRemote-fix")]
+[assembly: AssemblyTitle("CCODSETUP 2.5.0")]
+[assembly: AssemblyDescription("CCODSETUP 2.5.0")]
+[assembly: AssemblyCompany("$commit")]
+[assembly: AssemblyCopyright("$payloadHash")]
+public static class $typeName { public static int Main() { return 0; } }
+"@
+ Add-Type -TypeDefinition $setupSource -Language CSharp -OutputAssembly $installer -OutputType ConsoleApplication
$checksum = "$installer.sha256.txt"
$installerHash = Get-CcodTestFileSha256 -Path $installer
[IO.File]::WriteAllText($checksum, ("{0} *{1}`r`n" -f $installerHash, [IO.Path]::GetFileName($installer)), [Text.UTF8Encoding]::new($false))
$trayHost = Join-Path $root 'CodexRemote-fix-2.5.0-trayhost-provenance.json'
- [IO.File]::WriteAllText($trayHost, ('{"schemaVersion":1,"product":"CodexRemote-fix","version":"2.5.0","gitCommit":"' + ('a' * 40) + '","buildTimestampUtc":"2026-08-24T00:00:00.0000000Z"}'), [Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($trayHost, ('{"schemaVersion":1,"product":"CodexRemote-fix","version":"2.5.0","gitCommit":"' + $commit + '","buildTimestampUtc":"2026-08-24T00:00:00.0000000Z"}'), [Text.UTF8Encoding]::new($false))
+ $setupProvenance = Join-Path $root 'CodexRemote-fix-2.5.0-setup-provenance.json'
+ $setupProvenanceRecord = [ordered]@{
+ schemaVersion=1;product='CodexRemote-fix';version='2.5.0';gitCommit=$commit;buildTimestampUtc='2026-08-24T00:00:00.0000000Z'
+ payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64]123;sha256=$payloadHash;fileCount=3}
+ buildInputs=[ordered]@{innoTemplateSha256=('1'*64);destinationInventorySha256=('2'*64);compilerSha256=('3'*64);compilerFileVersion='6.7.3.0'}
+ peContract=[ordered]@{fileVersion='2.5.0.0';productVersion='2.5.0.0';productName='CodexRemote-fix';fileDescription='CCODSETUP 2.5.0';companyName=$commit;legalCopyright=$payloadHash}
+ }
+ [IO.File]::WriteAllText($setupProvenance,(($setupProvenanceRecord|ConvertTo-Json -Depth 8)+"`n"),[Text.UTF8Encoding]::new($false))
$manifest = Join-Path $root 'CodexRemote-fix-2.5.0-setup-release-manifest.json'
$assets = @(
[ordered]@{ name = [IO.Path]::GetFileName($installer); sha256 = $installerHash },
[ordered]@{ name = [IO.Path]::GetFileName($checksum); sha256 = Get-CcodTestFileSha256 -Path $checksum },
- [ordered]@{ name = [IO.Path]::GetFileName($trayHost); sha256 = Get-CcodTestFileSha256 -Path $trayHost }
+ [ordered]@{ name = [IO.Path]::GetFileName($trayHost); sha256 = Get-CcodTestFileSha256 -Path $trayHost },
+ [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodTestFileSha256 -Path $setupProvenance }
)
$record = [ordered]@{
schemaVersion = 1
product = 'CodexRemote-fix'
version = '2.5.0'
- gitCommit = ('a' * 40)
+ gitCommit = $commit
buildTimestampUtc = '2026-08-24T00:00:00.0000000Z'
assets = $assets
}
[IO.File]::WriteAllText($manifest, ($record | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false))
- return [pscustomobject]@{ Root = $root; Installer = $installer; Checksum = $checksum; TrayHost = $trayHost; Manifest = $manifest }
+ return [pscustomobject]@{ Root = $root; Installer = $installer; Checksum = $checksum; TrayHost = $trayHost; SetupProvenance = $setupProvenance; Manifest = $manifest; PayloadManifestSha256 = $payloadHash }
}
function New-CcodPortableReleaseFixture {
@@ -1165,9 +1189,13 @@ function Invoke-CcodInnoPayloadCompileFixture {
(Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe')
) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
if (-not $iscc) { throw 'Inno Setup 6 is required for the setup payload contract' }
- $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/O$output\")
+ $payloadManifestSha256 = Get-CcodTestFileSha256 -Path $manifestPath
+ $setupGitCommit = 'c' * 40
+ $setupProvenancePath = Join-Path $root 'setup-provenance.json'
+ [IO.File]::WriteAllText($setupProvenancePath,'{}',[Text.UTF8Encoding]::new($false))
+ $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath","/O$output\")
if ($IncludePayloadDefines) {
- $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$(Get-CcodTestFileSha256 -Path $manifestPath)")
+ $arguments = @('/DProjectVersion=2.5.21',"/DTrayHostArtifactDirectory=$tray","/DPortableArtifactDirectory=$portable","/DInstallerPayloadDirectory=$payload","/DInstallerPayloadManifestSha256=$payloadManifestSha256","/DSetupGitCommit=$setupGitCommit","/DSetupProvenancePath=$setupProvenancePath")
$arguments += "/O$output\"
}
. (Join-Path $repositoryRoot 'build\build.ps1') -Library
@@ -1198,6 +1226,8 @@ function Invoke-CcodInnoPayloadCompileFixture {
SetupPath = (Join-Path $output 'CodexRemote-fix-2.5.21-setup.exe')
GeneratedPath = $generatedPath
GeneratedSource = $generatedSource
+ PayloadManifestSha256 = $payloadManifestSha256
+ SetupGitCommit = $setupGitCommit
}
}
@@ -1418,6 +1448,47 @@ Invoke-CcodTest 'Inno compile packages the explicit manifest-bound installer pay
}
}
+# Production mutation caught: trusting ISCC exit zero and a newly computed sidecar without inspecting the final PE contract.
+Invoke-CcodTest 'compiled Setup independently binds PE versions commit and activation payload manifest hash' {
+ $modulePath = Join-Path $repositoryRoot 'build\SetupArtifact.psm1'
+ Assert-CcodTrue (Test-Path -LiteralPath $modulePath -PathType Leaf) 'independent Setup artifact validator exists'
+ Import-Module $modulePath -Force
+ $compile = Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines
+ try {
+ $validated = Test-CcodSetupArtifact -SetupPath $compile.SetupPath -ExpectedVersion '2.5.21' -ExpectedGitCommit $compile.SetupGitCommit -ExpectedPayloadManifestSha256 $compile.PayloadManifestSha256
+ Assert-CcodEqual $true ([bool]$validated.Valid) 'real ISCC Setup PE satisfies the independent version and payload contract'
+ Assert-CcodEqual '2.5.21.0' ([string]$validated.FileVersion) 'Setup PE FileVersion is exact'
+ Assert-CcodEqual '2.5.21.0' ([string]$validated.ProductVersion) 'Setup PE ProductVersion is exact'
+ Assert-CcodThrows {
+ Test-CcodSetupArtifact -SetupPath $compile.SetupPath -ExpectedVersion '2.5.21' -ExpectedGitCommit $compile.SetupGitCommit -ExpectedPayloadManifestSha256 ('0' * 64) | Out-Null
+ } 'CCOD_SETUP_PAYLOAD_BINDING_INVALID'
+ } finally {
+ if (Test-Path -LiteralPath $compile.Root) { Remove-Item -LiteralPath $compile.Root -Recurse -Force }
+ }
+}
+
+# Production mutation caught: cleaning payload stage/inventory only after a successful Setup build.
+Invoke-CcodTest 'build temporary Setup inputs are cleaned from the exact finally boundary after failure' {
+ . (Join-Path $repositoryRoot 'build\build.ps1') -Library
+ Assert-CcodTrue ($null -ne (Get-Command Invoke-CcodBuildTemporarySetupScope -ErrorAction SilentlyContinue)) 'build exposes its production temporary Setup scope'
+ $buildRoot = Join-Path $repositoryRoot 'build'
+ $payloadStage = Join-Path $buildRoot ('.installer-payload-stage-' + [guid]::NewGuid().ToString('N'))
+ $inventory = Join-Path $buildRoot ('.installer-destination-inventory-' + [guid]::NewGuid().ToString('N') + '.iss')
+ $fixturePayloadStage = $payloadStage
+ $fixtureInventory = $inventory
+ Assert-CcodThrows {
+ Invoke-CcodBuildTemporarySetupScope -BuildRoot $buildRoot -InstallerPayloadDirectory $fixturePayloadStage -DestinationInventoryPath $fixtureInventory -Action {
+ param($PayloadStagePath,$InventoryPath)
+ [IO.Directory]::CreateDirectory($PayloadStagePath) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $PayloadStagePath 'fixture.txt'),'fixture',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($InventoryPath,'fixture',[Text.UTF8Encoding]::new($false))
+ throw [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('fixture failure'),'CCOD_BUILD_FIXTURE_FAILURE',[Management.Automation.ErrorCategory]::OperationStopped,$null)
+ }
+ } 'CCOD_BUILD_FIXTURE_FAILURE'
+ Assert-CcodTrue (-not [IO.Directory]::Exists($payloadStage)) 'failed build leaves no installer payload stage'
+ Assert-CcodTrue (-not [IO.File]::Exists($inventory)) 'failed build leaves no destination inventory'
+}
+
Invoke-CcodTest 'Inno compile refuses a missing generated destination inventory' {
$compile = Invoke-CcodInnoPayloadCompileFixture -IncludePayloadDefines -OmitDestinationInventory
try {
@@ -1999,7 +2070,7 @@ Invoke-CcodTest '2.5.22 source metadata and documentation match the release cont
Assert-CcodTrue ($englishSection.Success -and $englishBullets.Count -ge 3 -and $englishBullets.Count -le 5) 'v2.5.22 English release notes contain three to five bullets'
$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 ($readme.Contains('v2.5.22 is the current stable release')) 'English README marks v2.5.22 stable without a version-by-version What''s new block'
+ Assert-CcodTrue ($readme.Contains('v2.5.22 is the current release candidate') -and $readme.Contains('stable Windows acceptance still requires recorded install, upgrade, reboot, repair, UI, and Defender verification')) 'English README does not claim stable Windows acceptance before real-machine evidence'
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.22-setup.exe')) 'English Quick Start names the setup installer'
Assert-CcodTrue ($quickStart.Contains('CodexRemote-fix-2.5.22-windows-x64.zip')) 'English Quick Start names the portable ZIP'
@@ -2010,7 +2081,7 @@ Invoke-CcodTest '2.5.22 source metadata and documentation match the release cont
Assert-CcodTrue ($readme.Contains('Setup embeds and hash-binds its versioned `installer-payload.manifest.json`')) 'English README identifies the embedded hash-bound setup payload manifest'
Assert-CcodTrue (-not $readme.Contains('shared payload manifest')) 'English README does not claim setup and portable share one payload manifest'
$readmeZh = Get-Content -LiteralPath (Join-Path $repositoryRoot 'README.zh-CN.md') -Raw -Encoding UTF8
- Assert-CcodTrue ($readmeZh -match 'v2\.5\.22 \u662F\u5F53\u524D\u7A33\u5B9A\u7248') 'Chinese README marks v2.5.22 stable'
+ Assert-CcodTrue ($readmeZh -match 'v2\.5\.22 \u662F\u5F53\u524D\u5019\u9009\u53D1\u5E03\u7248' -and $readmeZh -match '\u7A33\u5B9A\u7248\u9A8C\u6536\u4ECD\u9700\u8BB0\u5F55') 'Chinese README does not claim stable Windows acceptance before real-machine evidence'
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.22-setup.exe')) 'Chinese Quick Start names the setup installer'
Assert-CcodTrue ($readmeZh.Contains('CodexRemote-fix-2.5.22-windows-x64.zip')) 'Chinese Quick Start names the portable ZIP'
diff --git a/tools/New-InstallerDestinationInventory.ps1 b/tools/New-InstallerDestinationInventory.ps1
index 57f543f..7cb2706 100644
--- a/tools/New-InstallerDestinationInventory.ps1
+++ b/tools/New-InstallerDestinationInventory.ps1
@@ -43,6 +43,10 @@ function Assert-CcodInstallerInnoPreprocessorLines {
'#error InstallerPayloadDirectory must be supplied by the release builder',
'#ifndef InstallerPayloadManifestSha256',
'#error InstallerPayloadManifestSha256 must be supplied by the release builder',
+ '#ifndef SetupGitCommit',
+ '#error SetupGitCommit must be supplied by the release builder',
+ '#ifndef SetupProvenancePath',
+ '#error SetupProvenancePath must be supplied by the release builder',
'#endif'
)
$allowedInlineConstructs = @(
@@ -50,7 +54,9 @@ function Assert-CcodInstallerInnoPreprocessorLines {
'{#TrayHostArtifactDirectory}',
'{#PortableArtifactDirectory}',
'{#InstallerPayloadDirectory}',
- '{#InstallerPayloadManifestSha256}'
+ '{#InstallerPayloadManifestSha256}',
+ '{#SetupGitCommit}',
+ '{#SetupProvenancePath}'
)
for ($lineIndex = 0; $lineIndex -lt $Lines.Count; $lineIndex++) {
$line = [string]$Lines[$lineIndex]
diff --git a/tools/Test-ReleaseDefender.ps1 b/tools/Test-ReleaseDefender.ps1
index 3a07321..ab73cdb 100644
--- a/tools/Test-ReleaseDefender.ps1
+++ b/tools/Test-ReleaseDefender.ps1
@@ -486,7 +486,8 @@ function Test-CcodReleaseAssetManifest {
$expectedNames = @(
"CodexRemote-fix-$ExpectedVersion-setup.exe",
"CodexRemote-fix-$ExpectedVersion-setup.exe.sha256.txt",
- "CodexRemote-fix-$ExpectedVersion-trayhost-provenance.json"
+ "CodexRemote-fix-$ExpectedVersion-trayhost-provenance.json",
+ "CodexRemote-fix-$ExpectedVersion-setup-provenance.json"
)
if ($assets.Count -ne $expectedNames.Count) { Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' 'Release manifest does not bind the exact required asset set' $manifestFile }
$assetHashes = @{}
@@ -530,6 +531,22 @@ function Test-CcodReleaseAssetManifest {
$trayHostTimestampText -cne $manifestTimestamp) {
Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' 'TrayHost provenance is not bound to the release version, source commit, and timestamp' $expectedNames[2]
}
+ $setupProvenancePath = Join-Path $directory $expectedNames[3]
+ $setupArtifactModule = Join-Path (Split-Path $PSScriptRoot -Parent) 'build\SetupArtifact.psm1'
+ if (-not [IO.File]::Exists($setupArtifactModule)) {
+ Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' 'Setup artifact validator is missing' $setupArtifactModule
+ }
+ Import-Module $setupArtifactModule -Force
+ try {
+ $setupRaw = [IO.File]::ReadAllText($setupProvenancePath)
+ $setupRecord = $setupRaw | ConvertFrom-Json -ErrorAction Stop
+ $payloadHash = [string]$setupRecord.payloadManifest.sha256
+ if ($payloadHash -cnotmatch '^[0-9a-f]{64}$') { throw 'payload hash' }
+ Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenancePath -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $manifestTimestamp | Out-Null
+ Test-CcodSetupArtifact -SetupPath $installer -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash | Out-Null
+ } catch {
+ Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' ('Setup provenance or final PE contract is invalid: ' + $_.Exception.Message) $setupProvenancePath
+ }
return [pscustomobject][ordered]@{
Valid = $true
Version = $ExpectedVersion
@@ -537,6 +554,8 @@ function Test-CcodReleaseAssetManifest {
BuildTimestampUtc = $manifestTimestamp
InstallerSha256 = [string]$assetHashes[$expectedNames[0]]
InstallerName = $expectedNames[0]
+ PayloadManifestSha256 = $payloadHash
+ SetupProvenanceName = $expectedNames[3]
}
}
From 1f4259f7003d29d1023a567d15c877c22dfabd6c Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:45:22 +0800
Subject: [PATCH 053/187] fix: validate setup provenance across hosts
---
build/SetupArtifact.psm1 | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1
index 58995fe..4458e71 100644
--- a/build/SetupArtifact.psm1
+++ b/build/SetupArtifact.psm1
@@ -158,12 +158,14 @@ function Test-CcodSetupBuildProvenance {
$path = Assert-CcodSetupRegularFile -Path $ProvenancePath -Kind 'Setup provenance'
try { $raw = [IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)); $record = $raw | ConvertFrom-Json -ErrorAction Stop }
catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is invalid JSON' $path }
+ $timestampMatches = [regex]::Matches($raw,'"buildTimestampUtc"\s*:\s*"(?
[^"\\]+)"')
+ $timestampText = if ($timestampMatches.Count -eq 1) { [string]$timestampMatches[0].Groups['value'].Value } else { $null }
$fields = @('schemaVersion','product','version','gitCommit','buildTimestampUtc','payloadManifest','buildInputs','peContract')
if ($record -isnot [pscustomobject] -or ((@($record.PSObject.Properties.Name | Sort-Object) -join '|') -cne (@($fields | Sort-Object) -join '|')) -or
- $record.schemaVersion -isnot [int] -or $record.schemaVersion -ne 1 -or $record.product -isnot [string] -or $record.product -cne 'CodexRemote-fix' -or
+ ($record.schemaVersion -isnot [int] -and $record.schemaVersion -isnot [long]) -or [int64]$record.schemaVersion -ne 1 -or $record.product -isnot [string] -or $record.product -cne 'CodexRemote-fix' -or
$record.version -isnot [string] -or $record.version -cne $ExpectedVersion -or $record.gitCommit -isnot [string] -or $record.gitCommit -cne $ExpectedGitCommit -or
- -not (Test-CcodSetupCanonicalUtc ([string]$record.buildTimestampUtc)) -or
- (-not [string]::IsNullOrWhiteSpace($ExpectedBuildTimestampUtc) -and [string]$record.buildTimestampUtc -cne $ExpectedBuildTimestampUtc) -or
+ -not (Test-CcodSetupCanonicalUtc $timestampText) -or
+ (-not [string]::IsNullOrWhiteSpace($ExpectedBuildTimestampUtc) -and $timestampText -cne $ExpectedBuildTimestampUtc) -or
$record.payloadManifest.sha256 -isnot [string] -or $record.payloadManifest.sha256 -cne $ExpectedPayloadManifestSha256 -or
$record.payloadManifest.name -isnot [string] -or $record.payloadManifest.name -cne 'installer-payload.manifest.json' -or
[int64]$record.payloadManifest.length -le 0 -or [int]$record.payloadManifest.fileCount -le 0 -or
From 5293182b8e5fb6653b5261dd7b039760a5eeab06 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:48:58 +0800
Subject: [PATCH 054/187] docs: record final security fix evidence
---
.../task-5-report.md | 229 ++++++++++++++++++
1 file changed, 229 insertions(+)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
index f883720..b2dccac 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
@@ -340,3 +340,232 @@ normal desktop environment retains the earlier real-Supervisor lifecycle mutex
boundary. The parent will rerun the controlled isolated aggregate after scoped
re-review. No build, installer execution, install, publish, push, tag, release,
or signing occurred in this fix round.
+
+## Final security-fix wave — exact activation bytes, hard-link leaves, and Setup proof
+
+### Scope and safety boundary
+
+- Started from clean reviewed head `31fd365` in the named v2.5.22 worktree.
+- Preserved package/native version `2.5.22` / `2.5.22.0`, the authenticated
+ TrayHost protocol, receipt architecture, and existing CI/trace gates.
+- Did not execute an installer, install or upgrade the product, start or stop
+ real Codex/TrayHost/Supervisor/UI processes, publish, push, tag, sign, or
+ read/write WindowsApps or DPAPI device-key data.
+
+### RED evidence
+
+#### Exact activation bytes
+
+A real cross-process barrier supplied a test-only `powershell.exe` wrapper.
+The wrapper stopped after the activation parent verified the payload and before
+the child interpreter ran. The test then replaced the accepted manifest,
+installer, and lifecycle module. The old pathname flow returned success but
+proved that it parsed/executed the replacements:
+
+```text
+ReleaseWorkflow.SelfTest.ps1
+exit 1
+case=activation-executes-only-the-verified-manifest-installer-and-module-bytes-across-the-child-launch-barrier
+expected=[original:2.5.22]
+actual=[replacement:9.9.9]
+```
+
+No production test flag, environment switch, listener, or unauthenticated
+control input was added.
+
+#### Hard-linked install leaves
+
+The lifecycle regression created an outside sentinel and hard-linked each
+stable target leaf in turn. The old copy path did not reject either target:
+
+```text
+InstallLifecycle.SelfTest.ps1
+exit 1
+case=stable-bootstrap-and-public-uninstaller-reject-hard-linked-target-leaves-before-outside-bytes-change
+ASSERT_THROWS: expected CCOD_INSTALL_UNSAFE_LEAF
+```
+
+A separately compiled and safely executed Inno predicate fixture placed a hard
+link inside the existing Setup tree. The old `IsSafeExistingSetupTree` accepted
+the leaf, reached the simulated overwrite, and therefore did not write the
+fixture's success receipt (`result.txt` missing).
+
+#### Independent Setup artifact and provenance
+
+The real-ISCC artifact test was added before its validator:
+
+```text
+ReleaseWorkflow.SelfTest.ps1
+exit 1
+case=compiled-Setup-independently-binds-PE-versions-commit-and-activation-payload-manifest-hash
+ASSERT_TRUE: independent Setup artifact validator exists
+```
+
+After the validator API existed, the setup release fixture added the required
+setup-provenance asset before the release validator changed. The old validator
+then failed its exact asset contract, proving the provenance was not yet part
+of the accepted release:
+
+```text
+CCOD_RELEASE_MANIFEST_INVALID
+Release manifest does not bind the exact required asset set
+```
+
+#### Failure cleanup
+
+The cleanup regression injected a deterministic exception after creating the
+exact GUID-named installer payload stage and destination inventory. It first
+failed because the production finally scope did not exist:
+
+```text
+ASSERT_TRUE: build exposes its production temporary Setup scope
+```
+
+### Implementation contract
+
+#### Activation byte sealing
+
+- The activation parent opens the compile-bound manifest once, hashes and
+ parses the same byte array, and opens every current manifest record with
+ read-only/no-write/no-delete sharing.
+- It writes only those verified byte arrays into a unique sibling stage,
+ rehashes each staged leaf, and holds read handles for the entire child
+ lifetime. Original payload path replacement after the barrier cannot change
+ the child's inputs.
+- The child independently opens and holds the staged manifest/records, hashes
+ and parses the same manifest bytes, verifies exact version/package/file set,
+ and only then imports `InstallLifecycle.psm1`.
+- Lifecycle receives the accepted manifest as Base64 exact bytes plus the
+ expected SHA-256; it does not reopen the accepted manifest pathname for JSON
+ parsing. Existing final Ready pointer/runtime-manifest/version proof remains.
+
+#### Hard-link-safe leaves
+
+- Lifecycle now uses `CreateFileW` plus `GetFileInformationByHandle` and
+ requires `NumberOfLinks == 1`, regular/non-reparse shape, no alternate data
+ streams, and canonical install-root containment before/after copies.
+- Production copy writes a fresh same-directory temporary leaf and uses a
+ namespace replace/move, avoiding in-place mutation of an existing inode.
+- Stable `bootstrap.ps1`, the public uninstaller, staged runtime leaves,
+ runtime manifest, and recursively deleted install trees use the same leaf
+ contract.
+- Inno `PrepareToInstall` recursively checks every existing Setup leaf with
+ native handle link count plus stream enumeration before `[Files]` writes.
+
+#### Independent Setup artifact/provenance
+
+- `build/SetupArtifact.psm1` verifies final Setup PE `FileVersion` and
+ `ProductVersion` equal `.0`.
+- The final PE version resource independently binds product/version, the exact
+ 40-hex source commit, and the exact 64-hex installer payload-manifest SHA-256.
+ The negative test reuses the same final EXE with a mismatched expected hash
+ and requires `CCOD_SETUP_PAYLOAD_BINDING_INVALID`; no textual binary scan is
+ claimed.
+- `CodexRemote-fix-2.5.22-setup-provenance.json` records canonical
+ version/commit/timestamp, payload manifest name/length/hash/file count, Inno
+ template and inventory hashes, ISCC hash/version, and the expected PE
+ resource contract. It is embedded as a `dontcopy` Setup source and published
+ as an explicit release asset.
+- The setup release manifest hash-binds Setup, checksum, TrayHost provenance,
+ and Setup provenance. `Test-CcodReleaseAssetManifest` revalidates provenance
+ plus the final PE and rejects asset or metadata tampering.
+- Provenance validation reads the raw canonical timestamp representation and
+ accepts the JSON integer types produced by both Windows PowerShell 5.1 and
+ pwsh 7.
+
+#### Cleanup and documentation
+
+- The installer payload stage and destination inventory now live inside one
+ exact `try/finally` scope. Cleanup requires the build-root parent, exact GUID
+ leaf pattern, expected file/directory kind, and a reparse-free tree.
+- README and README.zh-CN now call v2.5.22 a release candidate and explicitly
+ leave stable Windows acceptance pending real install/upgrade/reboot/repair/UI/
+ Defender evidence. No What's-new block was added.
+
+### GREEN and regression evidence
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 0
+Release workflow self-tests passed.
+ includes real ISCC compilation, activation swap barrier, Inno hard-link
+ fixture, Setup PE positive/negative proof, failure cleanup, and release
+ provenance validation
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+exit 0; 3 production child-session trace cases
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -NativeOnly
+exit 0; 25 native fake-platform cases
+
+PowerShell AST parse of all modified .ps1/.psm1 files
+exit 0
+
+git diff --check
+exit 0
+```
+
+The lifecycle suite passed the new activation/hard-link cases and continued to
+the already documented live-Supervisor boundary. It was not reported green:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+exit 1
+case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal
+error=CCOD_LIFECYCLE_LEASE_TIMEOUT
+```
+
+No mutex bypass and no real Supervisor stop was attempted.
+
+### Clean-head build evidence
+
+Normal build from clean implementation head
+`1f4259f7003d29d1023a567d15c877c22dfabd6c`:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File build\build.ps1 -Version 2.5.22
+exit 0
+TEMP_SETUP_INPUT_COUNT=0
+```
+
+Both Windows PowerShell 5.1 and pwsh 7 independently returned
+`portable=True` and `setup=True`. Setup FileVersion/ProductVersion were both
+`2.5.22.0`; portable/setup release manifests and Setup provenance all bound
+commit `1f4259f7003d29d1023a567d15c877c22dfabd6c`. The embedded activation
+payload-manifest SHA-256 was
+`e03753640f65f28aebdf46eb113f8ad26317169cbfc424e1629a6795c752a4f1`.
+
+Final retained build evidence hashes:
+
+```text
+CodexRemote-fix-2.5.22-windows-x64.zip
+ a70419842a2509ae2035e6890474b3e7258d5f64682edb8813d6782adc1c299b
+CodexRemote-fix-2.5.22-setup.exe
+ 5f650bc187112a5256f5a5b5d37972a6400cfe6e32586956830458adfc6b53e9
+CodexRemote-fix-2.5.22-setup-provenance.json
+ 8f0d1973b69d440ca8dad699bb9065b3030ea4f455d757fc5278e706f6cc76e9
+CodexRemote-fix-2.5.22-setup-release-manifest.json
+ 559fa329221fcd092356119b8b4aa8bf4562afe173a432ca4251969db5c4a9e6
+```
+
+Two earlier ignored artifact sets were preserved rather than deleted at:
+
+```text
+C:\Users\33384\AppData\Local\Temp\ccod-v2522-pre-final-security-build-0ff1f869906848418dc15e9ef89d5375
+C:\Users\33384\AppData\Local\Temp\ccod-v2522-ca5a5f7-build-00f3b177a3974b02bee3fe3f99f2cbc0
+```
+
+### Commits and remaining acceptance boundary
+
+```text
+cb36d0b test: expose activation payload byte swap
+ed782ec fix: seal activation bytes and reject linked leaves
+ca5a5f7 fix: bind setup artifact provenance
+1f4259f fix: validate setup provenance across hosts
+```
+
+- The compiled installer was never executed. Generated assets are unsigned,
+ unpublished local evidence only.
+- Real setup install/upgrade, reboot, remote connection, About/language/logs,
+ repair, Defender, and controlled isolated aggregate acceptance remain
+ pending. README intentionally does not call v2.5.22 stable.
From f1ee9b7b8a592355f10e71e6b1d486ea9d29f876 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:05:51 +0800
Subject: [PATCH 055/187] test: expose linked whole-install deletion
---
.../persistence/InstallLifecycle.SelfTest.ps1 | 30 +++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/tests/persistence/InstallLifecycle.SelfTest.ps1 b/tests/persistence/InstallLifecycle.SelfTest.ps1
index 55e5af8..8672679 100644
--- a/tests/persistence/InstallLifecycle.SelfTest.ps1
+++ b/tests/persistence/InstallLifecycle.SelfTest.ps1
@@ -913,6 +913,36 @@ $results += Invoke-CcodTest 'stable bootstrap and public uninstaller reject hard
}
}
+# Production mutation caught: recursive whole-install deletion bypassing the strict per-leaf hard-link contract.
+$results += Invoke-CcodTest 'whole-install deletion rejects a nested hard-link before removing any in-tree leaf' {
+ $install = New-CcodLifecycleTempRoot
+ $outside = New-CcodLifecycleTempRoot
+ try {
+ $nested = Join-Path $install 'runtime\nested'
+ [IO.Directory]::CreateDirectory($nested) | Out-Null
+ [IO.Directory]::CreateDirectory($outside) | Out-Null
+ $sentinel = Join-Path $outside 'outside-sentinel.txt'
+ $linkedLeaf = Join-Path $nested 'linked-state.json'
+ $ordinaryLeaf = Join-Path $install 'ordinary-state.json'
+ [IO.File]::WriteAllText($sentinel,'outside-original',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($ordinaryLeaf,'ordinary-original',[Text.UTF8Encoding]::new($false))
+ New-Item -ItemType HardLink -Path $linkedLeaf -Target $sentinel | Out-Null
+
+ $caughtId = $null
+ try {
+ $module = Get-Module InstallLifecycle
+ & $module { param($Root) Remove-CcodLifecycleInstallTree -InstallRoot $Root -Adapters @{} } $install
+ } catch { $caughtId = ([string]$_.FullyQualifiedErrorId -split ',')[0] }
+
+ Assert-CcodEqual 'CCOD_INSTALL_UNSAFE_LEAF' $caughtId 'whole-install deletion rejects the nested hard-link before recursive removal'
+ Assert-CcodTrue ([IO.File]::Exists($linkedLeaf)) 'rejected delete retains the in-tree hard-link'
+ Assert-CcodEqual 'ordinary-original' ([IO.File]::ReadAllText($ordinaryLeaf,[Text.UTF8Encoding]::new($false))) 'preflight rejection retains other in-tree leaves'
+ Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText($sentinel,[Text.UTF8Encoding]::new($false))) 'preflight rejection retains outside sentinel bytes'
+ } finally {
+ foreach ($path in @($install,$outside)) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } }
+ }
+}
+
$results += Invoke-CcodTest 'runtime manifest leaf junction is rejected before write' {
$source = New-CcodLifecycleTempRoot
$install = New-CcodLifecycleTempRoot
From 1a4abece619f47115b9d9564777de58471eedbc6 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:08:47 +0800
Subject: [PATCH 056/187] fix: preflight whole-install tree deletion
---
src/persistence/modules/InstallLifecycle.psm1 | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/persistence/modules/InstallLifecycle.psm1 b/src/persistence/modules/InstallLifecycle.psm1
index e2a7e12..8385b5c 100644
--- a/src/persistence/modules/InstallLifecycle.psm1
+++ b/src/persistence/modules/InstallLifecycle.psm1
@@ -1803,6 +1803,7 @@ function Remove-CcodLifecycleInstallTree {
if (Test-CcodLifecycleReparse -Path $root) {
Throw-CcodLifecycleError 'CCOD_INSTALL_REPARSE_PATH' 'Install root is a reparse point' $root
}
+ [void](Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $root)
foreach ($item in Get-ChildItem -LiteralPath $root -Force -ErrorAction Stop) {
[void](Test-CcodLifecycleRemovePath -Root $root -Path $item.FullName)
}
From b14510876ea9f5c85521de3e652f020acdb656f0 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:09:27 +0800
Subject: [PATCH 057/187] docs: record whole-install delete fix
---
.../task-5-report.md | 69 +++++++++++++++++++
1 file changed, 69 insertions(+)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
index b2dccac..25e18fd 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
@@ -569,3 +569,72 @@ ca5a5f7 fix: bind setup artifact provenance
- Real setup install/upgrade, reboot, remote connection, About/language/logs,
repair, Defender, and controlled isolated aggregate acceptance remain
pending. README intentionally does not call v2.5.22 stable.
+
+## Final security fix round 1 — whole-install delete leaf preflight
+
+Scoped re-review found that `Remove-CcodLifecycleInstallTree` did not consume
+the strict recursive tree predicate. It checked only each top-level child's
+path/reparse ancestry and then called `Remove-Item -Recurse`, so a nested hard
+link bypassed the already implemented regular/non-reparse/no-ADS/single-link
+leaf contract.
+
+### RED
+
+The regression created an ordinary in-tree file plus a nested hard link to an
+outside sentinel, then invoked the real private whole-install delete function:
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\InstallLifecycle.SelfTest.ps1
+exit 1
+case=whole-install-deletion-rejects-a-nested-hard-link-before-removing-any-in-tree-leaf
+expected=[CCOD_INSTALL_UNSAFE_LEAF]
+actual=[]
+```
+
+The empty error id proves the old path accepted the hard link and completed
+recursive deletion instead of failing closed.
+
+### GREEN implementation and evidence
+
+`Remove-CcodLifecycleInstallTree` now calls
+`Assert-CcodLifecycleInstallTreeSafe -InstallRoot $root -Path $root` before
+enumerating or deleting anything. That existing predicate walks every nested
+leaf and applies the native link-count, ADS, regular-file, reparse, and root
+containment checks. Therefore a failing leaf is detected before either the
+hard-link name or any ordinary in-tree leaf is removed.
+
+The full lifecycle suite passed the new regression and advanced to the same
+later live-Supervisor boundary; it is still not represented as aggregate
+GREEN:
+
+```text
+InstallLifecycle.SelfTest.ps1
+new whole-install hard-link case passed
+later exit 1
+case=transactional-uninstall-reaches-ReadyForInno-only-after-recovery-protection-stop-task-proof-and-application-removal
+error=CCOD_LIFECYCLE_LEASE_TIMEOUT
+```
+
+Additional fresh verification:
+
+```text
+ReleaseWorkflow.SelfTest.ps1
+exit 0; real ISCC and release workflow self-tests passed
+
+PowerShell AST parse of modified module/test
+exit 0
+
+git diff --check
+exit 0
+```
+
+Commits:
+
+```text
+f1ee9b7 test: expose linked whole-install deletion
+1a4abec fix: preflight whole-install tree deletion
+```
+
+No installer/build/release/install operation was performed in this narrow
+round. No real process was started or stopped, and no WindowsApps or DPAPI data
+was accessed.
From 2633509a33bc43222d7bc65fc06558b5d3fcacf3 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:41:04 +0800
Subject: [PATCH 058/187] test: expose activation stage substitution
---
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 71 ++++++++++++++++++-
1 file changed, 70 insertions(+), 1 deletion(-)
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 85efd25..331b6b0 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -1279,7 +1279,8 @@ Invoke-CcodTest 'activation executes only the verified manifest installer and mo
while (-not [IO.File]::Exists($readyPath) -and [DateTime]::UtcNow -lt $deadline -and -not $process.HasExited) {
[Threading.Thread]::Sleep(10)
}
- Assert-CcodTrue ([IO.File]::Exists($readyPath)) 'barrier proves parent payload verification completed before child execution'
+ $barrierFailure = if ($process.HasExited) { $process.StandardOutput.ReadToEnd() + ' ' + $process.StandardError.ReadToEnd() } else { '' }
+ Assert-CcodTrue ([IO.File]::Exists($readyPath)) "barrier proves parent payload verification completed before child execution: $barrierFailure"
$replacementInstallerMarker = Join-Path $fixture.AppRoot 'replacement-installer-executed.txt'
$replacementInstaller = @"
@@ -1322,6 +1323,74 @@ Export-ModuleMember -Function Invoke-CcodFixtureInstall
}
}
+# Production mutation caught: creating/checking the stage by pathname and allowing directory substitution before the first staged leaf write.
+Invoke-CcodTest 'activation pins the stage directory before any verified leaf write' {
+ $fixture = New-CcodActivationPayloadFixture
+ $libraryRoot = Join-Path $fixture.AppRoot 'activation-library'
+ $outside = Join-Path $fixture.AppRoot 'outside-stage-target'
+ $libraryPath = Join-Path $libraryRoot 'ActivationFunctions.psm1'
+ $module = $null
+ $breakpoint = $null
+ $seal = $null
+ $attack = [pscustomobject]@{ Attempted=$false; Outcome='not-run'; Stage=$null; Captured=$null }
+ try {
+ [IO.Directory]::CreateDirectory($libraryRoot) | Out-Null
+ [IO.Directory]::CreateDirectory($outside) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $outside 'outside-sentinel.txt'),'outside-original',[Text.UTF8Encoding]::new($false))
+ $tokens = $null; $parseErrors = $null
+ $activationAst = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repositoryRoot 'Activate-CcodRemoteFix.ps1'),[ref]$tokens,[ref]$parseErrors)
+ Assert-CcodEqual 0 @($parseErrors).Count 'activation function library source parses before race fixture extraction'
+ $definitions = @($activationAst.EndBlock.Statements | Where-Object { $_ -is [Management.Automation.Language.FunctionDefinitionAst] } | ForEach-Object { $_.Extent.Text })
+ Assert-CcodTrue ($definitions.Count -gt 10) 'activation race fixture extracts the real production function set'
+ [IO.File]::WriteAllText($libraryPath,(($definitions -join "`r`n`r`n")+"`r`n"),[Text.UTF8Encoding]::new($false))
+ $module = Import-Module $libraryPath -Force -PassThru
+ $libraryLines = [IO.File]::ReadAllLines($libraryPath,[Text.UTF8Encoding]::new($false))
+ $barrierLines = @()
+ for ($index=0;$index-lt$libraryLines.Length;$index++) {
+ if ($libraryLines[$index] -cmatch '^\s*foreach \(\$snapshot in @\(\$snapshots\)') { $barrierLines += ($index + 1) }
+ }
+ Assert-CcodEqual 1 $barrierLines.Count 'activation stage write barrier is unique'
+ $appRoot = $fixture.AppRoot
+ $attackAction = {
+ if ($attack.Attempted) { return }
+ $attack.Attempted = $true
+ try {
+ $stages = @(Get-ChildItem -LiteralPath $appRoot -Directory -Force | Where-Object { $_.Name -cmatch '^\.activation-payload-stage-[0-9a-f]{32}$' })
+ if ($stages.Count -ne 1) { throw "expected one stage; found $($stages.Count)" }
+ $attack.Stage = [IO.Path]::GetFullPath($stages[0].FullName)
+ $expectedPrefix = [IO.Path]::GetFullPath($appRoot).TrimEnd('\') + '\'
+ if (-not $attack.Stage.StartsWith($expectedPrefix,[StringComparison]::OrdinalIgnoreCase)) { throw 'stage escaped fixture app root' }
+ $attack.Captured = $attack.Stage + '.captured'
+ [IO.Directory]::Move($attack.Stage,$attack.Captured)
+ New-Item -ItemType Junction -Path $attack.Stage -Target $outside | Out-Null
+ $attack.Outcome = 'substituted'
+ } catch { $attack.Outcome = 'blocked' }
+ }.GetNewClosure()
+ $breakpoint = Set-PSBreakpoint -Script $libraryPath -Line $barrierLines[0] -Action $attackAction
+ $caught = $null
+ try {
+ $seal = & $module {
+ param($AppRoot,$PayloadRoot,$ManifestSha)
+ New-CcodActivationPayloadSeal -AppRoot $AppRoot -Root $PayloadRoot -Version '2.5.22' -ExpectedManifestSha256 $ManifestSha
+ } $fixture.AppRoot $fixture.PayloadRoot $fixture.ManifestSha256
+ } catch { $caught = $_ }
+
+ Assert-CcodEqual 'blocked' $attack.Outcome 'stage directory substitution is blocked while the write boundary is pinned'
+ Assert-CcodTrue ($null -eq $caught -and $null -ne $seal) 'blocked substitution leaves the original verified stage usable'
+ Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText((Join-Path $outside 'outside-sentinel.txt'),[Text.UTF8Encoding]::new($false))) 'stage race cannot alter the outside sentinel'
+ Assert-CcodEqual 1 @(Get-ChildItem -LiteralPath $outside -File -Force -Recurse).Count 'stage race cannot redirect any verified leaf outside'
+ } finally {
+ if ($null -ne $breakpoint) { Remove-PSBreakpoint -Breakpoint $breakpoint -ErrorAction SilentlyContinue }
+ if ($null -ne $seal -and $null -ne $module) { try { & $module { param($Value) Close-CcodActivationPayloadSeal -Seal $Value } $seal } catch { } }
+ if ($null -ne $module) { Remove-Module -Name $module.Name -Force -ErrorAction SilentlyContinue }
+ if ($null -ne $attack.Stage -and (Test-Path -LiteralPath $attack.Stage)) {
+ $stageItem = Get-Item -LiteralPath $attack.Stage -Force
+ if (($stageItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { [IO.Directory]::Delete($attack.Stage) }
+ }
+ foreach ($path in @($attack.Captured,$fixture.AppRoot)) { if ($null -ne $path -and (Test-Path -LiteralPath $path)) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } }
+ }
+}
+
Invoke-CcodTest 'activation accepts a compile-bound installer manifest hash before payload verification' {
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-activation-hash-interface-' + [guid]::NewGuid().ToString('N'))
try {
From 39e838f4c0160fb1879017736ecb80a4bd6f0c0e Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:41:04 +0800
Subject: [PATCH 059/187] fix: pin activation stage write boundary
---
Activate-CcodRemoteFix.ps1 | 259 ++++++++++++++++++++++++++++++++++++-
1 file changed, 254 insertions(+), 5 deletions(-)
diff --git a/Activate-CcodRemoteFix.ps1 b/Activate-CcodRemoteFix.ps1
index 536f0df..d479e7b 100644
--- a/Activate-CcodRemoteFix.ps1
+++ b/Activate-CcodRemoteFix.ps1
@@ -86,6 +86,242 @@ function Open-CcodActivationLockedBytes {
}
}
+function Initialize-CcodActivationStageNative {
+ if ($null -ne ('CcodActivationStageNative' -as [type])) { return }
+ Add-Type -TypeDefinition @'
+using System;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+
+public static class CcodActivationStageNative
+{
+ private const uint FILE_READ_DATA = 0x00000001;
+ private const uint FILE_LIST_DIRECTORY = 0x00000001;
+ private const uint FILE_READ_ATTRIBUTES = 0x00000080;
+ private const uint GENERIC_READ = 0x80000000;
+ private const uint GENERIC_WRITE = 0x40000000;
+ private const uint SYNCHRONIZE = 0x00100000;
+ private const uint FILE_SHARE_READ = 0x00000001;
+ private const uint FILE_SHARE_WRITE = 0x00000002;
+ private const uint FILE_OPEN = 0x00000001;
+ private const uint FILE_CREATE = 0x00000002;
+ private const uint FILE_DIRECTORY_FILE = 0x00000001;
+ private const uint FILE_NON_DIRECTORY_FILE = 0x00000040;
+ private const uint FILE_SYNCHRONOUS_IO_NONALERT = 0x00000020;
+ private const uint FILE_OPEN_REPARSE_POINT = 0x00200000;
+ private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
+ private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
+ private const uint OPEN_EXISTING = 3;
+ private const uint OBJ_CASE_INSENSITIVE = 0x00000040;
+ private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
+ private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct UNICODE_STRING
+ {
+ public ushort Length;
+ public ushort MaximumLength;
+ public IntPtr Buffer;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct OBJECT_ATTRIBUTES
+ {
+ public int Length;
+ public IntPtr RootDirectory;
+ public IntPtr ObjectName;
+ public uint Attributes;
+ public IntPtr SecurityDescriptor;
+ public IntPtr SecurityQualityOfService;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct IO_STATUS_BLOCK
+ {
+ public IntPtr Status;
+ public UIntPtr Information;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct FILETIME
+ {
+ public uint Low;
+ public uint High;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct BY_HANDLE_FILE_INFORMATION
+ {
+ public uint FileAttributes;
+ public FILETIME CreationTime;
+ public FILETIME LastAccessTime;
+ public FILETIME LastWriteTime;
+ public uint VolumeSerialNumber;
+ public uint FileSizeHigh;
+ public uint FileSizeLow;
+ public uint NumberOfLinks;
+ public uint FileIndexHigh;
+ public uint FileIndexLow;
+ }
+
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern SafeFileHandle CreateFileW(string path, uint access, uint share, IntPtr security,
+ uint creation, uint flags, IntPtr template);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION information);
+
+ [DllImport("ntdll.dll")]
+ private static extern int NtCreateFile(out IntPtr fileHandle, uint desiredAccess,
+ ref OBJECT_ATTRIBUTES objectAttributes, out IO_STATUS_BLOCK ioStatusBlock, IntPtr allocationSize,
+ uint fileAttributes, uint shareAccess, uint createDisposition, uint createOptions,
+ IntPtr eaBuffer, uint eaLength);
+
+ private static void ValidateLeaf(string name)
+ {
+ if (String.IsNullOrWhiteSpace(name) || name == "." || name == ".." || name.IndexOf('\\') >= 0 || name.IndexOf('/') >= 0)
+ throw new ArgumentException("Relative stage leaf is invalid", "name");
+ }
+
+ private static SafeFileHandle OpenRelative(SafeFileHandle parent, string name, uint access, uint share,
+ uint disposition, uint options)
+ {
+ ValidateLeaf(name);
+ if (parent == null || parent.IsInvalid || parent.IsClosed) throw new ArgumentException("Parent directory pin is invalid", "parent");
+ IntPtr nameBuffer = IntPtr.Zero;
+ IntPtr unicodePointer = IntPtr.Zero;
+ try
+ {
+ nameBuffer = Marshal.StringToHGlobalUni(name);
+ UNICODE_STRING unicode = new UNICODE_STRING();
+ unicode.Length = checked((ushort)(name.Length * 2));
+ unicode.MaximumLength = checked((ushort)((name.Length + 1) * 2));
+ unicode.Buffer = nameBuffer;
+ unicodePointer = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(UNICODE_STRING)));
+ Marshal.StructureToPtr(unicode, unicodePointer, false);
+ OBJECT_ATTRIBUTES attributes = new OBJECT_ATTRIBUTES();
+ attributes.Length = Marshal.SizeOf(typeof(OBJECT_ATTRIBUTES));
+ attributes.RootDirectory = parent.DangerousGetHandle();
+ attributes.ObjectName = unicodePointer;
+ attributes.Attributes = OBJ_CASE_INSENSITIVE;
+ IO_STATUS_BLOCK statusBlock;
+ IntPtr rawHandle;
+ int status = NtCreateFile(out rawHandle, access, ref attributes, out statusBlock, IntPtr.Zero,
+ 0, share, disposition, options | FILE_SYNCHRONOUS_IO_NONALERT | FILE_OPEN_REPARSE_POINT,
+ IntPtr.Zero, 0);
+ if (status < 0 || rawHandle == IntPtr.Zero || rawHandle == new IntPtr(-1))
+ throw new InvalidOperationException("NtCreateFile failed: 0x" + unchecked((uint)status).ToString("x8"));
+ return new SafeFileHandle(rawHandle, true);
+ }
+ finally
+ {
+ if (unicodePointer != IntPtr.Zero) Marshal.FreeHGlobal(unicodePointer);
+ if (nameBuffer != IntPtr.Zero) Marshal.FreeHGlobal(nameBuffer);
+ }
+ }
+
+ public static SafeFileHandle OpenExistingDirectory(string path)
+ {
+ SafeFileHandle handle = CreateFileW(path, FILE_READ_ATTRIBUTES | SYNCHRONIZE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING,
+ FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
+ BY_HANDLE_FILE_INFORMATION information;
+ if (!GetFileInformationByHandle(handle, out information) ||
+ (information.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 ||
+ (information.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
+ {
+ int error = Marshal.GetLastWin32Error();
+ handle.Dispose();
+ if (error != 0) throw new Win32Exception(error);
+ throw new InvalidOperationException("Existing stage parent is not a plain directory");
+ }
+ return handle;
+ }
+
+ public static SafeFileHandle CreateDirectoryRelative(SafeFileHandle parent, string name)
+ {
+ return OpenRelative(parent, name, FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | SYNCHRONIZE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_CREATE, FILE_DIRECTORY_FILE);
+ }
+
+ public static SafeFileHandle CreateLeafRelative(SafeFileHandle parent, string name)
+ {
+ return OpenRelative(parent, name, GENERIC_WRITE | SYNCHRONIZE,
+ FILE_SHARE_READ, FILE_CREATE, FILE_NON_DIRECTORY_FILE);
+ }
+
+ public static SafeFileHandle OpenLeafIdentityRelative(SafeFileHandle parent, string name)
+ {
+ return OpenRelative(parent, name, FILE_READ_ATTRIBUTES | SYNCHRONIZE,
+ FILE_SHARE_READ | FILE_SHARE_WRITE, FILE_OPEN, FILE_NON_DIRECTORY_FILE);
+ }
+
+ public static SafeFileHandle OpenLeafReadRelative(SafeFileHandle parent, string name)
+ {
+ return OpenRelative(parent, name, GENERIC_READ | SYNCHRONIZE,
+ FILE_SHARE_READ, FILE_OPEN, FILE_NON_DIRECTORY_FILE);
+ }
+}
+'@
+}
+
+function Get-CcodActivationPinnedDirectory {
+ param(
+ [Parameter(Mandatory)][Collections.Generic.Dictionary[string,object]]$Directories,
+ [Parameter(Mandatory)][Collections.Generic.List[object]]$DirectoryPins,
+ [Parameter(Mandatory)][AllowEmptyString()][string]$RelativeDirectory
+ )
+ $normalized = $RelativeDirectory.Replace('/','\').Trim('\')
+ if ([string]::IsNullOrEmpty($normalized)) { return $Directories[''] }
+ $current = ''
+ $parent = $Directories['']
+ foreach ($segment in ($normalized -split '\\')) {
+ if ($segment -notmatch '^[A-Za-z0-9._-]+$' -or $segment -in @('.','..')) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
+ $current = if ([string]::IsNullOrEmpty($current)) { $segment } else { "$current\$segment" }
+ if ($Directories.ContainsKey($current)) { $parent = $Directories[$current]; continue }
+ $created = [CcodActivationStageNative]::CreateDirectoryRelative($parent,[string]$segment)
+ $DirectoryPins.Add($created)
+ $Directories.Add($current,$created)
+ $parent = $created
+ }
+ return $parent
+}
+
+function Open-CcodActivationPinnedLeafBytes {
+ param(
+ [Parameter(Mandatory)]$ParentDirectory,
+ [Parameter(Mandatory)][string]$Leaf,
+ [Parameter(Mandatory)][byte[]]$Bytes
+ )
+ $writerHandle = $null
+ $identityHandle = $null
+ $writer = $null
+ try {
+ $writerHandle = [CcodActivationStageNative]::CreateLeafRelative($ParentDirectory,$Leaf)
+ $writer = [IO.FileStream]::new($writerHandle,[IO.FileAccess]::Write,65536,$false)
+ $writerHandle = $null
+ $writer.Write($Bytes,0,$Bytes.Length)
+ $writer.Flush($true)
+ $identityHandle = [CcodActivationStageNative]::OpenLeafIdentityRelative($ParentDirectory,$Leaf)
+ $writer.Dispose(); $writer = $null
+ $readHandle = [CcodActivationStageNative]::OpenLeafReadRelative($ParentDirectory,$Leaf)
+ $stream = [IO.FileStream]::new($readHandle,[IO.FileAccess]::Read,65536,$false)
+ $memory = [IO.MemoryStream]::new()
+ try { $stream.CopyTo($memory); $readBytes = $memory.ToArray() }
+ finally { $memory.Dispose() }
+ $identityHandle.Dispose(); $identityHandle = $null
+ return [pscustomobject]@{ Stream=$stream; Bytes=$readBytes }
+ } catch {
+ if ($null -ne $writer) { $writer.Dispose() }
+ elseif ($null -ne $writerHandle) { $writerHandle.Dispose() }
+ if ($null -ne $identityHandle) { $identityHandle.Dispose() }
+ throw
+ }
+}
+
function Assert-CcodActivationNonReparsePath {
param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Path)
$canonicalRoot = [IO.Path]::GetFullPath($Root).TrimEnd('\')
@@ -140,6 +376,10 @@ function Close-CcodActivationPayloadSeal {
foreach ($stream in @($Seal.Streams)) {
if ($null -ne $stream) { try { $stream.Dispose() } catch { } }
}
+ $directoryPins = @($Seal.DirectoryPins)
+ for ($index=$directoryPins.Count-1;$index-ge0;$index--) {
+ if ($null -ne $directoryPins[$index]) { try { $directoryPins[$index].Dispose() } catch { } }
+ }
Remove-CcodActivationPayloadStage -AppRoot $Seal.AppRoot -StageRoot $Seal.StageRoot
}
@@ -168,6 +408,7 @@ function New-CcodActivationPayloadSeal {
[void](Assert-CcodActivationNonReparsePath -Root $payload -Path $manifestPath)
$sourceLocks = [Collections.Generic.List[object]]::new()
$stageLocks = [Collections.Generic.List[object]]::new()
+ $stageDirectoryPins = [Collections.Generic.List[object]]::new()
$stageRoot = Join-Path $app ('.activation-payload-stage-' + [guid]::NewGuid().ToString('N'))
try {
$manifestLock = Open-CcodActivationLockedBytes -Path $manifestPath -MaximumBytes 4194304
@@ -231,17 +472,23 @@ function New-CcodActivationPayloadSeal {
catch { throw 'CCOD_INSTALL_PAYLOAD_MANIFEST_INVALID' }
if ($packageSnapshot.Count -ne 1 -or $package.version -isnot [string] -or [string]$package.version -cne $Version) { throw 'CCOD_INSTALL_PAYLOAD_VERSION_MISMATCH' }
- [IO.Directory]::CreateDirectory($stageRoot) | Out-Null
+ Initialize-CcodActivationStageNative
+ $appPin = [CcodActivationStageNative]::OpenExistingDirectory($app)
+ $stageDirectoryPins.Add($appPin)
+ $stageName = [IO.Path]::GetFileName($stageRoot)
+ $stagePin = [CcodActivationStageNative]::CreateDirectoryRelative($appPin,$stageName)
+ $stageDirectoryPins.Add($stagePin)
+ $directories = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::Ordinal)
+ $directories.Add('',$stagePin)
[void](Assert-CcodActivationNonReparsePath -Root $app -Path $stageRoot)
foreach ($snapshot in @($snapshots) + @([pscustomobject]@{Relative='installer-payload.manifest.json';Bytes=$manifestLock.Bytes;Sha256=$ExpectedManifestSha256})) {
$destination = [IO.Path]::GetFullPath((Join-Path $stageRoot ([string]$snapshot.Relative).Replace('/','\')))
$stagePrefix = $stageRoot.TrimEnd('\') + '\'
if (-not $destination.StartsWith($stagePrefix,[StringComparison]::OrdinalIgnoreCase)) { throw 'CCOD_INSTALL_PAYLOAD_PATH_INVALID' }
- [IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) | Out-Null
- $writer = [IO.File]::Open($destination,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
- try { $writer.Write([byte[]]$snapshot.Bytes,0,([byte[]]$snapshot.Bytes).Length); $writer.Flush($true) } finally { $writer.Dispose() }
+ $relativeDirectory = [IO.Path]::GetDirectoryName(([string]$snapshot.Relative).Replace('/','\'))
+ $parentPin = Get-CcodActivationPinnedDirectory -Directories $directories -DirectoryPins $stageDirectoryPins -RelativeDirectory $relativeDirectory
+ $stageLock = Open-CcodActivationPinnedLeafBytes -ParentDirectory $parentPin -Leaf ([IO.Path]::GetFileName($destination)) -Bytes ([byte[]]$snapshot.Bytes)
[void](Assert-CcodActivationNonReparsePath -Root $stageRoot -Path $destination)
- $stageLock = Open-CcodActivationLockedBytes -Path $destination
$stageLocks.Add($stageLock)
if ((Get-CcodActivationBytesSha256 -Bytes $stageLock.Bytes) -cne [string]$snapshot.Sha256) { throw 'CCOD_INSTALL_FILE_HASH_MISMATCH' }
}
@@ -251,9 +498,11 @@ function New-CcodActivationPayloadSeal {
ManifestPath=Join-Path $stageRoot 'installer-payload.manifest.json'
ManifestBytes=$manifestLock.Bytes
Streams=@($stageLocks | ForEach-Object { $_.Stream })
+ DirectoryPins=@($stageDirectoryPins)
}
} catch {
foreach ($locked in @($stageLocks)) { try { $locked.Stream.Dispose() } catch { } }
+ for ($pinIndex=$stageDirectoryPins.Count-1;$pinIndex-ge0;$pinIndex--) { try { $stageDirectoryPins[$pinIndex].Dispose() } catch { } }
Remove-CcodActivationPayloadStage -AppRoot $app -StageRoot $stageRoot
throw
} finally {
From 7bdd78c9fdb27dc3e9ebdba02daddf0b23fc010b Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:53:50 +0800
Subject: [PATCH 060/187] test: expose setup write TOCTOU and provenance drift
---
.../persistence/ReleaseWorkflow.SelfTest.ps1 | 128 +++++++++++++++++-
1 file changed, 123 insertions(+), 5 deletions(-)
diff --git a/tests/persistence/ReleaseWorkflow.SelfTest.ps1 b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
index 331b6b0..332fde2 100644
--- a/tests/persistence/ReleaseWorkflow.SelfTest.ps1
+++ b/tests/persistence/ReleaseWorkflow.SelfTest.ps1
@@ -166,7 +166,12 @@ function New-CcodReleaseFixture {
$null = [IO.Directory]::CreateDirectory($root)
$installer = Join-Path $root 'CodexRemote-fix-2.5.0-setup.exe'
$commit = 'a' * 40
- $payloadHash = 'd' * 64
+ $payloadInput = Join-Path $root 'CodexRemote-fix-2.5.0-setup-payload-manifest.json'
+ $inventoryInput = Join-Path $root 'CodexRemote-fix-2.5.0-setup-destination-inventory.iss'
+ $payloadInputRecord = [ordered]@{schemaVersion=1;projectVersion='2.5.0';files=@([ordered]@{path='package.json';length=[int64]1;sha256=('d'*64)})}
+ [IO.File]::WriteAllText($payloadInput,($payloadInputRecord|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($inventoryInput,"procedure AddCcodExpectedSetupDirectories(Directories: TStrings);`r`nbegin`r`nend;`r`n",[Text.UTF8Encoding]::new($false))
+ $payloadHash = Get-CcodTestFileSha256 -Path $payloadInput
$typeName = 'SetupFixture' + [guid]::NewGuid().ToString('N')
$setupSource = @"
using System.Reflection;
@@ -189,17 +194,22 @@ public static class $typeName { public static int Main() { return 0; } }
$setupProvenance = Join-Path $root 'CodexRemote-fix-2.5.0-setup-provenance.json'
$setupProvenanceRecord = [ordered]@{
schemaVersion=1;product='CodexRemote-fix';version='2.5.0';gitCommit=$commit;buildTimestampUtc='2026-08-24T00:00:00.0000000Z'
- payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64]123;sha256=$payloadHash;fileCount=3}
- buildInputs=[ordered]@{innoTemplateSha256=('1'*64);destinationInventorySha256=('2'*64);compilerSha256=('3'*64);compilerFileVersion='6.7.3.0'}
+ payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64](Get-Item -LiteralPath $payloadInput).Length;sha256=$payloadHash;fileCount=1}
+ buildInputs=[ordered]@{innoTemplateSha256=$(Get-CcodTestFileSha256 -Path (Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'));destinationInventorySha256=$(Get-CcodTestFileSha256 -Path $inventoryInput);compilerSha256='PLACEHOLDER';compilerFileVersion='PLACEHOLDER'}
peContract=[ordered]@{fileVersion='2.5.0.0';productVersion='2.5.0.0';productName='CodexRemote-fix';fileDescription='CCODSETUP 2.5.0';companyName=$commit;legalCopyright=$payloadHash}
}
+ $iscc = @((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')) | Where-Object { $_ -and [IO.File]::Exists($_) } | Select-Object -First 1
+ $setupProvenanceRecord.buildInputs.compilerSha256 = Get-CcodTestFileSha256 -Path $iscc
+ $setupProvenanceRecord.buildInputs.compilerFileVersion = [string]([Diagnostics.FileVersionInfo]::GetVersionInfo($iscc).FileVersion)
[IO.File]::WriteAllText($setupProvenance,(($setupProvenanceRecord|ConvertTo-Json -Depth 8)+"`n"),[Text.UTF8Encoding]::new($false))
$manifest = Join-Path $root 'CodexRemote-fix-2.5.0-setup-release-manifest.json'
$assets = @(
[ordered]@{ name = [IO.Path]::GetFileName($installer); sha256 = $installerHash },
[ordered]@{ name = [IO.Path]::GetFileName($checksum); sha256 = Get-CcodTestFileSha256 -Path $checksum },
[ordered]@{ name = [IO.Path]::GetFileName($trayHost); sha256 = Get-CcodTestFileSha256 -Path $trayHost },
- [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodTestFileSha256 -Path $setupProvenance }
+ [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodTestFileSha256 -Path $setupProvenance },
+ [ordered]@{ name = [IO.Path]::GetFileName($payloadInput); sha256 = Get-CcodTestFileSha256 -Path $payloadInput },
+ [ordered]@{ name = [IO.Path]::GetFileName($inventoryInput); sha256 = Get-CcodTestFileSha256 -Path $inventoryInput }
)
$record = [ordered]@{
schemaVersion = 1
@@ -210,7 +220,7 @@ public static class $typeName { public static int Main() { return 0; } }
assets = $assets
}
[IO.File]::WriteAllText($manifest, ($record | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false))
- return [pscustomobject]@{ Root = $root; Installer = $installer; Checksum = $checksum; TrayHost = $trayHost; SetupProvenance = $setupProvenance; Manifest = $manifest; PayloadManifestSha256 = $payloadHash }
+ return [pscustomobject]@{ Root = $root; Installer = $installer; Checksum = $checksum; TrayHost = $trayHost; SetupProvenance = $setupProvenance; PayloadInput=$payloadInput;InventoryInput=$inventoryInput; Manifest = $manifest; PayloadManifestSha256 = $payloadHash }
}
function New-CcodPortableReleaseFixture {
@@ -1536,6 +1546,46 @@ Invoke-CcodTest 'compiled Setup independently binds PE versions commit and activ
}
}
+# Production mutation caught: accepting self-consistent but false Setup build-input hashes without comparing canonical files.
+Invoke-CcodTest 'Setup provenance rejects self-consistent wrong canonical build input hashes' {
+ Import-Module (Join-Path $repositoryRoot 'build\SetupArtifact.psm1') -Force
+ $root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-setup-provenance-inputs-' + [guid]::NewGuid().ToString('N'))
+ try {
+ [IO.Directory]::CreateDirectory($root) | Out-Null
+ $template = Join-Path $root 'CodexControlOtherDevices.iss'
+ $inventory = Join-Path $root 'InstallerDestinationInventory.iss'
+ $compiler = Join-Path $root 'ISCC.exe'
+ $payload = Join-Path $root 'installer-payload.manifest.json'
+ $provenance = Join-Path $root 'setup-provenance.json'
+ [IO.File]::WriteAllText($template,'canonical-template',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText($inventory,'canonical-inventory',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllBytes($compiler,[byte[]](1,2,3,4,5,6,7,8))
+ $payloadRecord = [ordered]@{schemaVersion=1;projectVersion='2.5.22';files=@([ordered]@{path='package.json';length=[int64]1;sha256=('a'*64)})}
+ [IO.File]::WriteAllText($payload,($payloadRecord|ConvertTo-Json -Depth 8),[Text.UTF8Encoding]::new($false))
+ $payloadHash = Get-CcodTestFileSha256 -Path $payload
+ $commit = 'b' * 40
+ $timestamp = '2026-08-28T00:00:00.0000000Z'
+ $record = [ordered]@{
+ schemaVersion=1;product='CodexRemote-fix';version='2.5.22';gitCommit=$commit;buildTimestampUtc=$timestamp
+ payloadManifest=[ordered]@{name='installer-payload.manifest.json';length=[int64](Get-Item -LiteralPath $payload).Length;sha256=$payloadHash;fileCount=1}
+ buildInputs=[ordered]@{innoTemplateSha256=('1'*64);destinationInventorySha256=('2'*64);compilerSha256=('3'*64);compilerFileVersion='0.0.0.0'}
+ peContract=[ordered]@{fileVersion='2.5.22.0';productVersion='2.5.22.0';productName='CodexRemote-fix';fileDescription='CCODSETUP 2.5.22';companyName=$commit;legalCopyright=$payloadHash}
+ }
+ [IO.File]::WriteAllText($provenance,(($record|ConvertTo-Json -Depth 8)+"`n"),[Text.UTF8Encoding]::new($false))
+
+ Assert-CcodThrows {
+ $validator = Get-Command Test-CcodSetupBuildProvenance
+ if ($validator.Parameters.ContainsKey('InnoTemplatePath')) {
+ Test-CcodSetupBuildProvenance -ProvenancePath $provenance -ExpectedVersion '2.5.22' -ExpectedGitCommit $commit -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $timestamp -InnoTemplatePath $template -DestinationInventoryPath $inventory -CompilerPath $compiler -PayloadManifestPath $payload | Out-Null
+ } else {
+ Test-CcodSetupBuildProvenance -ProvenancePath $provenance -ExpectedVersion '2.5.22' -ExpectedGitCommit $commit -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $timestamp | Out-Null
+ }
+ } 'CCOD_SETUP_PROVENANCE_INVALID'
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
# Production mutation caught: cleaning payload stage/inventory only after a successful Setup build.
Invoke-CcodTest 'build temporary Setup inputs are cleaned from the exact finally boundary after failure' {
. (Join-Path $repositoryRoot 'build\build.ps1') -Library
@@ -1581,6 +1631,16 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
Assert-CcodTrue $treeHelper.Success 'production Inno script exposes a recursive destination-tree predicate'
$inventoryValidator = [regex]::Match($inno,'(?ms)^function AreCcodExpectedSetupDirectoriesSafe\(.*?^end;')
Assert-CcodTrue $inventoryValidator.Success 'production Inno script exposes a generated destination-inventory validator'
+ $pinBlockMatch = [regex]::Match($inno,'(?ms)^// CCOD_SETUP_PIN_BEGIN\s*$\r?\n(?.*?)^// CCOD_SETUP_PIN_END\s*$')
+ $pinBlock = if ($pinBlockMatch.Success) { $pinBlockMatch.Groups['body'].Value } else { @'
+function PinCcodExistingSetupTree(const DirectoryName: String): Boolean;
+begin
+ Result := IsSafeExistingSetupTree(DirectoryName);
+end;
+procedure CloseCcodSetupPins();
+begin
+end;
+'@ }
Assert-CcodTrue ($inno -cmatch '(?m)^// CCOD_INSTALLER_DESTINATION_INVENTORY\s*$' -and $inno -cmatch '(?ms)^function PrepareToInstall\(var NeedsRestart: Boolean\): String;.*?AreCcodExpectedSetupDirectoriesSafe') 'PrepareToInstall consumes the generated inventory injected at the unique marker before file copy'
$root = Join-Path ([IO.Path]::GetTempPath()) ('ccod-inno-reparse-harness-' + [guid]::NewGuid().ToString('N'))
try {
@@ -1601,6 +1661,12 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
$fileWriteMarker = Join-Path $expectedFileRoot 'payload-write-marker.txt'
$junctionWriteMarker = Join-Path $expectedJunctionRoot 'payload-write-marker.txt'
$resultPath = Join-Path $root 'result.txt'
+ $concurrentTarget = Join-Path $root 'concurrent-target'
+ $concurrentMoved = Join-Path $root 'concurrent-target-moved'
+ $concurrentOutside = Join-Path $root 'concurrent-outside'
+ $attackScript = Join-Path $root 'Invoke-ConcurrentSubstitution.ps1'
+ $attackResult = Join-Path $root 'concurrent-attack.txt'
+ $pinWriteResult = Join-Path $root 'concurrent-write.txt'
[IO.Directory]::CreateDirectory($normal) | Out-Null
[IO.Directory]::CreateDirectory($target) | Out-Null
[IO.Directory]::CreateDirectory((Split-Path $nested -Parent)) | Out-Null
@@ -1615,6 +1681,23 @@ Invoke-CcodTest 'Inno exposes a pre-write payload-directory reparse gate' {
[IO.Directory]::CreateDirectory($hardLinkTree) | Out-Null
[IO.File]::WriteAllText($outsideHardLink,'outside-original',[Text.UTF8Encoding]::new($false))
New-Item -ItemType HardLink -Path $hardLinkLeaf -Target $outsideHardLink | Out-Null
+ [IO.Directory]::CreateDirectory($concurrentTarget) | Out-Null
+ [IO.Directory]::CreateDirectory($concurrentOutside) | Out-Null
+ [IO.File]::WriteAllText((Join-Path $concurrentTarget 'payload.txt'),'target-original',[Text.UTF8Encoding]::new($false))
+ [IO.File]::WriteAllText((Join-Path $concurrentOutside 'payload.txt'),'outside-original',[Text.UTF8Encoding]::new($false))
+ $attackSource = @'
+[CmdletBinding()]
+param([Parameter(Mandatory)][string]$Target,[Parameter(Mandatory)][string]$Moved,[Parameter(Mandatory)][string]$Outside,[Parameter(Mandatory)][string]$ResultPath)
+$ErrorActionPreference='Stop'
+try {
+ Move-Item -LiteralPath $Target -Destination $Moved -ErrorAction Stop
+ New-Item -ItemType Junction -Path $Target -Target $Outside -ErrorAction Stop | Out-Null
+ [IO.File]::WriteAllText($ResultPath,'substituted',[Text.UTF8Encoding]::new($false))
+} catch {
+ [IO.File]::WriteAllText($ResultPath,'blocked',[Text.UTF8Encoding]::new($false))
+}
+'@
+ [IO.File]::WriteAllText($attackScript,$attackSource,[Text.UTF8Encoding]::new($false))
[IO.Directory]::CreateDirectory((Join-Path $inventoryPayload 'src\persistence\modules')) | Out-Null
[IO.File]::WriteAllText((Join-Path $inventoryPayload 'src\persistence\modules\InstallLifecycle.psm1'),'fixture',[Text.UTF8Encoding]::new($false))
$inventoryPath = Join-Path $root 'InventoryFixture.iss'
@@ -1635,11 +1718,13 @@ const
CCOD_FILE_ATTRIBUTE_DIRECTORY = `$00000010;
CCOD_FILE_ATTRIBUTE_REPARSE_POINT = `$00000400;
CCOD_FILE_READ_ATTRIBUTES = `$00000080;
+ CCOD_DELETE_ACCESS = `$00010000;
CCOD_FILE_SHARE_READ = `$00000001;
CCOD_FILE_SHARE_WRITE = `$00000002;
CCOD_FILE_SHARE_DELETE = `$00000004;
CCOD_OPEN_EXISTING = 3;
CCOD_FILE_FLAG_OPEN_REPARSE_POINT = `$00200000;
+ CCOD_FILE_FLAG_BACKUP_SEMANTICS = `$02000000;
CCOD_INVALID_FILE_ATTRIBUTES = `$FFFFFFFF;
CCOD_INVALID_HANDLE_VALUE = -1;
CCOD_ERROR_HANDLE_EOF = 38;
@@ -1691,7 +1776,11 @@ $($leafHelper.Value)
$($treeHelper.Value)
$inventorySource
$($inventoryValidator.Value)
+$pinBlock
function InitializeSetup(): Boolean;
+var
+ AttackResultCode: Integer;
+ AttackParameters: String;
begin
if AreCcodExpectedSetupDirectoriesSafe('$($expectedFileRoot.Replace("'","''"))') then
SaveStringToFile('$($fileWriteMarker.Replace("'","''"))','unsafe write',False);
@@ -1699,6 +1788,23 @@ begin
SaveStringToFile('$($junctionWriteMarker.Replace("'","''"))','unsafe write',False);
if IsSafeExistingSetupTree('$($hardLinkTree.Replace("'","''"))') then
SaveStringToFile('$($hardLinkLeaf.Replace("'","''"))','unsafe overwrite',False);
+ if PinCcodExistingSetupTree('$($concurrentTarget.Replace("'","''"))') then
+ begin
+ AttackParameters := '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' +
+ '$($attackScript.Replace("'","''"))' + '" -Target "' + '$($concurrentTarget.Replace("'","''"))' +
+ '" -Moved "' + '$($concurrentMoved.Replace("'","''"))' + '" -Outside "' +
+ '$($concurrentOutside.Replace("'","''"))' + '" -ResultPath "' + '$($attackResult.Replace("'","''"))' + '"';
+ if (not Exec(ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), AttackParameters,
+ '', SW_HIDE, ewWaitUntilTerminated, AttackResultCode)) or (AttackResultCode <> 0) then
+ SaveStringToFile('$($attackResult.Replace("'","''"))','attacker-failed',False);
+ if SaveStringToFile('$((Join-Path $concurrentTarget 'payload.txt').Replace("'","''"))','setup-write',False) then
+ SaveStringToFile('$($pinWriteResult.Replace("'","''"))','write-ok',False)
+ else
+ SaveStringToFile('$($pinWriteResult.Replace("'","''"))','write-failed',False);
+ CloseCcodSetupPins();
+ end
+ else
+ SaveStringToFile('$($pinWriteResult.Replace("'","''"))','pin-failed',False);
if IsSafeExistingPayloadDirectory('$($normal.Replace("'","''"))') and
IsSafeExistingPayloadDirectory('$($missing.Replace("'","''"))') and
(not IsSafeExistingPayloadDirectory('$($junction.Replace("'","''"))')) and
@@ -1711,6 +1817,10 @@ begin
SaveStringToFile('$($resultPath.Replace("'","''"))','pass',False);
Result := False;
end;
+procedure DeinitializeSetup();
+begin
+ CloseCcodSetupPins();
+end;
"@
[IO.File]::WriteAllText($harnessPath,$harness,[Text.UTF8Encoding]::new($false))
$iscc = Join-Path $env:LOCALAPPDATA 'Programs\Inno Setup 6\ISCC.exe'
@@ -1722,7 +1832,15 @@ end;
Assert-CcodTrue (-not (Test-Path -LiteralPath $fileWriteMarker)) 'nested file-as-directory is rejected before simulated payload writes'
Assert-CcodTrue (-not (Test-Path -LiteralPath $junctionWriteMarker)) 'nested junction is rejected before simulated payload writes'
Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText($outsideHardLink,[Text.UTF8Encoding]::new($false))) 'hard-linked setup leaf is rejected before simulated outside overwrite'
+ Assert-CcodEqual 'blocked' ([IO.File]::ReadAllText($attackResult,[Text.UTF8Encoding]::new($false))) 'concurrent directory substitution is blocked after Setup preflight'
+ Assert-CcodEqual 'write-ok' ([IO.File]::ReadAllText($pinWriteResult,[Text.UTF8Encoding]::new($false))) 'retained pins still allow the legitimate Setup overwrite'
+ Assert-CcodEqual 'outside-original' ([IO.File]::ReadAllText((Join-Path $concurrentOutside 'payload.txt'),[Text.UTF8Encoding]::new($false))) 'concurrent substitution cannot redirect Setup bytes to the outside sentinel'
+ Assert-CcodEqual 'setup-write' ([IO.File]::ReadAllText((Join-Path $concurrentTarget 'payload.txt'),[Text.UTF8Encoding]::new($false))) 'simulated Setup write reaches the pinned destination identity'
} finally {
+ if (Test-Path -LiteralPath $concurrentTarget) {
+ $concurrentItem = Get-Item -LiteralPath $concurrentTarget -Force
+ if (($concurrentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { [IO.Directory]::Delete($concurrentTarget) }
+ }
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
}
}
From f5021b06297c1718e21249b38084ff439c8ce105 Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:53:50 +0800
Subject: [PATCH 061/187] fix: pin setup writes and verify provenance inputs
---
.github/workflows/release.yml | 4 +-
README.md | 4 +-
build/CodexControlOtherDevices.iss | 171 ++++++++++++++++++++++++++++-
build/SetupArtifact.psm1 | 27 +++--
build/build.ps1 | 12 +-
tools/Test-ReleaseDefender.ps1 | 16 ++-
6 files changed, 217 insertions(+), 17 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index deaf09f..4db31d4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -145,7 +145,7 @@ jobs:
throw "Could not inspect existing release assets for $tag."
}
}
- $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json') })
+ $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json', '.iss') })
$assetNames = @($assets | ForEach-Object { $_.Name })
$unexpected = @($existingAssets | Where-Object { $assetNames -cnotcontains $_ })
if ($unexpected.Count -ne 0) {
@@ -194,7 +194,7 @@ jobs:
if ($tag -cnotmatch '^v\d+\.\d+\.\d+$') {
throw "Release tag output is invalid: '$tag'."
}
- $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json') })
+ $assets = @(Get-ChildItem -LiteralPath (Join-Path $PWD 'build\dist') -File | Where-Object { $_.Extension -in @('.exe', '.zip', '.txt', '.json', '.iss') })
$assetNames = @($assets | ForEach-Object { $_.Name })
$publishedNames = @(& gh release view $tag --repo $env:GITHUB_REPOSITORY --json assets --jq '.assets[].name')
if ($LASTEXITCODE -ne 0 -or ((@($publishedNames | Sort-Object) -join '|') -cne (@($assetNames | Sort-Object) -join '|'))) {
diff --git a/README.md b/README.md
index 76c384a..838f510 100644
--- a/README.md
+++ b/README.md
@@ -62,8 +62,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 device-key store stay in place.
-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.
+Historical development checks used Windows 11, Codex Desktop `26.820.7780.0`,
+and Node.js `22.23.1`; they are not current v2.5.22 real-machine acceptance.
## Everyday use
diff --git a/build/CodexControlOtherDevices.iss b/build/CodexControlOtherDevices.iss
index c77f35c..e5c5a8c 100644
--- a/build/CodexControlOtherDevices.iss
+++ b/build/CodexControlOtherDevices.iss
@@ -56,6 +56,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl"
[Files]
Source: "{#SetupProvenancePath}"; DestDir: "{app}"; DestName: "setup-provenance.json"; Flags: dontcopy
+Source: "{#InstallerPayloadDirectory}\installer-payload.manifest.json"; DestDir: "{app}"; DestName: "setup-input-payload-manifest.json"; Flags: dontcopy
Source: "..\README.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\LICENSE"; DestDir: "{app}"; Flags: ignoreversion
@@ -121,11 +122,13 @@ const
CCOD_FILE_ATTRIBUTE_DIRECTORY = $00000010;
CCOD_FILE_ATTRIBUTE_REPARSE_POINT = $00000400;
CCOD_FILE_READ_ATTRIBUTES = $00000080;
+ CCOD_DELETE_ACCESS = $00010000;
CCOD_FILE_SHARE_READ = $00000001;
CCOD_FILE_SHARE_WRITE = $00000002;
CCOD_FILE_SHARE_DELETE = $00000004;
CCOD_OPEN_EXISTING = 3;
CCOD_FILE_FLAG_OPEN_REPARSE_POINT = $00200000;
+ CCOD_FILE_FLAG_BACKUP_SEMANTICS = $02000000;
CCOD_INVALID_FILE_ATTRIBUTES = $FFFFFFFF;
CCOD_INVALID_HANDLE_VALUE = -1;
CCOD_ERROR_HANDLE_EOF = 38;
@@ -278,6 +281,161 @@ begin
end;
end;
+// CCOD_SETUP_PIN_BEGIN
+var
+ CcodSetupPinHandles: array of Integer;
+
+procedure AddCcodSetupPin(Handle: Integer);
+var
+ Count: Integer;
+begin
+ Count := GetArrayLength(CcodSetupPinHandles);
+ SetArrayLength(CcodSetupPinHandles, Count + 1);
+ CcodSetupPinHandles[Count] := Handle;
+end;
+
+procedure CloseCcodSetupPins();
+var
+ Index: Integer;
+begin
+ for Index := GetArrayLength(CcodSetupPinHandles) - 1 downto 0 do
+ if CcodSetupPinHandles[Index] <> CCOD_INVALID_HANDLE_VALUE then
+ CloseHandle(CcodSetupPinHandles[Index]);
+ SetArrayLength(CcodSetupPinHandles, 0);
+end;
+
+function PinCcodExistingSetupDirectory(const DirectoryName: String): Boolean;
+var
+ Attributes: Cardinal;
+ DirectoryHandle: Integer;
+ Information: TCcodByHandleFileInformation;
+begin
+ Result := False;
+ Attributes := GetFileAttributesW(DirectoryName);
+ if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then
+ begin
+ Result := True;
+ Exit;
+ end;
+ if ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or
+ ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then Exit;
+ DirectoryHandle := CreateFileW(DirectoryName, CCOD_FILE_READ_ATTRIBUTES or CCOD_DELETE_ACCESS,
+ CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE, 0, CCOD_OPEN_EXISTING,
+ CCOD_FILE_FLAG_BACKUP_SEMANTICS or CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0);
+ if DirectoryHandle = CCOD_INVALID_HANDLE_VALUE then Exit;
+ if (not GetFileInformationByHandle(DirectoryHandle, Information)) or
+ ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or
+ ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then
+ begin
+ CloseHandle(DirectoryHandle);
+ Exit;
+ end;
+ Attributes := GetFileAttributesW(DirectoryName);
+ if (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or
+ ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) = 0) or
+ ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then
+ begin
+ CloseHandle(DirectoryHandle);
+ Exit;
+ end;
+ AddCcodSetupPin(DirectoryHandle);
+ Result := True;
+end;
+
+function PinCcodExistingSetupLeaf(const FileName: String): Boolean;
+var
+ Attributes: Cardinal;
+ FileHandle: Integer;
+ Information: TCcodByHandleFileInformation;
+begin
+ Result := False;
+ if not IsSafeExistingSetupLeaf(FileName) then Exit;
+ FileHandle := CreateFileW(FileName, CCOD_FILE_READ_ATTRIBUTES,
+ CCOD_FILE_SHARE_READ or CCOD_FILE_SHARE_WRITE, 0, CCOD_OPEN_EXISTING,
+ CCOD_FILE_FLAG_OPEN_REPARSE_POINT, 0);
+ if FileHandle = CCOD_INVALID_HANDLE_VALUE then Exit;
+ if (not GetFileInformationByHandle(FileHandle, Information)) or
+ (Information.NumberOfLinks <> 1) or
+ ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) or
+ ((Information.FileAttributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) or
+ (not HasOnlyDefaultDataStream(FileName)) then
+ begin
+ CloseHandle(FileHandle);
+ Exit;
+ end;
+ Attributes := GetFileAttributesW(FileName);
+ if (Attributes = CCOD_INVALID_FILE_ATTRIBUTES) or
+ ((Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) or
+ ((Attributes and CCOD_FILE_ATTRIBUTE_REPARSE_POINT) <> 0) then
+ begin
+ CloseHandle(FileHandle);
+ Exit;
+ end;
+ AddCcodSetupPin(FileHandle);
+ Result := True;
+end;
+
+function PinCcodExistingSetupTree(const DirectoryName: String): Boolean;
+var
+ Attributes: Cardinal;
+ FindRec: TFindRec;
+ ChildPath: String;
+begin
+ Result := PinCcodExistingSetupDirectory(DirectoryName);
+ if not Result then Exit;
+ Attributes := GetFileAttributesW(DirectoryName);
+ if Attributes = CCOD_INVALID_FILE_ATTRIBUTES then Exit;
+ if FindFirst(AddBackslash(DirectoryName) + '*', FindRec) then
+ begin
+ try
+ repeat
+ if (FindRec.Name <> '.') and (FindRec.Name <> '..') then
+ begin
+ ChildPath := AddBackslash(DirectoryName) + FindRec.Name;
+ if ((FindRec.Attributes and CCOD_FILE_ATTRIBUTE_DIRECTORY) <> 0) then
+ begin
+ if not PinCcodExistingSetupTree(ChildPath) then
+ begin
+ Result := False;
+ Exit;
+ end;
+ end
+ else if not PinCcodExistingSetupLeaf(ChildPath) then
+ begin
+ Result := False;
+ Exit;
+ end;
+ end;
+ until not FindNext(FindRec);
+ finally
+ FindClose(FindRec);
+ end;
+ end;
+end;
+
+function PinCcodExpectedSetupDirectories(const AppDirectory: String): Boolean;
+var
+ Directories: TStringList;
+ Index: Integer;
+ Candidate: String;
+begin
+ Result := False;
+ Directories := TStringList.Create;
+ try
+ AddCcodExpectedSetupDirectories(Directories);
+ if Directories.Count = 0 then Exit;
+ for Index := 0 to Directories.Count - 1 do
+ begin
+ Candidate := AddBackslash(AppDirectory) + Directories[Index];
+ if not PinCcodExistingSetupDirectory(Candidate) then Exit;
+ end;
+ Result := True;
+ finally
+ Directories.Free;
+ end;
+end;
+// CCOD_SETUP_PIN_END
+
function AreCcodExpectedSetupDirectoriesSafe(const AppDirectory: String): Boolean;
var
Directories: TStringList;
@@ -305,10 +463,15 @@ var
AppDirectory: String;
begin
Result := '';
+ CloseCcodSetupPins();
AppDirectory := ExpandConstant('{app}');
if not IsSafeExistingSetupTree(AppDirectory) or
not AreCcodExpectedSetupDirectoriesSafe(AppDirectory) then
- Result := 'CodexRemote-fix refused an unsafe existing payload directory.';
+ Result := 'CodexRemote-fix refused an unsafe existing payload directory.'
+ else if not PinCcodExistingSetupTree(AppDirectory) or
+ not PinCcodExpectedSetupDirectories(AppDirectory) then
+ Result := 'CodexRemote-fix could not retain the safe Setup write boundary.';
+ if Result <> '' then CloseCcodSetupPins();
end;
function NewActivationId(): String;
@@ -488,6 +651,7 @@ var
begin
if CurStep <> ssPostInstall then
Exit;
+ CloseCcodSetupPins();
ActivationId := NewActivationId();
ReceiptPath := ExpandConstant('{localappdata}\CodexControlOtherDevices\state\post-install-activation.json');
RefuseStaleActivationReceipt(ReceiptPath);
@@ -547,6 +711,11 @@ begin
end;
end;
+procedure DeinitializeSetup();
+begin
+ CloseCcodSetupPins();
+end;
+
function IsCanonicalUninstallTransactionId(const Value: String): Boolean;
var
Index: Integer;
diff --git a/build/SetupArtifact.psm1 b/build/SetupArtifact.psm1
index 4458e71..a5eda58 100644
--- a/build/SetupArtifact.psm1
+++ b/build/SetupArtifact.psm1
@@ -153,9 +153,21 @@ function Test-CcodSetupBuildProvenance {
[Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+$')][string]$ExpectedVersion,
[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{40}$')][string]$ExpectedGitCommit,
[Parameter(Mandatory)][ValidatePattern('^[0-9a-f]{64}$')][string]$ExpectedPayloadManifestSha256,
- [string]$ExpectedBuildTimestampUtc
+ [string]$ExpectedBuildTimestampUtc,
+ [Parameter(Mandatory)][string]$InnoTemplatePath,
+ [Parameter(Mandatory)][string]$DestinationInventoryPath,
+ [Parameter(Mandatory)][string]$CompilerPath,
+ [Parameter(Mandatory)][string]$PayloadManifestPath
)
$path = Assert-CcodSetupRegularFile -Path $ProvenancePath -Kind 'Setup provenance'
+ $template = Assert-CcodSetupRegularFile -Path $InnoTemplatePath -Kind 'Inno template'
+ $inventory = Assert-CcodSetupRegularFile -Path $DestinationInventoryPath -Kind 'Destination inventory'
+ $compiler = Assert-CcodSetupRegularFile -Path $CompilerPath -Kind 'Inno compiler'
+ $payload = Assert-CcodSetupRegularFile -Path $PayloadManifestPath -Kind 'Installer payload manifest'
+ try { $payloadRaw = [IO.File]::ReadAllText($payload,[Text.UTF8Encoding]::new($false)); $payloadRecord = $payloadRaw | ConvertFrom-Json -ErrorAction Stop }
+ catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Canonical installer payload manifest is invalid' $payload }
+ $actualPayloadHash = Get-CcodSetupArtifactHash -Path $payload
+ $actualCompilerVersion = ([string][Diagnostics.FileVersionInfo]::GetVersionInfo($compiler).FileVersion).Trim()
try { $raw = [IO.File]::ReadAllText($path,[Text.UTF8Encoding]::new($false)); $record = $raw | ConvertFrom-Json -ErrorAction Stop }
catch { Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is invalid JSON' $path }
$timestampMatches = [regex]::Matches($raw,'"buildTimestampUtc"\s*:\s*"(?[^"\\]+)"')
@@ -166,19 +178,20 @@ function Test-CcodSetupBuildProvenance {
$record.version -isnot [string] -or $record.version -cne $ExpectedVersion -or $record.gitCommit -isnot [string] -or $record.gitCommit -cne $ExpectedGitCommit -or
-not (Test-CcodSetupCanonicalUtc $timestampText) -or
(-not [string]::IsNullOrWhiteSpace($ExpectedBuildTimestampUtc) -and $timestampText -cne $ExpectedBuildTimestampUtc) -or
- $record.payloadManifest.sha256 -isnot [string] -or $record.payloadManifest.sha256 -cne $ExpectedPayloadManifestSha256 -or
+ $record.payloadManifest.sha256 -isnot [string] -or $record.payloadManifest.sha256 -cne $ExpectedPayloadManifestSha256 -or $record.payloadManifest.sha256 -cne $actualPayloadHash -or
$record.payloadManifest.name -isnot [string] -or $record.payloadManifest.name -cne 'installer-payload.manifest.json' -or
- [int64]$record.payloadManifest.length -le 0 -or [int]$record.payloadManifest.fileCount -le 0 -or
+ [int64]$record.payloadManifest.length -ne [int64](Get-Item -LiteralPath $payload -Force).Length -or
+ [int]$record.payloadManifest.fileCount -ne [int]@($payloadRecord.files).Count -or
$record.peContract.fileVersion -isnot [string] -or $record.peContract.fileVersion -cne "$ExpectedVersion.0" -or
$record.peContract.productVersion -isnot [string] -or $record.peContract.productVersion -cne "$ExpectedVersion.0" -or
$record.peContract.productName -isnot [string] -or $record.peContract.productName -cne 'CodexRemote-fix' -or
$record.peContract.fileDescription -isnot [string] -or $record.peContract.fileDescription -cne (Get-CcodSetupDescription -Version $ExpectedVersion) -or
$record.peContract.companyName -isnot [string] -or $record.peContract.companyName -cne $ExpectedGitCommit -or
$record.peContract.legalCopyright -isnot [string] -or $record.peContract.legalCopyright -cne $ExpectedPayloadManifestSha256 -or
- $record.buildInputs.innoTemplateSha256 -isnot [string] -or $record.buildInputs.innoTemplateSha256 -cnotmatch '^[0-9a-f]{64}$' -or
- $record.buildInputs.destinationInventorySha256 -isnot [string] -or $record.buildInputs.destinationInventorySha256 -cnotmatch '^[0-9a-f]{64}$' -or
- $record.buildInputs.compilerSha256 -isnot [string] -or $record.buildInputs.compilerSha256 -cnotmatch '^[0-9a-f]{64}$' -or
- $record.buildInputs.compilerFileVersion -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$record.buildInputs.compilerFileVersion)) {
+ $record.buildInputs.innoTemplateSha256 -isnot [string] -or $record.buildInputs.innoTemplateSha256 -cne (Get-CcodSetupArtifactHash -Path $template) -or
+ $record.buildInputs.destinationInventorySha256 -isnot [string] -or $record.buildInputs.destinationInventorySha256 -cne (Get-CcodSetupArtifactHash -Path $inventory) -or
+ $record.buildInputs.compilerSha256 -isnot [string] -or $record.buildInputs.compilerSha256 -cne (Get-CcodSetupArtifactHash -Path $compiler) -or
+ $record.buildInputs.compilerFileVersion -isnot [string] -or ([string]$record.buildInputs.compilerFileVersion).Trim() -cne $actualCompilerVersion) {
Throw-CcodSetupArtifactError 'CCOD_SETUP_PROVENANCE_INVALID' 'Setup provenance is not exactly bound to the release contract' $path
}
return $record
diff --git a/build/build.ps1 b/build/build.ps1
index c9cdf94..cea0e03 100644
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -331,8 +331,10 @@ $releaseManifest = Join-Path $dist "CodexRemote-fix-$Version-release-manifest.js
$setupExe = Join-Path $dist "CodexRemote-fix-$Version-setup.exe"
$setupChecksum = "$setupExe.sha256.txt"
$setupProvenance = Join-Path $dist "CodexRemote-fix-$Version-setup-provenance.json"
+$setupPayloadInput = Join-Path $dist "CodexRemote-fix-$Version-setup-payload-manifest.json"
+$setupInventoryInput = Join-Path $dist "CodexRemote-fix-$Version-setup-destination-inventory.iss"
$setupReleaseManifest = Join-Path $dist "CodexRemote-fix-$Version-setup-release-manifest.json"
-foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum,$setupProvenance,$setupReleaseManifest)) {
+foreach ($path in @($bundle,$checksum,$provenance,$payloadManifestAsset,$releaseManifest,$setupExe,$setupChecksum,$setupProvenance,$setupPayloadInput,$setupInventoryInput,$setupReleaseManifest)) {
if ([IO.File]::Exists($path) -or [IO.Directory]::Exists($path)) { throw "Refusing to overwrite immutable release output: $path" }
}
@@ -481,8 +483,10 @@ $iscc = $isccCandidates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -
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'
Import-Module (Join-Path $PSScriptRoot 'SetupArtifact.psm1') -Force
+[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerPayloadManifestPath -Kind 'Installer payload manifest'),$setupPayloadInput,$false)
+[IO.File]::Copy((Assert-CcodBuildRegularFile -Path $installerDestinationInventoryPath -Kind 'Installer destination inventory'),$setupInventoryInput,$false)
$setupProvenanceRecord = New-CcodSetupBuildProvenance -Version $Version -GitCommit $gitCommit -BuildTimestampUtc $buildTimestampUtc -PayloadManifestPath $installerPayloadManifestPath -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -OutputPath $setupProvenance
-Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc | Out-Null
+Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenance -ExpectedVersion $Version -ExpectedGitCommit $gitCommit -ExpectedPayloadManifestSha256 $installerPayloadManifestSha256 -ExpectedBuildTimestampUtc $buildTimestampUtc -InnoTemplatePath $issPath -DestinationInventoryPath $installerDestinationInventoryPath -CompilerPath $iscc -PayloadManifestPath $installerPayloadManifestPath | Out-Null
$isccArguments = @(
"/DProjectVersion=$Version",
"/DTrayHostArtifactDirectory=$trayHostArtifact",
@@ -507,7 +511,9 @@ $setupRecord = [ordered]@{
[ordered]@{ name = [IO.Path]::GetFileName($setupExe); sha256 = $setupHash },
[ordered]@{ name = [IO.Path]::GetFileName($setupChecksum); sha256 = Get-CcodBuildFileSha256 -Path $setupChecksum },
[ordered]@{ name = [IO.Path]::GetFileName($provenance); sha256 = Get-CcodBuildFileSha256 -Path $provenance },
- [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodBuildFileSha256 -Path $setupProvenance }
+ [ordered]@{ name = [IO.Path]::GetFileName($setupProvenance); sha256 = Get-CcodBuildFileSha256 -Path $setupProvenance },
+ [ordered]@{ name = [IO.Path]::GetFileName($setupPayloadInput); sha256 = Get-CcodBuildFileSha256 -Path $setupPayloadInput },
+ [ordered]@{ name = [IO.Path]::GetFileName($setupInventoryInput); sha256 = Get-CcodBuildFileSha256 -Path $setupInventoryInput }
)
}
Write-CcodBuildUtf8 -Path $setupReleaseManifest -Text (($setupRecord | ConvertTo-Json -Depth 8) + [Environment]::NewLine)
diff --git a/tools/Test-ReleaseDefender.ps1 b/tools/Test-ReleaseDefender.ps1
index ab73cdb..f9ddffe 100644
--- a/tools/Test-ReleaseDefender.ps1
+++ b/tools/Test-ReleaseDefender.ps1
@@ -487,7 +487,9 @@ function Test-CcodReleaseAssetManifest {
"CodexRemote-fix-$ExpectedVersion-setup.exe",
"CodexRemote-fix-$ExpectedVersion-setup.exe.sha256.txt",
"CodexRemote-fix-$ExpectedVersion-trayhost-provenance.json",
- "CodexRemote-fix-$ExpectedVersion-setup-provenance.json"
+ "CodexRemote-fix-$ExpectedVersion-setup-provenance.json",
+ "CodexRemote-fix-$ExpectedVersion-setup-payload-manifest.json",
+ "CodexRemote-fix-$ExpectedVersion-setup-destination-inventory.iss"
)
if ($assets.Count -ne $expectedNames.Count) { Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' 'Release manifest does not bind the exact required asset set' $manifestFile }
$assetHashes = @{}
@@ -542,7 +544,15 @@ function Test-CcodReleaseAssetManifest {
$setupRecord = $setupRaw | ConvertFrom-Json -ErrorAction Stop
$payloadHash = [string]$setupRecord.payloadManifest.sha256
if ($payloadHash -cnotmatch '^[0-9a-f]{64}$') { throw 'payload hash' }
- Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenancePath -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $manifestTimestamp | Out-Null
+ $repositoryRoot = Split-Path $PSScriptRoot -Parent
+ $innoTemplatePath = Join-Path $repositoryRoot 'build\CodexControlOtherDevices.iss'
+ $compiler = @(
+ (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')
+ ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and [IO.File]::Exists($_) } | Select-Object -First 1
+ if ($null -eq $compiler) { throw 'canonical ISCC compiler missing' }
+ Test-CcodSetupBuildProvenance -ProvenancePath $setupProvenancePath -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash -ExpectedBuildTimestampUtc $manifestTimestamp -InnoTemplatePath $innoTemplatePath -DestinationInventoryPath (Join-Path $directory $expectedNames[5]) -CompilerPath $compiler -PayloadManifestPath (Join-Path $directory $expectedNames[4]) | Out-Null
Test-CcodSetupArtifact -SetupPath $installer -ExpectedVersion $ExpectedVersion -ExpectedGitCommit ([string]$manifest.gitCommit) -ExpectedPayloadManifestSha256 $payloadHash | Out-Null
} catch {
Throw-CcodReleaseDefenderError 'CCOD_RELEASE_MANIFEST_INVALID' ('Setup provenance or final PE contract is invalid: ' + $_.Exception.Message) $setupProvenancePath
@@ -556,6 +566,8 @@ function Test-CcodReleaseAssetManifest {
InstallerName = $expectedNames[0]
PayloadManifestSha256 = $payloadHash
SetupProvenanceName = $expectedNames[3]
+ SetupPayloadInputName = $expectedNames[4]
+ SetupInventoryInputName = $expectedNames[5]
}
}
From 8102bded7e668fff59f581cdfb1ce98cde3f3bca Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 16:54:21 +0800
Subject: [PATCH 062/187] docs: record final TOCTOU remediation
---
.../task-5-report.md | 97 +++++++++++++++++++
1 file changed, 97 insertions(+)
diff --git a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
index 25e18fd..f5356b2 100644
--- a/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
+++ b/.superpowers/sdd/2026-08-28-v2522-install-runtime-reliability/task-5-report.md
@@ -638,3 +638,100 @@ f1ee9b7 test: expose linked whole-install deletion
No installer/build/release/install operation was performed in this narrow
round. No real process was started or stopped, and no WindowsApps or DPAPI data
was accessed.
+
+## Final remediation round 2 — pinned write boundaries and canonical provenance
+
+### Deterministic RED evidence
+
+Activation stage substitution used the real production function set extracted
+from `Activate-CcodRemoteFix.ps1`. A debugger barrier fired after the old stage
+pathname check and before the first verified leaf write. The barrier renamed
+the stage and replaced it with an outside junction:
+
+```text
+case=activation-pins-the-stage-directory-before-any-verified-leaf-write
+expected=[blocked]
+actual=[substituted]
+```
+
+The real compiled Inno harness retained the old preflight result, launched a
+concurrent PowerShell substitution attempt, and then simulated the Setup write:
+
+```text
+case=Inno-exposes-a-pre-write-payload-directory-reparse-gate
+expected concurrent attack=[blocked]
+actual=[substituted]
+```
+
+The Setup provenance fixture supplied canonical template, inventory, compiler,
+and payload files while its record used self-consistent false `111...`,
+`222...`, and `333...` hashes. The old validator accepted the record:
+
+```text
+case=Setup-provenance-rejects-self-consistent-wrong-canonical-build-input-hashes
+ASSERT_THROWS: expected CCOD_SETUP_PROVENANCE_INVALID
+```
+
+### Implementation
+
+- Activation now pins the app directory and atomically creates the stage and
+ every nested stage directory with relative `NtCreateFile(FILE_CREATE)`
+ handles. Each leaf transitions from its writer handle to identity/read pins,
+ is rehashed through the pinned read handle, and remains non-writable and
+ non-deletable through the child installer lifetime. All pins close in reverse
+ order before exact safe cleanup.
+- Inno retains a global native handle set from `PrepareToInstall` until the
+ start of `ssPostInstall`. Existing directories are opened with read/delete
+ access and no delete sharing; existing leaves are held with read/write
+ sharing but no delete sharing. The real harness proves the concurrent
+ directory rename/junction substitution is blocked while the legitimate
+ overwrite succeeds. `DeinitializeSetup` closes every pin on abort.
+- Setup provenance validation now requires the actual canonical Inno template,
+ generated destination inventory, ISCC executable, and installer payload
+ manifest paths. It compares real hashes, compiler version, payload length,
+ and payload file count rather than only checking SHA-256 text shape.
+- The exact Setup payload manifest and generated inventory are release-bound
+ assets. Build and publish validation use them with the repository template
+ and installed canonical ISCC path; release upload/read-back includes `.iss`.
+- README's historical Windows 11 validation line is explicitly qualified as
+ historical development evidence, not current v2.5.22 real-machine
+ acceptance.
+
+### GREEN evidence
+
+```text
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\persistence\ReleaseWorkflow.SelfTest.ps1
+exit 0
+Release workflow self-tests passed.
+ - original source-byte swap barrier passed
+ - activation stage substitution was blocked
+ - real compiled Inno concurrent substitution was blocked
+ - simulated legitimate Setup overwrite succeeded
+ - outside sentinel remained unchanged
+ - false canonical provenance hashes were rejected
+ - real ISCC fixture compilation passed
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -ProductionTraceOnly
+exit 0; 3 cases
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests\trayhost\Invoke-TrayHostSelfTest.ps1 -NativeOnly
+exit 0; 25 cases
+
+PowerShell parser checks and git diff --check
+exit 0
+```
+
+Commits:
+
+```text
+2633509 test: expose activation stage substitution
+39e838f fix: pin activation stage write boundary
+7bdd78c test: expose setup write TOCTOU and provenance drift
+f5021b0 fix: pin setup writes and verify provenance inputs
+```
+
+No real installer, Codex, TrayHost, or Supervisor process was run or stopped.
+No build candidate was published, pushed, tagged, signed, or installed, and no
+WindowsApps or DPAPI data was accessed. Controlled aggregate validation, final
+candidate rebuild, complete branch review, and real-machine acceptance remain
+with the controller.
From 144fc23fe9e2d7dec9c4858a654af78fae74746c Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Fri, 28 Aug 2026 20:50:41 +0800
Subject: [PATCH 063/187] docs: define transactional install release design
---
...22-transactional-install-release-design.md | 217 ++++++++++++++++++
1 file changed, 217 insertions(+)
create mode 100644 docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md
diff --git a/docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md b/docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md
new file mode 100644
index 0000000..c61a9d6
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md
@@ -0,0 +1,217 @@
+# v2.5.22 Transactional Install and Release Acceptance Design
+
+## Status
+
+The user approved this architecture on 2026-08-28. It supersedes the
+incomplete installer/write-boundary portions of the earlier v2.5.22 designs.
+`v2.5.22` is not tagged or released, so this design retains that version rather
+than creating a second public version number.
+
+## Goal
+
+Make a fresh install, a supported legacy upgrade, and a same-version repair
+converge on one sealed current-user runtime. A failed transaction must retain a
+known runnable runtime or fail without creating an active mixed state. The
+release path must not expose assets publicly until both Setup and the EXE-based
+portable ZIP are verified, scanned, and accepted on the current Windows host.
+
+## Evidence and non-goals
+
+The existing candidate already has useful foundations: versioned manifests,
+exact payload hashes, source-byte sealing in the activation worker, runtime
+pointer revalidation, strict process identity, and authenticated tray receipts.
+It does not yet make Setup writes, activation-script execution, lifecycle
+copies/promotions/deletes, Defender release evidence, and reboot acceptance one
+end-to-end transaction.
+
+This design does not modify `ChatGPT.exe`, `app.asar`, `WindowsApps`, account
+state, or the current-user DPAPI device-key store. It does not add an external
+remote-control channel or change the authenticated TrayHost protocol.
+
+## Invariants
+
+- All product state remains current-user scoped.
+- The release version is exactly `2.5.22` until it is publicly released.
+- A runtime may become active only from a manifest-listed sealed package whose
+ version, source commit, and payload hash match the transaction contract.
+- A file write, promotion, or delete uses a pinned parent identity and a
+ single relative leaf name. Pathname checks are validation, not authority.
+- A failure before pointer commit leaves the old pointer and stable shell
+ untouched. A failure after pointer commit uses a monotonic compensating
+ generation pointing back to the retained old runtime; it never rewrites a
+ generation number.
+- `Ready` requires a matching active pointer, runtime manifest, Supervisor,
+ authenticated TrayHost, expected version, and terminal activation receipt.
+- A release remains draft until its exact asset set and both Defender receipts
+ are read back successfully. A failed scan or missing evidence is a hard
+ release failure, never a bypass.
+
+## Architecture
+
+### 1. Sealed package generation and trusted Setup bootstrap
+
+Build produces one manifest-bound package archive for Setup, containing the
+complete runtime source and stable-shell candidates. The Setup executable keeps
+that archive, its manifest, and the activation bootstrap as `dontcopy` inputs.
+The installer no longer relies on a writable `{app}` tree as the code execution
+source.
+
+At `PrepareToInstall`, Setup:
+
+1. extracts the bootstrap and package only to its private temporary area;
+2. rehashes the extracted files against compile-time values while their handles
+ deny write and delete sharing;
+3. starts a sealed bootstrap process from that locked source;
+4. waits for the bootstrap to create and pin the target transaction root and
+ return a fixed capability/ready result; and
+5. performs no recursive product payload extraction itself.
+
+The bootstrap is the only component allowed to unpack the package into the
+current-user install root. It keeps its package and destination pins until the
+terminal receipt has been validated. A diagnostic copy under `{app}` may exist
+after a successful transaction, but Setup must never execute that copy as part
+of the install success path.
+
+The old Inno `AppId`, installed user-facing name, current-user scope, and
+existing control-panel uninstall entry are preserved for migration. A failed
+transaction never unregisters or recursively deletes a legacy entry. Legacy
+installer shell files become compatibility inputs only; they are never used as
+a source-file discovery root. After `Ready`, the transaction records the
+sealed package identity beside the existing entry so an explicit future
+uninstall can distinguish owned legacy files from unknown files.
+
+### 2. Private pinned file transaction module
+
+Add one private persistence module,
+`InstallFileTransaction.psm1`. Its native interop is internal; callers do not
+receive arbitrary pathname write APIs.
+
+Its required operations are:
+
+| Operation | Contract |
+| --- | --- |
+| Open/create pinned directory | Opens or creates exactly one child under an already pinned parent; rejects reparse points and unexpected identities. |
+| Create pinned temporary leaf | Creates only a single generated leaf under a pinned directory with no overwrite of an existing inode. |
+| Copy and verify | Reads sealed source bytes, writes through the pinned leaf, flushes, and rehashes through the same handle. |
+| Commit promotion | Atomically promotes a verified temporary leaf within the pinned directory; identity or sharing ambiguity fails closed. |
+| Enumerate/delete owned tree | Walks by pinned handles, rejects reparse points, alternate streams, and multi-link leaves, then removes only transaction-owned entries from leaves upward. |
+| Close transaction | Releases handles only after a terminal success/failure decision; failed cleanup preserves its owned candidate for later diagnosis rather than performing a pathname-recursive delete. |
+
+The module accepts only pinned handles and validated relative segments. It
+cannot be called with an untrusted absolute destination. Existing checks for
+ADS, reparse points, link count, manifest membership, length, and SHA-256
+remain mandatory at every transition.
+
+### 3. Transaction state machine and compatibility recovery
+
+The bootstrap persists a private, bounded transaction record under the existing
+state root. It contains a transaction ID, old/new runtime IDs, old/new
+generation, sealed package hash, phase, and candidate object names. It contains
+no credentials, remote keys, arbitrary paths, or raw exception text.
+
+```text
+Prepared
+ -> PackageVerified
+ -> RuntimeStaged
+ -> PreviousProtectionStopped
+ -> RuntimePromoted
+ -> StableShellCommitted
+ -> PointerCommitted
+ -> ProtectionReady
+ -> Ready
+```
+
+- Before `PointerCommitted`, a failure leaves the previous stable shell and
+ active pointer unchanged, then proves the retained previous runtime can run.
+- After `PointerCommitted`, a failure records a new, higher compensating
+ generation targeting the retained previous runtime and restores the matching
+ stable shell. If compensation cannot be proven, it writes no `Ready`, retains
+ all candidates, and returns a stable rollback code.
+- Legacy installations without the transaction record start at `Idle`; their
+ active runtime is treated as a compatibility candidate only after the
+ existing strict identity and manifest checks succeed.
+- A same-version repair reuses a package only when version and sealed package
+ hash both match. A same version with different bytes fails as a conflict,
+ preventing a mixed repair.
+- Unknown legacy files are never recursively removed. Cleanup is limited to
+ transaction-owned objects that the module can prove safe.
+
+### 4. Defender and draft-release promotion
+
+Both local installer entry paths scan their sealed input before activation:
+
+- portable scans its verified payload as today, retaining the existing strict
+ Defender failure behavior;
+- Setup bootstrap scans the verified Setup package before beginning the
+ transaction.
+
+Release publishing becomes a staged promotion:
+
+```text
+clean build -> exact asset manifests -> draft release -> official draft download
+-> SHA/manifest revalidation -> Defender scan of Setup and portable ZIP
+-> receipts bound to exact assets -> full install/reboot acceptance -> public release
+```
+
+The release workflow uploads only an explicit 11-item asset allowlist. It
+creates a draft, uploads the exact set, reads every asset back, and never
+publishes a partial asset set. Its Defender gate has two explicit origins:
+
+- `TrustedWorkflowArtifact` for an internally generated candidate whose
+ artifact identity, manifests, version, commit, and hashes are verified; and
+- `InternetDownload` for an official browser download that must retain actual
+ ZoneId 3. The workflow must not synthesize a Zone identifier.
+
+Each receipt binds asset SHA-256, manifest SHA-256, version, commit, Defender
+platform/signature data, timestamps, and zero new detections. Disabled Defender,
+missing real-time protection, stale/missing signature metadata, missing receipt,
+or a scan error blocks promotion. The release workflow must use a documented
+Defender-capable Windows runner; if unavailable, it fails while leaving the
+release draft private for review.
+
+### 5. Acceptance evidence
+
+Automated acceptance must cover:
+
+1. clean fresh install with initially missing transaction directories;
+2. legacy upgrade from the currently released installer;
+3. same-version repair with same hash and rejection of same-version/different
+ hash input;
+4. failures injected at each state-machine phase, including post-pointer
+ compensation;
+5. pinned copy, promotion, and deletion boundaries retaining outside sentinel
+ data unchanged;
+6. Setup and portable artifact integrity, both manifest contracts, exact
+ 11-item release assets, and dual Defender receipts;
+7. a clean Windows runner full suite, never treating a live local mutex as a
+ pass; and
+8. a current-machine official-draft acceptance: full uninstall/reinstall,
+ reboot, active-runtime/process identity, DPAPI key preservation, tray
+ About/language/logs/repair actions, and a real second-device remote
+ connection.
+
+The last tray and remote interactions require recorded end-user evidence
+(screenshots plus redacted logs) in addition to programmatic status checks.
+No release may be called stable until this evidence exists.
+
+## Files and interfaces expected to change
+
+- `build/build.ps1`, `build/CodexControlOtherDevices.iss`, payload and
+ destination inventory generation;
+- `Activate-CcodRemoteFix.ps1` and a private
+ `src/persistence/modules/InstallFileTransaction.psm1`;
+- `src/persistence/modules/InstallLifecycle.psm1` and its transaction/state
+ tests;
+- `Install-CodexRemote-fix.ps1`, `tools/Test-ReleaseDefender.ps1`, CI/release
+ workflows, release contract tests, and installed lifecycle acceptance tests;
+- concise English and Chinese README/CHANGELOG text that only describes
+ verified behavior.
+
+The implementation plan will map these changes into reviewable TDD tasks with
+separate source, workflow, and installed-machine acceptance gates.
+
+## Explicit non-release rule
+
+All local `build/dist` directories produced before this transaction design are
+historical evidence only. They must not be uploaded, tagged, installed, or used
+for acceptance. A clean candidate is built only from the final reviewed commit.
From 0ecf0a5be00f488a239422bd10d589875c0c6c8f Mon Sep 17 00:00:00 2001
From: naipi11 <101078423+naipi11@users.noreply.github.com>
Date: Sat, 29 Aug 2026 08:27:25 +0800
Subject: [PATCH 064/187] docs: plan transactional install release
---
...-29-v2522-transactional-install-release.md | 961 ++++++++++++++++++
...22-transactional-install-release-design.md | 35 +-
2 files changed, 986 insertions(+), 10 deletions(-)
create mode 100644 docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md
diff --git a/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md b/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md
new file mode 100644
index 0000000..afd8d7c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-29-v2522-transactional-install-release.md
@@ -0,0 +1,961 @@
+# v2.5.22 Transactional Install and Release Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship the unreleased v2.5.22 as a sealed, recoverable current-user
+installation transaction whose final Setup and portable artifacts cannot become
+public until they pass exact asset, Defender, and current-machine acceptance
+gates.
+
+**Architecture:** A private pinned file-transaction module replaces pathname
+mutation for all lifecycle staging, promotion, and owned cleanup. A sealed
+Setup package and locked temporary bootstrap feed that transaction, which
+persists a monotonic phase record and compensates an unsuccessful post-pointer
+upgrade. Release evidence is separate from product state: exact asset
+contracts, dual Defender receipts, a clean runner, a private draft, and a
+multi-reboot local acceptance record form a promotion chain.
+
+**Tech Stack:** Windows PowerShell 5.1, .NET Framework/C# `Add-Type` interop,
+Inno Setup 6, Node.js 22, GitHub Actions, Microsoft Defender, existing
+PowerShell persistence self-tests.
+
+**Spec:** `docs/superpowers/specs/2026-08-28-v2522-transactional-install-release-design.md`
+
+## Global Constraints
+
+- Keep the release version exactly `2.5.22`; it is not tagged or public.
+- Never modify `ChatGPT.exe`, `app.asar`, `WindowsApps`, account state, or the
+ DPAPI device-key store.
+- Preserve the authenticated TrayHost wire schema and strict Codex process
+ identity rules. Do not add a listener, a production test mode, or an external
+ remote-control path.
+- File mutations are authorized only by pinned parent identities plus
+ single-segment leaves. Path checks, hashes, ADS checks, and link counts are
+ validation layers, not authority for a later pathname write or delete.
+- Every implementation change starts with a deterministic behavioral RED,
+ followed by the smallest GREEN change, focused test evidence, a commit, and
+ a scoped review.
+- A failed pre-pointer transaction preserves the previous pointer and stable
+ shell. A failed post-pointer transaction records a higher compensating
+ generation or returns `CCOD_INSTALL_ROLLBACK_FAILED` without `Ready`.
+- Use only clean, fresh build candidates. Existing `build/dist` assets are
+ historical evidence and must never be uploaded, installed, or used for
+ acceptance.
+- Real Defender scans, GitHub draft/release operations, installation, Codex
+ restart, Windows reboot, and second-device remote confirmation are final
+ acceptance operations only; no task below performs them unless its command
+ explicitly targets a disposable test fixture.
+- All production and release receipts are bounded and sanitized: stable codes,
+ hashes, version, commit, timestamps, and phases only; never credentials,
+ DPAPI material, user paths, tokens, raw logs, or exception text.
+
+---
+
+### Task 1: Create private handle-bound installation file transactions
+
+**Files:**
+- Create: `src/persistence/modules/InstallFileTransaction.psm1`
+- Create: `tests/persistence/InstallFileTransaction.SelfTest.ps1`
+- Modify: `tests/PersistenceSelfTest.ps1`
+- Modify: `tests/Validate.ps1`
+
+**Interfaces:**
+
+`InstallFileTransaction.psm1` owns all native handles and exposes only these
+private module functions:
+
+```powershell
+Open-CcodInstallFileTransaction -InstallRoot
+Open-CcodInstallPinnedDirectory -Transaction -ParentDirectory -Leaf [-CreateIfMissing]
+New-CcodInstallPinnedTemporaryLeaf -Transaction -ParentDirectory -Leaf
+Copy-CcodInstallSealedFile -Transaction -SourcePath -DestinationLeaf -ExpectedLength -ExpectedSha256
+Commit-CcodInstallPinnedPromotion -Transaction -ParentDirectory -TemporaryLeaf -DestinationLeaf
+Write-CcodInstallPinnedJson -Transaction -ParentDirectory -Leaf -Value