Harden RF node control and agent updates

This commit is contained in:
LittleSam129
2026-09-30 11:42:19 +03:00
parent a94f2db4ee
commit 0d6fff224e
34 changed files with 14010 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
$ErrorActionPreference = "Stop"
$taskName = "SDR Rover HTTP Control Server"
$repository = Split-Path -Parent $PSScriptRoot
$pythonCommand = Get-Command python.exe -ErrorAction Stop
$python = $pythonCommand.Source
$serverScript = Join-Path $repository "tools\rf001_http_control.py"
$tokenFile = Join-Path $repository ".local\rf001_http_token.txt"
$stateFile = Join-Path $repository ".local\rf001_http_server_state.json"
$logFile = Join-Path $repository ".local\logs\rf001_http_service.log"
foreach ($required in @($python, $serverScript, $tokenFile)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "Required file is absent: $required"
}
}
$arguments = @(
"-u",
"-B",
('"{0}"' -f $serverScript),
"serve",
"--bind", "192.168.255.185",
"--port", "8765",
"--allowed-agent-ip", "192.168.255.56",
"--state-file", ('"{0}"' -f $stateFile),
"--token-file", ('"{0}"' -f $tokenFile),
"--log-file", ('"{0}"' -f $logFile),
"--bind-retry-seconds", "300"
) -join " "
$account = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $repository
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $account
$principal = New-ScheduledTaskPrincipal -UserId $account -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-RestartCount 10 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-MultipleInstances IgnoreNew
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($null -ne $existing) {
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
}
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Settings $settings `
-Description "Authenticated RF-001 control server. No network RF TX operation." `
-Force | Out-Null
Start-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds 2
$task = Get-ScheduledTask -TaskName $taskName
$taskInfo = Get-ScheduledTaskInfo -TaskName $taskName
"AUTOSTART_TASK=$taskName"
"AUTOSTART_ACCOUNT=$account"
"AUTOSTART_MODE=USER_LOGON"
"TASK_STATE=$($task.State)"
"LAST_TASK_RESULT=$($taskInfo.LastTaskResult)"
"RF_TX=0"

View File

@@ -0,0 +1,994 @@
$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"

File diff suppressed because it is too large Load Diff

422
tools/rf001_agent_update.py Normal file
View File

