$ErrorActionPreference = "Stop" $taskName = "SDR Rover RX Agent" $sourceRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)) $installRoot = Join-Path $env:LOCALAPPDATA "SDR_Rover_RX_Agent" $versionsRoot = Join-Path $installRoot "versions" $launcherRoot = Join-Path $installRoot "launcher" $sharedRoot = Join-Path $installRoot "shared" $logsRoot = Join-Path $installRoot "logs" $repository = Join-Path $env:USERPROFILE "Documents\Codex\SDR_Rover_Video" $python = Join-Path $repository ".venv\Scripts\pythonw.exe" $hackrfRoot = Join-Path $env:USERPROFILE "Documents\Codex\sdr-tools\envs\sdr\Library\bin" $workRoot = Join-Path $env:USERPROFILE "Documents\SDR_Rover_Runtime" $manifestPath = Join-Path $sourceRoot "BOOTSTRAP_MANIFEST.json" $signaturePath = Join-Path $sourceRoot "BOOTSTRAP_MANIFEST.hmac" $legacyToken = Join-Path $installRoot "app\.local\rf001_http_token.txt" $sharedToken = Join-Path $sharedRoot "rf001_http_token.txt" $expectedReferenceHash = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9" function Get-LowerSha256([string]$Path) { $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) $sha256 = [Security.Cryptography.SHA256]::Create() try { return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace("-", "").ToLowerInvariant() } finally { $sha256.Dispose() $stream.Dispose() } } function Test-FixedTimeHex([string]$Left, [string]$Right) { if ($Left.Length -ne $Right.Length) { return $false } $difference = 0 for ($index = 0; $index -lt $Left.Length; $index++) { $difference = $difference -bor ([int][char]$Left[$index] -bxor [int][char]$Right[$index]) } return $difference -eq 0 } function Assert-ExactReleasePath( [string]$InstallRoot, [string]$VersionsRoot, [string]$ReleaseId, [string]$TargetRelease ) { $expectedVersions = [IO.Path]::GetFullPath((Join-Path $InstallRoot "versions")) $actualVersions = [IO.Path]::GetFullPath($VersionsRoot) if (-not $expectedVersions.Equals($actualVersions, [StringComparison]::OrdinalIgnoreCase)) { throw "Release versions root is outside the fixed install root" } $expectedTarget = [IO.Path]::GetFullPath((Join-Path $actualVersions $ReleaseId)) $actualTarget = [IO.Path]::GetFullPath($TargetRelease) if (-not $expectedTarget.Equals($actualTarget, [StringComparison]::OrdinalIgnoreCase)) { throw "Release target path is not the installer-defined canonical path" } } function Get-ReleaseDirectoryState( [string]$TargetRelease, [object[]]$ReleaseRows, [string]$ReleasePrefix ) { if (-not (Test-Path -LiteralPath $TargetRelease)) { return [pscustomobject]@{ state = "TARGET_ABSENT" mismatches = @() conflicting_files = @() } } if (-not (Test-Path -LiteralPath $TargetRelease -PathType Container)) { $item = Get-Item -LiteralPath $TargetRelease -Force $digest = $null if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { try { $digest = Get-LowerSha256 $item.FullName } catch { $digest = $null } } return [pscustomobject]@{ state = "TARGET_CONFLICT" mismatches = @([ordered]@{ kind = "TARGET_NOT_DIRECTORY" path = "." expected_bytes = $null actual_bytes = [int64]$item.Length expected_sha256 = $null actual_sha256 = $digest }) conflicting_files = @([ordered]@{ path = "." bytes = [int64]$item.Length sha256 = $digest reparse_point = (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) }) } } $expected = @{} $normalizedRows = @($ReleaseRows | ForEach-Object { $_ }) foreach ($row in $normalizedRows) { $pathValues = @($row.path) $byteValues = @($row.bytes) $hashValues = @($row.sha256) if ($pathValues.Count -ne 1 -or $byteValues.Count -ne 1 -or $hashValues.Count -ne 1) { throw "Release manifest row fields are absent or ambiguous" } $manifestPath = [string]$pathValues[0] if (-not $manifestPath.StartsWith($ReleasePrefix, [StringComparison]::Ordinal)) { throw "Release manifest row is outside the expected prefix: $manifestPath" } $relative = $manifestPath.Substring($ReleasePrefix.Length) if ( [string]::IsNullOrWhiteSpace($relative) -or $relative -match '(^/|\\|(^|/)\.\.(/|$)|:)' ) { throw "Unsafe path inside release manifest: $relative" } $key = $relative.ToLowerInvariant() if ($expected.ContainsKey($key)) { throw "Duplicate path inside release manifest: $relative" } $expected[$key] = [pscustomobject]@{ path = $relative bytes = [int64]($byteValues[0]) sha256 = ([string]$hashValues[0]).ToLowerInvariant() } } if ($expected.Count -eq 0) { throw "Release manifest does not contain any files" } $targetPrefix = [IO.Path]::GetFullPath($TargetRelease).TrimEnd('\') + '\' $actual = @{} $mismatches = @() $conflictingFiles = @() $entries = @(Get-ChildItem -LiteralPath $TargetRelease -Recurse -Force) foreach ($entry in $entries) { $relative = $entry.FullName.Substring($targetPrefix.Length).Replace('\', '/') $isReparsePoint = ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 if ($entry.PSIsContainer) { if ($isReparsePoint) { $mismatches += [ordered]@{ kind = "REPARSE_POINT" path = $relative expected_bytes = $null actual_bytes = $null expected_sha256 = $null actual_sha256 = $null } } continue } $digest = $null $hashError = $null if (-not $isReparsePoint) { try { $digest = Get-LowerSha256 $entry.FullName } catch { $hashError = $_.Exception.Message } } $metadata = [pscustomobject]@{ path = $relative bytes = [int64]$entry.Length sha256 = $digest reparse_point = $isReparsePoint hash_error = $hashError } $conflictingFiles += $metadata $key = $relative.ToLowerInvariant() if ($actual.ContainsKey($key)) { $mismatches += [ordered]@{ kind = "DUPLICATE_PATH" path = $relative expected_bytes = $null actual_bytes = [int64]$entry.Length expected_sha256 = $null actual_sha256 = $digest } continue } $actual[$key] = $metadata if ($isReparsePoint) { $mismatches += [ordered]@{ kind = "REPARSE_POINT" path = $relative expected_bytes = $null actual_bytes = [int64]$entry.Length expected_sha256 = $null actual_sha256 = $null } } elseif ($null -ne $hashError) { $mismatches += [ordered]@{ kind = "HASH_UNAVAILABLE" path = $relative expected_bytes = $null actual_bytes = [int64]$entry.Length expected_sha256 = $null actual_sha256 = $null error = $hashError } } } foreach ($key in $expected.Keys) { $wanted = $expected[$key] if (-not $actual.ContainsKey($key)) { $mismatches += [ordered]@{ kind = "MISSING" path = $wanted.path expected_bytes = $wanted.bytes actual_bytes = $null expected_sha256 = $wanted.sha256 actual_sha256 = $null } continue } $found = $actual[$key] if ($found.reparse_point -or $null -ne $found.hash_error) { continue } if ($found.bytes -ne $wanted.bytes) { $mismatches += [ordered]@{ kind = "SIZE_MISMATCH" path = $wanted.path expected_bytes = $wanted.bytes actual_bytes = $found.bytes expected_sha256 = $wanted.sha256 actual_sha256 = $found.sha256 } } if ($found.sha256 -ne $wanted.sha256) { $mismatches += [ordered]@{ kind = "SHA256_MISMATCH" path = $wanted.path expected_bytes = $wanted.bytes actual_bytes = $found.bytes expected_sha256 = $wanted.sha256 actual_sha256 = $found.sha256 } } } foreach ($key in $actual.Keys) { if (-not $expected.ContainsKey($key)) { $found = $actual[$key] $mismatches += [ordered]@{ kind = "UNEXPECTED" path = $found.path expected_bytes = $null actual_bytes = $found.bytes expected_sha256 = $null actual_sha256 = $found.sha256 } } } $state = if ($mismatches.Count -eq 0) { "TARGET_VERIFIED" } else { "TARGET_CONFLICT" } return [pscustomobject]@{ state = $state mismatches = @($mismatches) conflicting_files = @($conflictingFiles) } } function Assert-ReleaseMetadata( [string]$ReleaseRoot, [string]$ExpectedBootstrapId, [string]$ExpectedReleaseId, [string]$ExpectedAgentVersion, [int]$ExpectedProtocolVersion ) { $readyPath = Join-Path $ReleaseRoot "READY.json" if (-not (Test-Path -LiteralPath $readyPath -PathType Leaf)) { throw "Release READY metadata is absent: $readyPath" } $ready = Get-Content -LiteralPath $readyPath -Raw -Encoding UTF8 | ConvertFrom-Json if ( $ready.schema_version -ne 1 -or $ready.status -ne "READY" -or $ready.bootstrap_id -ne $ExpectedBootstrapId -or $ready.release_id -ne $ExpectedReleaseId -or $ready.agent_version -ne $ExpectedAgentVersion -or $ready.protocol_version -ne $ExpectedProtocolVersion -or $ready.hardware_accessed -ne $false -or $ready.rf_tx -ne 0 ) { throw "Release READY metadata does not match the signed bootstrap manifest" } } function New-UniqueQuarantineDestination( [string]$InstallRoot, [string]$ReleaseId, [string]$Category ) { if ($Category -notin @("conflict", "failed_staging", "failed_release")) { throw "Unsupported quarantine category" } $quarantineRoot = Join-Path $InstallRoot "quarantine" New-Item -ItemType Directory -Force -Path $quarantineRoot | Out-Null for ($attempt = 0; $attempt -lt 16; $attempt++) { $stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfff'Z'") $unique = [Guid]::NewGuid().ToString("N") $name = "{0}_{1}_{2}_{3}" -f $ReleaseId, $Category, $stamp, $unique $candidate = Join-Path $quarantineRoot $name if ( -not (Test-Path -LiteralPath $candidate) -and -not (Test-Path -LiteralPath ($candidate + ".diagnostic.json")) ) { return $candidate } } throw "Could not allocate a unique quarantine path" } function Write-CreateNewUtf8Json([string]$Path, [object]$Value) { $encoding = New-Object Text.UTF8Encoding($false) $bytes = $encoding.GetBytes((($Value | ConvertTo-Json -Depth 10) + "`n")) $stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } finally { $stream.Dispose() } } function Move-ConflictingTargetToQuarantine( [string]$InstallRoot, [string]$VersionsRoot, [string]$TargetRelease, [string]$ReleaseId, [string]$BootstrapId, [object]$State ) { Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease if ($State.state -ne "TARGET_CONFLICT") { throw "Only TARGET_CONFLICT may be moved by the conflict quarantine operation" } $destination = New-UniqueQuarantineDestination $InstallRoot $ReleaseId "conflict" $diagnosticPath = $destination + ".diagnostic.json" $diagnostic = [ordered]@{ schema_version = 1 timestamp_utc = [DateTime]::UtcNow.ToString("o") source_path = [IO.Path]::GetFullPath($TargetRelease) quarantine_path = [IO.Path]::GetFullPath($destination) bootstrap_id = $BootstrapId expected_release_id = $ReleaseId mismatches = @($State.mismatches) conflicting_files = @($State.conflicting_files) } Write-CreateNewUtf8Json $diagnosticPath $diagnostic Move-Item -LiteralPath $TargetRelease -Destination $destination return [pscustomobject]@{ path = $destination diagnostic_path = $diagnosticPath } } function Move-FailedReleasePathToQuarantine( [string]$InstallRoot, [string]$Path, [string]$ReleaseId, [string]$BootstrapId, [string]$Category, [string]$Cause ) { $versionsRoot = [IO.Path]::GetFullPath((Join-Path $InstallRoot "versions")).TrimEnd('\') + '\' $fullPath = [IO.Path]::GetFullPath($Path) if (-not $fullPath.StartsWith($versionsRoot, [StringComparison]::OrdinalIgnoreCase)) { throw "Failed release path is outside the fixed versions root" } $leaf = Split-Path -Leaf $fullPath $validCanonical = $leaf.Equals($ReleaseId, [StringComparison]::OrdinalIgnoreCase) $validStaging = $leaf.StartsWith(".$ReleaseId.staging_", [StringComparison]::OrdinalIgnoreCase) if ( ($Category -eq "failed_release" -and -not $validCanonical) -or ($Category -eq "failed_staging" -and -not $validStaging) ) { throw "Failed release path is not an installer-defined release path" } $destination = New-UniqueQuarantineDestination $InstallRoot $ReleaseId $Category $diagnosticPath = $destination + ".diagnostic.json" $diagnostic = [ordered]@{ schema_version = 1 timestamp_utc = [DateTime]::UtcNow.ToString("o") source_path = $fullPath quarantine_path = [IO.Path]::GetFullPath($destination) bootstrap_id = $BootstrapId expected_release_id = $ReleaseId category = $Category cause = $Cause } Write-CreateNewUtf8Json $diagnosticPath $diagnostic Move-Item -LiteralPath $fullPath -Destination $destination return $destination } function Install-CleanRelease( [string]$InstallRoot, [string]$VersionsRoot, [string]$SourceRelease, [string]$TargetRelease, [string]$ReleaseId, [string]$BootstrapId, [string]$ReleaseBootstrapId, [string]$AgentVersion, [int]$ProtocolVersion, [object[]]$ReleaseRows, [string]$ReleasePrefix ) { if ([string]::IsNullOrWhiteSpace($ReleaseBootstrapId)) { $ReleaseBootstrapId = $BootstrapId } Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease if (Test-Path -LiteralPath $TargetRelease) { throw "Canonical release path is not absent before clean deployment" } $staging = Join-Path $VersionsRoot (".{0}.staging_{1}" -f $ReleaseId, [Guid]::NewGuid().ToString("N")) $canonicalCreated = $false try { New-Item -ItemType Directory -Path $staging | Out-Null Copy-Item -LiteralPath (Join-Path $SourceRelease "app") -Destination $staging -Recurse Copy-Item -LiteralPath (Join-Path $SourceRelease "READY.json") -Destination $staging $stagingState = Get-ReleaseDirectoryState ` -TargetRelease $staging ` -ReleaseRows $ReleaseRows ` -ReleasePrefix $ReleasePrefix if ($stagingState.state -ne "TARGET_VERIFIED") { throw "Staged release failed exact file verification: $($stagingState.mismatches | ConvertTo-Json -Depth 6 -Compress)" } Assert-ReleaseMetadata ` -ReleaseRoot $staging ` -ExpectedBootstrapId $ReleaseBootstrapId ` -ExpectedReleaseId $ReleaseId ` -ExpectedAgentVersion $AgentVersion ` -ExpectedProtocolVersion $ProtocolVersion Move-Item -LiteralPath $staging -Destination $TargetRelease $canonicalCreated = $true $targetState = Get-ReleaseDirectoryState ` -TargetRelease $TargetRelease ` -ReleaseRows $ReleaseRows ` -ReleasePrefix $ReleasePrefix if ($targetState.state -ne "TARGET_VERIFIED") { throw "Canonical release failed exact file verification: $($targetState.mismatches | ConvertTo-Json -Depth 6 -Compress)" } Assert-ReleaseMetadata ` -ReleaseRoot $TargetRelease ` -ExpectedBootstrapId $ReleaseBootstrapId ` -ExpectedReleaseId $ReleaseId ` -ExpectedAgentVersion $AgentVersion ` -ExpectedProtocolVersion $ProtocolVersion return $targetState } catch { $failure = $_.Exception.Message $quarantineErrors = @() if ($canonicalCreated -and (Test-Path -LiteralPath $TargetRelease)) { try { Move-FailedReleasePathToQuarantine $InstallRoot $TargetRelease $ReleaseId $BootstrapId "failed_release" $failure | Out-Null } catch { $quarantineErrors += $_.Exception.Message } } if (Test-Path -LiteralPath $staging) { try { Move-FailedReleasePathToQuarantine $InstallRoot $staging $ReleaseId $BootstrapId "failed_staging" $failure | Out-Null } catch { $quarantineErrors += $_.Exception.Message } } if ($quarantineErrors.Count -ne 0) { throw "Clean release deployment failed: $failure; failed-path quarantine errors: $($quarantineErrors -join '; ')" } throw "Clean release deployment failed: $failure" } } function Prepare-TargetRelease( [string]$InstallRoot, [string]$VersionsRoot, [string]$SourceRelease, [string]$TargetRelease, [string]$ReleaseId, [string]$BootstrapId, [string]$ReleaseBootstrapId, [string]$AgentVersion, [int]$ProtocolVersion, [object[]]$ReleaseRows, [string]$ReleasePrefix ) { if ([string]::IsNullOrWhiteSpace($ReleaseBootstrapId)) { $ReleaseBootstrapId = $BootstrapId } Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease $sourceState = Get-ReleaseDirectoryState ` -TargetRelease $SourceRelease ` -ReleaseRows $ReleaseRows ` -ReleasePrefix $ReleasePrefix if ($sourceState.state -ne "TARGET_VERIFIED") { throw "Signed bootstrap release content is not exact: $($sourceState.mismatches | ConvertTo-Json -Depth 6 -Compress)" } Assert-ReleaseMetadata ` -ReleaseRoot $SourceRelease ` -ExpectedBootstrapId $ReleaseBootstrapId ` -ExpectedReleaseId $ReleaseId ` -ExpectedAgentVersion $AgentVersion ` -ExpectedProtocolVersion $ProtocolVersion $initial = Get-ReleaseDirectoryState ` -TargetRelease $TargetRelease ` -ReleaseRows $ReleaseRows ` -ReleasePrefix $ReleasePrefix $quarantine = $null if ($initial.state -eq "TARGET_CONFLICT") { $quarantine = Move-ConflictingTargetToQuarantine ` -InstallRoot $InstallRoot ` -VersionsRoot $VersionsRoot ` -TargetRelease $TargetRelease ` -ReleaseId $ReleaseId ` -BootstrapId $BootstrapId ` -State $initial } if ($initial.state -ne "TARGET_VERIFIED") { $verified = Install-CleanRelease ` -InstallRoot $InstallRoot ` -VersionsRoot $VersionsRoot ` -SourceRelease $SourceRelease ` -TargetRelease $TargetRelease ` -ReleaseId $ReleaseId ` -BootstrapId $BootstrapId ` -ReleaseBootstrapId $ReleaseBootstrapId ` -AgentVersion $AgentVersion ` -ProtocolVersion $ProtocolVersion ` -ReleaseRows $ReleaseRows ` -ReleasePrefix $ReleasePrefix } else { Assert-ReleaseMetadata ` -ReleaseRoot $TargetRelease ` -ExpectedBootstrapId $ReleaseBootstrapId ` -ExpectedReleaseId $ReleaseId ` -ExpectedAgentVersion $AgentVersion ` -ExpectedProtocolVersion $ProtocolVersion $verified = $initial } return [pscustomobject]@{ initial_state = $initial.state final_state = $verified.state reused = ($initial.state -eq "TARGET_VERIFIED") quarantine_path = if ($null -eq $quarantine) { $null } else { $quarantine.path } quarantine_diagnostic = if ($null -eq $quarantine) { $null } else { $quarantine.diagnostic_path } } } function Invoke-SignedAgentSelfTest( [string]$Python, [string]$AgentScript, [string]$ExpectedAgentVersion, [string]$StandardOutputPath, [string]$StandardErrorPath ) { $process = Start-Process ` -FilePath $Python ` -ArgumentList @("-B", "-X", "utf8", $AgentScript, "--self-test") ` -RedirectStandardOutput $StandardOutputPath ` -RedirectStandardError $StandardErrorPath ` -Wait -PassThru -WindowStyle Hidden if ($process.ExitCode -ne 0) { throw "Agent self-test exited with code $($process.ExitCode)" } $output = if (Test-Path -LiteralPath $StandardOutputPath) { Get-Content -LiteralPath $StandardOutputPath -Raw -Encoding UTF8 } else { "" } if ($output -notmatch ("(?m)^AGENT VERSION = {0}\s*$" -f [regex]::Escape($ExpectedAgentVersion))) { throw "Agent self-test did not confirm signed AGENT_VERSION" } if ($output -notmatch '(?m)^SELF TEST = PASS\s*$') { throw "Agent self-test did not report PASS" } return $output } foreach ($required in @( $manifestPath, $signaturePath, $python, (Join-Path $repository ".git"), (Join-Path $hackrfRoot "hackrf_info.exe"), (Join-Path $hackrfRoot "hackrf_clock.exe"), (Join-Path $hackrfRoot "hackrf_transfer.exe") )) { if (-not (Test-Path -LiteralPath $required)) { throw "Required path is absent: $required" } } if ((Test-Path -LiteralPath $sharedToken) -and (Test-Path -LiteralPath $legacyToken)) { if ((Get-LowerSha256 $sharedToken) -ne (Get-LowerSha256 $legacyToken)) { throw "Installed signing-token copies differ; refusing to choose one" } } if (Test-Path -LiteralPath $sharedToken) { $tokenSource = $sharedToken } elseif (Test-Path -LiteralPath $legacyToken) { $tokenSource = $legacyToken } else { throw "The existing HTTP signing token is absent; it is intentionally not in this package" } $manifestBytes = [IO.File]::ReadAllBytes($manifestPath) $tokenText = [IO.File]::ReadAllText($tokenSource, [Text.Encoding]::UTF8).Trim() $tokenBytes = [Text.Encoding]::UTF8.GetBytes($tokenText) if ($tokenBytes.Length -lt 32) { throw "The existing HTTP signing token is invalid" } $domainBytes = [Text.Encoding]::UTF8.GetBytes("RF001-UPDATE-MANIFEST-V1`n") $signedBytes = New-Object byte[] ($domainBytes.Length + $manifestBytes.Length) [Buffer]::BlockCopy($domainBytes, 0, $signedBytes, 0, $domainBytes.Length) [Buffer]::BlockCopy($manifestBytes, 0, $signedBytes, $domainBytes.Length, $manifestBytes.Length) $hmac = New-Object Security.Cryptography.HMACSHA256 -ArgumentList (, $tokenBytes) try { $expectedSignature = ([BitConverter]::ToString($hmac.ComputeHash($signedBytes))).Replace("-", "").ToLowerInvariant() } finally { $hmac.Dispose() } $suppliedSignature = [IO.File]::ReadAllText($signaturePath, [Text.Encoding]::ASCII).Trim().ToLowerInvariant() if (-not (Test-FixedTimeHex $expectedSignature $suppliedSignature)) { throw "Bootstrap manifest signature mismatch" } $manifest = [Text.Encoding]::UTF8.GetString($manifestBytes) | ConvertFrom-Json if ($manifest.schema_version -ne 1 -or $manifest.package_kind -ne "BOOTSTRAP") { throw "Unsupported bootstrap manifest" } if ($manifest.protocol_version -ne 1) { throw "Unsupported agent protocol version" } if ($manifest.main_server -ne "http://192.168.255.185:8765") { throw "Bootstrap MAIN address differs from the approved address" } if ($manifest.allowed_rx_ipv4 -ne "192.168.255.56") { throw "Bootstrap RX address differs from the approved address" } if ($manifest.reference_sha256 -ne $expectedReferenceHash) { throw "Bootstrap reference identity is invalid" } $releaseId = [string]$manifest.agent_release_id $agentVersion = [string]$manifest.agent_version $bootstrapId = [string]$manifest.bootstrap_id $releaseBootstrapId = [string]$manifest.agent_release_bootstrap_id if ([string]::IsNullOrWhiteSpace($releaseBootstrapId)) { $releaseBootstrapId = $bootstrapId } if ($bootstrapId -notmatch '^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') { throw "Bootstrap ID is invalid" } if ($releaseBootstrapId -notmatch '^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') { throw "Agent release bootstrap ID is invalid" } if ($releaseId -notmatch '^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') { throw "Bootstrap release_id is invalid" } if ($agentVersion -notmatch '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') { throw "Bootstrap agent version is invalid" } if ($releaseId -ne "rf001-agent-$agentVersion") { throw "Bootstrap release_id does not match agent_version" } $releasePrefix = "versions/$releaseId/" $expectedPaths = @( "INSTALL_RX_AGENT.cmd", "README_RX_AGENT.txt", "UNINSTALL_RX_AGENT.cmd", "launcher/rf001_agent_launcher.py", "reference/lab065_reference_staircase.cs8", "tools/install_rf001_rx_autostart.ps1", "tools/uninstall_rf001_rx_autostart.ps1", "${releasePrefix}READY.json", "${releasePrefix}app/tools/hackrf_process_check.py", "${releasePrefix}app/tools/rf001_agent_update.py", "${releasePrefix}app/tools/rf001_capture_runner.py", "${releasePrefix}app/tools/rf001_http_control.py", "${releasePrefix}app/tools/rf001_offline_decode.py", "${releasePrefix}app/tools/rf001_rx_capture.py", "${releasePrefix}app/tools/rf001_rx_http_agent.py", "${releasePrefix}app/tools/rf001_staircase_job.py", "${releasePrefix}app/tools/rf001_staircase_search.py" ) $expectedSet = @{} foreach ($path in $expectedPaths) { $expectedSet[$path.ToLowerInvariant()] = $true } $manifestSet = @{} $sourcePrefix = $sourceRoot.TrimEnd('\') + '\' foreach ($row in @($manifest.files)) { $relative = [string]$row.path if ($relative -match '(^/|\\|(^|/)\.\.(/|$)|:)' -or [string]::IsNullOrWhiteSpace($relative)) { throw "Unsafe bootstrap path: $relative" } $key = $relative.ToLowerInvariant() if ($manifestSet.ContainsKey($key)) { throw "Duplicate bootstrap path: $relative" } if (-not $expectedSet.ContainsKey($key)) { throw "Bootstrap file is outside the fixed allowlist: $relative" } $manifestSet[$key] = $true $source = [IO.Path]::GetFullPath((Join-Path $sourceRoot ($relative.Replace('/', '\')))) if (-not $source.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Bootstrap path escaped source root: $relative" } if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Bootstrap file is absent: $relative" } $item = Get-Item -LiteralPath $source if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Bootstrap reparse points are prohibited: $relative" } if ($item.Length -ne [int64]$row.bytes) { throw "Bootstrap file size mismatch: $relative" } if ((Get-LowerSha256 $source) -ne ([string]$row.sha256).ToLowerInvariant()) { throw "Bootstrap file SHA256 mismatch: $relative" } } if ($manifestSet.Count -ne $expectedSet.Count) { throw "Bootstrap manifest does not contain the complete fixed allowlist" } $actualFiles = Get-ChildItem -LiteralPath $sourceRoot -Recurse -File | ForEach-Object { $_.FullName.Substring($sourcePrefix.Length).Replace('\', '/') } $allowedPackageFiles = @{} foreach ($path in $expectedPaths + @("BOOTSTRAP_MANIFEST.json", "BOOTSTRAP_MANIFEST.hmac")) { $allowedPackageFiles[$path.ToLowerInvariant()] = $true } foreach ($relative in $actualFiles) { if (-not $allowedPackageFiles.ContainsKey($relative.ToLowerInvariant())) { throw "Unexpected file in the bootstrap package: $relative" } } if ($actualFiles.Count -ne $allowedPackageFiles.Count) { throw "Bootstrap package file set is incomplete or ambiguous" } $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if ($null -eq $existingTask) { throw "The existing scheduled task '$taskName' is absent" } $wasRunning = $existingTask.State -eq "Running" $backupRoot = Join-Path $installRoot ( "backups\bootstrap_{0}_{1}" -f [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfff'Z'"), [Guid]::NewGuid().ToString("N").Substring(0, 12) ) New-Item -ItemType Directory -Path $backupRoot | Out-Null $taskXmlPath = Join-Path $backupRoot "scheduled_task.xml" [IO.File]::WriteAllText($taskXmlPath, (Export-ScheduledTask -TaskName $taskName), [Text.Encoding]::Unicode) $backupInventory = @() foreach ($name in @("app", "launcher", "shared", "agent_config.json", "active_release.json", "launcher_status.json", "agent_health.json")) { $current = Join-Path $installRoot $name $existed = Test-Path -LiteralPath $current $backupInventory += [ordered]@{ name = $name existed = $existed container = ($existed -and (Test-Path -LiteralPath $current -PathType Container)) } if ($existed) { Copy-Item -LiteralPath $current -Destination (Join-Path $backupRoot $name) -Recurse -Force } } Write-CreateNewUtf8Json (Join-Path $backupRoot "install_state_inventory.json") $backupInventory New-Item -ItemType Directory -Force -Path $versionsRoot, $launcherRoot, $sharedRoot, $logsRoot | Out-Null if (-not (Test-Path -LiteralPath $sharedToken)) { Copy-Item -LiteralPath $tokenSource -Destination $sharedToken } if ((Get-LowerSha256 $sharedToken) -ne (Get-LowerSha256 $tokenSource)) { throw "The preserved signing token differs after copying" } $sourceRelease = Join-Path $sourceRoot ($releasePrefix.Replace('/', '\').TrimEnd('\')) $targetRelease = Join-Path $versionsRoot $releaseId $sourceAgentScript = Join-Path $sourceRelease "app\tools\rf001_rx_http_agent.py" Invoke-SignedAgentSelfTest ` -Python $python ` -AgentScript $sourceAgentScript ` -ExpectedAgentVersion $agentVersion ` -StandardOutputPath (Join-Path $backupRoot "source_agent_self_test.stdout.txt") ` -StandardErrorPath (Join-Path $backupRoot "source_agent_self_test.stderr.txt") | Out-Null $releaseRows = @($manifest.files | Where-Object { ([string]$_.path).StartsWith($releasePrefix) }) $targetPreparation = Prepare-TargetRelease ` -InstallRoot $installRoot ` -VersionsRoot $versionsRoot ` -SourceRelease $sourceRelease ` -TargetRelease $targetRelease ` -ReleaseId $releaseId ` -BootstrapId $bootstrapId ` -ReleaseBootstrapId $releaseBootstrapId ` -AgentVersion $agentVersion ` -ProtocolVersion ([int]$manifest.protocol_version) ` -ReleaseRows $releaseRows ` -ReleasePrefix $releasePrefix Copy-Item -LiteralPath (Join-Path $sourceRoot "launcher\rf001_agent_launcher.py") -Destination $launcherRoot -Force Copy-Item -LiteralPath (Join-Path $sourceRoot "tools\uninstall_rf001_rx_autostart.ps1") -Destination $installRoot -Force Copy-Item -LiteralPath (Join-Path $sourceRoot "UNINSTALL_RX_AGENT.cmd") -Destination $installRoot -Force $referenceRoot = Join-Path $workRoot "references" $referenceDestination = Join-Path $referenceRoot "lab065_reference_staircase.cs8" New-Item -ItemType Directory -Force -Path $referenceRoot | Out-Null if (Test-Path -LiteralPath $referenceDestination) { if ((Get-LowerSha256 $referenceDestination) -ne $expectedReferenceHash) { throw "The existing staircase reference differs; refusing to overwrite it" } } else { Copy-Item -LiteralPath (Join-Path $sourceRoot "reference\lab065_reference_staircase.cs8") -Destination $referenceDestination } if ((Get-LowerSha256 $referenceDestination) -ne $expectedReferenceHash) { throw "Installed staircase reference SHA256 is invalid" } $configPath = Join-Path $installRoot "agent_config.json" $activeReleasePath = Join-Path $installRoot "active_release.json" $launcherStatusPath = Join-Path $installRoot "launcher_status.json" $launcherRequestPath = Join-Path $installRoot "launcher_request.json" $healthPath = Join-Path $installRoot "agent_health.json" $agentLogPath = Join-Path $logsRoot "agent.log" $launcherLogPath = Join-Path $logsRoot "launcher.log" $agentArguments = @( "--server", "http://192.168.255.185:8765", "--token-file", $sharedToken, "--agent-id", "user-pc", "--repository", $repository, "--hackrf-info", (Join-Path $hackrfRoot "hackrf_info.exe"), "--hackrf-clock", (Join-Path $hackrfRoot "hackrf_clock.exe"), "--hackrf-transfer", (Join-Path $hackrfRoot "hackrf_transfer.exe"), "--work-root", $workRoot, "--poll-interval", "5", "--quiet-idle" ) $config = [ordered]@{ schema_version = 1 install_root = $installRoot releases_root = $versionsRoot active_release_file = $activeReleasePath launcher_status = $launcherStatusPath launcher_request = $launcherRequestPath health_file = $healthPath agent_log = $agentLogPath startup_timeout_seconds = 30 restart_delay_seconds = 2 agent_arguments = $agentArguments hardware_accessed = $false rf_tx = 0 } [IO.File]::WriteAllText($configPath, (($config | ConvertTo-Json -Depth 5 -Compress) + "`n"), [Text.Encoding]::UTF8) $activeRelease = [ordered]@{ schema_version = 1 release_id = $releaseId updated_at = [DateTime]::UtcNow.ToString("o") } [IO.File]::WriteAllText($activeReleasePath, (($activeRelease | ConvertTo-Json -Compress) + "`n"), [Text.Encoding]::UTF8) $agentScript = Join-Path $targetRelease "app\tools\rf001_rx_http_agent.py" $selfTestOutput = Join-Path $backupRoot "new_agent_self_test.stdout.txt" $selfTestError = Join-Path $backupRoot "new_agent_self_test.stderr.txt" Invoke-SignedAgentSelfTest ` -Python $python ` -AgentScript $agentScript ` -ExpectedAgentVersion $agentVersion ` -StandardOutputPath $selfTestOutput ` -StandardErrorPath $selfTestError | Out-Null $launcherScript = Join-Path $launcherRoot "rf001_agent_launcher.py" $launcherArguments = @( "-u", "-B", "-X", "utf8", ('"{0}"' -f $launcherScript), "--config", ('"{0}"' -f $configPath), "--log-file", ('"{0}"' -f $launcherLogPath) ) -join " " $action = New-ScheduledTaskAction -Execute $python -Argument $launcherArguments -WorkingDirectory $launcherRoot $healthBefore = if (Test-Path -LiteralPath $healthPath) { (Get-Item -LiteralPath $healthPath).LastWriteTimeUtc } else { [DateTime]::MinValue } $installStartedAt = [DateTime]::UtcNow try { Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue Set-ScheduledTask -TaskName $taskName -Action $action | Out-Null Start-ScheduledTask -TaskName $taskName $deadline = [DateTime]::UtcNow.AddSeconds(35) $confirmed = $false do { Start-Sleep -Milliseconds 500 $currentTask = Get-ScheduledTask -TaskName $taskName if ((Test-Path -LiteralPath $healthPath) -and (Test-Path -LiteralPath $launcherStatusPath)) { $healthItem = Get-Item -LiteralPath $healthPath $health = Get-Content -LiteralPath $healthPath -Raw -Encoding UTF8 | ConvertFrom-Json $launcherStatus = Get-Content -LiteralPath $launcherStatusPath -Raw -Encoding UTF8 | ConvertFrom-Json if ( $currentTask.State -eq "Running" -and $healthItem.LastWriteTimeUtc -gt $healthBefore -and $healthItem.LastWriteTimeUtc -ge $installStartedAt.AddSeconds(-2) -and $health.state -eq "RUNNING" -and $health.agent_version -eq $agentVersion -and $health.release_id -eq $releaseId -and $health.rf_tx -eq 0 -and $launcherStatus.state -eq "RUNNING" -and $launcherStatus.active_release_id -eq $releaseId -and $launcherStatus.active_agent_version -eq $agentVersion -and $launcherStatus.rf_tx -eq 0 ) { $confirmed = $true } } } while (-not $confirmed -and [DateTime]::UtcNow -lt $deadline) if (-not $confirmed) { $currentInfo = Get-ScheduledTaskInfo -TaskName $taskName throw "Versioned agent did not report healthy startup; LastTaskResult=$($currentInfo.LastTaskResult)" } } catch { $failure = $_.Exception.Message Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue $xml = [IO.File]::ReadAllText($taskXmlPath, [Text.Encoding]::Unicode) Register-ScheduledTask -TaskName $taskName -Xml $xml -Force | Out-Null foreach ($name in @("launcher", "agent_config.json", "active_release.json", "launcher_status.json", "agent_health.json")) { $inventoryRow = @($backupInventory | Where-Object { $_.name -eq $name }) if ($inventoryRow.Count -ne 1) { throw "Bootstrap failed and rollback inventory is invalid. Original cause: $failure" } $current = Join-Path $installRoot $name $saved = Join-Path $backupRoot $name if ($inventoryRow[0].existed) { if ($inventoryRow[0].container) { if (-not (Test-Path -LiteralPath $current -PathType Container)) { New-Item -ItemType Directory -Path $current -Force | Out-Null } Copy-Item -Path (Join-Path $saved '*') -Destination $current -Recurse -Force } else { Copy-Item -LiteralPath $saved -Destination $current -Force } } } if ($wasRunning) { Start-ScheduledTask -TaskName $taskName } throw "Bootstrap failed; previous scheduled task restored. Cause: $failure" } $taskAfter = Get-ScheduledTask -TaskName $taskName $taskInfoAfter = Get-ScheduledTaskInfo -TaskName $taskName "BOOTSTRAP_INSTALL=COMPLETE" "INSTALL_ROOT=$installRoot" "BACKUP_ROOT=$backupRoot" "AUTOSTART_TASK=$taskName" "AUTOSTART_ACCOUNT=$($taskAfter.Principal.UserId)" "AUTOSTART_RUN_LEVEL=$($taskAfter.Principal.RunLevel)" "TASK_STATE=$($taskAfter.State)" "LAST_TASK_RESULT=$($taskInfoAfter.LastTaskResult)" "BOOTSTRAP_ID=$bootstrapId" "AGENT_RELEASE_ID=$releaseId" "AGENT_VERSION=$agentVersion" "TARGET_INITIAL_STATE=$($targetPreparation.initial_state)" "TARGET_FINAL_STATE=$($targetPreparation.final_state)" "TARGET_REUSED=$($targetPreparation.reused)" "CONFLICT_QUARANTINE_PATH=$($targetPreparation.quarantine_path)" "CONFLICT_DIAGNOSTIC_PATH=$($targetPreparation.quarantine_diagnostic)" "CLEAN_RELEASE_HASH_VERIFICATION=PASS" "REFERENCE_SHA256=$expectedReferenceHash" "MANIFEST_AUTHENTICATION=HMAC-SHA256" "ARBITRARY_SHELL=DISABLED" "HARDWARE_ACCESSED=false" "RF_TX=0"