@@ -0,0 +1,422 @@
"""Fixed release packaging and verification for the RF-001 RX agent.
The module deliberately has no command execution, SDR access, or generic
archive extraction. A release contains one exact allowlist of Python files
and a content manifest. Approval metadata is authenticated separately with
the already provisioned HTTP-channel HMAC secret.
"""
from __future__ import annotations
import ast
from dataclasses import dataclass
import hashlib
import hmac
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
from typing import Any, Mapping
import zipfile
UPDATE_MANIFEST_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
CONTENT_MANIFEST_NAME = "RELEASE_CONTENT.json"
RELEASE_ID_PREFIX = "rf001-agent-"
RELEASE_ID_PATTERN = re.compile(r"^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
AGENT_VERSION_PATTERN = re.compile(r"^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
MAX_PACKAGE_BYTES = 64 * 1024 * 1024
MAX_UNCOMPRESSED_BYTES = 128 * 1024 * 1024
MAX_ARCHIVE_ENTRIES = 32
MAX_CONTENT_MANIFEST_BYTES = 64 * 1024
RELEASE_SOURCE_FILES: dict[str, str] = {
"app/tools/hackrf_process_check.py": "tools/hackrf_process_check.py",
"app/tools/rf001_agent_update.py": "tools/rf001_agent_update.py",
"app/tools/rf001_capture_runner.py": "tools/rf001_capture_runner.py",
"app/tools/rf001_http_control.py": "tools/rf001_http_control.py",
"app/tools/rf001_offline_decode.py": "tools/rf001_offline_decode.py",
"app/tools/rf001_rx_capture.py": "tools/rf001_rx_capture.py",
"app/tools/rf001_rx_http_agent.py": "tools/rf001_rx_http_agent.py",
"app/tools/rf001_staircase_job.py": "tools/rf001_staircase_job.py",
"app/tools/rf001_staircase_search.py": "tools/rf001_staircase_search.py",
}
REQUIRED_RELEASE_FILES = frozenset(RELEASE_SOURCE_FILES)
class ReleaseValidationError(ValueError):
"""A release is malformed, unauthenticated, or outside its allowlist."""
@dataclass(frozen=True)
class ReleaseInspection:
release_id: str
agent_version: str
protocol_version: int
package_sha256: str
package_bytes: int
content_manifest_sha256: str
uncompressed_bytes: int
file_count: int
def descriptor(self) -> dict[str, object]:
return {
"schema_version": 1,
"release_id": self.release_id,
"agent_version": self.agent_version,
"protocol_version": self.protocol_version,
"package_sha256": self.package_sha256,
"package_bytes": self.package_bytes,
"content_manifest_sha256": self.content_manifest_sha256,
"uncompressed_bytes": self.uncompressed_bytes,
"file_count": self.file_count,
}
def source_agent_version(agent_source: Path) -> str:
"""Read the single literal AGENT_VERSION from an agent source file."""
try:
syntax = ast.parse(agent_source.read_text(encoding="utf-8"), agent_source.name)
except (OSError, SyntaxError, UnicodeError) as error:
raise ReleaseValidationError("agent source cannot be parsed") from error
values: list[ast.expr] = []
for statement in syntax.body:
if isinstance(statement, ast.Assign):
if any(
isinstance(target, ast.Name) and target.id == "AGENT_VERSION"
for target in statement.targets
):
if not (
len(statement.targets) == 1
and isinstance(statement.targets[0], ast.Name)
and statement.targets[0].id == "AGENT_VERSION"
):
raise ReleaseValidationError(
"agent source AGENT_VERSION assignment is ambiguous"
)
values.append(statement.value)
elif (
isinstance(statement, ast.AnnAssign)
and isinstance(statement.target, ast.Name)
and statement.target.id == "AGENT_VERSION"
):
if statement.value is None:
raise ReleaseValidationError("agent source AGENT_VERSION has no value")
values.append(statement.value)
if len(values) != 1:
raise ReleaseValidationError(
"agent source must contain one AGENT_VERSION assignment"
)
value = values[0]
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
raise ReleaseValidationError("agent source AGENT_VERSION must be a literal string")
if AGENT_VERSION_PATTERN.fullmatch(value.value) is None:
raise ReleaseValidationError("agent source AGENT_VERSION is invalid")
return value.value
def validate_release_identity(
source_root: Path,
*,
release_id: str,
requested_agent_version: str,
) -> str:
"""Require release id, requested version, and packaged source to agree."""
if RELEASE_ID_PATTERN.fullmatch(release_id) is None:
raise ReleaseValidationError("release_id is invalid")
if AGENT_VERSION_PATTERN.fullmatch(requested_agent_version) is None:
raise ReleaseValidationError("agent_version is invalid")
if release_id != f"{RELEASE_ID_PREFIX}{requested_agent_version}":
raise ReleaseValidationError("release_id does not match agent_version")
agent_source = (
source_root / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]
).resolve()
actual_agent_version = source_agent_version(agent_source)
if actual_agent_version != requested_agent_version:
raise ReleaseValidationError(
"source AGENT_VERSION does not match requested agent_version"
)
return actual_agent_version
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while block := stream.read(4 * 1024 * 1024):
digest.update(block)
return digest.hexdigest()
def sign_update_manifest(token: bytes, manifest: Mapping[str, Any]) -> str:
return hmac.new(
token,
UPDATE_MANIFEST_DOMAIN + canonical_json_bytes(manifest),
hashlib.sha256,
).hexdigest()
def verify_update_manifest_signature(
token: bytes,
manifest: Mapping[str, Any],
signature: str,
) -> None:
if SHA256_PATTERN.fullmatch(signature) is None:
raise ReleaseValidationError("update manifest signature is invalid")
expected = sign_update_manifest(token, manifest)
if not hmac.compare_digest(expected, signature):
raise ReleaseValidationError("update manifest signature mismatch")
def _validate_member_name(name: str) -> PurePosixPath:
if not name or "\\" in name or name.startswith("/"):
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
path = PurePosixPath(name)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
if any(":" in part for part in path.parts):
raise ReleaseValidationError(f"drive-like archive path: {name!r}")
return path
def _is_link(info: zipfile.ZipInfo) -> bool:
unix_mode = (info.external_attr >> 16) & 0xFFFF
return stat.S_IFMT(unix_mode) == stat.S_IFLNK
def _load_content_manifest(
archive: zipfile.ZipFile,
info: zipfile.ZipInfo,
) -> tuple[dict[str, Any], bytes]:
if info.file_size > MAX_CONTENT_MANIFEST_BYTES:
raise ReleaseValidationError("release content manifest is too large")
raw = archive.read(info)
try:
decoded = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise ReleaseValidationError("release content manifest is invalid") from error
if not isinstance(decoded, dict):
raise ReleaseValidationError("release content manifest must be an object")
if raw != canonical_json_bytes(decoded):
raise ReleaseValidationError("release content manifest is not canonical JSON")
return decoded, raw
def _validate_content_manifest(manifest: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
if manifest.get("schema_version") != 1:
raise ReleaseValidationError("unsupported release content schema")
release_id = manifest.get("release_id")
agent_version = manifest.get("agent_version")
protocol_version = manifest.get("protocol_version")
if not isinstance(release_id, str) or RELEASE_ID_PATTERN.fullmatch(release_id) is None:
raise ReleaseValidationError("release_id is invalid")
if not isinstance(agent_version, str) or AGENT_VERSION_PATTERN.fullmatch(agent_version) is None:
raise ReleaseValidationError("agent_version is invalid")
if type(protocol_version) is not int or protocol_version <= 0:
raise ReleaseValidationError("protocol_version is invalid")
rows = manifest.get("files")
if not isinstance(rows, list):
raise ReleaseValidationError("release files list is invalid")
files: dict[str, dict[str, Any]] = {}
for row in rows:
if not isinstance(row, dict):
raise ReleaseValidationError("release file entry is invalid")
name = row.get("path")
digest = row.get("sha256")
size = row.get("bytes")
if not isinstance(name, str):
raise ReleaseValidationError("release file path is invalid")
_validate_member_name(name)
key = name.casefold()
if key in (existing.casefold() for existing in files):
raise ReleaseValidationError("duplicate release file path")
if SHA256_PATTERN.fullmatch(str(digest)) is None:
raise ReleaseValidationError(f"release file hash is invalid: {name}")
if type(size) is not int or size < 0:
raise ReleaseValidationError(f"release file size is invalid: {name}")
files[name] = {"path": name, "sha256": digest, "bytes": size}
if frozenset(files) != REQUIRED_RELEASE_FILES:
unexpected = sorted(set(files) - REQUIRED_RELEASE_FILES)
missing = sorted(REQUIRED_RELEASE_FILES - set(files))
raise ReleaseValidationError(
f"release file allowlist mismatch; unexpected={unexpected}; missing={missing}"
)
return files
def inspect_release_archive(
package_path: Path,
*,
expected_package_sha256: str | None = None,
expected_package_bytes: int | None = None,
expected_content_manifest_sha256: str | None = None,
extraction_root: Path | None = None,
) -> ReleaseInspection:
package_path = package_path.resolve()
package_bytes = package_path.stat().st_size
if package_bytes <= 0 or package_bytes > MAX_PACKAGE_BYTES:
raise ReleaseValidationError("release package size is outside the limit")
if expected_package_bytes is not None and package_bytes != expected_package_bytes:
raise ReleaseValidationError("release package size mismatch")
package_sha256 = sha256_file(package_path)
if expected_package_sha256 is not None and package_sha256 != expected_package_sha256:
raise ReleaseValidationError("release package SHA256 mismatch")
with zipfile.ZipFile(package_path, "r") as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_ARCHIVE_ENTRIES:
raise ReleaseValidationError("release archive entry count is invalid")
seen: set[str] = set()
regular: dict[str, zipfile.ZipInfo] = {}
uncompressed_bytes = 0
for info in entries:
path = _validate_member_name(info.filename)
normalized = path.as_posix()
key = normalized.casefold()
if key in seen:
raise ReleaseValidationError("ambiguous duplicate archive entry")
seen.add(key)
if info.flag_bits & 0x1:
raise ReleaseValidationError("encrypted archive entries are prohibited")
if _is_link(info):
raise ReleaseValidationError("archive links are prohibited")
if info.is_dir():
raise ReleaseValidationError("explicit archive directory entries are prohibited")
uncompressed_bytes += info.file_size
if uncompressed_bytes > MAX_UNCOMPRESSED_BYTES:
raise ReleaseValidationError("release uncompressed size exceeds the limit")
regular[normalized] = info
if CONTENT_MANIFEST_NAME not in regular:
raise ReleaseValidationError("release content manifest is absent")
content_manifest, content_raw = _load_content_manifest(
archive,
regular[CONTENT_MANIFEST_NAME],
)
content_sha256 = hashlib.sha256(content_raw).hexdigest()
if (
expected_content_manifest_sha256 is not None
and content_sha256 != expected_content_manifest_sha256
):
raise ReleaseValidationError("release content manifest SHA256 mismatch")
files = _validate_content_manifest(content_manifest)
expected_names = set(REQUIRED_RELEASE_FILES) | {CONTENT_MANIFEST_NAME}
if set(regular) != expected_names:
raise ReleaseValidationError("release archive contents are not allowlisted")
for name, metadata in files.items():
info = regular[name]
if info.file_size != metadata["bytes"]:
raise ReleaseValidationError(f"release file size mismatch: {name}")
digest = hashlib.sha256()
with archive.open(info, "r") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
if digest.hexdigest() != metadata["sha256"]:
raise ReleaseValidationError(f"release file SHA256 mismatch: {name}")
if extraction_root is not None:
extraction_root = extraction_root.resolve()
extraction_root.mkdir(parents=True, exist_ok=False)
for name in sorted(REQUIRED_RELEASE_FILES):
target = extraction_root.joinpath(*PurePosixPath(name).parts)
target.parent.mkdir(parents=True, exist_ok=True)
resolved = target.resolve()
try:
resolved.relative_to(extraction_root)
except ValueError as error:
raise ReleaseValidationError("archive path escaped extraction root") from error
with archive.open(regular[name], "r") as source:
with target.open("xb") as destination:
shutil.copyfileobj(source, destination, length=1024 * 1024)
manifest_target = extraction_root / CONTENT_MANIFEST_NAME
with manifest_target.open("xb") as stream:
stream.write(content_raw)
return ReleaseInspection(
release_id=str(content_manifest["release_id"]),
agent_version=str(content_manifest["agent_version"]),
protocol_version=int(content_manifest["protocol_version"]),
package_sha256=package_sha256,
package_bytes=package_bytes,
content_manifest_sha256=content_sha256,
uncompressed_bytes=uncompressed_bytes,
file_count=len(files),
)
def build_release_archive(
source_root: Path,
package_path: Path,
*,
release_id: str,
agent_version: str,
protocol_version: int,
) -> ReleaseInspection:
if protocol_version <= 0:
raise ReleaseValidationError("protocol_version is invalid")
source_root = source_root.resolve()
validate_release_identity(
source_root,
release_id=release_id,
requested_agent_version=agent_version,
)
rows = []
sources: dict[str, Path] = {}
for archive_name, relative_source in RELEASE_SOURCE_FILES.items():
source = (source_root / relative_source).resolve()
if not source.is_file():
raise FileNotFoundError(f"release source is absent: {source}")
sources[archive_name] = source
rows.append(
{
"path": archive_name,
"bytes": source.stat().st_size,
"sha256": sha256_file(source),
}
)
content = {
"schema_version": 1,
"release_id": release_id,
"agent_version": agent_version,
"protocol_version": protocol_version,
"files": sorted(rows, key=lambda row: str(row["path"])),
}
content_raw = canonical_json_bytes(content)
package_path.parent.mkdir(parents=True, exist_ok=True)
if package_path.exists():
raise FileExistsError(f"release package already exists: {package_path}")
timestamp = (2026, 9, 29, 0, 0, 0)
with zipfile.ZipFile(
package_path,
"x",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
manifest_info = zipfile.ZipInfo(CONTENT_MANIFEST_NAME, timestamp)
manifest_info.compress_type = zipfile.ZIP_DEFLATED
manifest_info.external_attr = 0o100600 << 16
archive.writestr(manifest_info, content_raw)
for name in sorted(sources):
info = zipfile.ZipInfo(name, timestamp)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100600 << 16
archive.writestr(info, sources[name].read_bytes())
return inspect_release_archive(package_path)
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_bytes(canonical_json_bytes(value) + b"\n")
os.replace(temporary, path)

View File

@@ -0,0 +1,334 @@
"""Безопасный запуск уже распакованного bootstrap-пакета RX-агента.
Утилита принадлежит MAIN и не входит в подписанный bootstrap. Она проверяет
HMAC и точный состав распакованного пакета до запуска, сохраняет stdout,
stderr и снимки дерева только во внешнем каталоге, а после завершения
подтверждает, что пакет не изменился.
"""
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import hashlib
import hmac
import json
import os
from pathlib import Path, PurePosixPath
import re
import subprocess
from typing import Any, Sequence
MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
INSTALLER_NAME = "INSTALL_RX_AGENT.cmd"
SIGNATURE_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
class BootstrapInstallError(RuntimeError):
"""Базовая ошибка безопасного запуска bootstrap."""
class PackageValidationError(BootstrapInstallError):
"""Пакет не прошёл HMAC, manifest или точную проверку состава."""
class EvidencePathError(BootstrapInstallError):
"""Каталог доказательств небезопасен или уже содержит журналы запуска."""
class PackageMutationError(BootstrapInstallError):
"""Дерево bootstrap изменилось во время запуска установщика."""
@dataclass(frozen=True)
class TreeEntry:
path: str
bytes: int
sha256: str
modified_time_ns: int
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
def snapshot_tree(root: Path) -> list[TreeEntry]:
entries: list[TreeEntry] = []
for path in sorted(
(candidate for candidate in root.rglob("*") if candidate.is_file()),
key=lambda candidate: candidate.as_posix().casefold(),
):
stat = path.stat()
entries.append(
TreeEntry(
path=path.relative_to(root).as_posix(),
bytes=stat.st_size,
sha256=sha256_file(path),
modified_time_ns=stat.st_mtime_ns,
)
)
return entries
def _content_identity(entries: Sequence[TreeEntry]) -> list[tuple[str, int, str]]:
return [(entry.path, entry.bytes, entry.sha256) for entry in entries]
def _write_json(path: Path, value: Any) -> None:
path.write_text(
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def _relative_manifest_path(value: object) -> str:
if not isinstance(value, str) or not value:
raise PackageValidationError("bootstrap manifest contains an empty path")
if "\\" in value or ":" in value:
raise PackageValidationError(f"unsafe bootstrap path: {value}")
relative = PurePosixPath(value)
if relative.is_absolute() or ".." in relative.parts or "." in relative.parts:
raise PackageValidationError(f"unsafe bootstrap path: {value}")
return relative.as_posix()
def validate_pristine_package(package_root: Path, token_file: Path) -> dict[str, Any]:
package_root = package_root.resolve()
token_file = token_file.resolve()
if not package_root.is_dir():
raise PackageValidationError("bootstrap package directory is absent")
manifest_path = package_root / MANIFEST_NAME
signature_path = package_root / SIGNATURE_NAME
installer_path = package_root / INSTALLER_NAME
for required in (manifest_path, signature_path, installer_path, token_file):
if not required.is_file():
raise PackageValidationError(f"required file is absent: {required}")
manifest_bytes = manifest_path.read_bytes()
token = token_file.read_text(encoding="utf-8").strip().encode("utf-8")
if len(token) < 32:
raise PackageValidationError("installed signing token is invalid")
supplied_signature = signature_path.read_text(encoding="ascii").strip().lower()
expected_signature = hmac.new(
token,
SIGNATURE_DOMAIN + manifest_bytes,
hashlib.sha256,
).hexdigest()
if not SHA256_PATTERN.fullmatch(supplied_signature) or not hmac.compare_digest(
supplied_signature,
expected_signature,
):
raise PackageValidationError("bootstrap manifest signature mismatch")
try:
manifest = json.loads(manifest_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise PackageValidationError("bootstrap manifest is invalid JSON") from error
if not isinstance(manifest, dict):
raise PackageValidationError("bootstrap manifest root must be an object")
if manifest.get("schema_version") != 1 or manifest.get("package_kind") != "BOOTSTRAP":
raise PackageValidationError("unsupported bootstrap manifest")
rows = manifest.get("files")
if not isinstance(rows, list) or not rows:
raise PackageValidationError("bootstrap manifest file list is invalid")
expected: dict[str, tuple[int, str]] = {}
for row in rows:
if not isinstance(row, dict):
raise PackageValidationError("bootstrap manifest file row is invalid")
relative = _relative_manifest_path(row.get("path"))
folded = relative.casefold()
if folded in expected:
raise PackageValidationError(f"duplicate bootstrap path: {relative}")
byte_count = row.get("bytes")
digest = row.get("sha256")
if type(byte_count) is not int or byte_count < 0:
raise PackageValidationError(f"invalid bootstrap size: {relative}")
if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest.lower()):
raise PackageValidationError(f"invalid bootstrap SHA256: {relative}")
expected[folded] = (byte_count, digest.lower())
source = package_root.joinpath(*PurePosixPath(relative).parts)
if not source.is_file() or source.is_symlink():
raise PackageValidationError(f"bootstrap file is absent or unsafe: {relative}")
if source.stat().st_size != byte_count:
raise PackageValidationError(f"bootstrap file size mismatch: {relative}")
if sha256_file(source) != digest.lower():
raise PackageValidationError(f"bootstrap file SHA256 mismatch: {relative}")
actual_entries = snapshot_tree(package_root)
actual = {entry.path.casefold() for entry in actual_entries}
allowed = set(expected) | {MANIFEST_NAME.casefold(), SIGNATURE_NAME.casefold()}
unexpected = sorted(actual - allowed)
missing = sorted(allowed - actual)
if unexpected or missing:
raise PackageValidationError(
"bootstrap package allowlist mismatch; "
f"unexpected={unexpected}; missing={missing}"
)
return {
"bootstrap_id": manifest.get("bootstrap_id"),
"agent_release_id": manifest.get("agent_release_id"),
"agent_version": manifest.get("agent_version"),
"hmac": "PASS",
"manifest_file_count": len(expected),
"package_file_count": len(actual_entries),
"unexpected": 0,
"missing": 0,
}
def _assert_external_evidence(package_root: Path, evidence_root: Path) -> None:
package_root = package_root.resolve()
evidence_root = evidence_root.resolve()
if evidence_root == package_root or package_root in evidence_root.parents:
raise EvidencePathError("evidence directory must be outside the package tree")
def run_installer(
package_root: Path,
evidence_root: Path,
token_file: Path,
*,
command: Sequence[str] | None = None,
timeout_seconds: float | None = None,
) -> dict[str, Any]:
package_root = package_root.resolve()
evidence_root = evidence_root.resolve()
_assert_external_evidence(package_root, evidence_root)
preflight = validate_pristine_package(package_root, token_file)
evidence_root.mkdir(parents=True, exist_ok=True)
paths = {
"stdout": evidence_root / "install.stdout.txt",
"stderr": evidence_root / "install.stderr.txt",
"runtime": evidence_root / "install_runtime.json",
"before": evidence_root / "package_tree_before.json",
"immediate": evidence_root / "package_tree_immediate.json",
"after": evidence_root / "package_tree_after.json",
"preflight": evidence_root / "package_preflight.json",
}
occupied = [str(path) for path in paths.values() if path.exists()]
if occupied:
raise EvidencePathError(f"runtime evidence paths already exist: {occupied}")
before = snapshot_tree(package_root)
_write_json(paths["preflight"], preflight)
_write_json(paths["before"], [asdict(entry) for entry in before])
invocation = list(command) if command is not None else [
os.environ.get("COMSPEC", "cmd.exe"),
"/d",
"/c",
"call",
str(package_root / INSTALLER_NAME),
]
metadata: dict[str, Any] = {
"started_at_utc": utc_now(),
"package_root": str(package_root),
"evidence_root": str(evidence_root),
"working_directory": str(package_root),
"stdout_path": str(paths["stdout"]),
"stderr_path": str(paths["stderr"]),
"rf_rx": 0,
"rf_tx": 0,
}
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
with paths["stdout"].open("wb") as stdout_stream, paths["stderr"].open(
"wb"
) as stderr_stream:
process = subprocess.Popen(
invocation,
cwd=package_root,
stdin=subprocess.DEVNULL,
stdout=stdout_stream,
stderr=stderr_stream,
creationflags=creation_flags,
)
metadata["installer_process_id"] = process.pid
immediate = snapshot_tree(package_root)
_write_json(paths["immediate"], [asdict(entry) for entry in immediate])
metadata["package_content_unchanged_immediate"] = (
_content_identity(immediate) == _content_identity(before)
)
metadata["package_metadata_unchanged_immediate"] = immediate == before
try:
exit_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired as error:
metadata["timed_out"] = True
metadata["completed_at_utc"] = utc_now()
_write_json(paths["runtime"], metadata)
raise BootstrapInstallError(
"installer timeout; the process was not restarted or killed automatically"
) from error
after = snapshot_tree(package_root)
_write_json(paths["after"], [asdict(entry) for entry in after])
metadata.update(
{
"completed_at_utc": utc_now(),
"exit_code": exit_code,
"timed_out": False,
"package_content_unchanged_after": (
_content_identity(after) == _content_identity(before)
),
"package_metadata_unchanged_after": after == before,
"stdout_bytes": paths["stdout"].stat().st_size,
"stderr_bytes": paths["stderr"].stat().st_size,
}
)
_write_json(paths["runtime"], metadata)
if not metadata["package_content_unchanged_immediate"] or not metadata[
"package_content_unchanged_after"
]:
raise PackageMutationError("bootstrap package changed during installer run")
if exit_code != 0:
raise BootstrapInstallError(f"installer exited with code {exit_code}")
return metadata
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package-root", required=True, type=Path)
parser.add_argument("--evidence-root", required=True, type=Path)
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--timeout-seconds", type=float, default=None)
return parser.parse_args()
def main() -> int:
arguments = parse_args()
try:
result = run_installer(
arguments.package_root,
arguments.evidence_root,
arguments.token_file,
timeout_seconds=arguments.timeout_seconds,
)
except BootstrapInstallError as error:
print(f"BOOTSTRAP INSTALL = FAILED: {error}")
print("RF RX = 0")
print("RF TX = 0")
return 1
print("BOOTSTRAP INSTALL = COMPLETE")
print(f"INSTALL EXIT CODE = {result['exit_code']}")
print(f"EVIDENCE ROOT = {result['evidence_root']}")
print("PACKAGE TREE UNCHANGED = YES")
print("RF RX = 0")
print("RF TX = 0")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,16 @@
RF-001 RX Agent — первоначальная установка механизма обновления
1. Распакуйте ZIP целиком в отдельный каталог.
2. Запустите INSTALL_RX_AGENT.cmd обычным двойным щелчком.
3. Дождитесь строки BOOTSTRAP_INSTALL=COMPLETE и нажмите любую клавишу.
Установщик использует существующее задание «SDR Rover RX Agent» и уже
установленный секрет HTTP-канала. Секрета в этом ZIP нет. Пользователь,
уровень прав, сетевые настройки, драйверы и параметры SDR не меняются.
Установка не запускает захват или передачу. При неудаче запуска новой версии
восстанавливается прежнее определение задания Планировщика.
MAIN: 192.168.255.185:8765
Разрешённый адрес RX: 192.168.255.56
RF TX: 0

View File

@@ -0,0 +1,355 @@
"""Build authenticated RF-001 agent release and bootstrap packages."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import hashlib
import hmac
import json
from pathlib import Path, PurePosixPath
import re
import shutil
from typing import BinaryIO, Mapping
import zipfile
try:
from tools.rf001_agent_update import (
RELEASE_SOURCE_FILES,
UPDATE_MANIFEST_DOMAIN,
build_release_archive,
canonical_json_bytes,
sha256_file,
validate_release_identity,
)
except ModuleNotFoundError: # direct execution from tools/
from rf001_agent_update import ( # type: ignore[no-redef]
RELEASE_SOURCE_FILES,
UPDATE_MANIFEST_DOMAIN,
build_release_archive,
canonical_json_bytes,
sha256_file,
validate_release_identity,
)
BOOTSTRAP_ID_PATTERN = re.compile(
r"^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$"
)
BOOTSTRAP_MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
BOOTSTRAP_SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
REFERENCE_SHA256 = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
MAX_BOOTSTRAP_BYTES = 64 * 1024 * 1024
@dataclass(frozen=True)
class BootstrapInspection:
bootstrap_id: str
agent_release_id: str
agent_version: str
protocol_version: int
package_sha256: str
package_bytes: int
content_manifest_sha256: str
file_count: int
def _token_bytes(path: Path) -> bytes:
value = path.read_text(encoding="utf-8-sig").strip().encode("utf-8")
if len(value) < 32:
raise ValueError("existing signing token is missing or too short")
return value
def _validate_archive_name(name: str) -> None:
path = PurePosixPath(name)
if (
not name
or "\\" in name
or name.startswith("/")
or path.is_absolute()
or any(part in ("", ".", "..") or ":" in part for part in path.parts)
):
raise ValueError(f"unsafe bootstrap path: {name!r}")
def _source_files(
source_root: Path,
*,
agent_release_id: str,
) -> dict[str, Path]:
result = {
"INSTALL_RX_AGENT.cmd": source_root / "INSTALL_RX_AGENT.cmd",
"README_RX_AGENT.txt": source_root / "tools" / "rf001_bootstrap_readme.txt",
"UNINSTALL_RX_AGENT.cmd": source_root / "UNINSTALL_RX_AGENT.cmd",
"launcher/rf001_agent_launcher.py": source_root
/ "tools"
/ "rf001_agent_launcher.py",
"reference/lab065_reference_staircase.cs8": source_root
/ ".local"
/ "rf001_probe"
/ "lab065_reference_staircase.cs8",
"tools/install_rf001_rx_autostart.ps1": source_root
/ "tools"
/ "install_rf001_rx_autostart.ps1",
"tools/uninstall_rf001_rx_autostart.ps1": source_root
/ "tools"
/ "uninstall_rf001_rx_autostart.ps1",
}
for archive_name, relative in RELEASE_SOURCE_FILES.items():
result[f"versions/{agent_release_id}/{archive_name}"] = source_root / relative
return result
def _copy_to_zip(
archive: zipfile.ZipFile,
name: str,
source: Path,
*,
timestamp: tuple[int, int, int, int, int, int],
) -> None:
info = zipfile.ZipInfo(name, timestamp)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100600 << 16
with source.open("rb") as input_stream, archive.open(info, "w") as output_stream:
shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
def _write_bytes(
archive: zipfile.ZipFile,
name: str,
value: bytes,
*,
timestamp: tuple[int, int, int, int, int, int],
) -> None:
info = zipfile.ZipInfo(name, timestamp)
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100600 << 16
archive.writestr(info, value)
def build_bootstrap_archive(
source_root: Path,
package_path: Path,
*,
bootstrap_id: str,
agent_release_id: str,
agent_version: str,
protocol_version: int,
token_path: Path,
agent_release_bootstrap_id: str | None = None,
) -> BootstrapInspection:
if BOOTSTRAP_ID_PATTERN.fullmatch(bootstrap_id) is None:
raise ValueError("bootstrap_id is invalid")
if protocol_version <= 0:
raise ValueError("protocol_version is invalid")
release_bootstrap_id = agent_release_bootstrap_id or bootstrap_id
if BOOTSTRAP_ID_PATTERN.fullmatch(release_bootstrap_id) is None:
raise ValueError("agent_release_bootstrap_id is invalid")
source_root = source_root.resolve()
validate_release_identity(
source_root,
release_id=agent_release_id,
requested_agent_version=agent_version,
)
sources = _source_files(source_root, agent_release_id=agent_release_id)
for name, source in sources.items():
_validate_archive_name(name)
if not source.is_file():
raise FileNotFoundError(f"bootstrap source is absent: {source}")
reference = sources["reference/lab065_reference_staircase.cs8"]
if sha256_file(reference) != REFERENCE_SHA256:
raise ValueError("staircase reference SHA256 is invalid")
ready = canonical_json_bytes(
{
"agent_version": agent_version,
"bootstrap_id": release_bootstrap_id,
"hardware_accessed": False,
"protocol_version": protocol_version,
"release_id": agent_release_id,
"rf_tx": 0,
"schema_version": 1,
"status": "READY",
}
) + b"\n"
ready_name = f"versions/{agent_release_id}/READY.json"
rows = [
{
"path": name,
"bytes": source.stat().st_size,
"sha256": sha256_file(source),
}
for name, source in sources.items()
]
rows.append(
{
"path": ready_name,
"bytes": len(ready),
"sha256": hashlib.sha256(ready).hexdigest(),
}
)
manifest: dict[str, object] = {
"schema_version": 1,
"package_kind": "BOOTSTRAP",
"bootstrap_id": bootstrap_id,
"agent_release_bootstrap_id": release_bootstrap_id,
"agent_release_id": agent_release_id,
"agent_version": agent_version,
"protocol_version": protocol_version,
"main_server": "http://192.168.255.185:8765",
"allowed_rx_ipv4": "192.168.255.56",
"reference_sha256": REFERENCE_SHA256,
"files": sorted(rows, key=lambda row: str(row["path"])),
"hardware_accessed": False,
"rf_tx": 0,
}
manifest_raw = canonical_json_bytes(manifest)
signature = hmac.new(
_token_bytes(token_path),
UPDATE_MANIFEST_DOMAIN + manifest_raw,
hashlib.sha256,
).hexdigest().encode("ascii") + b"\n"
package_path.parent.mkdir(parents=True, exist_ok=True)
if package_path.exists():
raise FileExistsError(f"bootstrap package already exists: {package_path}")
timestamp = (2026, 9, 29, 0, 0, 0)
with zipfile.ZipFile(
package_path,
"x",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
_write_bytes(
archive, BOOTSTRAP_MANIFEST_NAME, manifest_raw, timestamp=timestamp
)
_write_bytes(
archive, BOOTSTRAP_SIGNATURE_NAME, signature, timestamp=timestamp
)
_write_bytes(archive, ready_name, ready, timestamp=timestamp)
for name in sorted(sources):
_copy_to_zip(archive, name, sources[name], timestamp=timestamp)
return inspect_bootstrap_archive(package_path, token_path=token_path)
def _digest_stream(stream: BinaryIO) -> str:
digest = hashlib.sha256()
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
def inspect_bootstrap_archive(
package_path: Path,
*,
token_path: Path,
) -> BootstrapInspection:
package_bytes = package_path.stat().st_size
if package_bytes <= 0 or package_bytes > MAX_BOOTSTRAP_BYTES:
raise ValueError("bootstrap package size is outside the limit")
with zipfile.ZipFile(package_path, "r") as archive:
infos: dict[str, zipfile.ZipInfo] = {}
for info in archive.infolist():
_validate_archive_name(info.filename)
key = info.filename.casefold()
if key in (name.casefold() for name in infos):
raise ValueError("duplicate bootstrap archive entry")
if info.is_dir() or info.flag_bits & 0x1:
raise ValueError("bootstrap archive contains a prohibited entry")
infos[info.filename] = info
if BOOTSTRAP_MANIFEST_NAME not in infos or BOOTSTRAP_SIGNATURE_NAME not in infos:
raise ValueError("bootstrap authentication metadata is absent")
manifest_raw = archive.read(infos[BOOTSTRAP_MANIFEST_NAME])
manifest = json.loads(manifest_raw.decode("utf-8"))
if not isinstance(manifest, dict) or manifest_raw != canonical_json_bytes(manifest):
raise ValueError("bootstrap manifest is not canonical JSON")
supplied = archive.read(infos[BOOTSTRAP_SIGNATURE_NAME]).decode("ascii").strip()
expected = hmac.new(
_token_bytes(token_path),
UPDATE_MANIFEST_DOMAIN + manifest_raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(supplied, expected):
raise ValueError("bootstrap manifest signature mismatch")
rows = manifest.get("files")
if not isinstance(rows, list):
raise ValueError("bootstrap file list is invalid")
expected_names = {BOOTSTRAP_MANIFEST_NAME, BOOTSTRAP_SIGNATURE_NAME}
for row in rows:
if not isinstance(row, dict):
raise ValueError("bootstrap file entry is invalid")
name = row.get("path")
if not isinstance(name, str):
raise ValueError("bootstrap file path is invalid")
_validate_archive_name(name)
if name in expected_names or name not in infos:
raise ValueError("bootstrap file set is invalid")
expected_names.add(name)
info = infos[name]
if info.file_size != row.get("bytes"):
raise ValueError(f"bootstrap file size mismatch: {name}")
with archive.open(info, "r") as stream:
digest = _digest_stream(stream)
if digest != row.get("sha256"):
raise ValueError(f"bootstrap file SHA256 mismatch: {name}")
if set(infos) != expected_names:
raise ValueError("bootstrap archive contains unexpected files")
return BootstrapInspection(
bootstrap_id=str(manifest["bootstrap_id"]),
agent_release_id=str(manifest["agent_release_id"]),
agent_version=str(manifest["agent_version"]),
protocol_version=int(manifest["protocol_version"]),
package_sha256=sha256_file(package_path),
package_bytes=package_bytes,
content_manifest_sha256=hashlib.sha256(manifest_raw).hexdigest(),
file_count=len(rows),
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
release = subparsers.add_parser("release")
release.add_argument("--source-root", type=Path, required=True)
release.add_argument("--package", type=Path, required=True)
release.add_argument("--release-id", required=True)
release.add_argument("--agent-version", required=True)
release.add_argument("--protocol-version", type=int, default=1)
bootstrap = subparsers.add_parser("bootstrap")
bootstrap.add_argument("--source-root", type=Path, required=True)
bootstrap.add_argument("--package", type=Path, required=True)
bootstrap.add_argument("--bootstrap-id", required=True)
bootstrap.add_argument("--agent-release-id", required=True)
bootstrap.add_argument("--agent-version", required=True)
bootstrap.add_argument("--agent-release-bootstrap-id")
bootstrap.add_argument("--protocol-version", type=int, default=1)
bootstrap.add_argument("--token-file", type=Path, required=True)
arguments = parser.parse_args()
if arguments.command == "release":
inspection = build_release_archive(
arguments.source_root,
arguments.package,
release_id=arguments.release_id,
agent_version=arguments.agent_version,
protocol_version=arguments.protocol_version,
)
else:
inspection = build_bootstrap_archive(
arguments.source_root,
arguments.package,
bootstrap_id=arguments.bootstrap_id,
agent_release_id=arguments.agent_release_id,
agent_version=arguments.agent_version,
protocol_version=arguments.protocol_version,
token_path=arguments.token_file,
agent_release_bootstrap_id=arguments.agent_release_bootstrap_id,
)
print(f"PACKAGE = {arguments.package.resolve()}")
print(f"PACKAGE SHA256 = {inspection.package_sha256}")
print(f"PACKAGE BYTES = {inspection.package_bytes}")
print("HARDWARE ACCESSED = false")
print("RF TX = 0")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,133 @@
"""Run one finite RF-001 RX child and persist start/final evidence."""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import subprocess
from typing import Mapping
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def atomic_json(path: Path, value: Mapping[str, object]) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while block := stream.read(1024 * 1024):
digest.update(block)
return digest.hexdigest()
def run_capture(
executable: Path,
arguments_path: Path,
capture_path: Path,
stdout_path: Path,
stderr_path: Path,
started_path: Path,
result_path: Path,
) -> int:
arguments_value = json.loads(arguments_path.read_text(encoding="utf-8-sig"))
if not isinstance(arguments_value, list) or not all(
isinstance(item, str) for item in arguments_value
):
raise ValueError("arguments JSON must be an array of strings")
started_at = utc_now()
child: subprocess.Popen[bytes] | None = None
exit_code: int | None = None
runner_error: str | None = None
try:
with stdout_path.open("xb") as stdout_stream, stderr_path.open(
"xb"
) as stderr_stream:
child = subprocess.Popen(
[str(executable), *arguments_value],
stdin=subprocess.DEVNULL,
stdout=stdout_stream,
stderr=stderr_stream,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
close_fds=True,
)
atomic_json(
started_path,
{
"state": "RUNNING",
"pid": int(child.pid),
"started_at": started_at,
"capture_path": str(capture_path),
"rf_tx": 0,
},
)
exit_code = int(child.wait())
except Exception as error: # persisted at the process boundary
runner_error = f"{type(error).__name__}: {error}"
finished_at = utc_now()
capture_size = capture_path.stat().st_size if capture_path.is_file() else 0
capture_hash: str | None = None
if capture_path.is_file():
try:
capture_hash = sha256_file(capture_path)
except OSError as error:
detail = f"{type(error).__name__}: {error}"
runner_error = detail if runner_error is None else f"{runner_error}; {detail}"
duration = (
datetime.fromisoformat(finished_at) - datetime.fromisoformat(started_at)
).total_seconds()
atomic_json(
result_path,
{
"pid": int(child.pid) if child is not None else None,
"started_at": started_at,
"finished_at": finished_at,
"duration": duration,
"exit_code": exit_code,
"capture_path": str(capture_path),
"capture_size": capture_size,
"sha256": capture_hash,
"runner_error": runner_error,
"rf_tx": 0,
},
)
return 255 if exit_code is None else exit_code
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--executable", required=True, type=Path)
parser.add_argument("--arguments-json", required=True, type=Path)
parser.add_argument("--capture-path", required=True, type=Path)
parser.add_argument("--stdout-path", required=True, type=Path)
parser.add_argument("--stderr-path", required=True, type=Path)
parser.add_argument("--started-status", required=True, type=Path)
parser.add_argument("--result-status", required=True, type=Path)
arguments = parser.parse_args()
raise SystemExit(
run_capture(
arguments.executable,
arguments.arguments_json,
arguments.capture_path,
arguments.stdout_path,
arguments.stderr_path,
arguments.started_status,
arguments.result_status,
)
)
if __name__ == "__main__":
main()

1805
tools/rf001_http_control.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,742 @@
"""Isolated Windows diagnostic for the RF-001 launcher/health handshake.
The probe imports the production launcher and calls its ``start_release`` path,
but replaces only the child command with this module's ``health-child`` mode.
That child imports ``write_health`` from the immutable canonical release, writes
one diagnostic health file, never polls HTTP, never touches SDR, and exits after
the parent has preserved process evidence.
"""
from __future__ import annotations
import argparse
import base64
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import importlib.util
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import threading
import time
from types import ModuleType
from typing import Any, Mapping, Sequence
EXPECTED_STATE = "RUNNING"
NO_HARDWARE = False
NO_RF_TX = 0
_ORIGINAL_POPEN = subprocess.Popen
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def parse_utc(value: object) -> datetime | None:
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def read_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(value, dict):
raise ValueError(f"JSON object is required: {path}")
return value
@dataclass(frozen=True)
class ProcessRecord:
pid: int
parent_pid: int
name: str | None
executable: str | None
command_line: str | None
created_at: str | None
@dataclass(frozen=True)
class HandshakeExpectation:
release_id: str
agent_version: str
launch_nonce: str
state: str
launcher_pid: int
popen_pid: int
launched_at: str
diagnostic_script: str
def _powershell_json(script: str) -> object:
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
process = _ORIGINAL_POPEN(
[
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
encoded,
],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=False,
)
stdout, stderr = process.communicate(timeout=20)
if process.returncode != 0:
detail = stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"Windows process inventory failed: {detail}")
text = stdout.decode("utf-8-sig", errors="strict").strip()
if not text:
return []
return json.loads(text)
def capture_windows_processes() -> list[ProcessRecord]:
if os.name != "nt":
raise RuntimeError("Windows process inventory is required")
script = r'''
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
$rows = @(Get-CimInstance Win32_Process | ForEach-Object {
$created = $null
if ($null -ne $_.CreationDate) {
$created = $_.CreationDate.ToUniversalTime().ToString("o")
}
[ordered]@{
pid = [int]$_.ProcessId
parent_pid = [int]$_.ParentProcessId
name = $_.Name
executable = $_.ExecutablePath
command_line = $_.CommandLine
created_at = $created
}
})
ConvertTo-Json -InputObject $rows -Depth 4 -Compress
'''
raw = _powershell_json(script)
if isinstance(raw, dict):
raw = [raw]
if not isinstance(raw, list):
raise ValueError("Windows process inventory is not a list")
records: list[ProcessRecord] = []
for row in raw:
if not isinstance(row, dict):
continue
records.append(
ProcessRecord(
pid=int(row["pid"]),
parent_pid=int(row["parent_pid"]),
name=row.get("name") if isinstance(row.get("name"), str) else None,
executable=(
row.get("executable")
if isinstance(row.get("executable"), str)
else None
),
command_line=(
row.get("command_line")
if isinstance(row.get("command_line"), str)
else None
),
created_at=(
row.get("created_at")
if isinstance(row.get("created_at"), str)
else None
),
)
)
return records
def process_map(records: Sequence[ProcessRecord]) -> dict[int, ProcessRecord]:
return {record.pid: record for record in records}
def is_descendant(
child_pid: int,
ancestor_pid: int,
records: Sequence[ProcessRecord],
) -> bool:
by_pid = process_map(records)
current = by_pid.get(child_pid)
visited: set[int] = set()
while current is not None and current.pid not in visited:
if current.parent_pid == ancestor_pid:
return True
visited.add(current.pid)
current = by_pid.get(current.parent_pid)
return False
def _record_is_fresh(record: ProcessRecord, launched_at: datetime) -> bool:
created_at = parse_utc(record.created_at)
if created_at is None:
return False
return created_at >= launched_at
def _record_matches_launch(
record: ProcessRecord,
expectation: HandshakeExpectation,
) -> bool:
command_line = record.command_line or ""
return (
expectation.launch_nonce in command_line
and expectation.diagnostic_script.casefold() in command_line.casefold()
)
def compare_health_fields(
health: Mapping[str, Any] | None,
expectation: HandshakeExpectation,
) -> dict[str, bool]:
if health is None:
return {
"release_id_match": False,
"agent_version_match": False,
"launch_nonce_match": False,
"state_match": False,
"pid_match": False,
}
return {
"release_id_match": health.get("release_id") == expectation.release_id,
"agent_version_match": health.get("agent_version")
== expectation.agent_version,
"launch_nonce_match": health.get("launch_nonce")
== expectation.launch_nonce,
"state_match": health.get("state") == expectation.state,
"pid_match": health.get("pid") == expectation.popen_pid,
}
def evaluate_identity(
health: Mapping[str, Any] | None,
expectation: HandshakeExpectation,
records: Sequence[ProcessRecord],
*,
popen_alive: bool,
health_process_alive: bool,
) -> dict[str, Any]:
fields = compare_health_fields(health, expectation)
result: dict[str, Any] = {
**fields,
"identity_valid": False,
"identity_model": "REJECTED",
"reason": None,
}
if health is None:
result["reason"] = "HEALTH_ABSENT"
return result
if not all(
fields[name]
for name in (
"release_id_match",
"agent_version_match",
"launch_nonce_match",
"state_match",
)
):
result["reason"] = "HEALTH_FIELDS_MISMATCH"
return result
updated_at = parse_utc(health.get("updated_at"))
launched_at = parse_utc(expectation.launched_at)
if updated_at is None or launched_at is None or updated_at < launched_at:
result["reason"] = "STALE_HEALTH"
return result
health_pid = health.get("pid")
if type(health_pid) is not int:
result["reason"] = "HEALTH_PID_INVALID"
return result
by_pid = process_map(records)
health_record = by_pid.get(health_pid)
if health_record is None:
result["reason"] = "HEALTH_PROCESS_ABSENT"
return result
if not health_process_alive:
result["reason"] = "HEALTH_PROCESS_DIED"
return result
if not _record_is_fresh(health_record, launched_at):
result["reason"] = "PROCESS_CREATION_TIME_MISMATCH"
return result
if not _record_matches_launch(health_record, expectation):
result["reason"] = "PROCESS_COMMAND_MISMATCH"
return result
if health_pid == expectation.popen_pid:
if not popen_alive:
result["reason"] = "TARGET_DIED"
return result
result.update(
identity_valid=True,
identity_model="EXACT_POPEN_PROCESS",
reason="PASS",
)
return result
popen_record = by_pid.get(expectation.popen_pid)
if popen_record is None:
result["reason"] = "POPEN_PROCESS_NOT_CAPTURED"
return result
direct_children = [
record
for record in records
if record.parent_pid == expectation.popen_pid
and _record_is_fresh(record, launched_at)
and _record_matches_launch(record, expectation)
]
if health_record.parent_pid != expectation.popen_pid:
if is_descendant(health_pid, expectation.popen_pid, records):
result["reason"] = "UNEXPECTED_GRANDCHILD"
else:
result["reason"] = "UNRELATED_PID"
return result
if len(direct_children) != 1 or direct_children[0].pid != health_pid:
result["reason"] = "AMBIGUOUS_DIRECT_CHILD"
return result
if not _record_matches_launch(popen_record, expectation):
result["reason"] = "POPEN_COMMAND_MISMATCH"
return result
result.update(
identity_valid=True,
identity_model="UNIQUE_DIRECT_CHILD_WITH_NONCE_AND_CREATION_TIME",
reason="PASS",
)
return result
def strict_owned_process_ids(
expectation: HandshakeExpectation,
records: Sequence[ProcessRecord],
) -> list[int]:
launched_at = parse_utc(expectation.launched_at)
if launched_at is None:
return []
result: list[int] = []
for record in records:
if record.pid == expectation.launcher_pid:
continue
if not _record_is_fresh(record, launched_at):
continue
if not _record_matches_launch(record, expectation):
continue
if record.pid == expectation.popen_pid or is_descendant(
record.pid, expectation.popen_pid, records
):
result.append(record.pid)
return sorted(set(result), reverse=True)
def relevant_process_tree(
records: Sequence[ProcessRecord],
expectation: HandshakeExpectation,
health_pid: int | None,
) -> list[dict[str, Any]]:
by_pid = process_map(records)
selected: set[int] = {expectation.launcher_pid, expectation.popen_pid}
if health_pid is not None:
selected.add(health_pid)
for record in records:
if is_descendant(record.pid, expectation.popen_pid, records):
selected.add(record.pid)
for seed in tuple(selected):
current = by_pid.get(seed)
while current is not None and current.parent_pid not in selected:
if current.parent_pid == 0:
break
selected.add(current.parent_pid)
current = by_pid.get(current.parent_pid)
return [asdict(by_pid[pid]) for pid in sorted(selected) if pid in by_pid]
def _load_module(path: Path, name: str) -> ModuleType:
specification = importlib.util.spec_from_file_location(name, path)
if specification is None or specification.loader is None:
raise ImportError(f"Could not load Python module: {path}")
module = importlib.util.module_from_spec(specification)
sys.modules[name] = module
specification.loader.exec_module(module)
return module
def run_health_child(arguments: argparse.Namespace) -> int:
agent_source = arguments.agent_source.resolve()
agent = _load_module(agent_source, f"rf001_diag_agent_{os.getpid()}")
namespace = argparse.Namespace(
health_file=arguments.health_file.resolve(),
release_id=arguments.release_id,
launch_nonce=arguments.launch_nonce,
)
child_report = arguments.child_report.resolve()
started_at = utc_now()
atomic_write_json(
child_report,
{
"schema_version": 1,
"phase": "BEFORE_HEALTH",
"pid": os.getpid(),
"parent_pid": os.getppid(),
"sys_executable": sys.executable,
"command_line": list(sys.argv),
"started_at": started_at,
"network_task_polling": False,
"hardware_accessed": NO_HARDWARE,
"rf_tx": NO_RF_TX,
},
)
agent.write_health(namespace, None)
health_written_at = utc_now()
atomic_write_json(
child_report,
{
"schema_version": 1,
"phase": "HEALTH_WRITTEN",
"pid": os.getpid(),
"parent_pid": os.getppid(),
"sys_executable": sys.executable,
"command_line": list(sys.argv),
"started_at": started_at,
"health_written_at": health_written_at,
"network_task_polling": False,
"hardware_accessed": NO_HARDWARE,
"rf_tx": NO_RF_TX,
},
)
time.sleep(arguments.hold_seconds)
return 0
def _process_is_present(pid: int, records: Sequence[ProcessRecord]) -> bool:
return any(record.pid == pid for record in records)
def _terminate_owned_processes(
expectation: HandshakeExpectation,
records: Sequence[ProcessRecord],
) -> list[dict[str, Any]]:
actions: list[dict[str, Any]] = []
for pid in strict_owned_process_ids(expectation, records):
fresh = capture_windows_processes()
current = process_map(fresh).get(pid)
original = process_map(records).get(pid)
if current is None or original is None:
continue
if current.created_at != original.created_at or not _record_matches_launch(
current, expectation
):
actions.append({"pid": pid, "status": "IDENTITY_CHANGED_NOT_TERMINATED"})
continue
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
actions.append({"pid": pid, "status": "ALREADY_EXITED"})
else:
actions.append({"pid": pid, "status": "TERMINATED"})
return actions
def run_probe_once(
launcher: ModuleType,
*,
canonical_release: Path,
output_directory: Path,
startup_timeout: float,
hold_seconds: float,
) -> dict[str, Any]:
output_directory.mkdir(parents=True, exist_ok=False)
ready = read_json(canonical_release / "READY.json")
release_id = str(ready["release_id"])
agent_version = str(ready["agent_version"])
agent_source = canonical_release / "app" / "tools" / "rf001_rx_http_agent.py"
health_path = output_directory / "diagnostic_health.json"
child_report_path = output_directory / "diagnostic_child.json"
config: dict[str, object] = {
"install_root": str(output_directory / "isolated_install_root"),
"releases_root": str(canonical_release.parent),
"active_release_file": str(output_directory / "unused_active_release.json"),
"launcher_status": str(output_directory / "unused_launcher_status.json"),
"launcher_request": str(output_directory / "unused_launcher_request.json"),
"health_file": str(health_path),
"agent_log": str(output_directory / "diagnostic_child.log"),
"startup_timeout_seconds": startup_timeout,
"agent_arguments": [],
}
captured: dict[str, Any] = {}
process_ready = threading.Event()
health_observed = threading.Event()
observer_snapshot: list[ProcessRecord] = []
original_command = launcher._agent_command
original_spawn_agent = launcher._spawn_agent
launcher_pid = os.getpid()
launched_at = utc_now()
def diagnostic_command(
_config: Mapping[str, Any],
actual_agent_source: Path,
actual_release_id: str,
launch_nonce: str,
) -> list[str]:
captured["launch_nonce"] = launch_nonce
command = [
sys.executable,
"-u",
"-B",
"-X",
"utf8",
str(Path(__file__).resolve()),
"health-child",
"--agent-source",
str(actual_agent_source),
"--release-id",
actual_release_id,
"--launch-nonce",
launch_nonce,
"--health-file",
str(health_path),
"--child-report",
str(child_report_path),
"--hold-seconds",
str(hold_seconds),
]
captured["command"] = command
return command
def recording_spawn_agent(
command: Sequence[str],
*,
cwd: Path,
log_stream: Any,
) -> subprocess.Popen[str]:
process = original_spawn_agent(command, cwd=cwd, log_stream=log_stream)
captured["process"] = process
captured["popen_pid"] = process.pid
captured["popen_created_at"] = utc_now()
process_ready.set()
return process
def observer() -> None:
nonlocal observer_snapshot
if not process_ready.wait(timeout=10):
return
deadline = time.monotonic() + max(10.0, startup_timeout + 5.0)
while time.monotonic() < deadline:
if health_path.exists() and child_report_path.exists():
observer_snapshot = capture_windows_processes()
health_observed.set()
return
time.sleep(0.02)
observer_thread = threading.Thread(target=observer, name="rf001-health-observer")
observer_thread.start()
launcher._agent_command = diagnostic_command
launcher._spawn_agent = recording_spawn_agent
start_result = "UNKNOWN"
start_error: str | None = None
returned_process: subprocess.Popen[str] | None = None
try:
returned_process, returned_version = launcher.start_release(
config,
release_id,
expected_version=agent_version,
)
start_result = "PASS"
captured["returned_version"] = returned_version
except Exception as error:
start_result = "FAIL"
start_error = f"{type(error).__name__}: {error}"
finally:
launcher._agent_command = original_command
launcher._spawn_agent = original_spawn_agent
observer_thread.join(timeout=15)
popen_pid = int(captured.get("popen_pid", -1))
launch_nonce = str(captured.get("launch_nonce", ""))
expectation = HandshakeExpectation(
release_id=release_id,
agent_version=agent_version,
launch_nonce=launch_nonce,
state=EXPECTED_STATE,
launcher_pid=launcher_pid,
popen_pid=popen_pid,
launched_at=launched_at,
diagnostic_script=str(Path(__file__).resolve()),
)
health = read_json(health_path) if health_path.exists() else None
child_report = read_json(child_report_path) if child_report_path.exists() else None
evidence_snapshot = observer_snapshot or capture_windows_processes()
health_pid = health.get("pid") if isinstance(health, dict) else None
typed_health_pid = health_pid if type(health_pid) is int else None
popen_process = captured.get("process")
popen_alive_at_evaluation = (
isinstance(popen_process, subprocess.Popen) and popen_process.poll() is None
)
health_process_alive = (
typed_health_pid is not None
and _process_is_present(typed_health_pid, evidence_snapshot)
)
evaluation = evaluate_identity(
health,
expectation,
evidence_snapshot,
popen_alive=popen_alive_at_evaluation,
health_process_alive=health_process_alive,
)
post_failure_snapshot = capture_windows_processes()
cleanup_actions = _terminate_owned_processes(expectation, post_failure_snapshot)
deadline = time.monotonic() + 5.0
final_snapshot = capture_windows_processes()
while strict_owned_process_ids(expectation, final_snapshot) and time.monotonic() < deadline:
time.sleep(0.1)
final_snapshot = capture_windows_processes()
orphan_pids = strict_owned_process_ids(expectation, final_snapshot)
health_created_at = None
if health_path.exists():
health_created_at = datetime.fromtimestamp(
health_path.stat().st_ctime,
tz=timezone.utc,
).isoformat()
report: dict[str, Any] = {
"schema_version": 1,
"mode": "ISOLATED_LAUNCHER_HEALTH_DIAGNOSTIC",
"launcher_source": str(Path(launcher.__file__).resolve()),
"canonical_release": str(canonical_release.resolve()),
"canonical_release_id": release_id,
"canonical_agent_version": agent_version,
"launcher_pid": launcher_pid,
"launcher_executable": sys.executable,
"launcher_command_line": list(sys.argv),
"popen_pid": popen_pid,
"popen_command": captured.get("command"),
"popen_created_at": captured.get("popen_created_at"),
"launch_nonce": launch_nonce,
"launched_at": launched_at,
"start_release_result": start_result,
"start_release_error": start_error,
"health_observed_before_cleanup": health_observed.is_set(),
"health_path": str(health_path.resolve()),
"health_file_created_at": health_created_at,
"health": health,
"child_report": child_report,
"field_comparison": compare_health_fields(health, expectation),
"strict_identity_evaluation": evaluation,
"process_tree_at_health": relevant_process_tree(
evidence_snapshot,
expectation,
typed_health_pid,
),
"popen_alive_at_evaluation": popen_alive_at_evaluation,
"health_process_alive_at_evaluation": health_process_alive,
"cleanup_actions": cleanup_actions,
"orphan_pids_after_cleanup": orphan_pids,
"network_task_polling": False,
"hardware_accessed": NO_HARDWARE,
"rf_tx": NO_RF_TX,
"completed_at": utc_now(),
}
atomic_write_json(output_directory / "diagnostic_report.json", report)
return report
def run_probe(arguments: argparse.Namespace) -> int:
canonical_release = arguments.canonical_release.resolve()
launcher_source = arguments.launcher_source.resolve()
output_root = arguments.output_root.resolve()
if output_root.exists():
raise FileExistsError(f"diagnostic output root already exists: {output_root}")
output_root.mkdir(parents=True)
launcher = _load_module(launcher_source, "rf001_diagnostic_launcher")
reports = []
for index in range(1, arguments.runs + 1):
reports.append(
run_probe_once(
launcher,
canonical_release=canonical_release,
output_directory=output_root / f"run_{index:02d}",
startup_timeout=arguments.startup_timeout,
hold_seconds=arguments.hold_seconds,
)
)
nonces = [str(report["launch_nonce"]) for report in reports]
summary = {
"schema_version": 1,
"mode": "ISOLATED_LAUNCHER_HEALTH_DIAGNOSTIC",
"runs": reports,
"run_count": len(reports),
"nonces_unique": len(nonces) == len(set(nonces)),
"orphan_pids_after_test": sorted(
{
pid
for report in reports
for pid in report["orphan_pids_after_cleanup"]
}
),
"network_task_polling": False,
"hardware_accessed": NO_HARDWARE,
"rf_tx": NO_RF_TX,
"completed_at": utc_now(),
}
atomic_write_json(output_root / "diagnostic_summary.json", summary)
print(f"DIAGNOSTIC_REPORT={output_root / 'diagnostic_summary.json'}")
print(f"RUNS={len(reports)}")
print(f"NONCES_UNIQUE={summary['nonces_unique']}")
print(f"ORPHAN_PIDS={summary['orphan_pids_after_test']}")
print("NETWORK_TASK_POLLING=false")
print("HARDWARE_ACCESSED=false")
print("RF_TX=0")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
child = subparsers.add_parser("health-child")
child.add_argument("--agent-source", required=True, type=Path)
child.add_argument("--release-id", required=True)
child.add_argument("--launch-nonce", required=True)
child.add_argument("--health-file", required=True, type=Path)
child.add_argument("--child-report", required=True, type=Path)
child.add_argument("--hold-seconds", required=True, type=float)
probe = subparsers.add_parser("probe")
probe.add_argument("--launcher-source", required=True, type=Path)
probe.add_argument("--canonical-release", required=True, type=Path)
probe.add_argument("--output-root", required=True, type=Path)
probe.add_argument("--runs", type=int, default=2)
probe.add_argument("--startup-timeout", type=float, default=2.0)
probe.add_argument("--hold-seconds", type=float, default=10.0)
return parser
def main() -> None:
arguments = build_parser().parse_args()
if arguments.command == "health-child":
raise SystemExit(run_health_child(arguments))
if arguments.runs < 1 or arguments.runs > 4:
raise ValueError("runs must be 1 to 4")
if arguments.startup_timeout <= 0 or arguments.hold_seconds <= 0:
raise ValueError("diagnostic timeouts must be positive")
raise SystemExit(run_probe(arguments))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,86 @@
"""Fixed software-only RF-001A decoder used by the HTTP RX agent."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
from typing import Any, Callable
Decoder = Callable[..., dict[str, Any]]
def decode_offline(
repository: Path,
iq_path: Path,
truth_path: Path,
report_path: Path,
*,
capture_started_at: str | None = None,
tx_started_at: str | None = None,
tx_finished_at: str | None = None,
decoder: Decoder | None = None,
) -> dict[str, Any]:
"""Decode one generated cs8 file with the dependency-free legacy backend."""
repository = repository.resolve()
if not repository.is_dir():
raise FileNotFoundError(f"repository is absent: {repository}")
for required in (iq_path, truth_path):
if not required.is_file():
raise FileNotFoundError(f"required input is absent: {required}")
if report_path.exists():
raise FileExistsError(f"report already exists: {report_path}")
if decoder is None:
sys.path.insert(0, str(repository))
from tools.qpsk_rf_rx import decode_file
decoder = decode_file
result = decoder(
iq_path,
iq_format="cs8",
truth_metadata_path=truth_path,
decoder_backend="legacy",
capture_started_at=capture_started_at,
tx_started_at=tx_started_at,
tx_finished_at=tx_finished_at,
)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return result
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", required=True, type=Path)
parser.add_argument("--iq-file", required=True, type=Path)
parser.add_argument("--truth-metadata", required=True, type=Path)
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--capture-started-at")
parser.add_argument("--tx-started-at")
parser.add_argument("--tx-finished-at")
arguments = parser.parse_args()
result = decode_offline(
arguments.repository,
arguments.iq_file,
arguments.truth_metadata,
arguments.report,
capture_started_at=arguments.capture_started_at,
tx_started_at=arguments.tx_started_at,
tx_finished_at=arguments.tx_finished_at,
)
print(
"RF-001 OFFLINE RX: "
f"CRC={result['crc_valid_packets']}; "
f"exact={result['exact_packets']}; RF TX=0"
)
if __name__ == "__main__":
main()

684
tools/rf001_rx_capture.py Normal file
View File

@@ -0,0 +1,684 @@
"""Fixed, receive-only RF-001A capture operations for the HTTP agent.
This module has no transmit operation. A capture uses one immutable RF profile,
one known RX serial number, a unique directory outside Git, and one finite
``hackrf_transfer`` child process. Failed readiness checks stop that exact
process tree and never retry the capture.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import hashlib
import json
import math
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
from typing import Any, Callable, Mapping, Sequence
FIXED_RX_SERIAL = "000000000000000097c063dc2e5327d3"
PROFILE_NAME = "RF001A_COMMON_CLOCK_001"
MINIMUM_FREE_BYTES = 6_000_000_000
READINESS_TIMEOUT_SECONDS = 5.0
CAPTURE_SECONDS = 300
CAPTURE_SAMPLES = 2_400_000_000
CAPTURE_FILE_NAME = "rf001a_ota_capture.cs8"
MANIFEST_FILE_NAME = "capture_manifest.json"
STARTED_FILE_NAME = "capture_started.json"
RESULT_FILE_NAME = "capture_result.json"
REPORT_FILE_NAME = "offline_decode_report.json"
MAX_REPORT_BYTES = 192 * 1024
EXPECTED_PROFILE: dict[str, object] = {
"test_id": PROFILE_NAME,
"center_frequency_hz": 1_400_000_000,
"sample_rate_hz": 8_000_000,
"symbol_rate_sps": 500_000,
"modulation": "QPSK",
"inner_fec": "2/3",
"application_payload_bytes": 256,
"packet_count": 100,
"inter_packet_gap_ms": 5,
"clock_mode": "COMMON_10_MHZ",
"rx_lna_db": 24,
"rx_vga_db": 24,
"rf_amp_enabled": False,
"antenna_bias_enabled": False,
"hardware_format": "cs8",
"rx_capture_seconds": 20,
"rx_capture_samples": 160_000_000,
}
CommandRunner = Callable[[Sequence[str], float], Any]
BackgroundLauncher = Callable[[Sequence[str]], int]
ProcessGate = Callable[[], tuple[bool, Mapping[str, object]]]
ProcessStopper = Callable[[int], None]
def _atomic_json(path: Path, value: Mapping[str, object]) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def _read_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(value, dict):
raise ValueError(f"JSON object required: {path}")
return value
def load_fixed_profile(repository: Path) -> dict[str, object]:
path = repository / "configs" / "rf" / "rf001a_common_clock.json"
profile = _read_json(path)
mismatches = {
key: {"expected": expected, "actual": profile.get(key)}
for key, expected in EXPECTED_PROFILE.items()
if profile.get(key) != expected
}
if mismatches:
raise ValueError(f"RF-001A capture profile mismatch: {mismatches}")
return {key: profile[key] for key in EXPECTED_PROFILE}
def resolve_hackrf_tools(
hackrf_info_path: Path | None,
hackrf_clock_path: Path | None,
hackrf_transfer_path: Path | None,
) -> tuple[Path, Path, Path]:
if hackrf_info_path is None:
raise FileNotFoundError("hackrf_info path is required for RX capture")
base = hackrf_info_path.parent
info = hackrf_info_path
clock = hackrf_clock_path or base / "hackrf_clock.exe"
transfer = hackrf_transfer_path or base / "hackrf_transfer.exe"
for path in (info, clock, transfer):
if not path.is_file():
raise FileNotFoundError(f"required HackRF tool is absent: {path}")
return info, clock, transfer
def default_process_gate() -> tuple[bool, Mapping[str, object]]:
from tools.hackrf_process_check import ProcessState, check_processes
check = check_processes()
return check.state is ProcessState.CLEAR, check.to_dict()
def default_background_launcher(arguments: Sequence[str]) -> int:
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
subprocess, "CREATE_NO_WINDOW", 0
)
process = subprocess.Popen(
list(arguments),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=flags,
close_fds=True,
)
return int(process.pid)
def default_process_stopper(pid: int) -> None:
completed = subprocess.run(
["taskkill.exe", "/PID", str(pid), "/T", "/F"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if completed.returncode not in {0, 128}:
raise RuntimeError(
f"taskkill failed for pid {pid}: {completed.returncode}: "
f"{completed.stderr.strip()}"
)
def _iso_utc(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("timestamp must contain a timezone")
return parsed.astimezone(timezone.utc)
def _bounded_command(result: Any) -> dict[str, object]:
return {
"arguments": list(result.arguments),
"returncode": int(result.returncode),
"stdout": str(result.stdout)[-8_000:],
"stderr": str(result.stderr)[-8_000:],
}
def start_rx_capture(
repository: Path,
work_root: Path,
session_id: str,
rx_serial: str,
*,
hackrf_info_path: Path | None,
hackrf_clock_path: Path | None,
hackrf_transfer_path: Path | None,
command_runner: CommandRunner,
process_gate: ProcessGate = default_process_gate,
background_launcher: BackgroundLauncher = default_background_launcher,
process_stopper: ProcessStopper = default_process_stopper,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> tuple[dict[str, object], list[str]]:
"""Start one finite RX capture and return only after the READY gate."""
errors: list[str] = []
result: dict[str, object] = {
"session_id": session_id,
"state": "NOT_READY",
"hardware_accessed": False,
"common_clock_verified": False,
"rf_rx": 0,
"rf_tx": 0,
"automatic_retry": False,
}
runner_pid: int | None = None
child_pid: int | None = None
try:
if rx_serial != FIXED_RX_SERIAL:
raise ValueError("RX serial does not match the fixed RF-001 receiver")
profile = load_fixed_profile(repository)
info_path, clock_path, transfer_path = resolve_hackrf_tools(
hackrf_info_path,
hackrf_clock_path,
hackrf_transfer_path,
)
session_directory = work_root / "rx_capture" / session_id
session_directory.parent.mkdir(parents=True, exist_ok=True)
if session_directory.exists():
raise FileExistsError(f"capture session already exists: {session_directory}")
free_bytes = shutil.disk_usage(session_directory.parent).free
result["free_bytes_before"] = free_bytes
if free_bytes < MINIMUM_FREE_BYTES:
raise RuntimeError(
f"capture volume has {free_bytes} free bytes; "
f"{MINIMUM_FREE_BYTES} required"
)
clear, process_evidence = process_gate()
result["process_gate"] = dict(process_evidence)
if not clear:
raise RuntimeError("conflicting SDR process gate is not CLEAR")
info = command_runner([str(info_path)], 20.0)
result["hackrf_info"] = _bounded_command(info)
result["hardware_accessed"] = True
if info.returncode != 0 or rx_serial.lower() not in (
f"{info.stdout}\n{info.stderr}".lower()
):
raise RuntimeError("hackrf_info did not confirm the fixed RX serial")
clock = command_runner([str(clock_path), "-d", rx_serial, "-i"], 20.0)
result["hackrf_clock"] = _bounded_command(clock)
clock_lines = {
line.strip().lower()
for line in f"{clock.stdout}\n{clock.stderr}".splitlines()
}
if clock.returncode != 0 or not any(
line.endswith("clkin status: clock signal detected")
for line in clock_lines
):
raise RuntimeError("CLKIN clock signal was not detected")
result["common_clock_verified"] = True
session_directory.mkdir()
capture_path = session_directory / CAPTURE_FILE_NAME
arguments_path = session_directory / "hackrf_transfer_arguments.json"
stdout_path = session_directory / "hackrf_transfer.stdout.txt"
stderr_path = session_directory / "hackrf_transfer.stderr.txt"
started_path = session_directory / STARTED_FILE_NAME
final_path = session_directory / RESULT_FILE_NAME
manifest_path = session_directory / MANIFEST_FILE_NAME
runner_path = Path(__file__).resolve().with_name("rf001_capture_runner.py")
if not runner_path.is_file():
raise FileNotFoundError(f"capture runner is absent: {runner_path}")
transfer_arguments = [
"-d", rx_serial,
"-r", str(capture_path),
"-f", str(profile["center_frequency_hz"]),
"-s", str(profile["sample_rate_hz"]),
"-n", str(CAPTURE_SAMPLES),
"-l", str(profile["rx_lna_db"]),
"-g", str(profile["rx_vga_db"]),
"-a", "0",
"-p", "0",
"-B",
]
arguments_path.write_text(
json.dumps(transfer_arguments, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
launch_arguments = [
sys.executable,
"-B",
"-X",
"utf8",
str(runner_path),
"--executable",
str(transfer_path),
"--arguments-json",
str(arguments_path),
"--capture-path",
str(capture_path),
"--stdout-path",
str(stdout_path),
"--stderr-path",
str(stderr_path),
"--started-status",
str(started_path),
"--result-status",
str(final_path),
]
runner_pid = background_launcher(launch_arguments)
result["runner_pid"] = runner_pid
result["rf_rx"] = 1
deadline = monotonic() + READINESS_TIMEOUT_SECONDS
first_positive_size: int | None = None
latest_size = 0
started: dict[str, Any] | None = None
while monotonic() < deadline:
if final_path.is_file():
final = _read_json(final_path)
raise RuntimeError(f"capture exited before READY: {final}")
if started_path.is_file():
started = _read_json(started_path)
raw_pid = started.get("pid")
if type(raw_pid) is int and raw_pid > 0:
child_pid = raw_pid
if capture_path.is_file():
latest_size = capture_path.stat().st_size
if latest_size > 0:
if first_positive_size is None:
first_positive_size = latest_size
elif latest_size > first_positive_size and started is not None:
break
sleep(0.2)
else:
raise RuntimeError("capture did not become READY within five seconds")
if started is None or child_pid is None or first_positive_size is None:
raise RuntimeError("capture readiness evidence is incomplete")
capture_started_at = str(started.get("started_at", ""))
expected_end = _iso_utc(capture_started_at) + timedelta(
seconds=CAPTURE_SECONDS
)
expected_bytes = CAPTURE_SAMPLES * 2
manifest: dict[str, object] = {
"schema_version": 1,
"session_id": session_id,
"profile": profile,
"capture_seconds": CAPTURE_SECONDS,
"capture_samples": CAPTURE_SAMPLES,
"capture_duration_reason": "manual AUTHORIZE RF TX gate",
"rx_serial": rx_serial,
"capture_path": str(capture_path),
"capture_started_at": capture_started_at,
"expected_capture_end": expected_end.isoformat(),
"expected_capture_bytes": expected_bytes,
"runner_pid": runner_pid,
"child_pid": child_pid,
"started_status_path": str(started_path),
"result_status_path": str(final_path),
"stdout_path": str(stdout_path),
"stderr_path": str(stderr_path),
"common_clock_verified": True,
"automatic_retry": False,
"rf_rx": 1,
"rf_tx": 0,
}
_atomic_json(manifest_path, manifest)
result.update(
{
"state": "READY",
"session_directory": str(session_directory),
"manifest_path": str(manifest_path),
"capture_path": str(capture_path),
"capture_started_at": capture_started_at,
"expected_capture_end": expected_end.isoformat(),
"expected_capture_bytes": expected_bytes,
"child_pid": child_pid,
"capture_bytes_at_ready": latest_size,
"common_clock_verified": True,
}
)
except (
ImportError,
OSError,
ValueError,
RuntimeError,
subprocess.SubprocessError,
) as error:
errors.append(f"{type(error).__name__}: {error}")
for pid in (child_pid, runner_pid):
if pid is None:
continue
try:
process_stopper(pid)
except (OSError, RuntimeError, subprocess.SubprocessError) as stop_error:
errors.append(
f"failed to stop pid {pid}: {type(stop_error).__name__}: "
f"{stop_error}"
)
result["rf_rx"] = 0
return result, errors
def read_capture_status(
work_root: Path,
session_id: str,
) -> tuple[dict[str, object], list[str]]:
errors: list[str] = []
result: dict[str, object] = {
"session_id": session_id,
"hardware_accessed": False,
"rf_tx": 0,
}
try:
session_directory = work_root / "rx_capture" / session_id
manifest = _read_json(session_directory / MANIFEST_FILE_NAME)
if manifest.get("session_id") != session_id or manifest.get("rf_tx") != 0:
raise ValueError("capture manifest does not match the requested session")
capture_path = Path(str(manifest["capture_path"]))
final_path = Path(str(manifest["result_status_path"]))
resolved_session = session_directory.resolve()
for path in (capture_path, final_path):
try:
path.resolve().relative_to(resolved_session)
except ValueError as error:
raise ValueError("capture manifest path escapes the session") from error
expected_bytes = int(manifest["expected_capture_bytes"])
current_bytes = capture_path.stat().st_size if capture_path.is_file() else 0
result.update(
{
"manifest_path": str(session_directory / MANIFEST_FILE_NAME),
"capture_path": str(capture_path),
"capture_started_at": manifest.get("capture_started_at"),
"expected_capture_end": manifest.get("expected_capture_end"),
"expected_capture_bytes": expected_bytes,
"capture_bytes": current_bytes,
"common_clock_verified": manifest.get("common_clock_verified"),
}
)
expected_end = _iso_utc(str(manifest["expected_capture_end"]))
result["capture_window_open"] = datetime.now(timezone.utc) < expected_end
if final_path.is_file():
final = _read_json(final_path)
result["runner_result"] = final
complete = (
final.get("exit_code") == 0
and final.get("runner_error") in {None, ""}
and final.get("capture_size") == expected_bytes
and current_bytes == expected_bytes
)
result["state"] = "CAPTURE_COMPLETE" if complete else "CAPTURE_FAILED"
if not complete:
errors.append("finite RX capture did not meet the complete-file gate")
else:
result["state"] = "CAPTURE_RUNNING"
except (OSError, ValueError, KeyError, TypeError) as error:
errors.append(f"{type(error).__name__}: {error}")
result["state"] = "STATUS_FAILED"
return result, errors
def validate_tx_window(
capture_started_at: str,
tx_started_at: str,
tx_finished_at: str,
) -> None:
capture = _iso_utc(capture_started_at)
started = _iso_utc(tx_started_at)
finished = _iso_utc(tx_finished_at)
if not capture <= started <= finished:
raise ValueError("TX timestamps are outside the ordered capture window")
def decode_completed_capture(
repository: Path,
work_root: Path,
session_id: str,
tx_started_at: str,
tx_finished_at: str,
*,
command_runner: CommandRunner,
) -> tuple[dict[str, object], list[str]]:
errors: list[str] = []
result: dict[str, object] = {
"session_id": session_id,
"mode": "OFFLINE_CAPTURE_DECODE",
"hardware_accessed": False,
"rf_tx": 0,
}
try:
status, status_errors = read_capture_status(work_root, session_id)
if status_errors or status.get("state") != "CAPTURE_COMPLETE":
raise RuntimeError(f"capture is not complete: {status_errors or status}")
capture_started_at = str(status["capture_started_at"])
validate_tx_window(capture_started_at, tx_started_at, tx_finished_at)
session_directory = work_root / "rx_capture" / session_id
report_path = session_directory / "offline_decode_report.json"
if report_path.exists():
raise FileExistsError(f"decode report already exists: {report_path}")
decoder_script = Path(__file__).resolve().with_name("rf001_offline_decode.py")
truth_path = repository / "configs" / "rf" / "rf001a_truth_metadata.json"
decode = command_runner(
[
os.fspath(Path(sys.executable)),
"-B",
"-X",
"utf8",
str(decoder_script),
"--repository",
str(repository),
"--iq-file",
str(status["capture_path"]),
"--truth-metadata",
str(truth_path),
"--report",
str(report_path),
"--capture-started-at",
capture_started_at,
"--tx-started-at",
tx_started_at,
"--tx-finished-at",
tx_finished_at,
],
1_800.0,
)
result["decode"] = _bounded_command(decode)
if decode.returncode != 0:
raise RuntimeError(f"offline capture decoder exited with {decode.returncode}")
report = _read_json(report_path)
diagnostics = read_capture_diagnostics(
work_root,
session_id,
tx_started_at,
tx_finished_at,
)
if diagnostics[1]:
raise RuntimeError(
f"capture diagnostics failed: {diagnostics[1]}"
)
result.update(
{
"report_path": str(report_path),
"crc_valid_packets": report.get("crc_valid_packets"),
"exact_packets": report.get("exact_packets"),
"peak_analysis_memory_bytes": report.get("peak_analysis_memory_bytes"),
"window_source": report.get("window_source"),
"diagnostics": diagnostics[0],
}
)
except (OSError, ValueError, RuntimeError, KeyError, TypeError) as error:
errors.append(f"{type(error).__name__}: {error}")
return result, errors
def _power_metrics(
capture_path: Path,
start_sample: int,
stop_sample: int,
*,
chunk_samples: int = 1_000_000,
) -> dict[str, object]:
"""Measure one fixed cs8 window without loading it all into memory."""
import numpy as np
total_samples = capture_path.stat().st_size // 2
if not 0 <= start_sample < stop_sample <= total_samples:
raise ValueError("diagnostic window is outside the cs8 capture")
raw = np.memmap(capture_path, dtype=np.int8, mode="r")
power_sum = 0.0
i_sum = 0.0
q_sum = 0.0
rail_codes = 0
peak_component = 0
for chunk_start in range(start_sample, stop_sample, chunk_samples):
chunk_stop = min(stop_sample, chunk_start + chunk_samples)
values = np.asarray(
raw[chunk_start * 2:chunk_stop * 2],
dtype=np.int8,
).reshape(-1, 2)
wide = values.astype(np.float64)
i_values = wide[:, 0]
q_values = wide[:, 1]
power_sum += float(np.sum(i_values * i_values + q_values * q_values))
i_sum += float(np.sum(i_values))
q_sum += float(np.sum(q_values))
rail_codes += int(np.count_nonzero((values == -128) | (values == 127)))
peak_component = max(peak_component, int(np.max(np.abs(wide))))
sample_count = stop_sample - start_sample
mean_power = power_sum / sample_count
return {
"start_sample": start_sample,
"stop_sample": stop_sample,
"sample_count": sample_count,
"mean_power_code_squared": mean_power,
"rms_complex_normalized": math.sqrt(mean_power) / 128.0,
"mean_i_code": i_sum / sample_count,
"mean_q_code": q_sum / sample_count,
"peak_component_code": peak_component,
"rail_codes": rail_codes,
}
def read_capture_diagnostics(
work_root: Path,
session_id: str,
tx_started_at: str,
tx_finished_at: str,
) -> tuple[dict[str, object], list[str]]:
"""Return the fixed report and signal metrics for one completed capture."""
errors: list[str] = []
result: dict[str, object] = {
"session_id": session_id,
"mode": "OFFLINE_CAPTURE_DIAGNOSTICS",
"hardware_accessed": False,
"rf_tx": 0,
}
try:
status, status_errors = read_capture_status(work_root, session_id)
if status_errors or status.get("state") != "CAPTURE_COMPLETE":
raise RuntimeError(f"capture is not complete: {status_errors or status}")
capture_started_at = str(status["capture_started_at"])
validate_tx_window(capture_started_at, tx_started_at, tx_finished_at)
capture_path = Path(str(status["capture_path"]))
session_directory = work_root / "rx_capture" / session_id
report_path = session_directory / REPORT_FILE_NAME
if not report_path.is_file():
raise FileNotFoundError(f"decode report is absent: {report_path}")
report_size = report_path.stat().st_size
if report_size <= 0 or report_size > MAX_REPORT_BYTES:
raise ValueError(
f"decode report size {report_size} is outside the allowed range"
)
report_bytes = report_path.read_bytes()
report = json.loads(report_bytes.decode("utf-8-sig"))
if not isinstance(report, dict) or report.get("rf_tx") != 0:
raise ValueError("decode report is invalid or does not preserve rf_tx = 0")
runner_result = status.get("runner_result")
if not isinstance(runner_result, dict):
raise ValueError("capture result is absent")
capture_sha256 = str(runner_result.get("sha256", ""))
if report.get("iq_sha256") != capture_sha256:
raise ValueError("decode report does not match the capture SHA-256")
sample_rate = int(EXPECTED_PROFILE["sample_rate_hz"])
total_samples = capture_path.stat().st_size // 2
capture_time = _iso_utc(capture_started_at)
tx_start = _iso_utc(tx_started_at)
tx_finish = _iso_utc(tx_finished_at)
tx_start_sample = round((tx_start - capture_time).total_seconds() * sample_rate)
tx_stop_sample = round((tx_finish - capture_time).total_seconds() * sample_rate)
tx_start_sample = max(0, min(total_samples, tx_start_sample))
tx_stop_sample = max(0, min(total_samples, tx_stop_sample))
if tx_stop_sample <= tx_start_sample:
raise ValueError("TX window does not overlap the capture")
tx_samples = tx_stop_sample - tx_start_sample
pre_start = max(0, tx_start_sample - tx_samples)
post_stop = min(total_samples, tx_stop_sample + tx_samples)
signal = _power_metrics(capture_path, tx_start_sample, tx_stop_sample)
pre = _power_metrics(capture_path, pre_start, tx_start_sample)
post = _power_metrics(capture_path, tx_stop_sample, post_stop)
baseline_power = (
float(pre["mean_power_code_squared"])
+ float(post["mean_power_code_squared"])
) / 2.0
signal_power = float(signal["mean_power_code_squared"])
power_ratio = signal_power / baseline_power if baseline_power > 0 else None
power_delta_db = (
10.0 * math.log10(power_ratio)
if power_ratio is not None and power_ratio > 0
else None
)
result.update(
{
"report_path": str(report_path),
"report_bytes": report_size,
"report_sha256": hashlib.sha256(report_bytes).hexdigest(),
"capture_sha256": capture_sha256,
"report": report,
"signal_metrics": {
"tx_window": signal,
"pre_tx_baseline": pre,
"post_tx_baseline": post,
"tx_to_baseline_power_ratio": power_ratio,
"tx_to_baseline_db": power_delta_db,
},
}
)
except (
ImportError,
OSError,
ValueError,
RuntimeError,
KeyError,
TypeError,
) as error:
errors.append(f"{type(error).__name__}: {error}")
return result, errors

1529
tools/rf001_rx_http_agent.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
"""Detached software-only worker for one fixed STAIRCASE_SEARCH task."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tools.rf001_http_control import PROTOCOL_VERSION, STAIRCASE_SEARCH_TASK, utc_now
from tools.rf001_rx_http_agent import run_staircase_search
def _atomic_write(path: Path, value: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
+ "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def run_job(spec_path: Path, result_path: Path) -> dict[str, object]:
spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
if not isinstance(spec, dict):
raise ValueError("job spec must be a JSON object")
task = spec.get("task")
if not isinstance(task, dict) or task.get("kind") != STAIRCASE_SEARCH_TASK:
raise ValueError("job spec does not contain STAIRCASE_SEARCH")
started_at = utc_now()
task_result, errors = run_staircase_search(
Path(str(spec["work_root"])),
str(task["session_id"]),
str(task["expected_rx_sha256"]),
str(task["expected_reference_sha256"]),
progress_path=Path(str(spec["progress_path"])),
)
result = {
"protocol_version": PROTOCOL_VERSION,
"task_id": str(task["task_id"]),
"agent_id": str(spec["agent_id"]),
"status": "COMPLETE" if not errors else "FAILED",
"started_at": started_at,
"finished_at": utc_now(),
"result": task_result,
"errors": errors,
"rf_tx": 0,
}
_atomic_write(result_path, result)
return result
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--job-spec", required=True, type=Path)
parser.add_argument("--result", required=True, type=Path)
arguments = parser.parse_args()
result = run_job(arguments.job_spec, arguments.result)
print(f"TASK ID = {result['task_id']}")
print(f"TASK STATUS = {result['status']}")
print("HARDWARE ACCESSED = false")
print("RF TX = 0")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,937 @@
"""Потоковый поиск эталонной лестницы Lab064/Lab065 в большом cs8-файле.
Утилита не обращается к SDR, не использует пакетный приёмник и не меняет
исходные IQ-файлы. Полная запись просматривается блоками с перекрытием.
"""
from __future__ import annotations
import argparse
import ctypes
from dataclasses import asdict, dataclass
import hashlib
import json
import math
import os
from pathlib import Path
import threading
import time
from typing import Callable, Iterable
import numpy as np
from scipy import signal
EXPECTED_REFERENCE_SHA256 = (
"fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
)
ANALYZER_VERSION = "2026.09.28.1"
@dataclass(frozen=True)
class ReferenceLayout:
sample_rate_hz: int = 8_000_000
symbol_rate: int = 20_000
carrier_hz: float = 50_000.0
leading_samples: int = 800_000
segment_samples: int = 800_000
gap_samples: int = 400_000
trailing_samples: int = 800_000
levels_db: tuple[float, ...] = (-18.0, -12.0, -6.0, 0.0)
@property
def total_samples(self) -> int:
return (
self.leading_samples
+ len(self.levels_db) * self.segment_samples
+ (len(self.levels_db) - 1) * self.gap_samples
+ self.trailing_samples
)
def active_windows(self) -> tuple[tuple[int, int], ...]:
cursor = self.leading_samples
windows = []
for index in range(len(self.levels_db)):
windows.append((cursor, cursor + self.segment_samples))
cursor += self.segment_samples
if index + 1 < len(self.levels_db):
cursor += self.gap_samples
return tuple(windows)
def background_windows(self) -> tuple[tuple[int, int], ...]:
width = min(self.gap_samples, self.leading_samples, self.trailing_samples) // 2
cursor = (self.leading_samples - width) // 2
windows = [(cursor, cursor + width)]
cursor = self.leading_samples + self.segment_samples
for index in range(len(self.levels_db) - 1):
start = cursor + (self.gap_samples - width) // 2
windows.append((start, start + width))
cursor += self.gap_samples + self.segment_samples
final_start = self.total_samples - self.trailing_samples
start = final_start + (self.trailing_samples - width) // 2
windows.append((start, start + width))
return tuple(windows)
@dataclass(frozen=True)
class SearchCriteria:
decimation: int = 32
input_block_samples: int = 4_000_000
anti_alias_passband_hz: float = 75_000.0
anti_alias_stopband_hz: float = 120_000.0
anti_alias_attenuation_db: float = 80.0
feature_period_seconds: float = 0.001
refinement_radius_seconds: float = 0.003
cfo_min_hz: float = -3.0
cfo_max_hz: float = 3.0
cfo_step_hz: float = 0.25
normalized_correlation_minimum: float = 0.05
peak_to_background_minimum_db: float = 6.0
maximum_step_error_db: float = 3.0
l3_matched_to_residual_minimum_db: float = -18.0
l3_spectral_contrast_minimum_db: float = 3.0
histogram_stride: int = 1024
maximum_reported_candidates: int = 5
DEFAULT_LAYOUT = ReferenceLayout()
DEFAULT_CRITERIA = SearchCriteria()
def current_process_rss_bytes() -> int:
if not hasattr(ctypes, "windll"):
return 0
class ProcessMemoryCounters(ctypes.Structure):
_fields_ = [
("cb", ctypes.c_ulong),
("page_fault_count", ctypes.c_ulong),
("peak_working_set_size", ctypes.c_size_t),
("working_set_size", ctypes.c_size_t),
("quota_peak_paged_pool_usage", ctypes.c_size_t),
("quota_paged_pool_usage", ctypes.c_size_t),
("quota_peak_non_paged_pool_usage", ctypes.c_size_t),
("quota_non_paged_pool_usage", ctypes.c_size_t),
("pagefile_usage", ctypes.c_size_t),
("peak_pagefile_usage", ctypes.c_size_t),
]
counters = ProcessMemoryCounters()
counters.cb = ctypes.sizeof(counters)
get_current_process = ctypes.windll.kernel32.GetCurrentProcess # type: ignore[attr-defined]
get_current_process.restype = ctypes.c_void_p
get_process_memory_info = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined]
get_process_memory_info.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(ProcessMemoryCounters),
ctypes.c_ulong,
]
get_process_memory_info.restype = ctypes.c_int
succeeded = get_process_memory_info(
get_current_process(),
ctypes.byref(counters),
counters.cb,
)
return int(counters.working_set_size) if succeeded else 0
class MemoryPeakSampler:
def __init__(self, poll_seconds: float = 0.02) -> None:
self.poll_seconds = poll_seconds
self.baseline_bytes = current_process_rss_bytes()
self.peak_bytes = self.baseline_bytes
self._stop = threading.Event()
self._thread = threading.Thread(target=self._sample, daemon=True)
def _sample(self) -> None:
while not self._stop.wait(self.poll_seconds):
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
def __enter__(self) -> "MemoryPeakSampler":
self._thread.start()
return self
def __exit__(self, *_exc: object) -> None:
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
self._stop.set()
self._thread.join(timeout=1.0)
@property
def additional_peak_bytes(self) -> int:
return max(0, self.peak_bytes - self.baseline_bytes)
def _write_progress(
path: Path,
*,
phase: str,
processed_samples: int,
total_samples: int,
) -> None:
value = {
"schema_version": 1,
"phase": phase,
"processed_samples": processed_samples,
"total_samples": total_samples,
"coverage_complete": (
phase == "COMPLETE" and processed_samples == total_samples
),
"updated_at_epoch": time.time(),
"hardware_accessed": False,
"rf_tx": 0,
}
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(value, separators=(",", ":"), sort_keys=True) + "\n",
encoding="utf-8",
)
os.replace(temporary, path)
def sha256_file(
path: Path,
progress: Callable[[int, int], None] | None = None,
) -> str:
digest = hashlib.sha256()
total = path.stat().st_size
processed = 0
with path.open("rb") as stream:
while block := stream.read(4 * 1024 * 1024):
digest.update(block)
processed += len(block)
if progress is not None:
progress(processed, total)
return digest.hexdigest()
def cs8_sample_count(path: Path) -> int:
size = path.stat().st_size
if size <= 0 or size % 2:
raise ValueError("cs8-файл должен содержать целое ненулевое число пар I/Q")
return size // 2
def design_antialias_filter(
layout: ReferenceLayout,
criteria: SearchCriteria,
) -> np.ndarray:
"""Build the fixed linear-phase FIR used before every decimation."""
if criteria.decimation <= 0:
raise ValueError("decimation must be positive")
if criteria.decimation == 1:
return np.ones(1, dtype=np.float64)
nyquist = layout.sample_rate_hz / 2
decimated_nyquist = layout.sample_rate_hz / (2 * criteria.decimation)
if not (
0 < criteria.anti_alias_passband_hz
< criteria.anti_alias_stopband_hz
< decimated_nyquist
):
raise ValueError("anti-alias passband and stopband are invalid")
transition_width = (
criteria.anti_alias_stopband_hz - criteria.anti_alias_passband_hz
) / nyquist
taps_count, beta = signal.kaiserord(
criteria.anti_alias_attenuation_db,
transition_width,
)
taps_count = max(3, taps_count | 1)
cutoff_hz = (
criteria.anti_alias_passband_hz + criteria.anti_alias_stopband_hz
) / 2
return signal.firwin(
taps_count,
cutoff_hz,
window=("kaiser", beta),
fs=layout.sample_rate_hz,
).astype(np.float64)
def _read_cs8_complex(path: Path, start: int, stop: int) -> np.ndarray:
"""Read one bounded source interval; out-of-file samples are zero."""
if start >= stop:
raise ValueError("invalid source interval")
total = cs8_sample_count(path)
values = np.zeros(stop - start, dtype=np.complex64)
clipped_start = max(0, start)
clipped_stop = min(total, stop)
if clipped_start >= clipped_stop:
return values
raw = np.memmap(path, dtype=np.int8, mode="r")
interleaved = np.asarray(
raw[2 * clipped_start:2 * clipped_stop],
dtype=np.float32,
)
destination = slice(clipped_start - start, clipped_stop - start)
values[destination] = interleaved[0::2] + 1j * interleaved[1::2]
return values
def read_filtered_decimated_window(
path: Path,
start: int,
stop: int,
layout: ReferenceLayout,
criteria: SearchCriteria,
taps: np.ndarray,
) -> np.ndarray:
"""Read a decimated window with FIR overlap and delay correction.
Output index ``k`` is centred on input sample ``k * decimation``. The
function reads half the FIR on both sides, so adjacent blocks have exactly
the overlap required to match one full-file convolution. Zeros are used
only outside the physical file.
"""
total = cs8_sample_count(path)
total_decimated = math.ceil(total / criteria.decimation)
if not 0 <= start < stop <= total_decimated:
raise ValueError("invalid filtered-decimated window")
delay = (taps.size - 1) // 2
first_center = start * criteria.decimation
last_center = (stop - 1) * criteria.decimation
source_start = first_center - delay
source_stop = last_center + delay + 1
values = _read_cs8_complex(path, source_start, source_stop)
if taps.size == 1:
return values[::criteria.decimation].astype(np.complex64, copy=False)
filtered = signal.fftconvolve(values, taps, mode="valid")
result = filtered[::criteria.decimation]
if result.size != stop - start:
raise RuntimeError("filtered decimator produced an unexpected length")
return result.astype(np.complex64, copy=False)
def iter_filtered_decimated(
path: Path,
layout: ReferenceLayout,
criteria: SearchCriteria,
taps: np.ndarray,
) -> Iterable[tuple[int, np.ndarray]]:
"""Yield the full file in bounded, boundary-correct decimated blocks."""
if criteria.input_block_samples <= 0:
raise ValueError("input block size must be positive")
total = cs8_sample_count(path)
total_decimated = math.ceil(total / criteria.decimation)
block = max(1, criteria.input_block_samples // criteria.decimation)
for start in range(0, total_decimated, block):
stop = min(total_decimated, start + block)
yield start, read_filtered_decimated_window(
path,
start,
stop,
layout,
criteria,
taps,
)
def load_reference(
path: Path,
layout: ReferenceLayout,
criteria: SearchCriteria,
*,
expected_sha256: str | None,
) -> tuple[np.ndarray, str]:
if cs8_sample_count(path) != layout.total_samples:
raise ValueError("размер эталона не соответствует фиксированной структуре")
digest = sha256_file(path)
if expected_sha256 is not None and digest != expected_sha256:
raise ValueError("SHA256 эталона не совпадает с разрешённым значением")
stop = math.ceil(layout.total_samples / criteria.decimation)
taps = design_antialias_filter(layout, criteria)
reference = read_filtered_decimated_window(
path,
0,
stop,
layout,
criteria,
taps,
)
reference -= np.mean(reference, dtype=np.complex128)
return reference, digest
def _frequency_grid(criteria: SearchCriteria) -> np.ndarray:
count = round(
(criteria.cfo_max_hz - criteria.cfo_min_hz) / criteria.cfo_step_hz
)
return criteria.cfo_min_hz + criteria.cfo_step_hz * np.arange(count + 1)
def _histogram_quantile(histogram: np.ndarray, quantile: float) -> float:
total = int(histogram.sum())
if total == 0:
return 0.0
target = math.ceil(total * quantile)
index = int(np.searchsorted(np.cumsum(histogram), target, side="left"))
return min(1.0, (index + 0.5) / histogram.size)
def _deduplicate_candidates(
candidates: Iterable[dict[str, float | int]],
minimum_separation: int,
limit: int,
) -> list[dict[str, float | int]]:
selected: list[dict[str, float | int]] = []
for candidate in sorted(candidates, key=lambda row: float(row["score"]), reverse=True):
start = int(candidate["start_decimated_sample"])
if any(
abs(start - int(existing["start_decimated_sample"])) < minimum_separation
for existing in selected
):
continue
selected.append(candidate)
if len(selected) == limit:
break
return selected
def _power_features(values: np.ndarray, stride: int) -> np.ndarray:
complete = values.size // stride
if complete == 0:
return np.empty(0, dtype=np.float64)
reshaped = values[:complete * stride].reshape(complete, stride)
return np.mean(np.abs(reshaped) ** 2, axis=1, dtype=np.float64)
def _normalized_sliding_scores(
values: np.ndarray,
template: np.ndarray,
) -> np.ndarray:
centred = template.astype(np.float64) - float(np.mean(template))
template_energy = float(np.vdot(centred, centred).real)
count = centred.size
prefix = np.concatenate(([0.0], np.cumsum(values, dtype=np.float64)))
prefix_power = np.concatenate(
([0.0], np.cumsum(values * values, dtype=np.float64))
)
window_sum = prefix[count:] - prefix[:-count]
window_power = prefix_power[count:] - prefix_power[:-count]
ac_power = window_power - (window_sum * window_sum) / count
denominator = np.sqrt(np.maximum(template_energy * ac_power, 1e-30))
correlation = signal.fftconvolve(values, centred[::-1], mode="valid")
return np.abs(correlation) / denominator
def _refine_candidate(
capture_path: Path,
reference: np.ndarray,
approximate_start: int,
layout: ReferenceLayout,
criteria: SearchCriteria,
taps: np.ndarray,
) -> dict[str, float | int]:
decimated_rate = layout.sample_rate_hz / criteria.decimation
radius = max(1, round(criteria.refinement_radius_seconds * decimated_rate))
total_decimated = math.ceil(
cs8_sample_count(capture_path) / criteria.decimation
)
first = max(0, approximate_start - radius)
last = min(total_decimated - reference.size, approximate_start + radius)
received = read_filtered_decimated_window(
capture_path,
first,
last + reference.size,
layout,
criteria,
taps,
)
count = reference.size
prefix_power = np.concatenate(
([0.0], np.cumsum(np.abs(received) ** 2, dtype=np.float64))
)
prefix_sum = np.concatenate(
([0.0 + 0.0j], np.cumsum(received, dtype=np.complex128))
)
window_power = prefix_power[count:] - prefix_power[:-count]
window_sum = prefix_sum[count:] - prefix_sum[:-count]
window_ac_power = window_power - (np.abs(window_sum) ** 2) / count
reference_energy = float(np.vdot(reference, reference).real)
denominator = np.sqrt(np.maximum(reference_energy * window_ac_power, 1e-30))
time_axis = np.arange(reference.size, dtype=np.float64) / decimated_rate
best_score = -1.0
best_index = 0
best_cfo = 0.0
for cfo_hz in _frequency_grid(criteria):
rotated = reference * np.exp(2j * np.pi * cfo_hz * time_axis)
correlation = signal.fftconvolve(
received,
np.conj(rotated[::-1]),
mode="valid",
)
scores = np.abs(correlation) / denominator
index = int(np.argmax(scores))
score = float(scores[index])
if score > best_score:
best_score = score
best_index = index
best_cfo = float(cfo_hz)
start_decimated = first + best_index
return {
"score": best_score,
"start_decimated_sample": start_decimated,
"start_sample": start_decimated * criteria.decimation,
"start_seconds": (
start_decimated * criteria.decimation / layout.sample_rate_hz
),
"cfo_hz": best_cfo,
}
def locate_reference_streaming(
capture_path: Path,
reference: np.ndarray,
layout: ReferenceLayout,
criteria: SearchCriteria,
progress: Callable[[int, int], None] | None = None,
) -> dict[str, object]:
"""Scan every source sample through a bounded anti-aliased feature stream."""
total_samples = cs8_sample_count(capture_path)
total_decimated = math.ceil(total_samples / criteria.decimation)
if total_decimated < reference.size:
raise ValueError("RX короче эталонной лестницы")
decimated_rate = layout.sample_rate_hz / criteria.decimation
feature_stride = round(criteria.feature_period_seconds * decimated_rate)
if feature_stride <= 0 or criteria.histogram_stride <= 0:
raise ValueError("feature period and histogram stride must be positive")
taps = design_antialias_filter(layout, criteria)
feature_parts: list[np.ndarray] = []
pending = np.empty(0, dtype=np.complex64)
maximum_loaded = 0
streamed_decimated = 0
for block_start, block in iter_filtered_decimated(
capture_path,
layout,
criteria,
taps,
):
if block_start != streamed_decimated:
raise RuntimeError("decimated stream is not contiguous")
streamed_decimated += block.size
if progress is not None:
progress(
min(total_samples, streamed_decimated * criteria.decimation),
total_samples,
)
combined = np.concatenate((pending, block)) if pending.size else block
complete = combined.size // feature_stride
used = complete * feature_stride
if used:
feature_parts.append(_power_features(combined[:used], feature_stride))
pending = combined[used:].copy()
maximum_loaded = max(maximum_loaded, int(combined.size))
if pending.size:
feature_parts.append(
np.array([np.mean(np.abs(pending) ** 2, dtype=np.float64)])
)
features = np.concatenate(feature_parts)
reference_features = _power_features(reference, feature_stride)
if features.size < reference_features.size:
raise ValueError("RX feature stream is shorter than the reference")
scores = _normalized_sliding_scores(features, reference_features)
sampled = np.clip(scores[::criteria.histogram_stride], 0.0, 1.0)
histogram = np.histogram(sampled, bins=2000, range=(0.0, 1.0))[0]
background_p99 = _histogram_quantile(histogram, 0.99)
candidate_count = min(max(20, criteria.maximum_reported_candidates * 8), scores.size)
indexes = np.argpartition(scores, -candidate_count)[-candidate_count:]
coarse = [
{
"score": float(scores[index]),
"start_decimated_sample": int(index * feature_stride),
"start_feature": int(index),
}
for index in indexes
]
coarse = _deduplicate_candidates(
coarse,
max(1, reference.size // 3),
criteria.maximum_reported_candidates,
)
refined = []
for row in coarse:
candidate = _refine_candidate(
capture_path,
reference,
int(row["start_decimated_sample"]),
layout,
criteria,
taps,
)
temporal_score = float(row["score"])
candidate["temporal_power_score"] = temporal_score
candidate["score_to_background_p99_db"] = (
20.0 * math.log10(temporal_score / background_p99)
if temporal_score > 0 and background_p99 > 0
else None
)
refined.append(candidate)
independent = _deduplicate_candidates(
refined,
max(1, reference.size // 3),
criteria.maximum_reported_candidates,
)
return {
"coverage_start_sample": 0,
"coverage_stop_sample": total_samples,
"coverage_seconds": total_samples / layout.sample_rate_hz,
"coverage_complete": streamed_decimated == total_decimated,
"source_samples_processed": total_samples,
"tested_feature_start_positions": int(scores.size),
"feature_stride_decimated_samples": feature_stride,
"feature_stride_source_samples": feature_stride * criteria.decimation,
"frequency_grid_hz": [
float(value) for value in _frequency_grid(criteria)
],
"background_score_p99": background_p99,
"candidates": independent,
"maximum_loaded_decimated_samples": maximum_loaded,
"maximum_loaded_iq_bytes": maximum_loaded * np.dtype(np.complex64).itemsize,
"anti_alias_filter": {
"kind": "linear_phase_fir_kaiser",
"tap_count": int(taps.size),
"passband_hz": criteria.anti_alias_passband_hz,
"stopband_hz": criteria.anti_alias_stopband_hz,
"design_attenuation_db": criteria.anti_alias_attenuation_db,
"group_delay_source_samples": (int(taps.size) - 1) // 2,
"position_delay_correction_source_samples": (
-((int(taps.size) - 1) // 2)
),
"boundary_handling": "symmetric_overlap_with_zero_extension_at_file_edges",
},
}
def _read_candidate(
capture_path: Path,
start_decimated: int,
count: int,
layout: ReferenceLayout,
criteria: SearchCriteria,
) -> np.ndarray:
taps = design_antialias_filter(layout, criteria)
return read_filtered_decimated_window(
capture_path,
start_decimated,
start_decimated + count,
layout,
criteria,
taps,
)
def _decimated_window(window: tuple[int, int], decimation: int) -> tuple[int, int]:
return round(window[0] / decimation), round(window[1] / decimation)
def _band_power(values: np.ndarray, sample_rate_hz: float, low: float, high: float) -> float:
nperseg = min(4096, values.size)
frequencies, density = signal.welch(
values,
fs=sample_rate_hz,
nperseg=nperseg,
noverlap=nperseg // 2,
return_onesided=False,
scaling="density",
)
mask = (frequencies >= low) & (frequencies <= high)
if not np.any(mask):
return 0.0
return float(np.trapezoid(density[mask], frequencies[mask]))
def verify_candidate(
capture_path: Path,
reference: np.ndarray,
candidate: dict[str, float | int],
layout: ReferenceLayout,
criteria: SearchCriteria,
) -> dict[str, object]:
start_decimated = int(candidate["start_decimated_sample"])
received = _read_candidate(
capture_path,
start_decimated,
reference.size,
layout,
criteria,
)
decimated_rate = layout.sample_rate_hz / criteria.decimation
cfo_hz = float(candidate["cfo_hz"])
time_axis = np.arange(reference.size, dtype=np.float64) / decimated_rate
rotated = reference * np.exp(2j * np.pi * cfo_hz * time_axis)
segment_decimated = round(layout.segment_samples / criteria.decimation)
trim = min(
max(1, round(21 * layout.sample_rate_hz / layout.symbol_rate / criteria.decimation)),
max(1, segment_decimated // 10),
)
rows = []
for index, window in enumerate(layout.active_windows()):
start, stop = _decimated_window(window, criteria.decimation)
start += trim
stop -= trim
expected = rotated[start:stop]
observed = received[start:stop]
expected = expected - np.mean(expected, dtype=np.complex128)
observed = observed - np.mean(observed, dtype=np.complex128)
expected_energy = float(np.vdot(expected, expected).real)
observed_energy = float(np.vdot(observed, observed).real)
gain = np.vdot(expected, observed) / max(expected_energy, 1e-30)
matched = gain * expected
residual = observed - matched
matched_power = float(np.mean(np.abs(matched) ** 2))
residual_power = float(np.mean(np.abs(residual) ** 2))
coherence = float(
abs(np.vdot(expected, observed))
/ math.sqrt(max(expected_energy * observed_energy, 1e-30))
)
rows.append(
{
"label": f"L{index}",
"expected_level_db": layout.levels_db[index],
"matched_power": matched_power,
"residual_power": residual_power,
"matched_to_residual_db": (
10.0 * math.log10(matched_power / residual_power)
if matched_power > 0 and residual_power > 0
else None
),
"normalized_coherence": coherence,
"gain_abs": float(abs(gain)),
}
)
step_rows = []
for lower, upper in zip(rows, rows[1:]):
expected_step = float(upper["expected_level_db"]) - float(lower["expected_level_db"])
lower_power = float(lower["matched_power"])
upper_power = float(upper["matched_power"])
observed_step = (
10.0 * math.log10(upper_power / lower_power)
if lower_power > 0 and upper_power > 0
else None
)
step_rows.append(
{
"comparison": f"{upper['label']}/{lower['label']}",
"expected_step_db": expected_step,
"observed_step_db": observed_step,
"error_db": observed_step - expected_step if observed_step is not None else None,
}
)
finite_errors = [
abs(float(row["error_db"]))
for row in step_rows
if row["error_db"] is not None
]
maximum_step_error = max(finite_errors) if len(finite_errors) == len(step_rows) else None
l3_start, l3_stop = _decimated_window(layout.active_windows()[-1], criteria.decimation)
background_start, background_stop = _decimated_window(
layout.background_windows()[-2], criteria.decimation
)
band_half_width = 0.675 * layout.symbol_rate
low = layout.carrier_hz - band_half_width
high = layout.carrier_hz + band_half_width
l3_band_power = _band_power(received[l3_start:l3_stop], decimated_rate, low, high)
background_band_power = _band_power(
received[background_start:background_stop], decimated_rate, low, high
)
spectral_contrast = (
10.0 * math.log10(l3_band_power / background_band_power)
if l3_band_power > 0 and background_band_power > 0
else None
)
l3_ratio = rows[-1]["matched_to_residual_db"]
matched_powers = [float(row["matched_power"]) for row in rows]
monotonic = all(upper > lower for lower, upper in zip(matched_powers, matched_powers[1:]))
step_pass = (
maximum_step_error is not None
and maximum_step_error <= criteria.maximum_step_error_db
and monotonic
)
coherence_pass = (
l3_ratio is not None
and float(l3_ratio) >= criteria.l3_matched_to_residual_minimum_db
)
spectral_pass = (
spectral_contrast is not None
and spectral_contrast >= criteria.l3_spectral_contrast_minimum_db
)
return {
"segments": rows,
"steps": step_rows,
"maximum_absolute_step_error_db": maximum_step_error,
"monotonic_matched_power": monotonic,
"step_pattern_pass": step_pass,
"l3_matched_to_residual_pass": coherence_pass,
"l3_band_power": l3_band_power,
"background_band_power": background_band_power,
"l3_spectral_contrast_db": spectral_contrast,
"spectral_contrast_pass": spectral_pass,
"qualified": step_pass and coherence_pass and spectral_pass,
}
def classify_search(
search: dict[str, object],
verifications: list[dict[str, object]],
criteria: SearchCriteria,
) -> tuple[str, list[str]]:
if search.get("coverage_complete") is not True:
return "ANALYSIS_INCOMPLETE", ["FULL_CAPTURE_COVERAGE_NOT_CONFIRMED"]
candidates = list(search["candidates"])
if not candidates:
return "NOT_DETECTED", ["NO_CANDIDATES"]
peak = candidates[0]
score = float(peak["score"])
contrast = peak.get("score_to_background_p99_db")
correlation_pass = score >= criteria.normalized_correlation_minimum
contrast_pass = (
contrast is not None
and float(contrast) >= criteria.peak_to_background_minimum_db
)
qualified = [row for row in verifications if row.get("qualified") is True]
reasons = []
if not correlation_pass:
reasons.append("NORMALIZED_CORRELATION_BELOW_THRESHOLD")
if not contrast_pass:
reasons.append("PEAK_TO_BACKGROUND_CONTRAST_BELOW_THRESHOLD")
if not qualified:
reasons.append("STEP_OR_SPECTRAL_CONFIRMATION_FAILED")
if correlation_pass and contrast_pass and len(qualified) == 1:
return "DETECTED", []
if (correlation_pass and contrast_pass) or qualified:
if len(qualified) > 1:
reasons.append("MULTIPLE_QUALIFIED_CANDIDATES")
return "AMBIGUOUS", reasons
return "NOT_DETECTED", reasons
def analyze(
reference_path: Path,
capture_path: Path,
report_path: Path,
*,
expected_capture_sha256: str,
layout: ReferenceLayout = DEFAULT_LAYOUT,
criteria: SearchCriteria = DEFAULT_CRITERIA,
expected_reference_sha256: str = EXPECTED_REFERENCE_SHA256,
progress_path: Path | None = None,
) -> dict[str, object]:
if report_path.exists():
raise FileExistsError(f"отчёт уже существует: {report_path}")
total_samples = cs8_sample_count(capture_path)
if progress_path is not None:
_write_progress(
progress_path,
phase="VERIFYING_REFERENCE",
processed_samples=0,
total_samples=total_samples,
)
reference, reference_sha256 = load_reference(
reference_path,
layout,
criteria,
expected_sha256=expected_reference_sha256,
)
capture_progress: Callable[[int, int], None] | None = None
if progress_path is not None:
capture_progress = lambda processed, total: _write_progress(
progress_path,
phase="VERIFYING_RX_SHA256",
processed_samples=processed // 2,
total_samples=total // 2,
)
capture_sha256 = sha256_file(capture_path, progress=capture_progress)
if capture_sha256 != expected_capture_sha256:
raise ValueError("SHA256 RX-файла не совпадает с ожидаемым значением")
analysis_started = time.perf_counter()
with MemoryPeakSampler() as memory:
search_progress: Callable[[int, int], None] | None = None
if progress_path is not None:
search_progress = lambda processed, total: _write_progress(
progress_path,
phase="SEARCHING",
processed_samples=processed,
total_samples=total,
)
search = locate_reference_streaming(
capture_path,
reference,
layout,
criteria,
progress=search_progress,
)
verifications = []
for candidate in search["candidates"]:
verification = verify_candidate(
capture_path,
reference,
candidate,
layout,
criteria,
)
verifications.append({"candidate": candidate, **verification})
verdict, reasons = classify_search(search, verifications, criteria)
analysis_seconds = time.perf_counter() - analysis_started
result = {
"schema_version": 1,
"analyzer_version": ANALYZER_VERSION,
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
"reference_profile": "LAB065_REFERENCE_STAIRCASE",
"reference_path": str(reference_path),
"reference_sha256": reference_sha256,
"capture_path": str(capture_path),
"capture_sha256": capture_sha256,
"capture_bytes": capture_path.stat().st_size,
"capture_complex_samples": cs8_sample_count(capture_path),
"layout": asdict(layout),
"criteria_fixed_before_capture_analysis": asdict(criteria),
"search": search,
"verifications": verifications,
"verdict": verdict,
"verdict_reasons": reasons,
"peak_process_memory_bytes": memory.peak_bytes,
"peak_additional_memory_bytes": memory.additional_peak_bytes,
"analysis_seconds": analysis_seconds,
"hardware_accessed": False,
"rf_tx": 0,
}
report_path.parent.mkdir(parents=True, exist_ok=True)
serialized = json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
with report_path.open("x", encoding="utf-8") as stream:
stream.write(serialized)
if progress_path is not None:
_write_progress(
progress_path,
phase="COMPLETE",
processed_samples=total_samples,
total_samples=total_samples,
)
return result
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--reference", required=True, type=Path)
parser.add_argument("--capture", required=True, type=Path)
parser.add_argument("--capture-sha256", required=True)
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--progress", type=Path)
arguments = parser.parse_args()
result = analyze(
arguments.reference,
arguments.capture,
arguments.report,
expected_capture_sha256=arguments.capture_sha256,
progress_path=arguments.progress,
)
print(f"REFERENCE STAIRCASE = {result['verdict']}; RF TX = 0")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,14 @@
$ErrorActionPreference = "Stop"
$taskName = "SDR Rover HTTP Control Server"
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($null -ne $task) {
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
"AUTOSTART_REMOVED=YES"
}
else {
"AUTOSTART_REMOVED=NOT_PRESENT"
}
"RUNTIME_DATA_REMOVED=NO"
"RF_TX=0"

View File

@@ -0,0 +1,15 @@
$ErrorActionPreference = "Stop"
$taskName = "SDR Rover RX Agent"
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($null -ne $task) {
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
"AUTOSTART_REMOVED=YES"
}
else {
"AUTOSTART_REMOVED=NOT_PRESENT"
}
"INSTALLATION_FILES_REMOVED=NO"
"RUNTIME_DATA_REMOVED=NO"
"RF_TX=0"