From 0d6fff224eeda2abd2fbd4e3400e6518b7931018 Mon Sep 17 00:00:00 2001 From: LittleSam129 <1qwe432@gmail.com> Date: Wed, 30 Sep 2026 11:42:19 +0300 Subject: [PATCH] Harden RF node control and agent updates --- INSTALL_RX_AGENT.cmd | 8 + UNINSTALL_RX_AGENT.cmd | 7 + .../RF001_BOOTSTRAP_20260929_5_ATTEMPT.md | 223 ++ docs/handoff/RF001_HTTP_CONTROL.md | 310 +++ .../RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md | 157 ++ tests/test_rf001_agent_launcher.py | 625 ++++++ tests/test_rf001_agent_update.py | 172 ++ ...st_rf001_bootstrap_install_orchestrator.py | 133 ++ tests/test_rf001_build_agent_packages.py | 319 +++ tests/test_rf001_capture_runner.py | 48 + tests/test_rf001_http_control.py | 537 +++++ tests/test_rf001_installer_transaction.py | 429 ++++ .../test_rf001_launcher_health_diagnostic.py | 235 +++ tests/test_rf001_offline_decode.py | 55 + tests/test_rf001_rx_capture.py | 356 ++++ tests/test_rf001_rx_http_agent.py | 824 ++++++++ tests/test_rf001_staircase_search.py | 354 ++++ tools/install_rf001_main_autostart.ps1 | 69 + tools/install_rf001_rx_autostart.ps1 | 994 +++++++++ tools/rf001_agent_launcher.py | 1009 +++++++++ tools/rf001_agent_update.py | 422 ++++ tools/rf001_bootstrap_install_orchestrator.py | 334 +++ tools/rf001_bootstrap_readme.txt | 16 + tools/rf001_build_agent_packages.py | 355 ++++ tools/rf001_capture_runner.py | 133 ++ tools/rf001_http_control.py | 1805 +++++++++++++++++ tools/rf001_launcher_health_diagnostic.py | 742 +++++++ tools/rf001_offline_decode.py | 86 + tools/rf001_rx_capture.py | 684 +++++++ tools/rf001_rx_http_agent.py | 1529 ++++++++++++++ tools/rf001_staircase_job.py | 74 + tools/rf001_staircase_search.py | 937 +++++++++ tools/uninstall_rf001_main_autostart.ps1 | 14 + tools/uninstall_rf001_rx_autostart.ps1 | 15 + 34 files changed, 14010 insertions(+) create mode 100644 INSTALL_RX_AGENT.cmd create mode 100644 UNINSTALL_RX_AGENT.cmd create mode 100644 docs/handoff/RF001_BOOTSTRAP_20260929_5_ATTEMPT.md create mode 100644 docs/handoff/RF001_HTTP_CONTROL.md create mode 100644 docs/handoff/RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md create mode 100644 tests/test_rf001_agent_launcher.py create mode 100644 tests/test_rf001_agent_update.py create mode 100644 tests/test_rf001_bootstrap_install_orchestrator.py create mode 100644 tests/test_rf001_build_agent_packages.py create mode 100644 tests/test_rf001_capture_runner.py create mode 100644 tests/test_rf001_http_control.py create mode 100644 tests/test_rf001_installer_transaction.py create mode 100644 tests/test_rf001_launcher_health_diagnostic.py create mode 100644 tests/test_rf001_offline_decode.py create mode 100644 tests/test_rf001_rx_capture.py create mode 100644 tests/test_rf001_rx_http_agent.py create mode 100644 tests/test_rf001_staircase_search.py create mode 100644 tools/install_rf001_main_autostart.ps1 create mode 100644 tools/install_rf001_rx_autostart.ps1 create mode 100644 tools/rf001_agent_launcher.py create mode 100644 tools/rf001_agent_update.py create mode 100644 tools/rf001_bootstrap_install_orchestrator.py create mode 100644 tools/rf001_bootstrap_readme.txt create mode 100644 tools/rf001_build_agent_packages.py create mode 100644 tools/rf001_capture_runner.py create mode 100644 tools/rf001_http_control.py create mode 100644 tools/rf001_launcher_health_diagnostic.py create mode 100644 tools/rf001_offline_decode.py create mode 100644 tools/rf001_rx_capture.py create mode 100644 tools/rf001_rx_http_agent.py create mode 100644 tools/rf001_staircase_job.py create mode 100644 tools/rf001_staircase_search.py create mode 100644 tools/uninstall_rf001_main_autostart.ps1 create mode 100644 tools/uninstall_rf001_rx_autostart.ps1 diff --git a/INSTALL_RX_AGENT.cmd b/INSTALL_RX_AGENT.cmd new file mode 100644 index 0000000..33766b6 --- /dev/null +++ b/INSTALL_RX_AGENT.cmd @@ -0,0 +1,8 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\install_rf001_rx_autostart.ps1" +set "EXIT_CODE=%ERRORLEVEL%" +echo. +if not "%EXIT_CODE%"=="0" echo INSTALL_FAILED=%EXIT_CODE% +pause +exit /b %EXIT_CODE% diff --git a/UNINSTALL_RX_AGENT.cmd b/UNINSTALL_RX_AGENT.cmd new file mode 100644 index 0000000..3c63d9d --- /dev/null +++ b/UNINSTALL_RX_AGENT.cmd @@ -0,0 +1,7 @@ +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\uninstall_rf001_rx_autostart.ps1" +set "EXIT_CODE=%ERRORLEVEL%" +echo. +pause +exit /b %EXIT_CODE% diff --git a/docs/handoff/RF001_BOOTSTRAP_20260929_5_ATTEMPT.md b/docs/handoff/RF001_BOOTSTRAP_20260929_5_ATTEMPT.md new file mode 100644 index 0000000..2bd358f --- /dev/null +++ b/docs/handoff/RF001_BOOTSTRAP_20260929_5_ATTEMPT.md @@ -0,0 +1,223 @@ +# RF001 bootstrap 2026.09.29.5 + +Дата: 29 сентября 2026 года. + +## Production fix + +Launcher теперь проверяет два допустимых варианта process identity: + +- `DIRECT`: health PID совпадает с `Popen.pid`; +- `WINDOWS_VENV_WRAPPER`: health PID принадлежит единственному непосредственному потомку проверенного venv wrapper. + +В обоих вариантах проверяются живой процесс, PID, PPID, creation time, полный путь executable, точная командная строка, release ID, agent version, одноразовый launch nonce и `state=RUNNING`. Перед принятием health process identity проверяется второй раз. Failure cleanup завершает только доказанную lineage и не выбирает процессы по имени. + +Health schema и `rf001_rx_http_agent.py` не изменялись. + +Локальные проверки: `102 passed`. Python compile и PowerShell parse прошли. Ruff и Pyright отсутствуют, поэтому не запускались. + +## Windows integration + +Изолированный запуск на RX использовал настоящий Windows venv, исправленный production launcher, canonical release A и настоящий agent health writer. Scheduled Task и legacy agent не изменялись. + +- `Popen.pid`: 11000; +- health PID: 13248; +- topology: wrapper 11000 -> direct child 13248; +- production `start_release()`: PASS; +- strict target identity: PASS; +- cleanup: PASS; +- orphan: 0; +- HTTP task polling: false; +- hardware accessed: false; +- RF TX: 0. + +Evidence: + +```text +C:\Users\Professional\Downloads\SDR_Rover_Diagnostics\launcher_identity_fix_20260929T135321Z\evidence\diagnostic_summary.json +``` + +## Bootstrap + +- ID: `rf001-bootstrap-2026.09.29.5`; +- SHA256: `4833cd22f3f1bd41ae1a847c66f258ccf9d4c6fb4eec5feb2d36e688324c6554`; +- размер: 1 506 140 байт; +- HMAC: PASS; +- manifest-файлы: 17, расхождений 0; +- release A: 10 файлов из 10 побайтно совпадают с `.4`, включая `READY.json`; +- signed `agent_release_bootstrap_id`: `rf001-bootstrap-2026.09.29.4`; +- `.1`, `.2`, `.3` и `.4` не изменялись; +- SOURCE/RX SHA256: MATCH. + +## Единственная попытка установки + +`INSTALL_RX_AGENT.cmd` был запущен один раз. Результат: `exit code 1`, fail-closed. + +Фактическая ошибка: + +```text +Unexpected file in the bootstrap package: install.stderr.txt +``` + +Причина доказана. Оркестратор заранее создал `install.stdout.txt` и `install.stderr.txt` внутри распакованного bootstrap-каталога, чтобы перенаправить вывод `cmd.exe`. Installer затем проверил строгий allowlist всех файлов каталога и отверг созданный оркестратором `install.stderr.txt` как посторонний файл. Ошибка возникла до копирования launcher, изменения Scheduled Task и остановки legacy agent. + +Это не ошибка HMAC, manifest, immutable release A или process identity fix. Bootstrap `.5` остался подписанным и прошёл preflight. Повторная установка запрещена текущим STOP-условием. `.6` не создавался. + +Для следующей отдельно разрешённой попытки вывод установщика должен сохраняться вне распакованного bootstrap-каталога, а запуск должен использовать новый чистый каталог распаковки. Текущий загрязнённый каталог не следует использовать повторно. + +## Безопасное состояние после отказа + +- Scheduled Task: `Running`, прежнее legacy action; +- active agent: `2026.09.25.1`; +- MAIN heartbeat: свежий; +- unfinished tasks: 0; +- installed launcher SHA256: `fb99882351ee96802d1edd9de947634f2a559e849399c8153a1290688030ed79`, прежний `.4` launcher; +- production agent topology: PID 7980 -> PID 17568; +- `hackrf_transfer`: 0; +- RX capture: 0; +- B update: NOT_RUN; +- staircase search: NOT_RUN; +- new RX: 0; +- new RF TX: 0. + +## Отдельно разрешённая попытка 2 + +После нового разрешения тот же неизменный ZIP `.5` повторно проверен на MAIN и +RX. SHA256 в обоих случаях: + +```text +4833cd22f3f1bd41ae1a847c66f258ccf9d4c6fb4eec5feb2d36e688324c6554 +``` + +Первая распаковка с `install.stdout.txt` и `install.stderr.txt` сохранена без +изменений: 21 файл до и после второй попытки. Для второй попытки создан новый +каталог: + +```text +C:\Users\Professional\Downloads\SDR_Rover_Bootstrap\rf001-bootstrap-2026.09.29.5_attempt2_clean +``` + +До запуска установщика получены следующие результаты: + +- HMAC: PASS; +- manifest: 17/17; +- файлов в пакете с двумя файлами аутентификации: 19/19; +- unexpected: 0; +- missing: 0; +- mismatch: 0; +- release A: 10/10; +- `INSTALL_RX_AGENT.cmd`: присутствует; +- canonical release A: 10/10, расхождений 0; +- active task: отсутствует; +- unfinished tasks: 0; +- unfinished hardware tasks: 0; +- `hackrf_transfer`: 0; +- RX capture process: 0. + +Все runtime-файлы второй попытки сохранены вне bootstrap-каталога: + +```text +C:\Users\Professional\AppData\Local\SDR_Rover_RX_Agent\install_logs\bootstrap_2026.09.29.5_attempt2_20260929T150351603Z +``` + +Установщик запущен ровно один раз из чистого каталога с явным рабочим +каталогом. Результат: `exit code 0`, stderr пуст. Дерево пакета сразу после +старта и после завершения побайтно не изменилось; timestamps также совпали. +Карантин из 11 файлов и первая загрязнённая распаковка не изменились. + +После установки Scheduled Task запускает versioned launcher. Release A +`2026.09.29.1` сообщил свежий MAIN heartbeat, launcher status перешёл в +`RUNNING / TARGET_HEALTHY`, active task отсутствовал. Legacy-процессов: 0. +Наблюдаемая цепочка Windows состояла из launcher wrapper, его Python-потомка, +agent wrapper и единственного agent child с одним launch nonce. Это одна +production lineage, а не два экземпляра агента. + +## Обновление A -> B + +Пакет `rf001-agent-2026.09.29.2.zip` проверен до постановки задачи: + +- SHA256: `225a032a927b672343f4ccf7a0ef0662f462e1ec45782c0437ba50ab73bac203`; +- release ID: `rf001-agent-2026.09.29.2`; +- agent version: `2026.09.29.2`; +- внутренний manifest: 9 файлов, расхождений 0; +- зарегистрированный HMAC: PASS. + +Обновление выполнено только через подписанный HTTP-канал: + +```text +UPDATE_20260929T151002Z_8B82A943 +``` + +Задача завершилась со статусом `COMPLETE`, self-test вернул 0. Launcher +переключился на B, active release и свежий MAIN heartbeat показывают +`2026.09.29.2`, launcher status: `RUNNING / TARGET_HEALTHY`. Rollback не +использовался. SCP/SSH-установка B не выполнялась. + +## Полный поиск лестницы + +После подтверждения B поставлена одна задача: + +```text +STAIRCASE_20260929T151048Z_EF3AA9DE +``` + +Итог: + +- session: `RF001A_20260925T125829Z_227EADB4`; +- coverage: 0...2 400 000 000 комплексных отсчётов, 300,0 с; +- RX SHA256: подтверждён; +- reference SHA256: подтверждён; +- result: `NOT_DETECTED`; +- лучший кандидат: sample 474 212 320, 59,27654 с; +- CFO: -3,0 Гц; +- candidate score: 0,009649134828942169; +- contrast to background p99: 4,052909297771804 дБ, ниже порога; +- spectral contrast: 3,6405801238037765 дБ; +- step pattern: FAIL; +- наблюдаемые ступени: +2,879031, -2,205885 и +15,541302 дБ при ожидаемых + +6,0 дБ; +- maximum absolute step error: 9,54130207648743 дБ; +- analysis time: 263,3271906 с; +- peak additional memory: 496 549 888 байт; +- peak process memory: 536 199 168 байт; +- hardware accessed: false; +- RF TX: 0. + +Причины вердикта: корреляция ниже порога, контраст пика к фону ниже порога, +структура ступеней или спектральное подтверждение не прошли. Пороги и алгоритм +не менялись, повтор задачи не выполнялся. + +Отчёт на RX: + +```text +C:\Users\Professional\Documents\SDR_Rover_Runtime\rx_capture\RF001A_20260925T125829Z_227EADB4\staircase_search_report.json +SHA256=b34ad21ae70a4290a0394a3ee3317bdd560e43dbe266aa3d7e1ce1c967c11dd7 +``` + +Результат принят MAIN и сохранён в +`.local/rf001_http_server_state.json` под указанным task ID. + +## Исправление MAIN-оркестрации + +Подписанный `.5` не изменялся. На MAIN добавлена отдельная утилита +`tools/rf001_bootstrap_install_orchestrator.py`. Она до запуска проверяет HMAC, +manifest, SHA256 и точный allowlist, запрещает каталог доказательств внутри +package tree, перенаправляет stdout/stderr только наружу и сравнивает снимки +пакета сразу после старта и после завершения. + +Регрессии в `tests/test_rf001_bootstrap_install_orchestrator.py` проверяют: + +- чистый пакет до запуска; +- внешние stdout/stderr; +- неизменность package tree; +- успешный точный allowlist; +- fail-closed для пакета с посторонним файлом; +- отказ от evidence-каталога внутри пакета. + +Итоговый RF001-набор: `122 passed`. Полный набор репозитория: `1296 passed`, +`4 failed`, `2 skipped`. Четыре сбоя находятся вне изменённого контура: два +старых теста `capture_process_runner.ps1` получили `sha256=null`, один тест +требует отсутствующий `data/raw/lab064/tx_metadata.json`, ещё один требует +недоступный Numba backend. Новая утилита компилируется; её отдельные регрессии: +`4 passed`. + +Новый bootstrap `.6` для этого исправления не требуется. diff --git a/docs/handoff/RF001_HTTP_CONTROL.md b/docs/handoff/RF001_HTTP_CONTROL.md new file mode 100644 index 0000000..8da2b0f --- /dev/null +++ b/docs/handoff/RF001_HTTP_CONTROL.md @@ -0,0 +1,310 @@ +# HTTP-канал управления RF-001 + +Текущее состояние: **HTTP-КАНАЛ РАБОТАЕТ; ВЕРСИОННЫЙ МЕХАНИЗМ ОБНОВЛЕНИЯ +И НЕБЛОКИРУЮЩИЙ `STAIRCASE_SEARCH` ПРОВЕРЕНЫ БЕЗ НОВОГО ДОСТУПА К РАДИО**. + +Канал заменяет Git как транспорт между работающими компьютерами. Git остаётся +для исходного кода и истории. Старые watcher-ы сохранены как резервный вариант. + +## Граница этапа 1 + +Реализованы: + +- JSON-сервер на основном ПК; +- исходящее подключение RX-агента с ноутбука к основному ПК; +- подпись каждого запроса HMAC-SHA256; +- защита от повторного использования запроса по времени и nonce; +- ограничение адреса RX-агента на сервере; +- фиксированные задачи `INVENTORY`, `OFFLINE_VERIFY`, `RX_CAPTURE_START`, + `RX_CAPTURE_STATUS`, `CAPTURE_DECODE`, `CAPTURE_DIAGNOSTICS` и + `STAIRCASE_SEARCH` и `UPDATE_AGENT`; +- чтение `hostname`, `whoami`, состояния Git и `hackrf_info`; +- сохранение результата на основном ПК; +- обязательное поле `rf_tx = 0` во всех сообщениях. + +На RX-ноутбуке 23 сентября 2026 года автономная проверка завершилась так: + +- ветка `video-development`; +- HEAD `a94f2db4ee8377201a97263166cb09431df5f5d6`; +- 100 CRC-корректных пакетов из 100; +- 100 точных пакетов из 100; +- `hardware_accessed = false`; +- ошибок нет; +- `rf_tx = 0`. + +В сеансе `RF001A_20260925T125829Z_227EADB4` уже были выполнены: + +- один конечный RX-захват с фиксированным профилем; +- проверка READY по фактическому PID, обнаруженному CLKIN и растущему файлу; +- чтение итогового состояния захвата; +- потоковое декодирование принятого IQ; +- полный потоковый поиск эталонной лестницы без обращения к SDR. + +Новые захваты и передачи не входят в текущее состояние и требуют отдельного +явного разрешения. Завершённый поиск лестницы сохраняет исходный результат +`NOT_DETECTED`; последующая диагностика должна записываться отдельно и не +подменять этот результат. + +Не входят в управляющий канал: + +- запуск `hackrf_transfer` в режиме TX; +- команда или сетевой метод RF TX; +- изменение firmware, CPLD, USB-драйвера или Zadig; +- commit и push. + +Для сервера и RX-агента подготовлен пользовательский автозапуск через +Планировщик заданий Windows. Службы Windows и права администратора для этого не +нужны. Сервер и агент работают постоянно после входа соответствующего +пользователя в Windows. Агент не содержит произвольной shell-команды. + +## Схема + +```text +ОСНОВНОЙ ПК 192.168.255.185:8765 + HTTP JSON server + очередь только фиксированных типов заданий + ^ + | исходящее соединение с подписью запроса + | +RX-НОУТБУК 192.168.255.56 + polling agent + hostname / whoami / Git / hackrf_info / offline 100/100 + RF TX = 0 +``` + +Сервер не раздаёт каталоги и произвольные пути. Он может выдать только заранее +зарегистрированный архив агента с точным `release_id` через ограниченный маршрут +обновлений. Произвольной shell-команды в протоколе нет. Неизвестный тип задания +и любое значение `rf_tx`, отличное от нуля, отклоняются. + +## Файлы + +- `tools/rf001_http_control.py`: сервер, локальная очередь и клиент управления; +- `tools/rf001_rx_http_agent.py`: RX-агент с фиксированной инвентаризацией; +- `tools/rf001_offline_decode.py`: фиксированный программный `legacy`-декодер; +- `tools/rf001_staircase_search.py`: потоковый поиск эталонной лестницы без + обращения к SDR; +- `tools/rf001_staircase_job.py`: фиксированный дочерний процесс длительного + поиска и сохранение результата до сетевого подтверждения; +- `tools/rf001_agent_update.py`: строгая проверка состава и распаковка + версионного архива; +- `tools/rf001_agent_launcher.py`: стабильный запускатель, переключение версии + и локальный откат при неудачном запуске; +- `tools/rf001_build_agent_packages.py`: воспроизводимая сборка выпусков и + первоначального пакета без включения секрета; +- `tools/rf001_rx_capture.py`: жёсткие RX-параметры и проверки READY/COMPLETE; +- `tools/rf001_capture_runner.py`: один конечный фоновый RX-процесс и журнал; +- `.local/rf001_http_token.txt`: общий секрет вне Git; +- `.local/rf001_http_server_state.json`: локальное состояние вне Git. + +## Подготовка общего секрета + +На основном ПК из корня проекта один раз выполнить: + +```powershell +python -B tools\rf001_http_control.py init-token +``` + +Команда не перезаписывает существующий секрет. Файл +`.local\rf001_http_token.txt` надо скопировать на RX-ноутбук по доверенному +каналу и не добавлять в Git. Сервер и агент должны читать одинаковое содержимое. + +## Запуск сервера на основном ПК + +```powershell +python -B tools\rf001_http_control.py serve ` + --bind 192.168.255.185 ` + --port 8765 ` + --allowed-agent-ip 192.168.255.56 +``` + +Ожидаемый стартовый вывод: + +```text +SERVER = http://192.168.255.185:8765 +ALLOWED AGENT IP = 192.168.255.56 +TASK KINDS = INVENTORY, OFFLINE_VERIFY, RX_CAPTURE_START, RX_CAPTURE_STATUS, CAPTURE_DECODE, CAPTURE_DIAGNOSTICS, STAIRCASE_SEARCH, UPDATE_AGENT +RF TX = 0 +``` + +Действующее правило Windows Firewall должно оставаться ограниченным программой +Python, TCP-портом `8765`, локальным адресом `192.168.255.185`, удалённым +адресом `192.168.255.56` и беспроводным интерфейсом. + +## Постановка задания на основном ПК + +```powershell +python -B tools\rf001_http_control.py enqueue-inventory --agent-id user-pc +``` + +Это локальная операция. Сервер принимает её только от собственного адреса и с +правильной подписью. Задание не содержит произвольной команды. + +## Автозапуск агента на RX-ноутбуке + +Первоначальный пакет содержит `INSTALL_RX_AGENT.cmd`. После распаковки его надо +запустить один раз. Установщик проверяет HMAC-манифест существующим секретом, +размеры и SHA256 всех файлов, сохраняет прежнюю установку и XML задания, а +затем меняет только действие существующего пользовательского задания +`SDR Rover RX Agent`. Пользователь, триггеры, параметры и уровень прав задания +сохраняются. Ожидаемый итог: + +```text +BOOTSTRAP_INSTALL=COMPLETE +TASK_STATE=Running +AGENT_VERSION=2026.09.29.1 +ARBITRARY_SHELL=DISABLED +RF_TX=0 +``` + +После этого ручной запуск на ноутбуке для штатных обновлений не требуется. +Запускатель хранит выпуски в отдельных каталогах, проверяет запуск по PID, +версии и одноразовой метке и при сбое возвращается к предыдущему локальному +выпуску. Очередь MAIN, IQ, эталоны, результаты, конфигурация и секрет находятся +вне каталога конкретной версии. + +## Модель доверия обновлений + +Манифест каждого выпуска подписывается `HMAC-SHA256` из стандартной библиотеки +Python тем же общим секретом, которым уже подписываются HTTP-запросы. Подпись +охватывает отдельный домен `RF001-UPDATE-MANIFEST-V1`, `release_id`, версию +агента, совместимую версию протокола, размер и SHA256 архива и хеш внутреннего +манифеста. Разрешение MAIN дополнительно привязано к `agent_id` и сроку +действия. Сам секрет в ZIP не включается и при установке не заменяется. + +Ограничение модели: общий симметричный секрет находится и на MAIN, и на +RX-ноутбуке. Компрометация одного из этих узлов позволяет создавать корректные +HMAC-подписи. Аппаратного корня доверия и независимой издательской подписи у +этого контура нет. + +Перед переключением агент скачивает архив не более двух раз за одну +сохранённую попытку обновления, проверяет подпись, размер, SHA256, точный список +файлов, хеш каждого файла, допустимость путей, предел распакованного объёма, +совместимость и свободное место. Затем выполняется программная самопроверка без +SDR. Если агент занят локальной задачей, обновление остаётся в состоянии +`WAITING_FOR_IDLE`. Наличие файлов само по себе не считается установкой: +фактическую версию MAIN получает из следующего сообщения работающего агента. + +## Поиск эталонной лестницы + +`STAIRCASE_SEARCH` принимает только идентификатор существующего сеанса и +ожидаемые SHA256 RX-файла и эталона. Агент сам строит пути внутри +`%USERPROFILE%\Documents\SDR_Rover_Runtime`: запись берётся из каталога +`rx_capture`, а эталон — из отдельного каталога `references`. Сетевой запрос не +может передать путь или команду. + +Перед запуском агент проверяет оба SHA256. Отчёт сохраняется рядом с записью +под именем `staircase_search_report.json`. Если подходящий отчёт уже существует, +он проверяется и возвращается без повторного анализа и без перезаписи. Для +задачи всегда заданы `hardware_accessed = false`, `rf_tx = 0` и +`automatic_retry = false`. + +Поиск выполняется отдельным фиксированным процессом. Агент продолжает опрос +сервера и сообщает фактическую фазу, число обработанных комплексных отсчётов и +общий объём. Эти значения берутся из потокового анализатора, а не оцениваются +по времени. Активная аренда продлевается даже после истечения обычного короткого +тайм-аута. Указатель задачи записывается до запуска процесса, поэтому перезапуск +агента не создаёт второй анализатор. Готовый результат остаётся на диске до +подтверждения MAIN и после обрыва сети отправляется без повторного вычисления. + +## Диагностический однократный запуск агента + +Команду выполнять из фактического клона `SDR_Rover_Video` на ноутбуке. Пути к +репозиторию, секрету и `hackrf_info.exe` надо подставить по результатам +инвентаризации ноутбука: + +```powershell +python -B tools\rf001_rx_http_agent.py ` + --server http://192.168.255.185:8765 ` + --token-file <путь-к-rf001_http_token.txt> ` + --agent-id user-pc ` + --repository <путь-к-SDR_Rover_Video> ` + --hackrf-info <путь-к-hackrf_info.exe> ` + --hackrf-clock <путь-к-hackrf_clock.exe> ` + --hackrf-transfer <путь-к-hackrf_transfer.exe> ` + --once +``` + +Без `--once` агент работает постоянно. Этот ручной режим нужен только для +диагностики; при установленном автозапуске его не используют. + +## Просмотр результата на основном ПК + +```powershell +python -B tools\rf001_http_control.py status +``` + +Для завершённой инвентаризации сервер показывает состояние задания +`COMPLETE` или `FAILED`, фактические результаты команд и `rf_tx = 0`. + +## Автономная проверка 100/100 + +После подтверждения одинакового HEAD на обоих компьютерах основной ПК ставит +фиксированное программное задание: + +```powershell +python -B tools\rf001_http_control.py enqueue-offline-verify ` + --agent-id user-pc ` + --expected-head a94f2db4ee8377201a97263166cb09431df5f5d6 +``` + +Агент сверяет ветку и HEAD чтением `.git`, поэтому `git.exe` для этой проверки +не нужен. Затем он запускает только два заранее заданных проектных сценария: +создание детерминированной RF-001A волны и её программное декодирование через +встроенный `legacy`-декодер, не требующий Numba. Рабочие +файлы сохраняются вне Git в `Documents\SDR_Rover_Runtime\offline_verify`. +Успех требует ровно 100 CRC-корректных и 100 точных пакетов. HackRF при этом не +открывается, `hardware_accessed = false`, `rf_tx = 0`. + +## Подготовленный RX-захват + +Сервер не примет постановку RX-захвата без точной локальной фразы +`AUTHORIZE RF RX`. До отдельного разрешения эту команду не выполнять: + +```powershell +python -B tools\rf001_http_control.py enqueue-rx-capture ` + --agent-id user-pc ` + --expected-head a94f2db4ee8377201a97263166cb09431df5f5d6 ` + --authorize-rx "AUTHORIZE RF RX" +``` + +Агент перед захватом проверяет: + +- ветку и HEAD без обязательного наличия `git.exe`; +- отсутствие конфликтующих SDR-процессов; +- серийный номер RX `000000000000000097c063dc2e5327d3`; +- строку `clock signal detected` от `hackrf_clock.exe`; +- не менее `6000000000` свободных байт; +- неизменность частоты `1400000000` Гц, частоты дискретизации `8000000` + отсчётов/с, LNA/VGA `24/24` дБ, выключенных RF amp и antenna bias. + +Для ручного барьера TX захват длится 300 секунд: `2400000000` комплексных +отсчётов, ожидаемый файл `4800000000` байт. Значение 20 секунд в исходном +профиле проверяется как часть исходных данных, но управляющий контур расширяет +окно до 300 секунд. Это зафиксировано в журнале сеанса. Автоматического повтора +нет. + +Фоновый процесс запускает фиксированный Python-runner. Старый +`capture_process_runner.ps1` и Git watcher-ы не изменены и остаются резервом. + +Состояние конкретного сеанса запрашивается отдельной фиксированной задачей: + +```powershell +python -B tools\rf001_http_control.py enqueue-rx-status ` + --agent-id user-pc ` + --session-id +``` + +TX разрешён только при свежем результате `READY`, открытом окне захвата и после +точной отдельной фразы `AUTHORIZE RF TX`. В сетевом протоколе операции TX нет. + +После завершения захвата сервер может поставить потоковое декодирование с тремя +фактическими временами. Отчёт и IQ остаются на RX-ноутбуке вне Git. + +## Текущее продолжение + +На RX-ноутбуке работает версия агента `2026.09.29.2`; штатное обновление через +подписанный HTTP-канал и полный `STAIRCASE_SEARCH` завершены. Дальнейшая работа +с существующим сеансом является только потоковой офлайн-диагностикой сохранённой +IQ-записи. Новый RX-захват и любой RF TX требуют нового отдельного разрешения; +разрешения, использованные в завершённом сеансе, повторно не применяются. diff --git a/docs/handoff/RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md b/docs/handoff/RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md new file mode 100644 index 0000000..9d88197 --- /dev/null +++ b/docs/handoff/RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md @@ -0,0 +1,157 @@ +# RF001 launcher/health diagnostic + +Дата проверки: 29 сентября 2026 года. + +## Ограничения проверки + +- Производственный RX-агент не останавливался и не изменялся. +- Задание Планировщика Windows не изменялось. +- Диагностический процесс не обращался к HTTP-очереди. +- Доступ к SDR не выполнялся: `hardware_accessed=false`, `RF RX=0`, `RF TX=0`. +- Bootstrap `rf001-bootstrap-2026.09.29.5` не создавался; `.4` повторно не запускался. +- Clean release A `rf001-agent-2026.09.29.1` использовался только для чтения кода health writer и проверки `READY.json`/manifest. + +## Безопасное состояние после проверки + +- Активный производственный агент: `2026.09.25.1`; heartbeat на MAIN свежий, active task отсутствует. +- Незавершённых задач: 0. Незавершённых аппаратных задач: 0. +- Задание `SDR Rover RX Agent`: `Running`; его действие по-прежнему запускает legacy agent, а не release A. +- Производственная цепочка legacy agent на RX: venv wrapper PID 7980 -> базовый Python PID 17568. +- `hackrf_transfer.exe`: 0 процессов. Процессов RX capture: 0. +- Диагностических процессов: 0. +- Clean release A: 10 файлов из 10 совпадают с `versions/rf001-agent-2026.09.29.1` внутри подписанного bootstrap `.4` по относительному пути, размеру и SHA256. +- Conflict quarantine сохранён. SHA256 его diagnostic manifest: `b92f15dd4b7cffef375fd108f454a41623ed7328220c73c3f2b847dc59e956bc`. +- `rf001-bootstrap-2026.09.29.5.zip` отсутствует. + +## Доказанная первопричина + +Два последовательных изолированных запуска через настоящий `start_release()` дали одинаковый результат. Поля `release_id`, `agent_version`, `launch_nonce` и `state` совпали. Единственным несовпавшим полем стало `health.pid == Popen.pid`. + +На проверенном Windows/venv способе запуска `Popen.pid` принадлежит процессу `venv\\Scripts\\pythonw.exe`, а фактический интерпретатор, который исполняет health writer, является его единственным прямым дочерним процессом с образом базового `Python313\\python.exe`. Связь установлена не по именам: сверены PID, PPID, время создания, полный путь образа, командная строка и одноразовый `launch_nonce`. + +### Запуск 1 + +- Launcher процесса диагностики: PID 7848. +- `Popen.pid`: 10284, образ `C:\\Users\\Professional\\Documents\\Codex\\SDR_Rover_Video\\.venv\\Scripts\\pythonw.exe`, PPID 7848, создан `2026-09-29T13:04:44.696889Z`. +- Health writer: PID 8452, образ `C:\\Users\\Professional\\AppData\\Local\\Programs\\Python\\Python313\\python.exe`, PPID 10284, создан `2026-09-29T13:04:44.702070Z`. +- Health обновлён `2026-09-29T16:04:44.866156+03:00`. +- Wrapper уже наблюдался 0,167845 с и отсутствовал к завершению прогона через 2,589228 с после создания. +- Agent child уже наблюдался 0,164086 с и отсутствовал к завершению прогона через 2,585469 с после создания. +- Точный момент завершения каждого процесса не опрашивался, поэтому приведены проверенные нижняя и верхняя границы, а не предполагаемая длительность. + +### Запуск 2 + +- Launcher процесса диагностики: PID 7848. +- `Popen.pid`: 3504, образ `C:\\Users\\Professional\\Documents\\Codex\\SDR_Rover_Video\\.venv\\Scripts\\pythonw.exe`, PPID 7848, создан `2026-09-29T13:04:47.290751Z`. +- Health writer: PID 14616, образ `C:\\Users\\Professional\\AppData\\Local\\Programs\\Python\\Python313\\python.exe`, PPID 3504, создан `2026-09-29T13:04:47.295668Z`. +- Health обновлён `2026-09-29T16:04:47.461326+03:00`. +- Wrapper уже наблюдался 0,169277 с и отсутствовал к завершению прогона через 2,574777 с после создания. +- Agent child уже наблюдался 0,165658 с и отсутствовал к завершению прогона через 2,571158 с после создания. +- Точный момент завершения каждого процесса не опрашивался. + +Оба `launch_nonce` различались: + +- `96acc3af17150f42d393238bcbdad3ff`; +- `e4a618547e4bcda4b6914bf81bbd7963`. + +В обоих случаях штатный `start_release()` завершился fail-closed: + +```text +RuntimeError: health file does not match the launched process +``` + +После каждого прогона процессов, принадлежащих диагностическому запуску, не осталось. Исходные health-файлы и снимки процессов сохранены на RX в: + +```text +C:\Users\Professional\Downloads\SDR_Rover_Diagnostics\launcher_health_20260929T130000Z\evidence +``` + +## Сравнение полей health + +| Поле | Запуск 1 | Запуск 2 | +|---|---:|---:| +| `release_id == rf001-agent-2026.09.29.1` | PASS | PASS | +| `agent_version == 2026.09.29.1` | PASS | PASS | +| `launch_nonce` | PASS | PASS | +| `state == RUNNING` | PASS | PASS | +| `health.pid == Popen.pid` | FAIL: 8452 != 10284 | FAIL: 14616 != 3504 | + +Следовательно, `Popen.pid` соответствует варианту C: это родитель фактического процесса агента. Он не является произвольным или посторонним процессом; это venv-обёртка с ожидаемой командной строкой, которая существует одновременно с прямым дочерним health writer. + +## Строгая модель идентичности + +Основным вариантом по-прежнему должен быть запуск, при котором `Popen.pid` принадлежит фактическому долгоживущему агенту. Для проверенной Windows/venv топологии, где это условие не выполняется, допустима только следующая строгая альтернатива: + +1. Сохранить точные проверки `release_id`, `agent_version`, одноразового `launch_nonce` и `state=RUNNING`. +2. Снять идентичность процесса `Popen` сразу после запуска: PID, путь образа, командную строку и время создания. +3. Если health PID отличается, принять только один живой прямой дочерний процесс этого конкретного wrapper. +4. Для дочернего процесса проверить время создания после начала данного запуска, точную команду с тем же agent entrypoint и nonce, ожидаемый путь интерпретатора и наличие процесса в момент принятия health. +5. Не принимать внука, произвольного потомка, самый новый `python.exe`, совпадение только по имени или один числовой PID. +6. При завершении или откате повторно сверить PID и время создания перед остановкой wrapper и строго связанных с ним потомков. Это закрывает повторное использование PID. + +Минимальное производственное исправление можно ограничить `rf001_agent_launcher.py`: добавить проверку уникального прямого потомка и такую же строгую очистку потомков при ошибке. Формат health менять не требуется, поскольку в нём уже есть nonce и собственный PID. Если получение надёжного времени создания и пути образа без дополнительного компонента окажется невозможно, health writer следует расширить полями process identity; отключать проверку PID нельзя. + +## Конечный автомат launcher + +Названия ниже описывают фактические фазы кода. Не все они сейчас записываются как отдельное значение в `launcher_status.json`. + +| Фаза | Допустимый вход | Процессы | Canonical release / active pointer | Scheduled Task | Health | Действие при ошибке | +|---|---|---|---|---|---|---| +| `LEGACY_ACTIVE` | Действующий агент здоров и idle | Legacy agent запущен | Указатель на legacy | Launcher не меняет определение задания | Legacy `RUNNING` | Запрос обновления принимается только при `safe_to_switch=true`, совпадающем `from_release_id` и idle health | +| `TARGET_VERIFIED` | Подписанный target прошёл `validate_ready_release()` | Новый процесс ещё не создан | Target уже существует в canonical releases; указатель ещё legacy | Без изменений | Legacy health | Отказ до остановки legacy | +| `STARTING_TARGET` | Target проверен | Legacy останавливается; создаётся wrapper target | Указатель атомарно меняется на target; status `STARTING_UPDATE` | Без изменений | Старый health больше не считается подтверждением | Переход в `TARGET_FAILED` | +| `WAITING_FOR_HEALTH` | `Popen` создан, nonce уникален | Wrapper и возможный прямой child | Указатель target | Без изменений | Ожидаются свежие точные поля и строгая process identity | Timeout, exit или mismatch завершают target fail-closed | +| `TARGET_HEALTHY` | Все health-поля и identity прошли | Target продолжает работу | Указатель target; status `RUNNING` | Без изменений | Target `RUNNING` | Дальнейший выход ведёт в `RESTARTING` | +| `TARGET_FAILED` | Startup exit, timeout или mismatch | Target должен быть остановлен без orphan | Target ещё был активным указателем | Без изменений | Ошибочный health не принимается | Начать rollback | +| `ROLLING_BACK` | Target не прошёл health gate | Target очищается, запускается legacy | Указатель возвращается на legacy | Без изменений | Для legacy требуется новый nonce и новый health | Ошибка rollback должна оставаться явной, а не считаться успехом | +| `LEGACY_RESTORED` | Legacy снова прошёл health gate | Legacy работает | Указатель legacy; status `ROLLED_BACK` | Без изменений | Свежий legacy `RUNNING` | Архивировать запрос и продолжить legacy | + +Существенный пробел текущего production-кода: `TARGET_FAILED` останавливает только объект `Popen`; общая строгая очистка потомка в launcher пока не реализована. В двух диагностических запусках orphan не осталось, но это наблюдение не заменяет будущую явную очистку по identity. + +## Проверенные гонки и регрессии + +Локальный набор `pytest` проверяет: + +- точное совпадение процесса: PASS; +- неверные nonce, release, version и state: FAIL; +- stale health: FAIL; +- посторонний PID: FAIL; +- единственный ожидаемый прямой child: PASS только со строгой lineage, nonce, командой и временем создания; +- неожиданный grandchild: FAIL; +- повторное использование PID с другим creation time: FAIL; +- target умер до health: FAIL; +- target записал health, но умер: FAIL; +- health в состоянии `STARTING`, до полной готовности: FAIL; +- rollback не принимает health с nonce прошлого запуска: FAIL; +- выбор процессов для cleanup не затрагивает посторонний процесс; +- cleanup останавливает только строго принадлежащие wrapper и child; +- два успешных логических запуска детерминированы и имеют разные nonce; +- health JSON заменяется атомарно. + +Команда проверки: + +```text +python -m pytest tests/test_rf001_launcher_health_diagnostic.py tests/test_rf001_agent_launcher.py tests/test_rf001_rx_http_agent.py tests/test_rf001_agent_update.py -q +``` + +Результат: `48 passed`, предупреждение только о недоступности `.pytest_cache` в текущей песочнице. + +Windows integration test использовал реальный RX venv, установленный launcher source, canonical release A и настоящий agent health writer. Выполнено два последовательных изолированных запуска. HTTP polling, SDR и аппаратные команды в этом режиме отсутствовали. + +## Файлы и следующий этап + +В этой диагностической фазе добавлены только: + +- `tools/rf001_launcher_health_diagnostic.py`; +- `tests/test_rf001_launcher_health_diagnostic.py`; +- этот отчёт. + +Для будущего производственного исправления потребуются как минимум: + +- `tools/rf001_agent_launcher.py`; +- `tests/test_rf001_agent_launcher.py`; +- тесты упаковки, подтверждающие неизменность подписанного состава и fail-closed rollback. + +`tools/rf001_rx_http_agent.py` потребуется менять только при выборе расширенного health process identity; доказанная минимальная схема может обойтись без изменения агента. + +После отдельного согласования исправления понадобится новый уникальный bootstrap, поскольку launcher на RX должен быть заменён проверенным пакетом. Bootstrap `.5` в рамках этой фазы не создавался. diff --git a/tests/test_rf001_agent_launcher.py b/tests/test_rf001_agent_launcher.py new file mode 100644 index 0000000..891b8ea --- /dev/null +++ b/tests/test_rf001_agent_launcher.py @@ -0,0 +1,625 @@ +"""Software-only tests for the stable versioned RX-agent launcher.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timedelta, timezone +import json +from pathlib import Path +import subprocess + +import pytest + +import tools.rf001_agent_launcher as launcher + + +class FakeProcess: + def __init__(self, process_id: int, returncode: int | None = None) -> None: + self.pid = process_id + self.returncode = returncode + + def poll(self) -> int | None: + return self.returncode + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + def wait(self, timeout: float | None = None) -> int: + del timeout + if self.returncode is None: + self.returncode = 0 + return self.returncode + + +NOW = datetime(2026, 9, 29, 13, 0, tzinfo=timezone.utc) +WRAPPER = r"C:\runtime\.venv\Scripts\pythonw.exe" +CHILD = r"C:\runtime\Python313\python.exe" +AGENT = r"C:\release\app\tools\rf001_rx_http_agent.py" + + +def _attempt( + *, + process_id: int = 200, + nonce: str = "current-launch-nonce", + returncode: int | None = None, +) -> launcher.LaunchAttempt: + command = ( + WRAPPER, + "-u", + "-B", + "-X", + "utf8", + AGENT, + "--launch-nonce", + nonce, + ) + return launcher.LaunchAttempt( + process=FakeProcess(process_id, returncode), # type: ignore[arg-type] + command=command, + wrapper_pid=process_id, + launch_boundary=NOW.isoformat(), + wrapper_executable=WRAPPER, + child_executable=CHILD, + launch_nonce=nonce, + ) + + +def _identity( + pid: int, + parent_pid: int, + *, + executable: str, + command: tuple[str, ...] | None = None, + seconds: float = 1.0, +) -> launcher.ProcessIdentity: + return launcher.ProcessIdentity( + pid=pid, + parent_pid=parent_pid, + executable=executable, + command_line=subprocess.list2cmdline(command or _attempt().command), + created_at=(NOW + timedelta(seconds=seconds)).isoformat(), + ) + + +def _wrapper(attempt: launcher.LaunchAttempt | None = None) -> launcher.ProcessIdentity: + value = attempt or _attempt() + return _identity( + value.wrapper_pid, + 100, + executable=value.wrapper_executable, + command=value.command, + ) + + +def _child(attempt: launcher.LaunchAttempt | None = None) -> launcher.ProcessIdentity: + value = attempt or _attempt() + return _identity( + value.wrapper_pid + 1, + value.wrapper_pid, + executable=value.child_executable, + command=value.command, + ) + + +def _health( + *, + pid: int = 200, + release_id: str = "rf001-agent-2026.09.29.1", + version: str = "2026.09.29.1", + nonce: str = "current-launch-nonce", + state: str = "RUNNING", + updated_at: str | None = None, +) -> dict[str, object]: + return { + "release_id": release_id, + "agent_version": version, + "launch_nonce": nonce, + "state": state, + "pid": pid, + "updated_at": updated_at or (NOW + timedelta(seconds=2)).isoformat(), + } + + +def _config(tmp_path: Path) -> dict[str, object]: + install = tmp_path / "install" + config: dict[str, object] = { + "install_root": str(install), + "releases_root": str(install / "versions"), + "active_release_file": str(install / "active_release.json"), + "launcher_status": str(install / "launcher_status.json"), + "launcher_request": str(install / "launcher_request.json"), + "health_file": str(install / "agent_health.json"), + "agent_log": str(install / "logs" / "agent.log"), + "agent_arguments": [], + } + return config + + +def _ready(config: dict[str, object], release_id: str, version: str) -> None: + root = Path(str(config["releases_root"])) / release_id + (root / "app" / "tools").mkdir(parents=True) + (root / "app" / "tools" / "rf001_rx_http_agent.py").write_text( + "# fixture\n", encoding="utf-8" + ) + (root / "READY.json").write_text( + json.dumps( + { + "status": "READY", + "release_id": release_id, + "agent_version": version, + } + ), + encoding="utf-8", + ) + + +def _request( + config: dict[str, object], + current: str, + target: str, + version: str, +) -> dict[str, object]: + value = { + "task_id": "UPDATE_TEST_001", + "from_release_id": current, + "release_id": target, + "agent_version": version, + "safe_to_switch": True, + } + path = Path(str(config["launcher_request"])) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + Path(str(config["health_file"])).write_text( + json.dumps({"busy": False, "active_task_id": None}), + encoding="utf-8", + ) + return value + + +def test_launcher_switches_only_to_ready_release( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config(tmp_path) + old = "rf001-agent-2026.09.29.1" + new = "rf001-agent-2026.09.29.2" + _ready(config, old, "2026.09.29.1") + _ready(config, new, "2026.09.29.2") + request = _request(config, old, new, "2026.09.29.2") + terminated: list[FakeProcess] = [] + monkeypatch.setattr( + launcher, + "terminate_process", + lambda process: terminated.append(process), + ) + monkeypatch.setattr( + launcher, + "start_release", + lambda *_args, **_kwargs: (FakeProcess(2), "2026.09.29.2"), + ) + process, release_id, version = launcher.switch_release( + config, + FakeProcess(1), + old, + "2026.09.29.1", + request, + ) + assert process.pid == 2 + assert release_id == new + assert version == "2026.09.29.2" + assert len(terminated) == 1 + active = json.loads(Path(str(config["active_release_file"])).read_text()) + assert active["release_id"] == new + status = json.loads(Path(str(config["launcher_status"])).read_text()) + assert status["state"] == "RUNNING" + assert status["phase"] == "TARGET_HEALTHY" + assert status["rf_tx"] == 0 + + +def test_launcher_rolls_back_after_new_version_start_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config(tmp_path) + old = "rf001-agent-2026.09.29.1" + new = "rf001-agent-2026.09.29.2" + _ready(config, old, "2026.09.29.1") + _ready(config, new, "2026.09.29.2") + request = _request(config, old, new, "2026.09.29.2") + calls: list[str] = [] + + def fake_start( + _config_value: object, + release_id: str, + *, + expected_version: str | None = None, + ) -> tuple[FakeProcess, str]: + del expected_version + calls.append(release_id) + if release_id == new: + raise RuntimeError("fixed startup failure") + return FakeProcess(3), "2026.09.29.1" + + monkeypatch.setattr(launcher, "terminate_process", lambda _process: None) + monkeypatch.setattr(launcher, "start_release", fake_start) + process, release_id, version = launcher.switch_release( + config, + FakeProcess(1), + old, + "2026.09.29.1", + request, + ) + assert process.pid == 3 + assert release_id == old + assert version == "2026.09.29.1" + assert calls == [new, old] + active = json.loads(Path(str(config["active_release_file"])).read_text()) + assert active["release_id"] == old + status = json.loads(Path(str(config["launcher_status"])).read_text()) + assert status["state"] == "ROLLED_BACK" + assert status["phase"] == "LEGACY_RESTORED" + assert "fixed startup failure" in status["error"] + + +def test_launcher_rejects_busy_agent_without_stopping_it(tmp_path: Path) -> None: + config = _config(tmp_path) + old = "rf001-agent-2026.09.29.1" + new = "rf001-agent-2026.09.29.2" + _ready(config, old, "2026.09.29.1") + _ready(config, new, "2026.09.29.2") + request = _request(config, old, new, "2026.09.29.2") + Path(str(config["health_file"])).write_text( + json.dumps({"busy": True, "active_task_id": "STAIRCASE_TEST"}), + encoding="utf-8", + ) + with pytest.raises(RuntimeError, match="not idle"): + launcher.switch_release( + config, + FakeProcess(1), + old, + "2026.09.29.1", + request, + ) + + +def test_direct_exact_identity_passes() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + result = launcher._validate_target_identity( + attempt, + wrapper, + attempt.wrapper_pid, + [wrapper], + ) + assert result.mode == "DIRECT" + assert result.target == wrapper + + +def test_unique_expected_wrapper_child_passes() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + child = _child(attempt) + result = launcher._validate_target_identity( + attempt, + wrapper, + child.pid, + [wrapper, child], + ) + assert result.mode == "WINDOWS_VENV_WRAPPER" + assert result.target == child + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("launch_nonce", "wrong"), + ("release_id", "rf001-agent-2026.09.29.9"), + ("agent_version", "2026.09.29.9"), + ("state", "STARTING"), + ], +) +def test_wrong_health_field_fails(field: str, value: str) -> None: + health = _health() + health[field] = value + assert not launcher._health_fields_match( + health, + release_id="rf001-agent-2026.09.29.1", + agent_version="2026.09.29.1", + launch_nonce="current-launch-nonce", + launch_boundary=NOW.isoformat(), + ) + + +def test_stale_health_fails() -> None: + health = _health(updated_at=(NOW - timedelta(seconds=1)).isoformat()) + assert not launcher._health_fields_match( + health, + release_id="rf001-agent-2026.09.29.1", + agent_version="2026.09.29.1", + launch_nonce="current-launch-nonce", + launch_boundary=NOW.isoformat(), + ) + + +def test_unrelated_pid_fails() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + unrelated = _identity(300, 999, executable=CHILD) + with pytest.raises(RuntimeError, match="not a direct wrapper child"): + launcher._validate_target_identity( + attempt, + wrapper, + unrelated.pid, + [wrapper, unrelated], + ) + + +def test_unrelated_child_fails() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + unrelated = replace( + _child(attempt), + command_line="python.exe unrelated.py", + ) + with pytest.raises(RuntimeError, match="absent or ambiguous"): + launcher._validate_target_identity( + attempt, + wrapper, + unrelated.pid, + [wrapper, unrelated], + ) + + +def test_unexpected_grandchild_fails() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + child = _child(attempt) + grandchild = replace(child, pid=202, parent_pid=child.pid) + with pytest.raises(RuntimeError, match="not a direct wrapper child"): + launcher._validate_target_identity( + attempt, + wrapper, + grandchild.pid, + [wrapper, child, grandchild], + ) + + +def test_two_matching_children_fail() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + first = _child(attempt) + second = replace(first, pid=first.pid + 1) + with pytest.raises(RuntimeError, match="absent or ambiguous"): + launcher._validate_target_identity( + attempt, + wrapper, + first.pid, + [wrapper, first, second], + ) + + +def test_creation_time_mismatch_fails() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + stale_child = replace( + _child(attempt), + created_at=(NOW - timedelta(seconds=30)).isoformat(), + ) + with pytest.raises(RuntimeError, match="absent or ambiguous"): + launcher._validate_target_identity( + attempt, + wrapper, + stale_child.pid, + [wrapper, stale_child], + ) + + +def test_pid_reuse_between_validations_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + child = _child(attempt) + reused = replace( + child, + created_at=(NOW + timedelta(seconds=3)).isoformat(), + ) + snapshots = iter(([wrapper, child], [wrapper, reused])) + monkeypatch.setattr( + launcher, + "_query_process_identities", + lambda *_args, **_kwargs: next(snapshots), + ) + with pytest.raises(RuntimeError, match="changed during validation"): + launcher._validate_target_twice( + attempt, + wrapper, + child.pid, + ) + + +def test_wrapper_dies_too_early() -> None: + attempt = _attempt(returncode=1) + wrapper = _wrapper(attempt) + with pytest.raises(RuntimeError, match="wrapper died"): + launcher._validate_target_identity( + attempt, + wrapper, + wrapper.pid, + [wrapper], + ) + + +def test_child_dies_before_final_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + child = _child(attempt) + snapshots = iter(([wrapper, child], [wrapper])) + monkeypatch.setattr( + launcher, + "_query_process_identities", + lambda *_args, **_kwargs: next(snapshots), + ) + with pytest.raises(RuntimeError, match="health process is absent"): + launcher._validate_target_twice( + attempt, + wrapper, + child.pid, + ) + + +def test_wrong_executable_fails() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + child = replace(_child(attempt), executable=r"C:\Other\python.exe") + with pytest.raises(RuntimeError, match="absent or ambiguous"): + launcher._validate_target_identity( + attempt, + wrapper, + child.pid, + [wrapper, child], + ) + + +def test_wrong_command_line_fails() -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + child = replace(_child(attempt), command_line="python.exe other_agent.py") + with pytest.raises(RuntimeError, match="absent or ambiguous"): + launcher._validate_target_identity( + attempt, + wrapper, + child.pid, + [wrapper, child], + ) + + +def test_cleanup_terminates_verified_child_and_wrapper_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + child = _child(attempt) + unrelated = _identity(300, 999, executable=CHILD) + alive = {wrapper.pid: wrapper, child.pid: child, unrelated.pid: unrelated} + terminated: list[int] = [] + + def query(wrapper_pid: int, health_pid: int | None = None) -> list[launcher.ProcessIdentity]: + del health_pid + return [ + identity + for identity in alive.values() + if identity.pid == wrapper_pid or identity.parent_pid == wrapper_pid + ] + + def terminate_identity(identity: launcher.ProcessIdentity) -> str: + terminated.append(identity.pid) + alive.pop(identity.pid) + return f"PID {identity.pid}: terminated" + + def terminate_wrapper(process: FakeProcess) -> None: + terminated.append(process.pid) + alive.pop(process.pid) + process.returncode = 0 + + monkeypatch.setattr(launcher, "_query_process_identities", query) + monkeypatch.setattr(launcher, "_terminate_verified_identity", terminate_identity) + monkeypatch.setattr(launcher, "terminate_process", terminate_wrapper) + + result = launcher.strict_failure_cleanup(attempt, wrapper) + + assert result.state == "COMPLETE" + assert result.orphan_verified_target_processes == () + assert terminated == [child.pid, wrapper.pid] + assert unrelated.pid in alive + + +def test_cleanup_unknown_identity_kills_nothing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempt = _attempt() + wrapper = _wrapper(attempt) + child = _child(attempt) + killed: list[int] = [] + monkeypatch.setattr( + launcher, + "_query_process_identities", + lambda *_args, **_kwargs: [wrapper, child], + ) + monkeypatch.setattr( + launcher, + "_terminate_verified_identity", + lambda identity: killed.append(identity.pid), + ) + + result = launcher.strict_failure_cleanup(attempt, None) + + assert result.state == "CLEANUP_INCOMPLETE" + assert killed == [] + + +def test_two_sequential_successful_starts_use_distinct_nonces( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _config(tmp_path) + release_id = "rf001-agent-2026.09.29.1" + _ready(config, release_id, "2026.09.29.1") + nonces = iter(("first-nonce", "second-nonce")) + process_ids = iter((200, 300)) + observed: list[tuple[int, str]] = [] + + monkeypatch.setattr(launcher.secrets, "token_hex", lambda _length: next(nonces)) + monkeypatch.setattr( + launcher, + "_runtime_executables", + lambda _value: (WRAPPER, CHILD), + ) + + def spawn( + command: list[str], + *, + cwd: Path, + log_stream: object, + ) -> FakeProcess: + del cwd, log_stream + process = FakeProcess(next(process_ids)) + nonce = command[command.index("--launch-nonce") + 1] + Path(str(config["health_file"])).write_text( + json.dumps(_health(pid=process.pid, nonce=nonce, updated_at="2099-01-01T00:00:00+00:00")), + encoding="utf-8", + ) + return process + + def capture(attempt: launcher.LaunchAttempt, *, timeout_seconds: float) -> launcher.ProcessIdentity: + del timeout_seconds + return _identity( + attempt.wrapper_pid, + 100, + executable=WRAPPER, + command=attempt.command, + ) + + def validate( + attempt: launcher.LaunchAttempt, + wrapper: launcher.ProcessIdentity, + health_pid: int, + ) -> launcher.ValidatedTarget: + observed.append((health_pid, attempt.launch_nonce)) + return launcher.ValidatedTarget("DIRECT", wrapper, wrapper) + + monkeypatch.setattr(launcher, "_spawn_agent", spawn) + monkeypatch.setattr(launcher, "_capture_wrapper_identity", capture) + monkeypatch.setattr(launcher, "_validate_target_twice", validate) + + first, _ = launcher.start_release(config, release_id) + second, _ = launcher.start_release(config, release_id) + + assert first.pid == 200 + assert second.pid == 300 + assert observed == [(200, "first-nonce"), (300, "second-nonce")] diff --git a/tests/test_rf001_agent_update.py b/tests/test_rf001_agent_update.py new file mode 100644 index 0000000..e0fdeea --- /dev/null +++ b/tests/test_rf001_agent_update.py @@ -0,0 +1,172 @@ +"""Software-only tests for fixed RF-001 agent release archives.""" + +from __future__ import annotations + +import json +from pathlib import Path +import stat +import zipfile + +import pytest + +from tools.rf001_agent_update import ( + CONTENT_MANIFEST_NAME, + RELEASE_SOURCE_FILES, + ReleaseValidationError, + build_release_archive, + canonical_json_bytes, + inspect_release_archive, + sign_update_manifest, + verify_update_manifest_signature, +) + + +def _source_tree(root: Path, *, agent_version: str = "2026.09.29.1") -> None: + for index, relative in enumerate(RELEASE_SOURCE_FILES.values()): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + content = f"# fixed fixture {index}\n" + if relative == RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]: + content += f'AGENT_VERSION = "{agent_version}"\n' + path.write_text(content, encoding="utf-8") + + +def test_release_roundtrip_uses_exact_allowlist(tmp_path: Path) -> None: + source = tmp_path / "source" + _source_tree(source) + package = tmp_path / "rf001-agent-2026.09.29.1.zip" + built = build_release_archive( + source, + package, + release_id="rf001-agent-2026.09.29.1", + agent_version="2026.09.29.1", + protocol_version=1, + ) + extraction = tmp_path / "extracted" + checked = inspect_release_archive( + package, + expected_package_sha256=built.package_sha256, + expected_package_bytes=built.package_bytes, + expected_content_manifest_sha256=built.content_manifest_sha256, + extraction_root=extraction, + ) + assert checked == built + assert { + path.relative_to(extraction).as_posix() + for path in extraction.rglob("*") + if path.is_file() + } == set(RELEASE_SOURCE_FILES) | {CONTENT_MANIFEST_NAME} + + +def test_update_manifest_rejects_bad_signature() -> None: + token = b"fixed-test-token-0123456789abcdef" + manifest = { + "schema_version": 1, + "release_id": "rf001-agent-2026.09.29.1", + "package_sha256": "a" * 64, + } + signature = sign_update_manifest(token, manifest) + verify_update_manifest_signature(token, manifest, signature) + with pytest.raises(ReleaseValidationError, match="mismatch"): + verify_update_manifest_signature(token, manifest, "0" * 64) + changed = dict(manifest, package_sha256="b" * 64) + with pytest.raises(ReleaseValidationError, match="mismatch"): + verify_update_manifest_signature(token, changed, signature) + + +@pytest.mark.parametrize( + "unsafe_name", + ( + "../outside.py", + "/absolute.py", + "C:/absolute.py", + "app\\tools\\backslash.py", + ), +) +def test_release_rejects_unsafe_paths(tmp_path: Path, unsafe_name: str) -> None: + package = tmp_path / "unsafe.zip" + with zipfile.ZipFile(package, "w") as archive: + archive.writestr(unsafe_name, b"x") + # zipfile normalizes a backslash to a slash before it can be inspected; + # that spelling still cannot enter the fixed release allowlist. + with pytest.raises( + ReleaseValidationError, + match="unsafe|drive-like|not allowlisted|manifest is absent", + ): + inspect_release_archive(package) + + +def test_release_rejects_symlink_and_duplicate_names(tmp_path: Path) -> None: + symlink_package = tmp_path / "symlink.zip" + with zipfile.ZipFile(symlink_package, "w") as archive: + info = zipfile.ZipInfo("app/tools/link.py") + info.create_system = 3 + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(info, b"target") + with pytest.raises(ReleaseValidationError, match="links"): + inspect_release_archive(symlink_package) + + duplicate_package = tmp_path / "duplicate.zip" + with zipfile.ZipFile(duplicate_package, "w") as archive: + archive.writestr("app/tools/a.py", b"a") + archive.writestr("APP/TOOLS/A.PY", b"b") + with pytest.raises(ReleaseValidationError, match="duplicate"): + inspect_release_archive(duplicate_package) + + +def test_release_rejects_unsupported_protocol_and_sha(tmp_path: Path) -> None: + source = tmp_path / "source" + _source_tree(source) + package = tmp_path / "release.zip" + built = build_release_archive( + source, + package, + release_id="rf001-agent-2026.09.29.1", + agent_version="2026.09.29.1", + protocol_version=2, + ) + assert built.protocol_version == 2 + with pytest.raises(ReleaseValidationError, match="package SHA256"): + inspect_release_archive(package, expected_package_sha256="0" * 64) + + +def test_content_manifest_must_be_canonical(tmp_path: Path) -> None: + package = tmp_path / "noncanonical.zip" + manifest = { + "schema_version": 1, + "release_id": "rf001-agent-2026.09.29.1", + "agent_version": "2026.09.29.1", + "protocol_version": 1, + "files": [], + } + assert canonical_json_bytes(manifest) != json.dumps(manifest).encode("utf-8") + with zipfile.ZipFile(package, "w") as archive: + archive.writestr(CONTENT_MANIFEST_NAME, json.dumps(manifest)) + with pytest.raises(ReleaseValidationError, match="canonical"): + inspect_release_archive(package) + + +@pytest.mark.parametrize( + ("source_version", "requested_version"), + ( + ("2026.09.29.2", "2026.09.29.1"), + ("2026.09.29.1", "2026.09.29.2"), + ), +) +def test_release_builder_rejects_version_mismatch_before_artifact( + tmp_path: Path, + source_version: str, + requested_version: str, +) -> None: + source = tmp_path / "source" + _source_tree(source, agent_version=source_version) + package = tmp_path / "must-not-exist.zip" + with pytest.raises(ReleaseValidationError, match="AGENT_VERSION|release_id"): + build_release_archive( + source, + package, + release_id=f"rf001-agent-{requested_version}", + agent_version=requested_version, + protocol_version=1, + ) + assert not package.exists() diff --git a/tests/test_rf001_bootstrap_install_orchestrator.py b/tests/test_rf001_bootstrap_install_orchestrator.py new file mode 100644 index 0000000..523ebad --- /dev/null +++ b/tests/test_rf001_bootstrap_install_orchestrator.py @@ -0,0 +1,133 @@ +"""Software-only regressions for external bootstrap install evidence.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +from pathlib import Path +import sys + +import pytest + +from tools.rf001_bootstrap_install_orchestrator import ( + EvidencePathError, + PackageValidationError, + SIGNATURE_DOMAIN, + run_installer, + snapshot_tree, + validate_pristine_package, +) + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _signed_package(tmp_path: Path) -> tuple[Path, Path]: + package = tmp_path / "clean-bootstrap" + package.mkdir() + (package / "INSTALL_RX_AGENT.cmd").write_text("@echo off\n", encoding="utf-8") + payload = package / "payload.bin" + payload.write_bytes(b"fixed bootstrap payload") + rows = [] + for path in sorted(item for item in package.rglob("*") if item.is_file()): + rows.append( + { + "path": path.relative_to(package).as_posix(), + "bytes": path.stat().st_size, + "sha256": _sha256(path), + } + ) + manifest = { + "schema_version": 1, + "package_kind": "BOOTSTRAP", + "bootstrap_id": "rf001-bootstrap-2099.01.01.1", + "agent_release_id": "rf001-agent-2099.01.01.1", + "agent_version": "2099.01.01.1", + "files": rows, + } + manifest_bytes = ( + json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n" + ).encode("utf-8") + (package / "BOOTSTRAP_MANIFEST.json").write_bytes(manifest_bytes) + token = tmp_path / "token.txt" + token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8") + signature = hmac.new( + token.read_text(encoding="utf-8").encode("utf-8"), + SIGNATURE_DOMAIN + manifest_bytes, + hashlib.sha256, + ).hexdigest() + (package / "BOOTSTRAP_MANIFEST.hmac").write_text( + signature + "\n", + encoding="ascii", + ) + return package, token + + +def test_pristine_package_uses_external_logs_and_remains_unchanged( + tmp_path: Path, +) -> None: + package, token = _signed_package(tmp_path) + evidence = tmp_path / "external-evidence" + before = snapshot_tree(package) + command = [ + sys.executable, + "-c", + "import sys; print('installer stdout'); print('installer stderr', file=sys.stderr)", + ] + + result = run_installer(package, evidence, token, command=command) + + assert result["exit_code"] == 0 + assert result["package_content_unchanged_immediate"] is True + assert result["package_content_unchanged_after"] is True + assert result["package_metadata_unchanged_after"] is True + assert snapshot_tree(package) == before + assert (evidence / "install.stdout.txt").read_text().strip() == "installer stdout" + assert (evidence / "install.stderr.txt").read_text().strip() == "installer stderr" + assert not (package / "install.stdout.txt").exists() + assert not (package / "install.stderr.txt").exists() + preflight = json.loads((evidence / "package_preflight.json").read_text()) + assert preflight["hmac"] == "PASS" + assert preflight["unexpected"] == 0 + assert preflight["missing"] == 0 + + +def test_contaminated_package_fails_closed_before_command(tmp_path: Path) -> None: + package, token = _signed_package(tmp_path) + (package / "install.stdout.txt").write_text("orchestration leak\n", encoding="utf-8") + evidence = tmp_path / "external-evidence" + command_marker = tmp_path / "command-ran.txt" + command = [ + sys.executable, + "-c", + f"from pathlib import Path; Path({str(command_marker)!r}).write_text('ran')", + ] + + with pytest.raises(PackageValidationError, match="allowlist mismatch"): + run_installer(package, evidence, token, command=command) + + assert not command_marker.exists() + assert not (evidence / "install.stdout.txt").exists() + assert not (evidence / "install.stderr.txt").exists() + + +def test_evidence_directory_inside_package_is_rejected(tmp_path: Path) -> None: + package, token = _signed_package(tmp_path) + evidence = package / "runtime-evidence" + + with pytest.raises(EvidencePathError, match="outside the package"): + run_installer(package, evidence, token, command=[sys.executable, "-c", "pass"]) + + +def test_pristine_validation_reports_exact_allowlist(tmp_path: Path) -> None: + package, token = _signed_package(tmp_path) + + result = validate_pristine_package(package, token) + + assert result["hmac"] == "PASS" + assert result["manifest_file_count"] == 2 + assert result["package_file_count"] == 4 + assert result["unexpected"] == 0 + assert result["missing"] == 0 diff --git a/tests/test_rf001_build_agent_packages.py b/tests/test_rf001_build_agent_packages.py new file mode 100644 index 0000000..a06197c --- /dev/null +++ b/tests/test_rf001_build_agent_packages.py @@ -0,0 +1,319 @@ +"""Software-only tests for the authenticated bootstrap package.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +from pathlib import PurePosixPath +import shutil +import subprocess +import unicodedata +import zipfile + +import pytest + +import tools.rf001_build_agent_packages as packages +from tools.rf001_agent_update import ( + RELEASE_SOURCE_FILES, + ReleaseValidationError, + source_agent_version, +) + + +def _bootstrap_source( + root: Path, + *, + agent_version: str = "2026.09.29.1", +) -> Path: + fixed = { + "INSTALL_RX_AGENT.cmd": "@echo off\n", + "UNINSTALL_RX_AGENT.cmd": "@echo off\n", + "tools/rf001_bootstrap_readme.txt": "fixed bootstrap readme\n", + "tools/rf001_agent_launcher.py": "# fixed launcher\n", + "tools/install_rf001_rx_autostart.ps1": "# fixed installer\n", + "tools/uninstall_rf001_rx_autostart.ps1": "# fixed uninstaller\n", + } + for name, content in fixed.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + for index, relative in enumerate(RELEASE_SOURCE_FILES.values()): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + content = f"# fixed release source {index}\n" + if relative == RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]: + content += f'AGENT_VERSION = "{agent_version}"\n' + path.write_text(content, encoding="utf-8") + reference = root / ".local/rf001_probe/lab065_reference_staircase.cs8" + reference.parent.mkdir(parents=True, exist_ok=True) + reference.write_bytes(b"fixed exact staircase fixture") + return reference + + +def _formed_installer_allowlist(release_id: str) -> list[str]: + powershell = shutil.which("powershell.exe") + if powershell is None: + pytest.skip("Windows PowerShell is required to evaluate the installer allowlist") + installer = Path(packages.__file__).with_name("install_rf001_rx_autostart.ps1") + environment = os.environ.copy() + environment["SDR_ROVER_INSTALLER_PATH"] = str(installer) + environment["SDR_ROVER_RELEASE_ID"] = release_id + command = r""" +$ErrorActionPreference = "Stop" +$tokens = $null +$parseErrors = $null +$syntax = [Management.Automation.Language.Parser]::ParseFile( + $env:SDR_ROVER_INSTALLER_PATH, + [ref]$tokens, + [ref]$parseErrors +) +if ($parseErrors.Count -ne 0) { + throw "Installer contains PowerShell parse errors" +} +$assignments = @($syntax.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -eq "expectedPaths" +}, $true)) +if ($assignments.Count -ne 1) { + throw "Installer expectedPaths assignment is absent or ambiguous" +} +$releasePrefix = "versions/$($env:SDR_ROVER_RELEASE_ID)/" +. ([scriptblock]::Create($assignments[0].Extent.Text)) +ConvertTo-Json -Compress -InputObject @($expectedPaths) +""" + completed = subprocess.run( + [powershell, "-NoProfile", "-NonInteractive", "-Command", command], + check=True, + capture_output=True, + encoding="utf-8", + env=environment, + ) + value = json.loads(completed.stdout.lstrip("\ufeff")) + assert isinstance(value, list) + assert all(isinstance(path, str) for path in value) + return value + + +def test_bootstrap_is_authenticated_and_excludes_secret( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "source" + reference = _bootstrap_source(source) + monkeypatch.setattr( + packages, + "REFERENCE_SHA256", + hashlib.sha256(reference.read_bytes()).hexdigest(), + ) + token = tmp_path / "token.txt" + token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8") + package = tmp_path / "bootstrap.zip" + built = packages.build_bootstrap_archive( + source, + package, + bootstrap_id="rf001-bootstrap-2026.09.29.1", + agent_release_id="rf001-agent-2026.09.29.1", + agent_version="2026.09.29.1", + protocol_version=1, + token_path=token, + ) + checked = packages.inspect_bootstrap_archive(package, token_path=token) + assert checked == built + assert checked.file_count == len(RELEASE_SOURCE_FILES) + 8 + with zipfile.ZipFile(package) as archive: + names = archive.namelist() + manifest = json.loads(archive.read(packages.BOOTSTRAP_MANIFEST_NAME)) + ready = json.loads( + archive.read("versions/rf001-agent-2026.09.29.1/READY.json") + ) + assert not any("token" in name.casefold() for name in names) + assert not any( + b"fixed-test-token" in archive.read(name) + for name in names + ) + actual_version = source_agent_version( + source / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"] + ) + assert manifest["agent_version"] == actual_version + assert ready["agent_version"] == actual_version + + +def test_new_bootstrap_preserves_existing_release_bytes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "source" + reference = _bootstrap_source(source) + monkeypatch.setattr( + packages, + "REFERENCE_SHA256", + hashlib.sha256(reference.read_bytes()).hexdigest(), + ) + token = tmp_path / "token.txt" + token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8") + base_package = tmp_path / "bootstrap-4.zip" + next_package = tmp_path / "bootstrap-5.zip" + release_id = "rf001-agent-2026.09.29.1" + packages.build_bootstrap_archive( + source, + base_package, + bootstrap_id="rf001-bootstrap-2026.09.29.4", + agent_release_id=release_id, + agent_version="2026.09.29.1", + protocol_version=1, + token_path=token, + ) + (source / "tools/rf001_agent_launcher.py").write_text( + "# corrected launcher\n", + encoding="utf-8", + ) + packages.build_bootstrap_archive( + source, + next_package, + bootstrap_id="rf001-bootstrap-2026.09.29.5", + agent_release_id=release_id, + agent_version="2026.09.29.1", + protocol_version=1, + token_path=token, + agent_release_bootstrap_id="rf001-bootstrap-2026.09.29.4", + ) + + release_prefix = f"versions/{release_id}/" + with zipfile.ZipFile(base_package) as base, zipfile.ZipFile(next_package) as new: + release_names = sorted( + name for name in base.namelist() if name.startswith(release_prefix) + ) + assert release_names + assert all(base.read(name) == new.read(name) for name in release_names) + assert base.read("launcher/rf001_agent_launcher.py") != new.read( + "launcher/rf001_agent_launcher.py" + ) + manifest = json.loads(new.read(packages.BOOTSTRAP_MANIFEST_NAME)) + ready = json.loads(new.read(f"{release_prefix}READY.json")) + assert manifest["bootstrap_id"] == "rf001-bootstrap-2026.09.29.5" + assert ( + manifest["agent_release_bootstrap_id"] + == "rf001-bootstrap-2026.09.29.4" + ) + assert ready["bootstrap_id"] == "rf001-bootstrap-2026.09.29.4" + + +def test_bootstrap_rejects_the_wrong_existing_secret( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "source" + reference = _bootstrap_source(source) + monkeypatch.setattr( + packages, + "REFERENCE_SHA256", + hashlib.sha256(reference.read_bytes()).hexdigest(), + ) + token = tmp_path / "token.txt" + token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8") + package = tmp_path / "bootstrap.zip" + packages.build_bootstrap_archive( + source, + package, + bootstrap_id="rf001-bootstrap-2026.09.29.1", + agent_release_id="rf001-agent-2026.09.29.1", + agent_version="2026.09.29.1", + protocol_version=1, + token_path=token, + ) + wrong = tmp_path / "wrong.txt" + wrong.write_text("another-fixed-token-0123456789abcdef", encoding="utf-8") + with pytest.raises(ValueError, match="signature mismatch"): + packages.inspect_bootstrap_archive(package, token_path=wrong) + + +def test_installer_allowlist_matches_signed_manifest_and_has_safe_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = tmp_path / "source" + reference = _bootstrap_source(source) + monkeypatch.setattr( + packages, + "REFERENCE_SHA256", + hashlib.sha256(reference.read_bytes()).hexdigest(), + ) + token = tmp_path / "token.txt" + token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8") + package = tmp_path / "bootstrap.zip" + release_id = "rf001-agent-2026.09.29.1" + packages.build_bootstrap_archive( + source, + package, + bootstrap_id="rf001-bootstrap-2026.09.29.2", + agent_release_id=release_id, + agent_version="2026.09.29.1", + protocol_version=1, + token_path=token, + ) + with zipfile.ZipFile(package) as archive: + manifest = json.loads(archive.read(packages.BOOTSTRAP_MANIFEST_NAME)) + manifest_paths = {row["path"] for row in manifest["files"]} + formed_paths = _formed_installer_allowlist(release_id) + + assert set(formed_paths) == manifest_paths + assert len(formed_paths) == len(manifest_paths) + assert f"versions/{release_id}/READY.json" in formed_paths + assert ( + f"versions/{release_id}/app/tools/hackrf_process_check.py" in formed_paths + ) + assert ( + f"versions/{release_id}/app/tools/rf001_staircase_search.py" in formed_paths + ) + for path in formed_paths: + assert not any(unicodedata.category(character) == "Cc" for character in path) + parsed = PurePosixPath(path) + assert not parsed.is_absolute() + assert all(part not in ("", ".", "..") for part in parsed.parts) + assert "\\" not in path + assert not any(":" in part for part in parsed.parts) + + with zipfile.ZipFile(package, "a") as archive: + archive.writestr("unexpected/extra.txt", b"prohibited") + with pytest.raises(ValueError, match="unexpected files"): + packages.inspect_bootstrap_archive(package, token_path=token) + + +@pytest.mark.parametrize( + ("source_version", "requested_version"), + ( + ("2026.09.29.2", "2026.09.29.1"), + ("2026.09.29.1", "2026.09.29.2"), + ), +) +def test_bootstrap_rejects_agent_version_mismatch_before_creating_artifact( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + source_version: str, + requested_version: str, +) -> None: + source = tmp_path / "source" + reference = _bootstrap_source(source, agent_version=source_version) + monkeypatch.setattr( + packages, + "REFERENCE_SHA256", + hashlib.sha256(reference.read_bytes()).hexdigest(), + ) + token = tmp_path / "token.txt" + token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8") + package = tmp_path / "must-not-exist.zip" + with pytest.raises(ReleaseValidationError, match="AGENT_VERSION|release_id"): + packages.build_bootstrap_archive( + source, + package, + bootstrap_id="rf001-bootstrap-2026.09.29.3", + agent_release_id=f"rf001-agent-{requested_version}", + agent_version=requested_version, + protocol_version=1, + token_path=token, + ) + assert not package.exists() diff --git a/tests/test_rf001_capture_runner.py b/tests/test_rf001_capture_runner.py new file mode 100644 index 0000000..9fe2eec --- /dev/null +++ b/tests/test_rf001_capture_runner.py @@ -0,0 +1,48 @@ +"""Software-only process evidence tests for the Python RX runner.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import sys + +from tools.rf001_capture_runner import run_capture + + +def test_python_runner_persists_child_exit_and_hash(tmp_path: Path) -> None: + child = tmp_path / "child.py" + capture = tmp_path / "capture.cs8" + capture_bytes = b"software-only-capture-evidence\x00\x01" + child.write_text( + "from pathlib import Path\n" + f"Path({str(capture)!r}).write_bytes({capture_bytes!r})\n", + encoding="utf-8", + ) + arguments = tmp_path / "arguments.json" + arguments.write_text(json.dumps(["-B", str(child)]), encoding="utf-8") + stdout = tmp_path / "stdout.txt" + stderr = tmp_path / "stderr.txt" + started = tmp_path / "started.json" + result = tmp_path / "result.json" + + exit_code = run_capture( + Path(sys.executable), + arguments, + capture, + stdout, + stderr, + started, + result, + ) + + start_record = json.loads(started.read_text(encoding="utf-8")) + final_record = json.loads(result.read_text(encoding="utf-8")) + assert exit_code == 0 + assert start_record["state"] == "RUNNING" + assert start_record["rf_tx"] == 0 + assert final_record["exit_code"] == 0 + assert final_record["capture_size"] == len(capture_bytes) + assert final_record["sha256"] == hashlib.sha256(capture_bytes).hexdigest() + assert final_record["runner_error"] is None + assert final_record["rf_tx"] == 0 diff --git a/tests/test_rf001_http_control.py b/tests/test_rf001_http_control.py new file mode 100644 index 0000000..abb17c6 --- /dev/null +++ b/tests/test_rf001_http_control.py @@ -0,0 +1,537 @@ +"""Software-only tests for the RF-001 HTTP control channel.""" + +from __future__ import annotations + +import threading +import time + +import pytest + +import tools.rf001_http_control as control_module +from tools.rf001_http_control import ( + AuthenticationError, + ControlHTTPServer, + ControlState, + PROTOCOL_VERSION, + ProtocolError, + RequestAuthenticator, + canonical_json, + request_json, + signed_headers, +) + + +TOKEN = b"software-only-test-token-0123456789abcdef0123456789abcdef" + + +def test_bootstrap_registration_uses_distinct_fixed_identifier( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + releases = tmp_path / "releases" + releases.mkdir() + package = releases / "rf001-bootstrap-2026.09.29.1.zip" + package.write_bytes(b"fixed bootstrap package") + monkeypatch.setattr(control_module, "DEFAULT_RELEASES_ROOT", releases) + state = ControlState(tmp_path / "state.json") + registered = state.register_bootstrap_release( + str(package), + release_id="rf001-bootstrap-2026.09.29.1", + agent_version="2026.09.29.1", + content_manifest_sha256="a" * 64, + token=TOKEN, + ) + assert registered["package_kind"] == "BOOTSTRAP" + assert registered["rf_tx"] == 0 + replacement = releases / "replacement.zip" + replacement.write_bytes(b"different bootstrap package") + with pytest.raises(ProtocolError, match="already registered differently"): + state.register_bootstrap_release( + str(replacement), + release_id="rf001-bootstrap-2026.09.29.1", + agent_version="2026.09.29.1", + content_manifest_sha256="b" * 64, + token=TOKEN, + ) + with pytest.raises(ProtocolError, match="release_id"): + state.register_bootstrap_release( + str(package), + release_id="rf001-agent-2026.09.29.1", + agent_version="2026.09.29.1", + content_manifest_sha256="a" * 64, + token=TOKEN, + ) + + +def test_request_authentication_rejects_replay_and_tampering() -> None: + authenticator = RequestAuthenticator(TOKEN, window_seconds=60) + body = canonical_json({"agent_id": "rx-user-pc", "rf_tx": 0}) + target = "/api/v1/agent/check-in" + headers = signed_headers( + TOKEN, + "POST", + target, + body, + timestamp=1_000, + nonce="a" * 32, + ) + + authenticator.verify("POST", target, body, headers, now=1_000) + with pytest.raises(AuthenticationError, match="already used"): + authenticator.verify("POST", target, body, headers, now=1_000) + + changed_headers = signed_headers( + TOKEN, + "POST", + target, + body, + timestamp=1_000, + nonce="b" * 32, + ) + with pytest.raises(AuthenticationError, match="mismatch"): + authenticator.verify( + "POST", + target, + body + b" ", + changed_headers, + now=1_000, + ) + + +def test_state_leases_inventory_and_records_idempotent_result(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + task = state.enqueue_inventory("rx-user-pc", include_hackrf_info=True) + response = state.check_in( + { + "protocol_version": PROTOCOL_VERSION, + "agent_id": "rx-user-pc", + "inventory": {"hostname": "User-PC", "rf_tx": 0}, + "rf_tx": 0, + } + ) + assert response["task"] == task + assert response["rf_tx"] == 0 + + result = { + "protocol_version": PROTOCOL_VERSION, + "task_id": task["task_id"], + "agent_id": "rx-user-pc", + "status": "COMPLETE", + "started_at": "2026-09-23T00:00:00+00:00", + "finished_at": "2026-09-23T00:00:01+00:00", + "result": {"hostname": "User-PC", "rf_tx": 0}, + "errors": [], + "rf_tx": 0, + } + accepted = state.record_result(result) + assert accepted == {"accepted": True, "duplicate": False, "rf_tx": 0} + duplicate = state.record_result(result) + assert duplicate == {"accepted": True, "duplicate": True, "rf_tx": 0} + + conflicting = dict(result) + conflicting["status"] = "FAILED" + with pytest.raises(ProtocolError, match="conflicting"): + state.record_result(conflicting) + + +def test_state_leases_fixed_offline_verify_task(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + expected_head = "a" * 40 + task = state.enqueue_offline_verify( + "rx-user-pc", + expected_head=expected_head, + ) + response = state.check_in( + { + "protocol_version": PROTOCOL_VERSION, + "agent_id": "rx-user-pc", + "inventory": {"hostname": "User-PC", "rf_tx": 0}, + "rf_tx": 0, + } + ) + assert response["task"] == task + assert task["kind"] == "OFFLINE_VERIFY" + assert task["expected_head"] == expected_head + assert task["rf_tx"] == 0 + + +def test_offline_verify_rejects_invalid_expected_head(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + with pytest.raises(ProtocolError, match="expected_head"): + state.enqueue_offline_verify("rx-user-pc", expected_head="main") + + +def test_rx_capture_requires_exact_local_authorization(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + with pytest.raises(ProtocolError, match="exact RX authorization"): + state.enqueue_rx_capture( + "rx-user-pc", + expected_head="a" * 40, + authorization="yes", + ) + + task = state.enqueue_rx_capture( + "rx-user-pc", + expected_head="a" * 40, + authorization="AUTHORIZE RF RX", + ) + assert task["kind"] == "RX_CAPTURE_START" + assert task["rx_serial"] == "000000000000000097c063dc2e5327d3" + assert task["rx_authorized"] is True + assert task["automatic_retry"] is False + assert task["rf_tx"] == 0 + + +def test_expired_rx_capture_lease_is_not_retried(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + task = state.enqueue_rx_capture( + "rx-user-pc", + expected_head="a" * 40, + authorization="AUTHORIZE RF RX", + ) + check_in = { + "protocol_version": PROTOCOL_VERSION, + "agent_id": "rx-user-pc", + "inventory": {"hostname": "User-PC", "rf_tx": 0}, + "rf_tx": 0, + } + + first = state.check_in(check_in) + assert first["task"] == task + + state._data["tasks"][0]["leased_at_epoch"] = int(time.time()) - 31 + second = state.check_in(check_in) + + assert second["task"] is None + stored = state.snapshot()["tasks"][0] + assert stored["state"] == "LEASE_EXPIRED" + assert "lease_expired_at" in stored + assert stored["task_id"] == task["task_id"] + + +def test_status_and_decode_tasks_use_fixed_session(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + session_id = "RF001A_20260923T150000Z_1234ABCD" + status = state.enqueue_rx_status("rx-user-pc", session_id=session_id) + decode = state.enqueue_capture_decode( + "rx-user-pc", + session_id=session_id, + tx_started_at="2026-09-23T15:00:05+00:00", + tx_finished_at="2026-09-23T15:00:07+00:00", + ) + diagnostics = state.enqueue_capture_diagnostics( + "rx-user-pc", + session_id=session_id, + tx_started_at="2026-09-23T15:00:05+00:00", + tx_finished_at="2026-09-23T15:00:07+00:00", + ) + assert status["kind"] == "RX_CAPTURE_STATUS" + assert decode["kind"] == "CAPTURE_DECODE" + assert diagnostics["kind"] == "CAPTURE_DIAGNOSTICS" + assert status["session_id"] == decode["session_id"] == session_id + assert diagnostics["session_id"] == session_id + assert status["rf_tx"] == decode["rf_tx"] == diagnostics["rf_tx"] == 0 + + with pytest.raises(ProtocolError, match="invalid session_id"): + state.enqueue_rx_status("rx-user-pc", session_id="..\\outside") + + +def test_staircase_task_has_only_fixed_hashes_and_no_retry(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + session_id = "RF001A_20260925T125829Z_227EADB4" + task = state.enqueue_staircase_search( + "rx-user-pc", + session_id=session_id, + expected_rx_sha256="2" * 64, + expected_reference_sha256="f" * 64, + ) + assert task["kind"] == "STAIRCASE_SEARCH" + assert task["session_id"] == session_id + assert task["expected_rx_sha256"] == "2" * 64 + assert task["expected_reference_sha256"] == "f" * 64 + assert task["hardware_accessed"] is False + assert task["automatic_retry"] is False + assert task["rf_tx"] == 0 + assert "capture_path" not in task + assert "reference_path" not in task + assert "command" not in task + + with pytest.raises(ProtocolError, match="invalid session_id"): + state.enqueue_staircase_search( + "rx-user-pc", + session_id="..\\outside", + expected_rx_sha256="2" * 64, + expected_reference_sha256="f" * 64, + ) + with pytest.raises(ProtocolError, match="expected_rx_sha256"): + state.enqueue_staircase_search( + "rx-user-pc", + session_id=session_id, + expected_rx_sha256="bad", + expected_reference_sha256="f" * 64, + ) + with pytest.raises(ProtocolError, match="expected_reference_sha256"): + state.enqueue_staircase_search( + "rx-user-pc", + session_id=session_id, + expected_rx_sha256="2" * 64, + expected_reference_sha256="bad", + ) + + +def test_long_task_heartbeat_renews_expired_lease_without_reissue(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + task = state.enqueue_staircase_search( + "rx-user-pc", + session_id="RF001A_20260925T125829Z_227EADB4", + expected_rx_sha256="2" * 64, + expected_reference_sha256="f" * 64, + ) + check_in = { + "protocol_version": PROTOCOL_VERSION, + "agent_id": "rx-user-pc", + "inventory": {"agent_version": "test", "rf_tx": 0}, + "rf_tx": 0, + } + assert state.check_in(check_in)["task"] == task + state._data["tasks"][0]["leased_at_epoch"] = int(time.time()) - 300 + active = { + **check_in, + "active_task": { + "task_id": task["task_id"], + "kind": "STAIRCASE_SEARCH", + "state": "RUNNING", + "phase": "SEARCHING", + "processed_samples": 400, + "total_samples": 1000, + "hardware_accessed": False, + "rf_tx": 0, + }, + } + response = state.check_in(active) + assert response["task"] is None + stored = state.snapshot()["tasks"][0] + assert stored["state"] == "LEASED" + assert stored["progress"]["processed_samples"] == 400 + + +def test_update_approval_is_bound_to_registered_release_and_not_repeated( + tmp_path, +) -> None: + state = ControlState(tmp_path / "state.json") + release_id = "rf001-agent-2026.09.29.2" + state._data["releases"][release_id] = { + "manifest": { + "schema_version": 1, + "release_id": release_id, + "agent_version": "2026.09.29.2", + "protocol_version": PROTOCOL_VERSION, + "package_kind": "AGENT_RELEASE", + "package_sha256": "a" * 64, + "package_bytes": 1234, + "content_manifest_sha256": "b" * 64, + }, + "manifest_signature": "c" * 64, + "package_path": str(tmp_path / "release.zip"), + } + task = state.enqueue_agent_update( + "rx-user-pc", + release_id=release_id, + validity_seconds=3600, + ) + assert task["kind"] == "UPDATE_AGENT" + assert task["release_id"] == release_id + assert task["package_sha256"] == "a" * 64 + assert task["automatic_retry"] is False + assert task["hardware_accessed"] is False + assert task["rf_tx"] == 0 + with pytest.raises(ProtocolError, match="already has an update task"): + state.enqueue_agent_update( + "rx-user-pc", + release_id=release_id, + validity_seconds=3600, + ) + + +def test_queued_update_waits_for_active_software_task(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + running = state.enqueue_staircase_search( + "rx-user-pc", + session_id="RF001A_20260925T125829Z_227EADB4", + expected_rx_sha256="2" * 64, + expected_reference_sha256="f" * 64, + ) + check_in = { + "protocol_version": PROTOCOL_VERSION, + "agent_id": "rx-user-pc", + "inventory": {"agent_version": "test", "rf_tx": 0}, + "rf_tx": 0, + } + assert state.check_in(check_in)["task"] == running + release_id = "rf001-agent-2026.09.29.2" + state._data["releases"][release_id] = { + "manifest": { + "schema_version": 1, + "release_id": release_id, + "agent_version": "2026.09.29.2", + "protocol_version": PROTOCOL_VERSION, + "package_kind": "AGENT_RELEASE", + "package_sha256": "a" * 64, + "package_bytes": 1234, + "content_manifest_sha256": "b" * 64, + }, + "manifest_signature": "c" * 64, + "package_path": str(tmp_path / "release.zip"), + } + update = state.enqueue_agent_update( + "rx-user-pc", release_id=release_id, validity_seconds=3600 + ) + response = state.check_in( + { + **check_in, + "active_task": { + "task_id": running["task_id"], + "kind": "STAIRCASE_SEARCH", + "state": "RUNNING", + "phase": "SEARCHING", + "processed_samples": 1, + "total_samples": 10, + "hardware_accessed": False, + "rf_tx": 0, + }, + } + ) + assert response["task"] is None + stored = next( + task + for task in state.snapshot()["tasks"] + if task["task_id"] == update["task_id"] + ) + assert stored["state"] == "QUEUED" + assert stored["update_status"] == "WAITING_FOR_IDLE" + assert stored["blocked_by_task_id"] == running["task_id"] + + +def test_http_server_end_to_end_inventory_without_rf(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + server = ControlHTTPServer( + ("127.0.0.1", 0), + state=state, + token=TOKEN, + allowed_agent_ip="127.0.0.1", + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + try: + task = request_json( + base_url, + TOKEN, + "POST", + "/api/v1/local/tasks/inventory", + {"agent_id": "rx-user-pc", "include_hackrf_info": True}, + ) + assert task["kind"] == "INVENTORY" + assert task["rf_tx"] == 0 + + check_in = request_json( + base_url, + TOKEN, + "POST", + "/api/v1/agent/check-in", + { + "protocol_version": PROTOCOL_VERSION, + "agent_id": "rx-user-pc", + "inventory": {"hostname": "User-PC", "rf_tx": 0}, + "rf_tx": 0, + }, + ) + assert check_in["task"] == task + assert check_in["rf_tx"] == 0 + + result = { + "protocol_version": PROTOCOL_VERSION, + "task_id": task["task_id"], + "agent_id": "rx-user-pc", + "status": "COMPLETE", + "started_at": "2026-09-23T00:00:00+00:00", + "finished_at": "2026-09-23T00:00:01+00:00", + "result": {"hostname": "User-PC", "rf_tx": 0}, + "errors": [], + "rf_tx": 0, + } + acknowledgement = request_json( + base_url, + TOKEN, + "POST", + "/api/v1/agent/result", + result, + ) + assert acknowledgement["accepted"] is True + assert acknowledgement["rf_tx"] == 0 + + status = request_json( + base_url, + TOKEN, + "GET", + "/api/v1/local/status", + ) + assert status["results"][task["task_id"]] == result + assert status["tasks"][0]["state"] == "COMPLETE" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_result_cannot_claim_rf_tx(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + task = state.enqueue_inventory("rx-user-pc", include_hackrf_info=False) + result = { + "protocol_version": PROTOCOL_VERSION, + "task_id": task["task_id"], + "agent_id": "rx-user-pc", + "status": "COMPLETE", + "result": {}, + "errors": [], + "rf_tx": 1, + } + with pytest.raises(ProtocolError, match="rf_tx = 0"): + state.record_result(result) + + +def test_cancelled_task_is_not_released_or_completed(tmp_path) -> None: + state = ControlState(tmp_path / "state.json") + task = state.enqueue_rx_capture( + "rx-user-pc", + expected_head="a" * 40, + authorization="AUTHORIZE RF RX", + ) + cancellation = state.cancel_task( + task["task_id"], + reason="software packaging failure before hardware access", + ) + assert cancellation["state"] == "CANCELLED" + assert cancellation["rf_tx"] == 0 + + response = state.check_in( + { + "protocol_version": PROTOCOL_VERSION, + "agent_id": "rx-user-pc", + "inventory": {"hostname": "User-PC", "rf_tx": 0}, + "rf_tx": 0, + } + ) + assert response["task"] is None + + result = { + "protocol_version": PROTOCOL_VERSION, + "task_id": task["task_id"], + "agent_id": "rx-user-pc", + "status": "FAILED", + "result": {"hardware_accessed": False, "rf_tx": 0}, + "errors": ["packaging failure"], + "rf_tx": 0, + } + with pytest.raises(ProtocolError, match="cancelled"): + state.record_result(result) diff --git a/tests/test_rf001_installer_transaction.py b/tests/test_rf001_installer_transaction.py new file mode 100644 index 0000000..8760c38 --- /dev/null +++ b/tests/test_rf001_installer_transaction.py @@ -0,0 +1,429 @@ +"""Software-only checks for transactional bootstrap target deployment.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + + +BOOTSTRAP_ID = "rf001-bootstrap-2026.09.29.4" +RELEASE_ID = "rf001-agent-2026.09.29.1" +AGENT_VERSION = "2026.09.29.1" +RELEASE_PREFIX = f"versions/{RELEASE_ID}/" +FUNCTIONS = ( + "Get-LowerSha256", + "Assert-ExactReleasePath", + "Get-ReleaseDirectoryState", + "Assert-ReleaseMetadata", + "New-UniqueQuarantineDestination", + "Write-CreateNewUtf8Json", + "Move-ConflictingTargetToQuarantine", + "Move-FailedReleasePathToQuarantine", + "Install-CleanRelease", + "Prepare-TargetRelease", +) + + +def _powershell() -> str: + executable = shutil.which("powershell.exe") + if executable is None: + pytest.skip("Windows PowerShell is required for installer transaction tests") + return executable + + +def _installer() -> Path: + return Path(__file__).parents[1] / "tools" / "install_rf001_rx_autostart.ps1" + + +def _sha256(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 _release_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]: + install_root = tmp_path / "install" + versions_root = install_root / "versions" + target = versions_root / RELEASE_ID + source = tmp_path / "signed-bootstrap" / "versions" / RELEASE_ID + tools = source / "app" / "tools" + tools.mkdir(parents=True) + (tools / "rf001_rx_http_agent.py").write_text( + f'AGENT_VERSION = "{AGENT_VERSION}"\n', encoding="utf-8" + ) + (tools / "rf001_agent_update.py").write_text( + "# fixed signed updater fixture\n", encoding="utf-8" + ) + ready = { + "agent_version": AGENT_VERSION, + "bootstrap_id": BOOTSTRAP_ID, + "hardware_accessed": False, + "protocol_version": 1, + "release_id": RELEASE_ID, + "rf_tx": 0, + "schema_version": 1, + "status": "READY", + } + (source / "READY.json").write_text( + json.dumps(ready, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + rows = [] + for path in sorted(item for item in source.rglob("*") if item.is_file()): + relative = path.relative_to(source).as_posix() + rows.append( + { + "path": RELEASE_PREFIX + relative, + "bytes": path.stat().st_size, + "sha256": _sha256(path), + } + ) + manifest = tmp_path / "release_rows.json" + manifest.write_text(json.dumps(rows), encoding="utf-8") + versions_root.mkdir(parents=True) + return source, install_root, target, manifest + + +def _function_loader() -> str: + names = ",".join(f'"{name}"' for name in FUNCTIONS) + return rf""" +$ErrorActionPreference = "Stop" +$tokens = $null +$parseErrors = $null +$syntax = [Management.Automation.Language.Parser]::ParseFile( + $env:RF001_INSTALLER, + [ref]$tokens, + [ref]$parseErrors +) +if ($parseErrors.Count -ne 0) {{ + throw "Installer contains PowerShell parse errors" +}} +foreach ($name in @({names})) {{ + $matches = @($syntax.FindAll({{ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq $name + }}, $true)) + if ($matches.Count -ne 1) {{ + throw "Installer function is absent or ambiguous: $name" + }} + . ([scriptblock]::Create($matches[0].Extent.Text)) +}} +$releaseRows = @(Get-Content -LiteralPath $env:RF001_ROWS -Raw -Encoding UTF8 | ConvertFrom-Json) +$installRoot = $env:RF001_INSTALL_ROOT +$versionsRoot = Join-Path $installRoot "versions" +$sourceRelease = $env:RF001_SOURCE_RELEASE +$targetRelease = Join-Path $versionsRoot "{RELEASE_ID}" +""" + + +def _run_powershell( + body: str, + *, + source: Path, + install_root: Path, + manifest: Path, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment.update( + { + "RF001_INSTALLER": str(_installer()), + "RF001_ROWS": str(manifest), + "RF001_INSTALL_ROOT": str(install_root), + "RF001_SOURCE_RELEASE": str(source), + } + ) + completed = subprocess.run( + [_powershell(), "-NoProfile", "-NonInteractive", "-Command", _function_loader() + body], + check=False, + capture_output=True, + encoding="utf-8", + env=environment, + ) + if check and completed.returncode != 0: + raise AssertionError( + f"PowerShell transaction harness failed ({completed.returncode}):\n" + f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}" + ) + return completed + + +def _prepare( + source: Path, + install_root: Path, + manifest: Path, +) -> dict[str, object]: + body = rf""" +$result = Prepare-TargetRelease ` + -InstallRoot $installRoot ` + -VersionsRoot $versionsRoot ` + -SourceRelease $sourceRelease ` + -TargetRelease $targetRelease ` + -ReleaseId "{RELEASE_ID}" ` + -BootstrapId "{BOOTSTRAP_ID}" ` + -AgentVersion "{AGENT_VERSION}" ` + -ProtocolVersion 1 ` + -ReleaseRows $releaseRows ` + -ReleasePrefix "{RELEASE_PREFIX}" +$final = Get-ReleaseDirectoryState ` + -TargetRelease $targetRelease ` + -ReleaseRows $releaseRows ` + -ReleasePrefix "{RELEASE_PREFIX}" +[ordered]@{{ + result = $result + final_state = $final.state +}} | ConvertTo-Json -Depth 10 -Compress +""" + completed = _run_powershell( + body, + source=source, + install_root=install_root, + manifest=manifest, + ) + value = json.loads(completed.stdout.lstrip("\ufeff")) + assert isinstance(value, dict) + return value + + +def _snapshot(root: Path) -> dict[str, tuple[int, int, str]]: + return { + path.relative_to(root).as_posix(): ( + path.stat().st_size, + path.stat().st_mtime_ns, + _sha256(path), + ) + for path in root.rglob("*") + if path.is_file() + } + + +def _assert_matches_manifest(target: Path, manifest: Path) -> None: + rows = json.loads(manifest.read_text(encoding="utf-8")) + expected = { + str(row["path"])[len(RELEASE_PREFIX) :]: ( + int(row["bytes"]), + str(row["sha256"]), + ) + for row in rows + } + actual = { + path.relative_to(target).as_posix(): (path.stat().st_size, _sha256(path)) + for path in target.rglob("*") + if path.is_file() + } + assert actual == expected + + +def test_absent_target_is_deployed_from_signed_manifest(tmp_path: Path) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + report = _prepare(source, install_root, manifest) + assert report["result"]["initial_state"] == "TARGET_ABSENT" + assert report["result"]["reused"] is False + assert report["final_state"] == "TARGET_VERIFIED" + _assert_matches_manifest(target, manifest) + + +def test_exact_target_is_reused_without_writing_bytes(tmp_path: Path) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + shutil.copytree(source, target) + before = _snapshot(target) + report = _prepare(source, install_root, manifest) + assert report["result"]["initial_state"] == "TARGET_VERIFIED" + assert report["result"]["reused"] is True + assert _snapshot(target) == before + + +def test_mixed_release_is_quarantined_before_clean_deployment(tmp_path: Path) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + shutil.copytree(source, target) + agent = target / "app" / "tools" / "rf001_rx_http_agent.py" + agent.write_text('AGENT_VERSION = "2026.09.29.2"\n', encoding="utf-8") + ready = json.loads((target / "READY.json").read_text(encoding="utf-8")) + ready["bootstrap_id"] = "rf001-bootstrap-2026.09.29.2" + (target / "READY.json").write_text(json.dumps(ready), encoding="utf-8") + conflict_hash = _sha256(agent) + + report = _prepare(source, install_root, manifest) + result = report["result"] + assert result["initial_state"] == "TARGET_CONFLICT" + quarantine = Path(str(result["quarantine_path"])) + diagnostic_path = Path(str(result["quarantine_diagnostic"])) + assert quarantine.is_dir() + assert _sha256(quarantine / "app" / "tools" / agent.name) == conflict_hash + diagnostic = json.loads(diagnostic_path.read_text(encoding="utf-8-sig")) + assert diagnostic["bootstrap_id"] == BOOTSTRAP_ID + assert diagnostic["expected_release_id"] == RELEASE_ID + assert {row["kind"] for row in diagnostic["mismatches"]} >= { + "SHA256_MISMATCH" + } + assert any(row["sha256"] == conflict_hash for row in diagnostic["conflicting_files"]) + _assert_matches_manifest(target, manifest) + + +@pytest.mark.parametrize( + ("case", "expected_kind"), + ( + ("ready_mismatch", "SHA256_MISMATCH"), + ("python_mismatch", "SHA256_MISMATCH"), + ("missing", "MISSING"), + ("unexpected", "UNEXPECTED"), + ), +) +def test_each_file_set_conflict_is_quarantined( + tmp_path: Path, + case: str, + expected_kind: str, +) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + shutil.copytree(source, target) + if case == "ready_mismatch": + (target / "READY.json").write_bytes(b"{}\n") + elif case == "python_mismatch": + (target / "app" / "tools" / "rf001_agent_update.py").write_bytes(b"changed\n") + elif case == "missing": + (target / "app" / "tools" / "rf001_agent_update.py").unlink() + else: + (target / "app" / "tools" / "unexpected.py").write_bytes(b"unexpected\n") + + result = _prepare(source, install_root, manifest)["result"] + diagnostic = json.loads( + Path(str(result["quarantine_diagnostic"])).read_text(encoding="utf-8-sig") + ) + assert expected_kind in {row["kind"] for row in diagnostic["mismatches"]} + _assert_matches_manifest(target, manifest) + + +def test_failure_after_quarantine_leaves_canonical_target_absent(tmp_path: Path) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + shutil.copytree(source, target) + (target / "READY.json").write_bytes(b"conflict\n") + body = rf""" +$state = Get-ReleaseDirectoryState ` + -TargetRelease $targetRelease ` + -ReleaseRows $releaseRows ` + -ReleasePrefix "{RELEASE_PREFIX}" +$quarantine = Move-ConflictingTargetToQuarantine ` + -InstallRoot $installRoot ` + -VersionsRoot $versionsRoot ` + -TargetRelease $targetRelease ` + -ReleaseId "{RELEASE_ID}" ` + -BootstrapId "{BOOTSTRAP_ID}" ` + -State $state +try {{ + throw "fixed failure after quarantine before target creation" +}} +catch {{ + [ordered]@{{ + error = $_.Exception.Message + canonical_exists = Test-Path -LiteralPath $targetRelease + quarantine_exists = Test-Path -LiteralPath $quarantine.path -PathType Container + diagnostic_exists = Test-Path -LiteralPath $quarantine.diagnostic_path -PathType Leaf + }} | ConvertTo-Json -Compress +}} +""" + completed = _run_powershell( + body, source=source, install_root=install_root, manifest=manifest + ) + report = json.loads(completed.stdout.lstrip("\ufeff")) + assert report == { + "error": "fixed failure after quarantine before target creation", + "canonical_exists": False, + "quarantine_exists": True, + "diagnostic_exists": True, + } + + +def test_failure_during_deployment_quarantines_partial_staging(tmp_path: Path) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + (source / "READY.json").unlink() + body = rf""" +try {{ + Install-CleanRelease ` + -InstallRoot $installRoot ` + -VersionsRoot $versionsRoot ` + -SourceRelease $sourceRelease ` + -TargetRelease $targetRelease ` + -ReleaseId "{RELEASE_ID}" ` + -BootstrapId "{BOOTSTRAP_ID}" ` + -AgentVersion "{AGENT_VERSION}" ` + -ProtocolVersion 1 ` + -ReleaseRows $releaseRows ` + -ReleasePrefix "{RELEASE_PREFIX}" | Out-Null + throw "deployment unexpectedly succeeded" +}} +catch {{ + $failed = @(Get-ChildItem -LiteralPath (Join-Path $installRoot "quarantine") -Directory | + Where-Object {{ $_.Name -like "{RELEASE_ID}_failed_staging_*" }}) + [ordered]@{{ + error = $_.Exception.Message + canonical_exists = Test-Path -LiteralPath $targetRelease + failed_staging_count = $failed.Count + }} | ConvertTo-Json -Compress +}} +""" + completed = _run_powershell( + body, source=source, install_root=install_root, manifest=manifest + ) + report = json.loads(completed.stdout.lstrip("\ufeff")) + assert "Clean release deployment failed" in report["error"] + assert report["canonical_exists"] is False + assert report["failed_staging_count"] == 1 + + +def test_installer_switch_failure_restores_saved_task_and_launcher_state() -> None: + text = _installer().read_text(encoding="utf-8-sig") + switch = text.index("Set-ScheduledTask -TaskName $taskName -Action $action") + catch = text.index("catch {", switch) + restore_task = text.index("Register-ScheduledTask -TaskName $taskName -Xml $xml -Force", catch) + restore_files = text.index('foreach ($name in @("launcher"', catch) + restart_old = text.index("if ($wasRunning)", catch) + assert switch < catch < restore_task < restore_files < restart_old + + +def test_successful_reinstall_is_idempotent(tmp_path: Path) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + first = _prepare(source, install_root, manifest) + before = _snapshot(target) + second = _prepare(source, install_root, manifest) + assert first["result"]["initial_state"] == "TARGET_ABSENT" + assert second["result"]["initial_state"] == "TARGET_VERIFIED" + assert second["result"]["reused"] is True + assert _snapshot(target) == before + quarantine = install_root / "quarantine" + assert not quarantine.exists() or not any(quarantine.iterdir()) + + +def test_existing_quarantine_is_never_overwritten(tmp_path: Path) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + shutil.copytree(source, target) + (target / "READY.json").write_bytes(b"conflict\n") + sentinel = install_root / "quarantine" / "existing-audit" / "sentinel.bin" + sentinel.parent.mkdir(parents=True) + sentinel.write_bytes(b"must remain unchanged") + before = _snapshot(sentinel.parent) + result = _prepare(source, install_root, manifest)["result"] + assert _snapshot(sentinel.parent) == before + assert Path(str(result["quarantine_path"])) != sentinel.parent + + +def test_matching_version_strings_do_not_validate_changed_bytes(tmp_path: Path) -> None: + source, install_root, target, manifest = _release_fixture(tmp_path) + shutil.copytree(source, target) + agent = target / "app" / "tools" / "rf001_rx_http_agent.py" + agent.write_text( + f'# changed bytes, same identity strings\nAGENT_VERSION = "{AGENT_VERSION}"\n', + encoding="utf-8", + ) + result = _prepare(source, install_root, manifest)["result"] + assert result["initial_state"] == "TARGET_CONFLICT" + assert result["reused"] is False + _assert_matches_manifest(target, manifest) diff --git a/tests/test_rf001_launcher_health_diagnostic.py b/tests/test_rf001_launcher_health_diagnostic.py new file mode 100644 index 0000000..22e3d8a --- /dev/null +++ b/tests/test_rf001_launcher_health_diagnostic.py @@ -0,0 +1,235 @@ +"""Regression tests for strict launcher/health process identity.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timedelta, timezone +import json +from pathlib import Path + +from tools.rf001_launcher_health_diagnostic import ( + HandshakeExpectation, + ProcessRecord, + _terminate_owned_processes, + atomic_write_json, + evaluate_identity, + strict_owned_process_ids, +) + + +NOW = datetime(2026, 9, 29, 13, 0, tzinfo=timezone.utc) +SCRIPT = r"C:\diagnostic\rf001_launcher_health_diagnostic.py" + + +def _expectation(*, popen_pid: int = 200) -> HandshakeExpectation: + return HandshakeExpectation( + release_id="rf001-agent-2026.09.29.1", + agent_version="2026.09.29.1", + launch_nonce="fixed-unique-nonce", + state="RUNNING", + launcher_pid=100, + popen_pid=popen_pid, + launched_at=NOW.isoformat(), + diagnostic_script=SCRIPT, + ) + + +def _record(pid: int, parent: int, *, seconds: float = 1.0) -> ProcessRecord: + return ProcessRecord( + pid=pid, + parent_pid=parent, + name="python.exe", + executable=r"C:\Python313\python.exe", + command_line=f'python "{SCRIPT}" --launch-nonce fixed-unique-nonce', + created_at=(NOW + timedelta(seconds=seconds)).isoformat(), + ) + + +def _health(*, pid: int = 200) -> dict[str, object]: + return { + "release_id": "rf001-agent-2026.09.29.1", + "agent_version": "2026.09.29.1", + "launch_nonce": "fixed-unique-nonce", + "state": "RUNNING", + "pid": pid, + "updated_at": (NOW + timedelta(seconds=2)).isoformat(), + } + + +def _evaluate( + health: dict[str, object] | None, + records: list[ProcessRecord], + *, + expectation: HandshakeExpectation | None = None, + popen_alive: bool = True, + health_process_alive: bool = True, +) -> dict[str, object]: + return evaluate_identity( + health, + expectation or _expectation(), + records, + popen_alive=popen_alive, + health_process_alive=health_process_alive, + ) + + +def test_exact_process_identity_passes() -> None: + result = _evaluate(_health(), [_record(200, 100)]) + assert result["identity_valid"] is True + assert result["identity_model"] == "EXACT_POPEN_PROCESS" + + +def test_wrong_nonce_fails() -> None: + health = _health() + health["launch_nonce"] = "wrong" + assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH" + + +def test_wrong_release_fails() -> None: + health = _health() + health["release_id"] = "rf001-agent-elsewhere" + assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH" + + +def test_wrong_version_fails() -> None: + health = _health() + health["agent_version"] = "2026.09.29.2" + assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH" + + +def test_health_before_running_state_fails() -> None: + health = _health() + health["state"] = "STARTING" + assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH" + + +def test_stale_health_fails() -> None: + health = _health() + health["updated_at"] = (NOW - timedelta(seconds=1)).isoformat() + assert _evaluate(health, [_record(200, 100)])["reason"] == "STALE_HEALTH" + + +def test_rollback_does_not_accept_previous_launch_health() -> None: + previous_health = _health() + previous_health["launch_nonce"] = "previous-launch-nonce" + assert ( + _evaluate(previous_health, [_record(200, 100)])["reason"] + == "HEALTH_FIELDS_MISMATCH" + ) + + +def test_unrelated_pid_fails() -> None: + result = _evaluate( + _health(pid=300), + [_record(200, 100), _record(300, 999)], + ) + assert result["reason"] == "UNRELATED_PID" + + +def test_unique_expected_wrapper_child_passes_strict_lineage() -> None: + result = _evaluate( + _health(pid=201), + [_record(200, 100), _record(201, 200)], + ) + assert result["identity_valid"] is True + assert ( + result["identity_model"] + == "UNIQUE_DIRECT_CHILD_WITH_NONCE_AND_CREATION_TIME" + ) + assert result["pid_match"] is False + + +def test_unexpected_grandchild_fails() -> None: + result = _evaluate( + _health(pid=202), + [_record(200, 100), _record(201, 200), _record(202, 201)], + ) + assert result["reason"] == "UNEXPECTED_GRANDCHILD" + + +def test_pid_reuse_creation_time_mismatch_fails() -> None: + reused = _record(200, 100, seconds=-30) + assert _evaluate(_health(), [reused])["reason"] == "PROCESS_CREATION_TIME_MISMATCH" + + +def test_target_dies_before_health_fails() -> None: + result = _evaluate(None, [_record(200, 100)], popen_alive=False) + assert result["reason"] == "HEALTH_ABSENT" + + +def test_target_writes_health_then_dies_immediately_fails() -> None: + result = _evaluate( + _health(), + [_record(200, 100)], + health_process_alive=False, + ) + assert result["reason"] == "HEALTH_PROCESS_DIED" + + +def test_orphan_selection_never_includes_unrelated_process() -> None: + expectation = _expectation() + related = _record(201, 200) + unrelated = replace(_record(300, 999), command_line="python unrelated.py") + assert strict_owned_process_ids( + expectation, + [_record(200, 100), related, unrelated], + ) == [201, 200] + + +def test_orphan_cleanup_terminates_only_strictly_owned_processes(monkeypatch) -> None: + expectation = _expectation() + wrapper = _record(200, 100) + child = _record(201, 200) + unrelated = replace(_record(300, 999), command_line="python unrelated.py") + records = [wrapper, child, unrelated] + terminated: list[int] = [] + + monkeypatch.setattr( + "tools.rf001_launcher_health_diagnostic.capture_windows_processes", + lambda: records, + ) + monkeypatch.setattr( + "tools.rf001_launcher_health_diagnostic.os.kill", + lambda pid, _signal: terminated.append(pid), + ) + + actions = _terminate_owned_processes(expectation, records) + + assert terminated == [201, 200] + assert actions == [ + {"pid": 201, "status": "TERMINATED"}, + {"pid": 200, "status": "TERMINATED"}, + ] + + +def test_repeated_success_is_deterministic_with_distinct_identity() -> None: + first = _expectation(popen_pid=200) + second = replace( + first, + popen_pid=300, + launch_nonce="second-unique-nonce", + launched_at=(NOW + timedelta(seconds=10)).isoformat(), + ) + first_result = _evaluate(_health(pid=200), [_record(200, 100)], expectation=first) + second_record = replace( + _record(300, 100, seconds=11), + command_line=f'python "{SCRIPT}" --launch-nonce second-unique-nonce', + ) + second_health = { + **_health(pid=300), + "launch_nonce": "second-unique-nonce", + "updated_at": (NOW + timedelta(seconds=12)).isoformat(), + } + second_result = _evaluate(second_health, [second_record], expectation=second) + assert first_result["identity_valid"] is True + assert second_result["identity_valid"] is True + assert first.launch_nonce != second.launch_nonce + assert first.popen_pid != second.popen_pid + + +def test_diagnostic_json_replacement_is_atomic(tmp_path: Path) -> None: + path = tmp_path / "health.json" + for sequence in range(20): + atomic_write_json(path, {"sequence": sequence}) + assert json.loads(path.read_text(encoding="utf-8")) == {"sequence": sequence} + assert not path.with_suffix(".json.tmp").exists() diff --git a/tests/test_rf001_offline_decode.py b/tests/test_rf001_offline_decode.py new file mode 100644 index 0000000..2c9fa8a --- /dev/null +++ b/tests/test_rf001_offline_decode.py @@ -0,0 +1,55 @@ +"""Software-only checks for the fixed HTTP-agent offline decoder.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from tools.rf001_offline_decode import decode_offline + + +def test_decode_offline_forces_legacy_and_refuses_overwrite(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + iq_path = tmp_path / "waveform.cs8" + truth_path = tmp_path / "truth.json" + report_path = tmp_path / "report.json" + iq_path.write_bytes(b"iq") + truth_path.write_text("{}", encoding="utf-8") + calls: list[dict[str, object]] = [] + + def fake_decoder(iq_file: Path, **options: object) -> dict[str, object]: + calls.append({"iq_file": iq_file, **options}) + return {"crc_valid_packets": 100, "exact_packets": 100} + + result = decode_offline( + repository, + iq_path, + truth_path, + report_path, + decoder=fake_decoder, + ) + + assert result == {"crc_valid_packets": 100, "exact_packets": 100} + assert calls == [ + { + "iq_file": iq_path, + "iq_format": "cs8", + "truth_metadata_path": truth_path, + "decoder_backend": "legacy", + "capture_started_at": None, + "tx_started_at": None, + "tx_finished_at": None, + } + ] + assert json.loads(report_path.read_text(encoding="utf-8")) == result + with pytest.raises(FileExistsError, match="already exists"): + decode_offline( + repository, + iq_path, + truth_path, + report_path, + decoder=fake_decoder, + ) diff --git a/tests/test_rf001_rx_capture.py b/tests/test_rf001_rx_capture.py new file mode 100644 index 0000000..73b4c82 --- /dev/null +++ b/tests/test_rf001_rx_capture.py @@ -0,0 +1,356 @@ +"""Software-only tests for the fixed RF-001A RX capture operations.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Sequence + +from tools.rf001_rx_capture import ( + CAPTURE_FILE_NAME, + FIXED_RX_SERIAL, + MANIFEST_FILE_NAME, + REPORT_FILE_NAME, + RESULT_FILE_NAME, + read_capture_diagnostics, + read_capture_status, + start_rx_capture, +) + + +class FakeResult: + def __init__(self, arguments: Sequence[str], stdout: str) -> None: + self.arguments = list(arguments) + self.returncode = 0 + self.stdout = stdout + self.stderr = "" + + +def _write_repository(repository: Path) -> None: + (repository / "configs" / "rf").mkdir(parents=True) + (repository / "tools").mkdir() + profile = { + "test_id": "RF001A_COMMON_CLOCK_001", + "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, + } + (repository / "configs" / "rf" / "rf001a_common_clock.json").write_text( + json.dumps(profile), + encoding="utf-8", + ) + + +def test_capture_start_uses_fixed_receive_only_command(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + _write_repository(repository) + tool_dir = tmp_path / "hackrf" + tool_dir.mkdir() + info = tool_dir / "hackrf_info.exe" + clock = tool_dir / "hackrf_clock.exe" + transfer = tool_dir / "hackrf_transfer.exe" + for path in (info, clock, transfer): + path.write_bytes(b"") + + commands: list[list[str]] = [] + + def runner(arguments: Sequence[str], timeout: float) -> FakeResult: + del timeout + command = list(arguments) + commands.append(command) + if command == [str(info)]: + return FakeResult(command, f"Serial number: {FIXED_RX_SERIAL}") + if command == [str(clock), "-d", FIXED_RX_SERIAL, "-i"]: + return FakeResult(command, "CLKIN status: clock signal detected") + raise AssertionError(command) + + launched: list[list[str]] = [] + capture_path: Path | None = None + + def launcher(arguments: Sequence[str]) -> int: + nonlocal capture_path + command = list(arguments) + launched.append(command) + capture_path = Path(command[command.index("--capture-path") + 1]) + started_path = Path(command[command.index("--started-status") + 1]) + started_path.write_text( + json.dumps( + { + "state": "RUNNING", + "pid": 4321, + "started_at": "2026-09-23T15:00:00+00:00", + "capture_path": str(capture_path), + } + ), + encoding="utf-8", + ) + capture_path.write_bytes(b"12") + return 1234 + + def grow(_: float) -> None: + assert capture_path is not None + with capture_path.open("ab") as stream: + stream.write(b"34") + + result, errors = start_rx_capture( + repository, + tmp_path / "runtime", + "RF001A_20260923T150000Z_1234ABCD", + FIXED_RX_SERIAL, + hackrf_info_path=info, + hackrf_clock_path=clock, + hackrf_transfer_path=transfer, + command_runner=runner, + process_gate=lambda: (True, {"state": "CLEAR"}), + background_launcher=launcher, + process_stopper=lambda pid: None, + sleep=grow, + ) + + assert errors == [] + assert result["state"] == "READY" + assert result["rf_rx"] == 1 + assert result["rf_tx"] == 0 + assert result["automatic_retry"] is False + assert result["common_clock_verified"] is True + assert len(launched) == 1 + arguments_path = Path( + launched[0][launched[0].index("--arguments-json") + 1] + ) + transfer_arguments = json.loads(arguments_path.read_text(encoding="utf-8")) + assert transfer_arguments == [ + "-d", FIXED_RX_SERIAL, + "-r", str(result["capture_path"]), + "-f", "1400000000", + "-s", "8000000", + "-n", "2400000000", + "-l", "24", + "-g", "24", + "-a", "0", + "-p", "0", + "-B", + ] + assert "-t" not in transfer_arguments + assert commands == [ + [str(info)], + [str(clock), "-d", FIXED_RX_SERIAL, "-i"], + ] + + +def test_capture_start_rejects_explicit_no_clock_status(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + _write_repository(repository) + tool_dir = tmp_path / "hackrf" + tool_dir.mkdir() + info = tool_dir / "hackrf_info.exe" + clock = tool_dir / "hackrf_clock.exe" + transfer = tool_dir / "hackrf_transfer.exe" + for path in (info, clock, transfer): + path.write_bytes(b"") + + def runner(arguments: Sequence[str], timeout: float) -> FakeResult: + del timeout + command = list(arguments) + if command == [str(info)]: + return FakeResult(command, f"Serial number: {FIXED_RX_SERIAL}") + if command == [str(clock), "-d", FIXED_RX_SERIAL, "-i"]: + return FakeResult(command, "CLKIN status: no clock signal detected") + raise AssertionError(command) + + launched: list[list[str]] = [] + result, errors = start_rx_capture( + repository, + tmp_path / "runtime", + "RF001A_20260925T120000Z_1234ABCD", + FIXED_RX_SERIAL, + hackrf_info_path=info, + hackrf_clock_path=clock, + hackrf_transfer_path=transfer, + command_runner=runner, + process_gate=lambda: (True, {"state": "CLEAR"}), + background_launcher=lambda arguments: launched.append(list(arguments)) or 1234, + process_stopper=lambda pid: None, + ) + + assert result["hardware_accessed"] is True + assert result["rf_rx"] == 0 + assert result["rf_tx"] == 0 + assert result["common_clock_verified"] is False + assert errors == ["RuntimeError: CLKIN clock signal was not detected"] + assert launched == [] + + +def test_capture_status_requires_exact_complete_file(tmp_path: Path) -> None: + session_id = "RF001A_20260923T150000Z_1234ABCD" + session = tmp_path / "rx_capture" / session_id + session.mkdir(parents=True) + capture = session / CAPTURE_FILE_NAME + capture.write_bytes(b"1234") + result_path = session / RESULT_FILE_NAME + result_path.write_text( + json.dumps( + { + "exit_code": 0, + "runner_error": None, + "capture_size": 4, + "sha256": "fixture", + } + ), + encoding="utf-8", + ) + (session / MANIFEST_FILE_NAME).write_text( + json.dumps( + { + "session_id": session_id, + "capture_path": str(capture), + "capture_started_at": "2026-09-23T15:00:00+00:00", + "expected_capture_end": "2026-09-23T15:00:20+00:00", + "expected_capture_bytes": 4, + "result_status_path": str(result_path), + "common_clock_verified": True, + "rf_tx": 0, + } + ), + encoding="utf-8", + ) + + result, errors = read_capture_status(tmp_path, session_id) + + assert errors == [] + assert result["state"] == "CAPTURE_COMPLETE" + assert result["capture_bytes"] == 4 + assert result["hardware_accessed"] is False + assert result["rf_tx"] == 0 + + +def test_capture_diagnostics_returns_report_and_signal_power(tmp_path: Path) -> None: + session_id = "RF001A_20260925T104504Z_5FA2C83B" + session = tmp_path / "rx_capture" / session_id + session.mkdir(parents=True) + capture = session / CAPTURE_FILE_NAME + samples = bytearray() + for index in range(200): + code = 10 if 80 <= index < 120 else 1 + samples.extend((code, code)) + capture.write_bytes(samples) + capture_sha256 = hashlib.sha256(samples).hexdigest() + result_path = session / RESULT_FILE_NAME + result_path.write_text( + json.dumps( + { + "exit_code": 0, + "runner_error": None, + "capture_size": len(samples), + "sha256": capture_sha256, + } + ), + encoding="utf-8", + ) + (session / MANIFEST_FILE_NAME).write_text( + json.dumps( + { + "session_id": session_id, + "capture_path": str(capture), + "capture_started_at": "2026-09-25T10:00:00+00:00", + "expected_capture_end": "2026-09-25T10:00:01+00:00", + "expected_capture_bytes": len(samples), + "result_status_path": str(result_path), + "common_clock_verified": True, + "rf_tx": 0, + } + ), + encoding="utf-8", + ) + report = { + "rf_tx": 0, + "iq_sha256": capture_sha256, + "detected_preambles": 0, + "rejected_candidates": 0, + "crc_valid_packets": 0, + "exact_packets": 0, + "rx_clipping_rail_codes": 0, + "analysis_windows": [{"start_sample": 64, "stop_sample": 136}], + } + (session / REPORT_FILE_NAME).write_text( + json.dumps(report), + encoding="utf-8", + ) + + diagnostics, errors = read_capture_diagnostics( + tmp_path, + session_id, + "2026-09-25T10:00:00.000010+00:00", + "2026-09-25T10:00:00.000015+00:00", + ) + + assert errors == [] + assert diagnostics["hardware_accessed"] is False + assert diagnostics["rf_tx"] == 0 + assert diagnostics["report"] == report + signal = diagnostics["signal_metrics"] + assert signal["tx_window"]["start_sample"] == 80 + assert signal["tx_window"]["stop_sample"] == 120 + assert signal["tx_to_baseline_power_ratio"] == 100.0 + assert signal["tx_to_baseline_db"] == 20.0 + + +def test_missing_process_gate_dependency_reports_failure_without_hardware( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + repository.mkdir() + _write_repository(repository) + tool_dir = tmp_path / "hackrf" + tool_dir.mkdir() + info = tool_dir / "hackrf_info.exe" + clock = tool_dir / "hackrf_clock.exe" + transfer = tool_dir / "hackrf_transfer.exe" + for path in (info, clock, transfer): + path.write_bytes(b"") + + commands: list[list[str]] = [] + + def runner(arguments: Sequence[str], timeout: float) -> FakeResult: + del timeout + commands.append(list(arguments)) + return FakeResult(arguments, "unexpected") + + def missing_gate() -> tuple[bool, dict[str, object]]: + raise ModuleNotFoundError("tools.hackrf_process_check") + + result, errors = start_rx_capture( + repository, + tmp_path / "runtime", + "RF001A_20260924T090807Z_B8E6794D", + FIXED_RX_SERIAL, + hackrf_info_path=info, + hackrf_clock_path=clock, + hackrf_transfer_path=transfer, + command_runner=runner, + process_gate=missing_gate, + process_stopper=lambda pid: None, + ) + + assert result["hardware_accessed"] is False + assert result["rf_rx"] == 0 + assert result["rf_tx"] == 0 + assert errors == [ + "ModuleNotFoundError: tools.hackrf_process_check" + ] + assert commands == [] diff --git a/tests/test_rf001_rx_http_agent.py b/tests/test_rf001_rx_http_agent.py new file mode 100644 index 0000000..d6274a0 --- /dev/null +++ b/tests/test_rf001_rx_http_agent.py @@ -0,0 +1,824 @@ +"""Software-only tests for the fixed-command RX HTTP agent.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta, timezone +import hashlib +import json +from pathlib import Path +import shutil +from typing import Sequence + +import pytest + +import tools.rf001_agent_update as update_module +import tools.rf001_rx_http_agent as agent_module +from tools.rf001_http_control import PROTOCOL_VERSION, ProtocolError +from tools.rf001_rx_http_agent import ( + CommandResult, + collect_inventory, + execute_task, + normalized_agent_id, + read_git_metadata, +) + + +def fake_runner(arguments: Sequence[str], timeout: float) -> CommandResult: + del timeout + command = list(arguments) + if command == ["hostname"]: + stdout = "User-PC" + elif command == ["whoami"]: + stdout = "user-pc\\professional" + elif command[0] == "git": + stdout = "fake-git-read-only-result" + elif command[0].lower().endswith("hackrf_info.exe"): + stdout = "hackrf_info version: 2024.02.1" + else: # pragma: no cover - protects the test allowlist + raise AssertionError(f"unexpected command: {command}") + return CommandResult(command, 0, stdout, "") + + +def test_agent_id_is_normalized_without_shell_content() -> None: + assert normalized_agent_id("User-PC") == "user-pc" + assert normalized_agent_id(" RX Laptop! ") == "rx-laptop" + + +def test_inventory_runs_only_fixed_read_only_commands(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + hackrf_info = tmp_path / "hackrf_info.exe" + hackrf_info.write_bytes(b"") + + inventory, errors = collect_inventory( + repository, + include_hackrf_info=True, + hackrf_info_path=hackrf_info, + command_runner=fake_runner, + ) + + assert errors == [] + assert inventory["rf_tx"] == 0 + assert inventory["hostname_command"]["stdout"] == "User-PC" + assert inventory["whoami_command"]["stdout"] == "user-pc\\professional" + assert inventory["hackrf_info"]["executed"] is True + assert inventory["hackrf_info"]["rf_tx"] == 0 + + +def test_agent_rejects_unknown_task_and_rf_tx(tmp_path: Path) -> None: + base_task: dict[str, object] = { + "protocol_version": PROTOCOL_VERSION, + "task_id": "INVENTORY_TEST_001", + "kind": "INVENTORY", + "agent_id": "rx-user-pc", + "created_at": "2026-09-23T00:00:00+00:00", + "include_hackrf_info": False, + "rf_tx": 0, + } + unknown = dict(base_task) + unknown["kind"] = "SHELL" + with pytest.raises(ProtocolError, match="unsupported task kind"): + execute_task( + unknown, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + command_runner=fake_runner, + ) + + transmit = dict(base_task) + transmit["rf_tx"] = 1 + with pytest.raises(ProtocolError, match="rf_tx = 0"): + execute_task( + transmit, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + command_runner=fake_runner, + ) + + +def test_agent_dispatches_fixed_capture_diagnostics_without_hardware( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[Path, str, str, str]] = [] + + def diagnostics( + work_root: Path, + session_id: str, + tx_started_at: str, + tx_finished_at: str, + ) -> tuple[dict[str, object], list[str]]: + calls.append((work_root, session_id, tx_started_at, tx_finished_at)) + return { + "session_id": session_id, + "hardware_accessed": False, + "rf_tx": 0, + "report": {"detected_preambles": 0}, + }, [] + + monkeypatch.setattr(agent_module, "read_capture_diagnostics", diagnostics) + session_id = "RF001A_20260925T104504Z_5FA2C83B" + task = { + "protocol_version": PROTOCOL_VERSION, + "task_id": "DIAG_TEST_001", + "kind": "CAPTURE_DIAGNOSTICS", + "agent_id": "rx-user-pc", + "created_at": "2026-09-25T10:51:00+00:00", + "session_id": session_id, + "tx_started_at": "2026-09-25T10:46:54+00:00", + "tx_finished_at": "2026-09-25T10:46:56+00:00", + "rf_tx": 0, + } + + result = execute_task( + task, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + work_root=tmp_path / "runtime", + command_runner=fake_runner, + ) + + assert result["status"] == "COMPLETE" + assert result["result"]["hardware_accessed"] is False + assert result["rf_tx"] == 0 + assert calls == [ + ( + tmp_path / "runtime", + session_id, + "2026-09-25T10:46:54+00:00", + "2026-09-25T10:46:56+00:00", + ) + ] + + +def _write_git_metadata(repository: Path, head: str) -> None: + git_dir = repository / ".git" + ref = git_dir / "refs" / "heads" / "video-development" + ref.parent.mkdir(parents=True) + (git_dir / "HEAD").write_text( + "ref: refs/heads/video-development\n", + encoding="ascii", + ) + ref.write_text(head + "\n", encoding="ascii") + + +def test_read_git_metadata_without_git_executable(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + head = "a94f2db4ee8377201a97263166cb09431df5f5d6" + _write_git_metadata(repository, head) + + metadata = read_git_metadata(repository) + + assert metadata["branch"] == "video-development" + assert metadata["head"] == head + assert metadata["status"] == "NOT_RUN_NO_GIT_EXECUTABLE" + + +def test_read_git_metadata_from_linked_worktree(tmp_path: Path) -> None: + repository = tmp_path / "worktree" + repository.mkdir() + common = tmp_path / "main.git" + worktree_git = common / "worktrees" / "worktree" + worktree_git.mkdir(parents=True) + (repository / ".git").write_text( + f"gitdir: {worktree_git}\n", + encoding="utf-8", + ) + (worktree_git / "commondir").write_text("../..\n", encoding="ascii") + (worktree_git / "HEAD").write_text( + "ref: refs/heads/video-development\n", + encoding="ascii", + ) + head = "a94f2db4ee8377201a97263166cb09431df5f5d6" + ref = common / "refs" / "heads" / "video-development" + ref.parent.mkdir(parents=True) + ref.write_text(head + "\n", encoding="ascii") + + metadata = read_git_metadata(repository) + + assert metadata["git_dir"] == str(worktree_git.resolve()) + assert metadata["common_dir"] == str(common.resolve()) + assert metadata["branch"] == "video-development" + assert metadata["head"] == head + + +def test_offline_verify_is_fixed_software_only_task(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + head = "a94f2db4ee8377201a97263166cb09431df5f5d6" + _write_git_metadata(repository, head) + (repository / "tools").mkdir() + (repository / "configs" / "rf").mkdir(parents=True) + for relative in ( + Path("tools/rf001a_prepare.py"), + Path("tools/qpsk_rf_rx.py"), + Path("configs/rf/rf001a_truth_metadata.json"), + ): + (repository / relative).write_text("fixture\n", encoding="utf-8") + + commands: list[list[str]] = [] + + def offline_runner(arguments: Sequence[str], timeout: float) -> CommandResult: + del timeout + command = list(arguments) + commands.append(command) + if "rf001a_prepare.py" in command[4]: + output = Path(command[command.index("--output-dir") + 1]) + output.mkdir() + (output / "tx_waveform.cs8").write_bytes(b"fixture") + (output / "tx_metadata.json").write_text("{}", encoding="utf-8") + elif "rf001_offline_decode.py" in command[4]: + report = Path(command[command.index("--report") + 1]) + report.write_text( + '{"crc_valid_packets": 100, "exact_packets": 100}', + encoding="utf-8", + ) + else: # pragma: no cover - protects the fixed command contract + raise AssertionError(command) + return CommandResult(command, 0, "PASS", "") + + task = { + "protocol_version": PROTOCOL_VERSION, + "task_id": "OFFLINE_TEST_001", + "kind": "OFFLINE_VERIFY", + "agent_id": "rx-user-pc", + "created_at": "2026-09-23T00:00:00+00:00", + "expected_head": head, + "rf_tx": 0, + } + result = execute_task( + task, + agent_id="rx-user-pc", + repository=repository, + hackrf_info_path=None, + work_root=tmp_path / "runtime", + command_runner=offline_runner, + ) + + assert result["status"] == "COMPLETE" + assert result["result"]["hardware_accessed"] is False + assert result["result"]["crc_valid_packets"] == 100 + assert result["result"]["exact_packets"] == 100 + assert "--repository" in commands[1] + assert all("hackrf" not in Path(command[0]).name.lower() for command in commands) + assert all("-t" not in command for command in commands) + assert result["rf_tx"] == 0 + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _staircase_fixture( + tmp_path: Path, +) -> tuple[Path, dict[str, object], Path, Path]: + work_root = tmp_path / "runtime" + session_id = "RF001A_20260925T125829Z_227EADB4" + session_directory = work_root / "rx_capture" / session_id + session_directory.mkdir(parents=True) + capture = session_directory / "rf001a_ota_capture.cs8" + capture.write_bytes(b"fixed-rx-capture") + reference = work_root / "references" / "lab065_reference_staircase.cs8" + reference.parent.mkdir(parents=True) + reference.write_bytes(b"fixed-staircase-reference") + task: dict[str, object] = { + "protocol_version": PROTOCOL_VERSION, + "task_id": "STAIRCASE_TEST_001", + "kind": "STAIRCASE_SEARCH", + "agent_id": "rx-user-pc", + "created_at": "2026-09-28T12:00:00+00:00", + "session_id": session_id, + "expected_rx_sha256": _sha256(capture), + "expected_reference_sha256": _sha256(reference), + "hardware_accessed": False, + "automatic_retry": False, + "rf_tx": 0, + } + return work_root, task, capture, reference + + +def _write_staircase_report(arguments: Sequence[str]) -> Path: + command = list(arguments) + report_path = Path(command[command.index("--report") + 1]) + report = { + "analyzer_version": "test", + "mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH", + "capture_sha256": command[command.index("--capture-sha256") + 1], + "reference_sha256": _sha256( + Path(command[command.index("--reference") + 1]) + ), + "search": { + "coverage_start_sample": 0, + "coverage_stop_sample": 8, + "coverage_seconds": 0.000001, + "coverage_complete": True, + "source_samples_processed": 8, + "frequency_grid_hz": [-3.0, 0.0, 3.0], + "background_score_p99": 0.01, + "candidates": [], + }, + "verifications": [], + "verdict": "NOT_DETECTED", + "verdict_reasons": ["NO_CANDIDATES"], + "analysis_seconds": 1.25, + "peak_process_memory_bytes": 1234, + "peak_additional_memory_bytes": 456, + "hardware_accessed": False, + "rf_tx": 0, + } + report_path.write_text(json.dumps(report), encoding="utf-8") + return report_path + + +def test_staircase_task_rejects_invalid_session_and_hashes(tmp_path: Path) -> None: + work_root, task, _capture, _reference = _staircase_fixture(tmp_path) + invalid_session = dict(task, session_id="..\\outside") + with pytest.raises(ProtocolError, match="session_id"): + execute_task( + invalid_session, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + work_root=work_root, + ) + + for field in ("expected_rx_sha256", "expected_reference_sha256"): + invalid_hash = dict(task) + invalid_hash[field] = "invalid" + with pytest.raises(ProtocolError, match=field): + execute_task( + invalid_hash, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + work_root=work_root, + ) + + +def test_staircase_hash_mismatch_does_not_start_child(tmp_path: Path) -> None: + work_root, task, _capture, _reference = _staircase_fixture(tmp_path) + calls: list[list[str]] = [] + + def forbidden_runner(arguments: Sequence[str], timeout: float) -> CommandResult: + del timeout + calls.append(list(arguments)) + raise AssertionError("child process must not run after a hash mismatch") + + for field in ("expected_rx_sha256", "expected_reference_sha256"): + changed = dict(task) + changed[field] = "0" * 64 + result = execute_task( + changed, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + work_root=work_root, + command_runner=forbidden_runner, + ) + assert result["status"] == "FAILED" + assert result["result"]["reference_detection"] == "EXECUTION_FAILED" + assert calls == [] + + +def test_staircase_repeat_reuses_existing_report_without_child( + tmp_path: Path, +) -> None: + work_root, task, _capture, _reference = _staircase_fixture(tmp_path) + calls: list[list[str]] = [] + + def analyzer_runner(arguments: Sequence[str], timeout: float) -> CommandResult: + assert timeout == 43_200.0 + calls.append(list(arguments)) + _write_staircase_report(arguments) + assert "hackrf" not in " ".join(arguments).lower() + return CommandResult(list(arguments), 0, "REFERENCE STAIRCASE = NOT_DETECTED", "") + + first = execute_task( + task, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + work_root=work_root, + command_runner=analyzer_runner, + ) + second = execute_task( + task, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + work_root=work_root, + command_runner=lambda *_args: (_ for _ in ()).throw( + AssertionError("existing report must not start another child") + ), + ) + assert first["status"] == "COMPLETE" + assert first["result"]["hardware_accessed"] is False + assert first["result"]["rf_tx"] == 0 + assert first["result"]["reused_existing_report"] is False + assert second["status"] == "COMPLETE" + assert second["result"]["reused_existing_report"] is True + assert len(calls) == 1 + + +def test_staircase_child_failure_is_execution_failed(tmp_path: Path) -> None: + work_root, task, _capture, _reference = _staircase_fixture(tmp_path) + + def failed_runner(arguments: Sequence[str], timeout: float) -> CommandResult: + del timeout + return CommandResult(list(arguments), 9, "", "fixed failure") + + result = execute_task( + task, + agent_id="rx-user-pc", + repository=tmp_path, + hackrf_info_path=None, + work_root=work_root, + command_runner=failed_runner, + ) + assert result["status"] == "FAILED" + assert result["result"]["reference_detection"] == "EXECUTION_FAILED" + assert result["result"]["hardware_accessed"] is False + assert result["result"]["rf_tx"] == 0 + assert "exited with 9" in result["errors"][0] + + +def _update_fixture( + tmp_path: Path, +) -> tuple[ + Path, + bytes, + dict[str, object], + dict[str, object], + argparse.Namespace, +]: + source = tmp_path / "source" + for index, relative in enumerate(update_module.RELEASE_SOURCE_FILES.values()): + path = source / relative + path.parent.mkdir(parents=True, exist_ok=True) + content = f"# fixed update fixture {index}\n" + if relative == update_module.RELEASE_SOURCE_FILES[ + "app/tools/rf001_rx_http_agent.py" + ]: + content += 'AGENT_VERSION = "2026.09.29.2"\n' + path.write_text(content, encoding="utf-8") + release_id = "rf001-agent-2026.09.29.2" + package = tmp_path / f"{release_id}.zip" + inspection = update_module.build_release_archive( + source, + package, + release_id=release_id, + agent_version="2026.09.29.2", + protocol_version=PROTOCOL_VERSION, + ) + manifest: dict[str, object] = { + **inspection.descriptor(), + "package_kind": "AGENT_RELEASE", + } + token = b"fixed-test-token-0123456789abcdef" + task: dict[str, object] = { + "protocol_version": PROTOCOL_VERSION, + "task_id": "UPDATE_TEST_001", + "kind": "UPDATE_AGENT", + "agent_id": "rx-user-pc", + "created_at": datetime.now(timezone.utc).isoformat(), + "release_id": release_id, + "agent_version": "2026.09.29.2", + "package_sha256": inspection.package_sha256, + "package_bytes": inspection.package_bytes, + "content_manifest_sha256": inspection.content_manifest_sha256, + "compatible_protocol_version": PROTOCOL_VERSION, + "approval_expires_at": ( + datetime.now(timezone.utc) + timedelta(hours=1) + ).isoformat(), + "automatic_retry": False, + "hardware_accessed": False, + "rf_tx": 0, + } + install_root = tmp_path / "install" + install_root.mkdir() + launcher_status = install_root / "launcher_status.json" + launcher_status.write_text( + json.dumps( + { + "active_release_id": "rf001-agent-2026.09.29.1", + "active_agent_version": "2026.09.29.1", + "rf_tx": 0, + } + ), + encoding="utf-8", + ) + arguments = argparse.Namespace( + agent_id="rx-user-pc", + server="http://127.0.0.1:8765", + timeout=1.0, + install_root=install_root, + launcher_request=install_root / "launcher_request.json", + launcher_status=launcher_status, + release_id="rf001-agent-2026.09.29.1", + ) + return package, token, task, manifest, arguments + + +def _successful_self_test( + arguments: Sequence[str], timeout: float +) -> CommandResult: + assert timeout == 120.0 + assert "hackrf" not in " ".join(arguments).lower() + return CommandResult(list(arguments), 0, "SELF TEST = PASS", "") + + +def test_agent_stages_authenticated_update_and_reuses_it( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + package, token, task, manifest, arguments = _update_fixture(tmp_path) + downloads: list[Path] = [] + + monkeypatch.setattr( + agent_module, + "request_json", + lambda *_args, **_kwargs: { + "manifest": manifest, + "manifest_signature": update_module.sign_update_manifest(token, manifest), + }, + ) + + def download( + _server: str, + _token: bytes, + _target: str, + destination: Path, + **_kwargs: object, + ) -> None: + downloads.append(destination) + shutil.copyfile(package, destination) + + monkeypatch.setattr(agent_module, "request_file", download) + result, errors, request = agent_module.stage_agent_update( + task, + arguments=arguments, + token=token, + command_runner=_successful_self_test, + ) + assert errors == [] + assert result["update_status"] == "STAGED" + assert result["hardware_accessed"] is False + assert result["rf_tx"] == 0 + assert request is not None + assert len(downloads) == 1 + + result, errors, repeated_request = agent_module.stage_agent_update( + task, + arguments=arguments, + token=token, + command_runner=lambda *_args: (_ for _ in ()).throw( + AssertionError("an already staged release must not run again") + ), + ) + assert errors == [] + assert result["update_status"] == "ALREADY_STAGED" + assert repeated_request is not None + assert { + key: value + for key, value in repeated_request.items() + if key != "requested_at" + } == {key: value for key, value in request.items() if key != "requested_at"} + assert len(downloads) == 1 + + +def test_agent_rejects_bad_update_signature_sha_and_protocol( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _package, token, task, manifest, arguments = _update_fixture(tmp_path) + network_downloads: list[str] = [] + monkeypatch.setattr( + agent_module, + "request_json", + lambda *_args, **_kwargs: { + "manifest": manifest, + "manifest_signature": "0" * 64, + }, + ) + monkeypatch.setattr( + agent_module, + "request_file", + lambda *_args, **_kwargs: network_downloads.append("called"), + ) + result, errors, request = agent_module.stage_agent_update( + task, + arguments=arguments, + token=token, + command_runner=_successful_self_test, + ) + assert result["update_status"] == "EXECUTION_FAILED" + assert any("signature mismatch" in error for error in errors) + assert request is None + assert network_downloads == [] + + incompatible = dict(task, compatible_protocol_version=PROTOCOL_VERSION + 1) + _result, errors, _request = agent_module.stage_agent_update( + incompatible, + arguments=arguments, + token=token, + command_runner=_successful_self_test, + ) + assert any("incompatible" in error for error in errors) + + changed_sha = dict(task, package_sha256="0" * 64) + signed = update_module.sign_update_manifest(token, manifest) + monkeypatch.setattr( + agent_module, + "request_json", + lambda *_args, **_kwargs: { + "manifest": manifest, + "manifest_signature": signed, + }, + ) + _result, errors, _request = agent_module.stage_agent_update( + changed_sha, + arguments=arguments, + token=token, + command_runner=_successful_self_test, + ) + assert any("field mismatch: package_sha256" in error for error in errors) + assert network_downloads == [] + + +def test_agent_retries_one_interrupted_download_then_stages( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + package, token, task, manifest, arguments = _update_fixture(tmp_path) + monkeypatch.setattr( + agent_module, + "request_json", + lambda *_args, **_kwargs: { + "manifest": manifest, + "manifest_signature": update_module.sign_update_manifest(token, manifest), + }, + ) + calls = 0 + + def interrupted_then_complete( + _server: str, + _token: bytes, + _target: str, + destination: Path, + **_kwargs: object, + ) -> None: + nonlocal calls + calls += 1 + if calls == 1: + destination.write_bytes(b"partial") + raise OSError("fixed interrupted transfer") + shutil.copyfile(package, destination) + + monkeypatch.setattr(agent_module, "request_file", interrupted_then_complete) + result, errors, request = agent_module.stage_agent_update( + task, + arguments=arguments, + token=token, + command_runner=_successful_self_test, + ) + assert errors == [] + assert result["update_status"] == "STAGED" + assert request is not None + assert calls == 2 + state = json.loads( + ( + arguments.install_root + / "staging" + / str(task["task_id"]) + / "download_state.json" + ).read_text(encoding="utf-8") + ) + assert state["network_attempts"] == 2 + + +def test_agent_does_not_exceed_persisted_download_attempt_limit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _package, token, task, manifest, arguments = _update_fixture(tmp_path) + monkeypatch.setattr( + agent_module, + "request_json", + lambda *_args, **_kwargs: { + "manifest": manifest, + "manifest_signature": update_module.sign_update_manifest(token, manifest), + }, + ) + calls = 0 + + def always_interrupted(*_args: object, **_kwargs: object) -> None: + nonlocal calls + calls += 1 + raise OSError("fixed interrupted transfer") + + monkeypatch.setattr(agent_module, "request_file", always_interrupted) + for _ in range(2): + result, errors, request = agent_module.stage_agent_update( + task, + arguments=arguments, + token=token, + command_runner=_successful_self_test, + ) + assert result["update_status"] == "EXECUTION_FAILED" + assert errors + assert request is None + assert calls == 2 + + +def test_agent_restart_does_not_duplicate_background_analyzer( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + work_root, task, _capture, _reference = _staircase_fixture(tmp_path) + starts: list[list[str]] = [] + + class FakeProcess: + pid = 4242 + + def fake_popen(arguments: Sequence[str], **_kwargs: object) -> FakeProcess: + starts.append(list(arguments)) + return FakeProcess() + + monkeypatch.setattr(agent_module.subprocess, "Popen", fake_popen) + pointer = agent_module.start_staircase_background_job( + task, + agent_id="rx-user-pc", + work_root=work_root, + ) + assert pointer["pid"] == 4242 + with pytest.raises(RuntimeError, match="already active"): + agent_module.start_staircase_background_job( + task, + agent_id="rx-user-pc", + work_root=work_root, + ) + assert len(starts) == 1 + assert pointer["hardware_accessed"] is False + assert pointer["rf_tx"] == 0 + + +def test_completed_background_result_survives_network_loss( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + work_root, task, _capture, _reference = _staircase_fixture(tmp_path) + directory = work_root / "agent_jobs" / str(task["task_id"]) + directory.mkdir(parents=True) + pointer = { + "schema_version": 1, + "task": task, + "pid": 999999, + "started_at": datetime.now(timezone.utc).isoformat(), + "automatic_retry": False, + "hardware_accessed": False, + "rf_tx": 0, + } + agent_module.atomic_write_json( + work_root / "agent_jobs" / "active_job.json", pointer + ) + result = { + "protocol_version": PROTOCOL_VERSION, + "task_id": task["task_id"], + "agent_id": "rx-user-pc", + "status": "COMPLETE", + "result": {"hardware_accessed": False, "rf_tx": 0}, + "errors": [], + "rf_tx": 0, + } + agent_module.atomic_write_json(directory / "result.json", result) + arguments = argparse.Namespace( + work_root=work_root, + server="http://127.0.0.1:8765", + timeout=1.0, + ) + monkeypatch.setattr( + agent_module, + "request_json", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + OSError("fixed network loss") + ), + ) + with pytest.raises(OSError, match="network loss"): + agent_module.deliver_background_result(arguments, b"token", pointer) + assert (work_root / "agent_jobs" / "active_job.json").is_file() + assert (directory / "result.json").is_file() + + monkeypatch.setattr( + agent_module, + "request_json", + lambda *_args, **_kwargs: {"accepted": True, "rf_tx": 0}, + ) + assert agent_module.deliver_background_result(arguments, b"token", pointer) + assert not (work_root / "agent_jobs" / "active_job.json").exists() + assert (directory / "delivered_job.json").is_file() + assert (directory / "result.json").is_file() diff --git a/tests/test_rf001_staircase_search.py b/tests/test_rf001_staircase_search.py new file mode 100644 index 0000000..eae34fe --- /dev/null +++ b/tests/test_rf001_staircase_search.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import hashlib +import math +from pathlib import Path + +import numpy as np +import pytest +from scipy import signal + +from tools.rf001_staircase_search import ( + DEFAULT_CRITERIA, + DEFAULT_LAYOUT, + EXPECTED_REFERENCE_SHA256, + ReferenceLayout, + SearchCriteria, + analyze, + design_antialias_filter, + locate_reference_streaming, + load_reference, + read_filtered_decimated_window, + verify_candidate, +) + + +LAYOUT = ReferenceLayout( + sample_rate_hz=8_000, + symbol_rate=200, + carrier_hz=500.0, + leading_samples=800, + segment_samples=800, + gap_samples=400, + trailing_samples=800, +) +CRITERIA = SearchCriteria( + decimation=1, + input_block_samples=50_000, + cfo_min_hz=-3.0, + cfo_max_hz=3.0, + cfo_step_hz=0.5, + histogram_stride=8, +) +PROJECT_ROOT = Path(__file__).resolve().parents[1] +EXACT_REFERENCE_PATH = ( + PROJECT_ROOT / ".local" / "rf001_probe" / "lab065_reference_staircase.cs8" +) + + +def _reference() -> np.ndarray: + random_generator = np.random.default_rng(20260925) + symbols = random_generator.choice((-1.0, 1.0), LAYOUT.segment_samples // 40) + base = np.repeat(symbols, 40).astype(np.complex64) + time_axis = np.arange(LAYOUT.segment_samples) / LAYOUT.sample_rate_hz + base *= np.exp(2j * np.pi * LAYOUT.carrier_hz * time_axis) + base *= 60.0 / np.sqrt(np.mean(np.abs(base) ** 2)) + values = np.zeros(LAYOUT.total_samples, dtype=np.complex64) + for level, (start, stop) in zip(LAYOUT.levels_db, LAYOUT.active_windows()): + values[start:stop] = base * (10 ** (level / 20)) + return values + + +def _write_cs8(path: Path, values: np.ndarray) -> str: + interleaved = np.empty(2 * values.size, dtype=np.int8) + interleaved[0::2] = np.clip(np.rint(values.real), -128, 127).astype(np.int8) + interleaved[1::2] = np.clip(np.rint(values.imag), -128, 127).astype(np.int8) + path.write_bytes(interleaved.tobytes()) + return hashlib.sha256(interleaved.tobytes()).hexdigest() + + +def _capture(reference: np.ndarray, *, gain: float, cfo_hz: float, start: int) -> np.ndarray: + random_generator = np.random.default_rng(417) + values = ( + random_generator.normal(0.0, 1.5, 1_000_000) + + 1j * random_generator.normal(0.0, 1.5, 1_000_000) + ).astype(np.complex64) + time_axis = np.arange(reference.size) / LAYOUT.sample_rate_hz + values[start:start + reference.size] += ( + gain + * reference + * np.exp(1j * (0.73 + 2 * np.pi * cfo_hz * time_axis)) + ) + return values + + +@pytest.mark.parametrize( + ("gain", "cfo_hz", "start"), + ((0.25, -2.5, 49_777), (0.75, 0.0, 420_201), (1.5, 2.5, 749_999)), +) +def test_unknown_position_gain_cfo_and_block_boundary( + tmp_path: Path, + gain: float, + cfo_hz: float, + start: int, +) -> None: + reference_path = tmp_path / "reference.cs8" + capture_path = tmp_path / "capture.cs8" + reference_hash = _write_cs8(reference_path, _reference()) + _write_cs8(capture_path, _capture(_reference(), gain=gain, cfo_hz=cfo_hz, start=start)) + reference, _ = load_reference( + reference_path, + LAYOUT, + CRITERIA, + expected_sha256=reference_hash, + ) + search = locate_reference_streaming(capture_path, reference, LAYOUT, CRITERIA) + candidate = search["candidates"][0] + verification = verify_candidate(capture_path, reference, candidate, LAYOUT, CRITERIA) + assert abs(int(candidate["start_sample"]) - start) <= 1 + assert abs(float(candidate["cfo_hz"]) - cfo_hz) <= CRITERIA.cfo_step_hz + assert float(candidate["score"]) >= CRITERIA.normalized_correlation_minimum + assert verification["qualified"] is True + + +def test_negative_control_without_staircase_is_not_detected(tmp_path: Path) -> None: + reference_path = tmp_path / "reference.cs8" + capture_path = tmp_path / "noise.cs8" + report_path = tmp_path / "report.json" + reference_hash = _write_cs8(reference_path, _reference()) + random_generator = np.random.default_rng(818) + noise = ( + random_generator.normal(0.0, 3.0, 1_000_000) + + 1j * random_generator.normal(0.0, 3.0, 1_000_000) + ).astype(np.complex64) + capture_hash = _write_cs8(capture_path, noise) + result = analyze( + reference_path, + capture_path, + report_path, + expected_capture_sha256=capture_hash, + layout=LAYOUT, + criteria=CRITERIA, + expected_reference_sha256=reference_hash, + ) + assert result["verdict"] == "NOT_DETECTED" + assert report_path.is_file() + assert result["hardware_accessed"] is False + assert result["rf_tx"] == 0 + + +def test_report_is_not_overwritten(tmp_path: Path) -> None: + reference_path = tmp_path / "reference.cs8" + capture_path = tmp_path / "capture.cs8" + report_path = tmp_path / "report.json" + reference_hash = _write_cs8(reference_path, _reference()) + capture_hash = _write_cs8( + capture_path, + _capture(_reference(), gain=1.0, cfo_hz=0.0, start=4_000), + ) + result = analyze( + reference_path, + capture_path, + report_path, + expected_capture_sha256=capture_hash, + layout=LAYOUT, + criteria=CRITERIA, + expected_reference_sha256=reference_hash, + ) + assert result["verdict"] == "DETECTED" + with pytest.raises(FileExistsError): + analyze( + reference_path, + capture_path, + report_path, + expected_capture_sha256=capture_hash, + layout=LAYOUT, + criteria=CRITERIA, + expected_reference_sha256=reference_hash, + ) + + +def _require_exact_reference() -> Path: + if not EXACT_REFERENCE_PATH.is_file(): + pytest.skip("exact Lab065 reference is not available in this checkout") + assert EXACT_REFERENCE_PATH.stat().st_size == 12_000_000 + assert hashlib.sha256(EXACT_REFERENCE_PATH.read_bytes()).hexdigest() == ( + EXPECTED_REFERENCE_SHA256 + ) + return EXACT_REFERENCE_PATH + + +def _read_exact_reference_values(path: Path) -> np.ndarray: + raw = np.memmap(path, dtype=np.int8, mode="r") + return ( + np.asarray(raw[0::2], dtype=np.float32) + + 1j * np.asarray(raw[1::2], dtype=np.float32) + ).astype(np.complex64) + + +def _write_noise_cs8(path: Path, samples: int, seed: int) -> None: + random_generator = np.random.default_rng(seed) + raw = np.memmap(path, dtype=np.int8, mode="w+", shape=(2 * samples,)) + block = 500_000 + for start in range(0, samples, block): + stop = min(samples, start + block) + count = stop - start + raw[2 * start:2 * stop:2] = np.clip( + np.rint(random_generator.normal(0.0, 0.75, count)), + -128, + 127, + ).astype(np.int8) + raw[2 * start + 1:2 * stop + 1:2] = np.clip( + np.rint(random_generator.normal(0.0, 0.75, count)), + -128, + 127, + ).astype(np.int8) + raw.flush() + del raw + + +def _inject_exact_reference( + capture_path: Path, + reference: np.ndarray, + *, + start: int, + gain: float, + phase: float, + cfo_hz: float, +) -> None: + raw = np.memmap(capture_path, dtype=np.int8, mode="r+") + existing = ( + np.asarray(raw[2 * start:2 * (start + reference.size):2], dtype=np.float32) + + 1j + * np.asarray( + raw[2 * start + 1:2 * (start + reference.size) + 1:2], + dtype=np.float32, + ) + ) + time_axis = np.arange(reference.size, dtype=np.float64) / DEFAULT_LAYOUT.sample_rate_hz + injected = existing + gain * reference * np.exp( + 1j * (phase + 2 * np.pi * cfo_hz * time_axis) + ) + raw[2 * start:2 * (start + reference.size):2] = np.clip( + np.rint(injected.real), -128, 127 + ).astype(np.int8) + raw[2 * start + 1:2 * (start + reference.size) + 1:2] = np.clip( + np.rint(injected.imag), -128, 127 + ).astype(np.int8) + raw.flush() + del raw + + +def test_antialias_filter_and_block_overlap_are_fixed() -> None: + reference_path = _require_exact_reference() + taps = design_antialias_filter(DEFAULT_LAYOUT, DEFAULT_CRITERIA) + assert taps.size == 895 + assert (taps.size - 1) // 2 == 447 + frequencies, response = signal.freqz( + taps, + worN=262_144, + fs=DEFAULT_LAYOUT.sample_rate_hz, + ) + assert np.max(np.abs(response[frequencies >= 120_000])) <= 10 ** (-79 / 20) + + total = math.ceil(DEFAULT_LAYOUT.total_samples / DEFAULT_CRITERIA.decimation) + split = 73_219 + whole = read_filtered_decimated_window( + reference_path, + 0, + total, + DEFAULT_LAYOUT, + DEFAULT_CRITERIA, + taps, + ) + blocked = np.concatenate( + ( + read_filtered_decimated_window( + reference_path, + 0, + split, + DEFAULT_LAYOUT, + DEFAULT_CRITERIA, + taps, + ), + read_filtered_decimated_window( + reference_path, + split, + total, + DEFAULT_LAYOUT, + DEFAULT_CRITERIA, + taps, + ), + ) + ) + np.testing.assert_allclose(blocked, whole, rtol=0, atol=2e-5) + + +def test_exact_reference_unknown_positions_gain_phase_noise_and_cfo( + tmp_path: Path, +) -> None: + reference_path = _require_exact_reference() + reference_values = _read_exact_reference_values(reference_path) + cases = ( + (123_457, 0.45, 0.73, -2.5), + (7_999_987, 0.70, -1.17, 0.0), + (15_999_983, 1.00, 2.21, 2.5), + ) + capture_path = tmp_path / "exact_reference_cases.cs8" + _write_noise_cs8(capture_path, 22_500_000, seed=2026092801) + for start, gain, phase, cfo_hz in cases: + _inject_exact_reference( + capture_path, + reference_values, + start=start, + gain=gain, + phase=phase, + cfo_hz=cfo_hz, + ) + reference, _ = load_reference( + reference_path, + DEFAULT_LAYOUT, + DEFAULT_CRITERIA, + expected_sha256=EXPECTED_REFERENCE_SHA256, + ) + search = locate_reference_streaming( + capture_path, + reference, + DEFAULT_LAYOUT, + DEFAULT_CRITERIA, + ) + assert search["coverage_complete"] is True + assert search["source_samples_processed"] == 22_500_000 + candidates = list(search["candidates"]) + for expected_start, _gain, _phase, expected_cfo in cases: + candidate = min( + candidates, + key=lambda row: abs(int(row["start_sample"]) - expected_start), + ) + assert abs(int(candidate["start_sample"]) - expected_start) <= 32 + assert abs(float(candidate["cfo_hz"]) - expected_cfo) <= 0.25 + assert float(candidate["score"]) >= DEFAULT_CRITERIA.normalized_correlation_minimum + verification = verify_candidate( + capture_path, + reference, + candidate, + DEFAULT_LAYOUT, + DEFAULT_CRITERIA, + ) + assert verification["qualified"] is True + + +def test_exact_reference_negative_control(tmp_path: Path) -> None: + reference_path = _require_exact_reference() + capture_path = tmp_path / "exact_reference_negative.cs8" + report_path = tmp_path / "negative_report.json" + _write_noise_cs8(capture_path, 8_000_000, seed=2026092802) + capture_hash = hashlib.sha256(capture_path.read_bytes()).hexdigest() + result = analyze( + reference_path, + capture_path, + report_path, + expected_capture_sha256=capture_hash, + ) + assert result["verdict"] == "NOT_DETECTED" + assert result["search"]["coverage_complete"] is True diff --git a/tools/install_rf001_main_autostart.ps1 b/tools/install_rf001_main_autostart.ps1 new file mode 100644 index 0000000..62ab70f --- /dev/null +++ b/tools/install_rf001_main_autostart.ps1 @@ -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" diff --git a/tools/install_rf001_rx_autostart.ps1 b/tools/install_rf001_rx_autostart.ps1 new file mode 100644 index 0000000..a18b50b --- /dev/null +++ b/tools/install_rf001_rx_autostart.ps1 @@ -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" diff --git a/tools/rf001_agent_launcher.py b/tools/rf001_agent_launcher.py new file mode 100644 index 0000000..665b5cd --- /dev/null +++ b/tools/rf001_agent_launcher.py @@ -0,0 +1,1009 @@ +"""Stable local launcher for versioned RF-001 RX-agent releases.""" + +from __future__ import annotations + +import argparse +import ctypes +from ctypes import wintypes +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import ntpath +import os +from pathlib import Path +import secrets +import signal +import shlex +import subprocess +import sys +import time +from typing import Any, Mapping, Sequence, TextIO + + +LAUNCHER_VERSION = "2026.09.29.1" +RELEASE_ID_PREFIX = "rf001-agent-" +PROCESS_QUERY_TIMEOUT_SECONDS = 8.0 +PROCESS_CLEANUP_TIMEOUT_SECONDS = 5.0 +PROCESS_CREATION_SKEW_SECONDS = 0.05 +LAUNCHER_PHASES = frozenset( + { + "LEGACY_ACTIVE", + "TARGET_VERIFIED", + "STARTING_TARGET", + "WAITING_FOR_HEALTH", + "TARGET_HEALTHY", + "TARGET_FAILED", + "ROLLING_BACK", + "LEGACY_RESTORED", + } +) + + +@dataclass(frozen=True) +class ProcessIdentity: + pid: int + parent_pid: int + executable: str + command_line: str + created_at: str + + +@dataclass(frozen=True) +class LaunchAttempt: + process: subprocess.Popen[str] + command: tuple[str, ...] + wrapper_pid: int + launch_boundary: str + wrapper_executable: str + child_executable: str + launch_nonce: str + + +@dataclass(frozen=True) +class ValidatedTarget: + mode: str + wrapper: ProcessIdentity + target: ProcessIdentity + + +@dataclass(frozen=True) +class CleanupResult: + state: str + actions: tuple[str, ...] + orphan_verified_target_processes: tuple[int, ...] + + +class StartupFailure(RuntimeError): + def __init__(self, message: str, cleanup: CleanupResult) -> None: + super().__init__(message) + self.cleanup = cleanup + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +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, separators=(",", ":"), 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 is required: {path}") + return value + + +def redirect_output(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + stream = path.open("a", encoding="utf-8", buffering=1) + sys.stdout = stream + sys.stderr = stream + + +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: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _path_key(value: str) -> str: + return ntpath.normcase(ntpath.normpath(value.strip().strip('"'))) + + +def _looks_like_windows_path(value: str) -> bool: + return len(value) >= 3 and value[1:3] in (":\\", ":/") + + +def _command_arguments(command_line: str) -> list[str] | None: + if not command_line.strip(): + return None + if os.name != "nt": + try: + return shlex.split(command_line, posix=False) + except ValueError: + return None + argument_count = ctypes.c_int() + command_to_argv = ctypes.windll.shell32.CommandLineToArgvW + command_to_argv.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] + command_to_argv.restype = ctypes.POINTER(wintypes.LPWSTR) + pointer = command_to_argv(command_line, ctypes.byref(argument_count)) + if not pointer: + return None + try: + return [pointer[index] for index in range(argument_count.value)] + finally: + ctypes.windll.kernel32.LocalFree(pointer) + + +def _command_matches(command_line: str, expected: Sequence[str]) -> bool: + actual = _command_arguments(command_line) + if actual is None or len(actual) != len(expected): + return False + for actual_value, expected_value in zip(actual, expected, strict=True): + if _looks_like_windows_path(actual_value) or _looks_like_windows_path( + expected_value + ): + if _path_key(actual_value) != _path_key(expected_value): + return False + elif actual_value != expected_value: + return False + return True + + +def _process_identity_from_json(value: Mapping[str, Any]) -> ProcessIdentity: + return ProcessIdentity( + pid=int(value["pid"]), + parent_pid=int(value["parent_pid"]), + executable=str(value.get("executable") or ""), + command_line=str(value.get("command_line") or ""), + created_at=str(value.get("created_at") or ""), + ) + + +def _query_process_identities( + wrapper_pid: int, + health_pid: int | None = None, +) -> list[ProcessIdentity]: + if os.name != "nt": + raise RuntimeError("strict process identity requires Windows") + process_ids = {wrapper_pid} + if health_pid is not None: + process_ids.add(health_pid) + process_terms = " OR ".join( + f"ProcessId = {process_id}" for process_id in sorted(process_ids) + ) + query = f"({process_terms}) OR ParentProcessId = {wrapper_pid}" + script = ( + "$ErrorActionPreference='Stop';" + "$ProgressPreference='SilentlyContinue';" + "[Console]::OutputEncoding=[Text.Encoding]::UTF8;" + f"$rows=@(Get-CimInstance Win32_Process -Filter '{query}' | ForEach-Object {{" + "[ordered]@{pid=[int]$_.ProcessId;parent_pid=[int]$_.ParentProcessId;" + "executable=[string]$_.ExecutablePath;command_line=[string]$_.CommandLine;" + "created_at=$_.CreationDate.ToUniversalTime().ToString('o')}});" + "[Console]::WriteLine((ConvertTo-Json -InputObject $rows -Compress))" + ) + system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")) + powershell = ( + system_root + / "System32" + / "WindowsPowerShell" + / "v1.0" + / "powershell.exe" + ) + executable = str(powershell) if powershell.is_file() else "powershell.exe" + result = subprocess.run( + [ + executable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-OutputFormat", + "Text", + "-Command", + script, + ], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8-sig", + errors="strict", + timeout=PROCESS_QUERY_TIMEOUT_SECONDS, + check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + if result.returncode != 0: + detail = result.stderr.strip() or f"exit code {result.returncode}" + raise RuntimeError(f"Windows process identity query failed: {detail}") + raw = result.stdout.strip() + if not raw: + return [] + value = json.loads(raw) + rows = value if isinstance(value, list) else [value] + if not all(isinstance(row, dict) for row in rows): + raise RuntimeError("Windows process identity query returned invalid JSON") + return [_process_identity_from_json(row) for row in rows] + + +def _same_process( + first: ProcessIdentity, + second: ProcessIdentity, + *, + require_parent: bool = True, +) -> bool: + return ( + first.pid == second.pid + and (not require_parent or first.parent_pid == second.parent_pid) + and _path_key(first.executable) == _path_key(second.executable) + and first.command_line == second.command_line + and first.created_at == second.created_at + ) + + +def _created_after_boundary(identity: ProcessIdentity, boundary: str) -> bool: + created_at = _parse_utc(identity.created_at) + launched_at = _parse_utc(boundary) + if created_at is None or launched_at is None: + return False + return created_at.timestamp() + PROCESS_CREATION_SKEW_SECONDS >= launched_at.timestamp() + + +def _runtime_executables(command_executable: str) -> tuple[str, str]: + wrapper = str(Path(command_executable).resolve()) + base_value = getattr(sys, "_base_executable", None) + if not isinstance(base_value, str) or not base_value: + base_value = command_executable + base = str(Path(base_value).resolve()) + return wrapper, base + + +def _wrapper_matches( + identity: ProcessIdentity, + attempt: LaunchAttempt, +) -> bool: + return ( + identity.pid == attempt.wrapper_pid + and _path_key(identity.executable) == _path_key(attempt.wrapper_executable) + and _created_after_boundary(identity, attempt.launch_boundary) + and _command_matches(identity.command_line, attempt.command) + ) + + +def _child_matches( + identity: ProcessIdentity, + attempt: LaunchAttempt, +) -> bool: + return ( + identity.parent_pid == attempt.wrapper_pid + and _path_key(identity.executable) == _path_key(attempt.child_executable) + and _created_after_boundary(identity, attempt.launch_boundary) + and _command_matches(identity.command_line, attempt.command) + ) + + +def _release_directory(config: Mapping[str, Any], release_id: str) -> Path: + if not release_id.startswith(RELEASE_ID_PREFIX): + raise ValueError("release_id is invalid") + releases_root = Path(str(config["releases_root"])).resolve() + candidate = (releases_root / release_id).resolve() + try: + candidate.relative_to(releases_root) + except ValueError as error: + raise ValueError("release directory escaped releases root") from error + return candidate + + +def validate_ready_release( + config: Mapping[str, Any], + release_id: str, + expected_version: str | None = None, +) -> tuple[Path, dict[str, Any]]: + release_directory = _release_directory(config, release_id) + ready_path = release_directory / "READY.json" + ready = read_json(ready_path) + if ready.get("status") != "READY" or ready.get("release_id") != release_id: + raise ValueError("release READY marker is invalid") + agent_version = ready.get("agent_version") + if not isinstance(agent_version, str): + raise ValueError("release agent_version is invalid") + if expected_version is not None and agent_version != expected_version: + raise ValueError("release agent_version does not match update request") + agent_script = release_directory / "app" / "tools" / "rf001_rx_http_agent.py" + if not agent_script.is_file(): + raise FileNotFoundError(f"release agent is absent: {agent_script}") + return agent_script, ready + + +def write_launcher_status( + config: Mapping[str, Any], + *, + state: str, + active_release_id: str, + active_agent_version: str | None, + error: str | None = None, + previous_release_id: str | None = None, + phase: str | None = None, + cleanup_state: str | None = None, +) -> None: + if phase is not None and phase not in LAUNCHER_PHASES: + raise ValueError(f"unsupported launcher phase: {phase}") + atomic_write_json( + Path(str(config["launcher_status"])), + { + "schema_version": 1, + "launcher_version": LAUNCHER_VERSION, + "state": state, + "active_release_id": active_release_id, + "active_agent_version": active_agent_version, + "previous_release_id": previous_release_id, + "error": error, + "phase": phase, + "cleanup_state": cleanup_state, + "updated_at": utc_now(), + "hardware_accessed": False, + "rf_tx": 0, + }, + ) + + +def _agent_command( + config: Mapping[str, Any], + agent_script: Path, + release_id: str, + launch_nonce: str, +) -> list[str]: + arguments = config.get("agent_arguments") + if not isinstance(arguments, list) or not all( + isinstance(value, str) for value in arguments + ): + raise ValueError("agent_arguments must be a list of strings") + return [ + sys.executable, + "-u", + "-B", + "-X", + "utf8", + str(agent_script), + *arguments, + "--release-id", + release_id, + "--launch-nonce", + launch_nonce, + "--health-file", + str(config["health_file"]), + "--launcher-status", + str(config["launcher_status"]), + "--launcher-request", + str(config["launcher_request"]), + "--install-root", + str(config["install_root"]), + ] + + +def _spawn_agent( + command: Sequence[str], + *, + cwd: Path, + log_stream: TextIO, +) -> subprocess.Popen[str]: + return subprocess.Popen( + list(command), + cwd=cwd, + stdin=subprocess.DEVNULL, + stdout=log_stream, + stderr=subprocess.STDOUT, + text=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + + +def _capture_wrapper_identity( + attempt: LaunchAttempt, + *, + timeout_seconds: float, +) -> ProcessIdentity: + deadline = time.monotonic() + timeout_seconds + last_error = "wrapper process identity was not observed" + while time.monotonic() < deadline: + if attempt.process.poll() is not None: + raise RuntimeError( + f"wrapper exited before identity capture with code " + f"{attempt.process.returncode}" + ) + try: + records = _query_process_identities(attempt.wrapper_pid) + except (OSError, RuntimeError, subprocess.SubprocessError, json.JSONDecodeError) as error: + last_error = f"process identity query failed: {type(error).__name__}: {error}" + else: + wrappers = [ + record + for record in records + if record.pid == attempt.wrapper_pid and _wrapper_matches(record, attempt) + ] + if len(wrappers) == 1: + return wrappers[0] + if wrappers: + last_error = "wrapper process identity is ambiguous" + else: + last_error = "wrapper process identity does not match launch" + time.sleep(0.05) + raise RuntimeError(last_error) + + +def _health_fields_match( + health: Mapping[str, Any], + *, + release_id: str, + agent_version: str, + launch_nonce: str, + launch_boundary: str, +) -> bool: + updated_at = _parse_utc(health.get("updated_at")) + boundary = _parse_utc(launch_boundary) + return ( + health.get("release_id") == release_id + and health.get("agent_version") == agent_version + and health.get("launch_nonce") == launch_nonce + and health.get("state") == "RUNNING" + and type(health.get("pid")) is int + and updated_at is not None + and boundary is not None + and updated_at >= boundary + ) + + +def _validate_target_identity( + attempt: LaunchAttempt, + wrapper_identity: ProcessIdentity, + health_pid: int, + records: Sequence[ProcessIdentity], +) -> ValidatedTarget: + if attempt.process.poll() is not None: + raise RuntimeError("wrapper died before process identity validation") + by_pid = {record.pid: record for record in records} + current_wrapper = by_pid.get(attempt.wrapper_pid) + if current_wrapper is None or not _same_process(current_wrapper, wrapper_identity): + raise RuntimeError("wrapper process identity changed or disappeared") + if not _wrapper_matches(current_wrapper, attempt): + raise RuntimeError("wrapper process does not match expected launch") + target = by_pid.get(health_pid) + if target is None: + raise RuntimeError("health process is absent") + if health_pid == attempt.wrapper_pid: + if not _same_process(target, wrapper_identity): + raise RuntimeError("direct target process identity changed") + return ValidatedTarget( + mode="DIRECT", + wrapper=current_wrapper, + target=target, + ) + + if target.parent_pid != attempt.wrapper_pid: + raise RuntimeError("health process is not a direct wrapper child") + matching_children = [ + record + for record in records + if record.pid != attempt.wrapper_pid and _child_matches(record, attempt) + ] + if len(matching_children) != 1 or matching_children[0].pid != health_pid: + raise RuntimeError("expected direct wrapper child is absent or ambiguous") + return ValidatedTarget( + mode="WINDOWS_VENV_WRAPPER", + wrapper=current_wrapper, + target=target, + ) + + +def _validate_target_twice( + attempt: LaunchAttempt, + wrapper_identity: ProcessIdentity, + health_pid: int, +) -> ValidatedTarget: + first_records = _query_process_identities(attempt.wrapper_pid, health_pid) + first = _validate_target_identity( + attempt, + wrapper_identity, + health_pid, + first_records, + ) + second_records = _query_process_identities(attempt.wrapper_pid, health_pid) + second = _validate_target_identity( + attempt, + wrapper_identity, + health_pid, + second_records, + ) + if first.mode != second.mode: + raise RuntimeError("target process topology changed during validation") + if not _same_process(first.wrapper, second.wrapper) or not _same_process( + first.target, + second.target, + ): + raise RuntimeError("target process identity changed during validation") + return second + + +def _identity_is_present(identity: ProcessIdentity) -> bool: + records = _query_process_identities(identity.pid, identity.pid) + current = next((record for record in records if record.pid == identity.pid), None) + return current is not None and _same_process(current, identity) + + +def _terminate_verified_identity(identity: ProcessIdentity) -> str: + records = _query_process_identities(identity.pid, identity.pid) + current = next((record for record in records if record.pid == identity.pid), None) + if current is None: + return f"PID {identity.pid}: already absent" + if not _same_process(current, identity): + raise RuntimeError(f"PID {identity.pid}: identity changed before cleanup") + os.kill(identity.pid, signal.SIGTERM) + deadline = time.monotonic() + PROCESS_CLEANUP_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if not _identity_is_present(identity): + return f"PID {identity.pid}: terminated" + time.sleep(0.05) + raise RuntimeError(f"PID {identity.pid}: did not terminate within timeout") + + +def _verified_matching_children( + attempt: LaunchAttempt, + records: Sequence[ProcessIdentity], +) -> list[ProcessIdentity]: + return [ + record + for record in records + if record.pid != attempt.wrapper_pid and _child_matches(record, attempt) + ] + + +def strict_failure_cleanup( + attempt: LaunchAttempt, + wrapper_identity: ProcessIdentity | None, +) -> CleanupResult: + actions: list[str] = [] + try: + records = _query_process_identities(attempt.wrapper_pid) + except Exception as error: + return CleanupResult( + state="CLEANUP_INCOMPLETE", + actions=(f"identity query failed: {type(error).__name__}: {error}",), + orphan_verified_target_processes=(), + ) + current_wrapper = next( + (record for record in records if record.pid == attempt.wrapper_pid), + None, + ) + matching_children = _verified_matching_children(attempt, records) + if wrapper_identity is None: + if current_wrapper is None and not matching_children: + return CleanupResult("COMPLETE", (), ()) + return CleanupResult( + state="CLEANUP_INCOMPLETE", + actions=("wrapper identity was never proven; no process terminated",), + orphan_verified_target_processes=tuple( + sorted(record.pid for record in matching_children) + ), + ) + if current_wrapper is not None and not _same_process( + current_wrapper, + wrapper_identity, + ): + return CleanupResult( + state="CLEANUP_INCOMPLETE", + actions=("wrapper PID identity changed; no process terminated",), + orphan_verified_target_processes=tuple( + sorted(record.pid for record in matching_children) + ), + ) + if len(matching_children) > 1: + return CleanupResult( + state="CLEANUP_INCOMPLETE", + actions=("matching direct child identity is ambiguous; no process terminated",), + orphan_verified_target_processes=tuple( + sorted(record.pid for record in matching_children) + ), + ) + try: + if matching_children: + actions.append(_terminate_verified_identity(matching_children[0])) + refreshed = _query_process_identities(attempt.wrapper_pid) + refreshed_wrapper = next( + (record for record in refreshed if record.pid == attempt.wrapper_pid), + None, + ) + if refreshed_wrapper is not None: + if not _same_process(refreshed_wrapper, wrapper_identity): + raise RuntimeError("wrapper identity changed before termination") + terminate_process(attempt.process) + actions.append(f"PID {attempt.wrapper_pid}: wrapper terminated") + except Exception as error: + actions.append(f"cleanup error: {type(error).__name__}: {error}") + + final_records = _query_process_identities(attempt.wrapper_pid) + remaining: list[int] = [] + final_wrapper = next( + (record for record in final_records if record.pid == attempt.wrapper_pid), + None, + ) + if final_wrapper is not None and _same_process(final_wrapper, wrapper_identity): + remaining.append(final_wrapper.pid) + remaining.extend( + record.pid for record in _verified_matching_children(attempt, final_records) + ) + unique_remaining = tuple(sorted(set(remaining))) + return CleanupResult( + state="COMPLETE" if not unique_remaining else "CLEANUP_INCOMPLETE", + actions=tuple(actions), + orphan_verified_target_processes=unique_remaining, + ) + + +def start_release( + config: Mapping[str, Any], + release_id: str, + *, + expected_version: str | None = None, +) -> tuple[subprocess.Popen[str], str]: + agent_script, ready = validate_ready_release( + config, + release_id, + expected_version, + ) + agent_version = str(ready["agent_version"]) + launch_nonce = secrets.token_hex(16) + health_file = Path(str(config["health_file"])) + health_before = health_file.stat().st_mtime_ns if health_file.exists() else None + log_path = Path(str(config["agent_log"])).resolve() + log_path.parent.mkdir(parents=True, exist_ok=True) + log_stream: TextIO = log_path.open("a", encoding="utf-8", buffering=1) + command = _agent_command(config, agent_script, release_id, launch_nonce) + launch_boundary = utc_now() + try: + process = _spawn_agent( + command, + cwd=agent_script.parents[2], + log_stream=log_stream, + ) + finally: + log_stream.close() + wrapper_executable, child_executable = _runtime_executables(command[0]) + attempt = LaunchAttempt( + process=process, + command=tuple(command), + wrapper_pid=process.pid, + launch_boundary=launch_boundary, + wrapper_executable=wrapper_executable, + child_executable=child_executable, + launch_nonce=launch_nonce, + ) + timeout = float(config.get("startup_timeout_seconds", 30.0)) + deadline = time.monotonic() + timeout + last_error = "agent health confirmation timed out" + wrapper_identity: ProcessIdentity | None = None + try: + wrapper_identity = _capture_wrapper_identity( + attempt, + timeout_seconds=min(5.0, max(0.1, timeout)), + ) + while time.monotonic() < deadline: + returncode = process.poll() + if returncode is not None: + last_error = f"agent wrapper exited during startup with code {returncode}" + break + if health_file.exists(): + changed = ( + health_before is None + or health_file.stat().st_mtime_ns != health_before + ) + if changed: + try: + health = read_json(health_file) + except (OSError, ValueError, json.JSONDecodeError) as error: + last_error = ( + "health file is invalid: " + f"{type(error).__name__}: {error}" + ) + else: + if not _health_fields_match( + health, + release_id=release_id, + agent_version=agent_version, + launch_nonce=launch_nonce, + launch_boundary=launch_boundary, + ): + last_error = "health fields do not match the current launch" + else: + try: + _validate_target_twice( + attempt, + wrapper_identity, + int(health["pid"]), + ) + except ( + OSError, + RuntimeError, + subprocess.SubprocessError, + json.JSONDecodeError, + ) as error: + last_error = ( + "process identity validation failed: " + f"{type(error).__name__}: {error}" + ) + else: + return process, agent_version + time.sleep(0.25) + except ( + OSError, + RuntimeError, + subprocess.SubprocessError, + json.JSONDecodeError, + ) as error: + last_error = f"startup identity failed: {type(error).__name__}: {error}" + cleanup = strict_failure_cleanup(attempt, wrapper_identity) + cleanup_detail = ( + f"cleanup={cleanup.state}; " + f"orphan_verified_target_processes=" + f"{list(cleanup.orphan_verified_target_processes)}" + ) + raise StartupFailure(f"{last_error}; {cleanup_detail}", cleanup) + + +def terminate_process(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=10.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5.0) + + +def _write_active_release(config: Mapping[str, Any], release_id: str) -> None: + atomic_write_json( + Path(str(config["active_release_file"])), + {"schema_version": 1, "release_id": release_id, "updated_at": utc_now()}, + ) + + +def _archive_request(config: Mapping[str, Any], request: Mapping[str, Any]) -> None: + request_path = Path(str(config["launcher_request"])) + history = request_path.parent / "request_history" + history.mkdir(parents=True, exist_ok=True) + task_id = str(request.get("task_id", "unknown")) + destination = history / f"{task_id}_{int(time.time())}.json" + os.replace(request_path, destination) + + +def switch_release( + config: Mapping[str, Any], + process: subprocess.Popen[str], + current_release_id: str, + current_version: str, + request: Mapping[str, Any], +) -> tuple[subprocess.Popen[str], str, str]: + target_release_id = request.get("release_id") + target_version = request.get("agent_version") + if request.get("safe_to_switch") is not True: + raise ValueError("launcher request is not marked safe to switch") + if request.get("from_release_id") != current_release_id: + raise ValueError("launcher request was created for another active release") + if not isinstance(target_release_id, str) or not isinstance(target_version, str): + raise ValueError("launcher request target is invalid") + health = read_json(Path(str(config["health_file"]))) + if health.get("busy") is not False or health.get("active_task_id") is not None: + raise RuntimeError("agent is not idle at the update switch point") + write_launcher_status( + config, + state="RUNNING", + active_release_id=current_release_id, + active_agent_version=current_version, + phase="LEGACY_ACTIVE", + ) + validate_ready_release(config, target_release_id, target_version) + write_launcher_status( + config, + state="RUNNING", + active_release_id=current_release_id, + active_agent_version=current_version, + previous_release_id=target_release_id, + phase="TARGET_VERIFIED", + ) + + terminate_process(process) + _write_active_release(config, target_release_id) + write_launcher_status( + config, + state="STARTING_UPDATE", + active_release_id=target_release_id, + active_agent_version=target_version, + previous_release_id=current_release_id, + phase="STARTING_TARGET", + ) + try: + write_launcher_status( + config, + state="STARTING_UPDATE", + active_release_id=target_release_id, + active_agent_version=target_version, + previous_release_id=current_release_id, + phase="WAITING_FOR_HEALTH", + ) + new_process, new_version = start_release( + config, + target_release_id, + expected_version=target_version, + ) + except Exception as error: + cleanup_state = ( + error.cleanup.state if isinstance(error, StartupFailure) else None + ) + write_launcher_status( + config, + state="STARTING_UPDATE", + active_release_id=target_release_id, + active_agent_version=target_version, + previous_release_id=current_release_id, + error=f"{type(error).__name__}: {error}", + phase="TARGET_FAILED", + cleanup_state=cleanup_state, + ) + _write_active_release(config, current_release_id) + write_launcher_status( + config, + state="STARTING", + active_release_id=current_release_id, + active_agent_version=current_version, + previous_release_id=target_release_id, + error=f"{type(error).__name__}: {error}", + phase="ROLLING_BACK", + cleanup_state=cleanup_state, + ) + rollback_process, rollback_version = start_release( + config, + current_release_id, + expected_version=current_version, + ) + write_launcher_status( + config, + state="ROLLED_BACK", + active_release_id=current_release_id, + active_agent_version=rollback_version, + previous_release_id=target_release_id, + error=f"{type(error).__name__}: {error}", + phase="LEGACY_RESTORED", + cleanup_state=cleanup_state, + ) + _archive_request(config, request) + return rollback_process, current_release_id, rollback_version + + write_launcher_status( + config, + state="RUNNING", + active_release_id=target_release_id, + active_agent_version=new_version, + previous_release_id=current_release_id, + phase="TARGET_HEALTHY", + cleanup_state="COMPLETE", + ) + _archive_request(config, request) + return new_process, target_release_id, new_version + + +def run_launcher(config: Mapping[str, Any], *, once: bool = False) -> None: + active = read_json(Path(str(config["active_release_file"]))) + release_id = active.get("release_id") + if not isinstance(release_id, str): + raise ValueError("active release pointer is invalid") + write_launcher_status( + config, + state="STARTING", + active_release_id=release_id, + active_agent_version=None, + phase="STARTING_TARGET", + ) + write_launcher_status( + config, + state="STARTING", + active_release_id=release_id, + active_agent_version=None, + phase="WAITING_FOR_HEALTH", + ) + process, agent_version = start_release(config, release_id) + write_launcher_status( + config, + state="RUNNING", + active_release_id=release_id, + active_agent_version=agent_version, + phase="TARGET_HEALTHY", + cleanup_state="COMPLETE", + ) + if once: + terminate_process(process) + return + request_path = Path(str(config["launcher_request"])) + while True: + if request_path.exists(): + try: + request = read_json(request_path) + process, release_id, agent_version = switch_release( + config, + process, + release_id, + agent_version, + request, + ) + except Exception as error: + write_launcher_status( + config, + state="UPDATE_REQUEST_REJECTED", + active_release_id=release_id, + active_agent_version=agent_version, + error=f"{type(error).__name__}: {error}", + ) + _archive_request(config, {"task_id": "rejected"}) + returncode = process.poll() + if returncode is not None: + write_launcher_status( + config, + state="RESTARTING", + active_release_id=release_id, + active_agent_version=agent_version, + error=f"agent exited with code {returncode}", + phase="STARTING_TARGET", + ) + time.sleep(float(config.get("restart_delay_seconds", 2.0))) + write_launcher_status( + config, + state="RESTARTING", + active_release_id=release_id, + active_agent_version=agent_version, + phase="WAITING_FOR_HEALTH", + ) + process, agent_version = start_release( + config, + release_id, + expected_version=agent_version, + ) + write_launcher_status( + config, + state="RUNNING", + active_release_id=release_id, + active_agent_version=agent_version, + phase="TARGET_HEALTHY", + cleanup_state="COMPLETE", + ) + time.sleep(0.5) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True, type=Path) + parser.add_argument("--log-file", type=Path) + parser.add_argument("--once", action="store_true") + arguments = parser.parse_args() + if arguments.log_file is not None: + redirect_output(arguments.log_file) + config = read_json(arguments.config.resolve()) + print(f"LAUNCHER VERSION = {LAUNCHER_VERSION}") + print("ARBITRARY SHELL = DISABLED") + print("RF TX = 0") + run_launcher(config, once=arguments.once) + + +if __name__ == "__main__": + main() diff --git a/tools/rf001_agent_update.py b/tools/rf001_agent_update.py new file mode 100644 index 0000000..da18e4f --- /dev/null +++ b/tools/rf001_agent_update.py @@ -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) diff --git a/tools/rf001_bootstrap_install_orchestrator.py b/tools/rf001_bootstrap_install_orchestrator.py new file mode 100644 index 0000000..31299f9 --- /dev/null +++ b/tools/rf001_bootstrap_install_orchestrator.py @@ -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()) diff --git a/tools/rf001_bootstrap_readme.txt b/tools/rf001_bootstrap_readme.txt new file mode 100644 index 0000000..6f160d4 --- /dev/null +++ b/tools/rf001_bootstrap_readme.txt @@ -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 diff --git a/tools/rf001_build_agent_packages.py b/tools/rf001_build_agent_packages.py new file mode 100644 index 0000000..3636c1e --- /dev/null +++ b/tools/rf001_build_agent_packages.py @@ -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() diff --git a/tools/rf001_capture_runner.py b/tools/rf001_capture_runner.py new file mode 100644 index 0000000..9b5a6a2 --- /dev/null +++ b/tools/rf001_capture_runner.py @@ -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() diff --git a/tools/rf001_http_control.py b/tools/rf001_http_control.py new file mode 100644 index 0000000..c8f66fe --- /dev/null +++ b/tools/rf001_http_control.py @@ -0,0 +1,1805 @@ +"""Authenticated HTTP control channel for the RF-001 RX laptop. + +The RX laptop always initiates the connection. The server exposes only a +small JSON API, never a filesystem, and accepts only fixed RF-001 task kinds. +RF capture is receive-only and requires an exact local authorization phrase. +RF transmission is deliberately absent from the network protocol. +""" + +from __future__ import annotations + +import argparse +from collections import OrderedDict +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import hmac +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import os +from pathlib import Path +import re +import secrets +import sys +import threading +import time +from typing import Any, Mapping +from urllib.error import HTTPError +from urllib.parse import parse_qs, quote, urlsplit +from urllib.request import ProxyHandler, Request, build_opener + +try: + from tools.rf001_agent_update import ( + RELEASE_ID_PATTERN, + ReleaseValidationError, + inspect_release_archive, + sign_update_manifest, + ) +except ModuleNotFoundError: # Direct execution from tools/. + from rf001_agent_update import ( # type: ignore[no-redef] + RELEASE_ID_PATTERN, + ReleaseValidationError, + inspect_release_archive, + sign_update_manifest, + ) + + +ROOT = Path(__file__).resolve().parents[1] +PROTOCOL_VERSION = 1 +MAX_JSON_BYTES = 256 * 1024 +AUTH_WINDOW_SECONDS = 60 +TASK_LEASE_SECONDS = 30 +INVENTORY_TASK = "INVENTORY" +OFFLINE_VERIFY_TASK = "OFFLINE_VERIFY" +RX_CAPTURE_START_TASK = "RX_CAPTURE_START" +RX_CAPTURE_STATUS_TASK = "RX_CAPTURE_STATUS" +CAPTURE_DECODE_TASK = "CAPTURE_DECODE" +CAPTURE_DIAGNOSTICS_TASK = "CAPTURE_DIAGNOSTICS" +STAIRCASE_SEARCH_TASK = "STAIRCASE_SEARCH" +UPDATE_AGENT_TASK = "UPDATE_AGENT" +RX_AUTHORIZATION_PHRASE = "AUTHORIZE RF RX" +FIXED_RX_SERIAL = "000000000000000097c063dc2e5327d3" +TASK_ID_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9_-]{2,95}$") +AGENT_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{2,63}$") +BOOTSTRAP_RELEASE_ID_PATTERN = re.compile( + r"^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$" +) +GIT_HEAD_PATTERN = re.compile(r"^[0-9a-f]{40}$") +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +SESSION_ID_PATTERN = re.compile(r"^RF001A_[0-9]{8}T[0-9]{6}Z_[0-9A-F]{8}$") +DEFAULT_STATE_PATH = ROOT / ".local" / "rf001_http_server_state.json" +DEFAULT_TOKEN_PATH = ROOT / ".local" / "rf001_http_token.txt" +DEFAULT_SERVER_URL = "http://192.168.255.185:8765" +DEFAULT_RELEASES_ROOT = ROOT / ".local" / "releases" + + +def redirect_output(path: Path) -> None: + """Send service-mode stdout and stderr to one line-buffered UTF-8 log.""" + + path.parent.mkdir(parents=True, exist_ok=True) + stream = path.open("a", encoding="utf-8", buffering=1) + sys.stdout = stream + sys.stderr = stream + + +class ProtocolError(ValueError): + """The peer sent an invalid or unsupported protocol message.""" + + +class AuthenticationError(ProtocolError): + """Request authentication failed.""" + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def canonical_json(value: Mapping[str, Any]) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def read_token(path: Path) -> bytes: + token = path.read_text(encoding="ascii").strip().encode("ascii") + if len(token) < 32: + raise ValueError("token must contain at least 32 ASCII bytes") + return token + + +def initialize_token(path: Path) -> str: + """Create a token without overwriting an existing credential.""" + + path.parent.mkdir(parents=True, exist_ok=True) + token = secrets.token_urlsafe(48) + try: + with path.open("x", encoding="ascii", newline="\n") as stream: + stream.write(token + "\n") + except FileExistsError as error: + raise FileExistsError(f"token already exists: {path}") from error + return token + + +def _signature_payload( + method: str, + target: str, + timestamp: str, + nonce: str, + body: bytes, +) -> bytes: + body_digest = hashlib.sha256(body).hexdigest() + fields = ( + str(PROTOCOL_VERSION), + timestamp, + nonce, + method.upper(), + target, + body_digest, + ) + return "\n".join(fields).encode("ascii") + + +def signed_headers( + token: bytes, + method: str, + target: str, + body: bytes, + *, + timestamp: int | None = None, + nonce: str | None = None, +) -> dict[str, str]: + timestamp_text = str(int(time.time()) if timestamp is None else timestamp) + nonce_text = secrets.token_hex(16) if nonce is None else nonce + if not re.fullmatch(r"[0-9a-f]{32}", nonce_text): + raise ValueError("nonce must be 32 lowercase hexadecimal characters") + payload = _signature_payload( + method, + target, + timestamp_text, + nonce_text, + body, + ) + signature = hmac.new(token, payload, hashlib.sha256).hexdigest() + return { + "Content-Type": "application/json", + "X-SDR-Version": str(PROTOCOL_VERSION), + "X-SDR-Timestamp": timestamp_text, + "X-SDR-Nonce": nonce_text, + "X-SDR-Signature": signature, + } + + +class RequestAuthenticator: + """Verify request signatures and reject nonce replay within the time window.""" + + def __init__( + self, + token: bytes, + *, + window_seconds: int = AUTH_WINDOW_SECONDS, + ) -> None: + self.token = token + self.window_seconds = window_seconds + self._nonces: OrderedDict[str, int] = OrderedDict() + self._lock = threading.Lock() + + def verify( + self, + method: str, + target: str, + body: bytes, + headers: Mapping[str, str], + *, + now: int | None = None, + ) -> None: + version = headers.get("X-SDR-Version", "") + timestamp_text = headers.get("X-SDR-Timestamp", "") + nonce = headers.get("X-SDR-Nonce", "") + signature = headers.get("X-SDR-Signature", "") + if version != str(PROTOCOL_VERSION): + raise AuthenticationError("unsupported authentication version") + if not timestamp_text.isdecimal(): + raise AuthenticationError("invalid authentication timestamp") + if not re.fullmatch(r"[0-9a-f]{32}", nonce): + raise AuthenticationError("invalid authentication nonce") + if not re.fullmatch(r"[0-9a-f]{64}", signature): + raise AuthenticationError("invalid authentication signature") + + current = int(time.time()) if now is None else now + request_time = int(timestamp_text) + if abs(current - request_time) > self.window_seconds: + raise AuthenticationError("authentication timestamp is outside the window") + expected = hmac.new( + self.token, + _signature_payload(method, target, timestamp_text, nonce, body), + hashlib.sha256, + ).hexdigest() + if not hmac.compare_digest(signature, expected): + raise AuthenticationError("authentication signature mismatch") + + cutoff = current - self.window_seconds + with self._lock: + while self._nonces: + oldest_nonce, oldest_time = next(iter(self._nonces.items())) + if oldest_time >= cutoff: + break + self._nonces.pop(oldest_nonce) + if nonce in self._nonces: + raise AuthenticationError("authentication nonce was already used") + self._nonces[nonce] = request_time + + +def _validate_agent_id(value: Any) -> str: + if not isinstance(value, str) or AGENT_ID_PATTERN.fullmatch(value) is None: + raise ProtocolError("invalid agent_id") + return value + + +def _validate_task_id(value: Any) -> str: + if not isinstance(value, str) or TASK_ID_PATTERN.fullmatch(value) is None: + raise ProtocolError("invalid task_id") + return value + + +def _require_mapping(value: Any, field: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ProtocolError(f"{field} must be a JSON object") + return value + + +def _validate_session_id(value: Any) -> str: + if not isinstance(value, str) or SESSION_ID_PATTERN.fullmatch(value) is None: + raise ProtocolError("invalid session_id") + return value + + +def _validate_timestamp(value: Any, field: str) -> str: + if not isinstance(value, str): + raise ProtocolError(f"{field} must be a timestamp string") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ProtocolError(f"{field} is not an ISO timestamp") from error + if parsed.tzinfo is None: + raise ProtocolError(f"{field} must contain a timezone") + return parsed.astimezone(timezone.utc).isoformat() + + +@dataclass(frozen=True) +class InventoryTask: + task_id: str + agent_id: str + created_at: str + include_hackrf_info: bool + + def as_mapping(self) -> dict[str, Any]: + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": self.task_id, + "kind": INVENTORY_TASK, + "agent_id": self.agent_id, + "created_at": self.created_at, + "include_hackrf_info": self.include_hackrf_info, + "rf_tx": 0, + } + + +@dataclass(frozen=True) +class OfflineVerifyTask: + task_id: str + agent_id: str + created_at: str + expected_head: str + + def as_mapping(self) -> dict[str, Any]: + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": self.task_id, + "kind": OFFLINE_VERIFY_TASK, + "agent_id": self.agent_id, + "created_at": self.created_at, + "expected_head": self.expected_head, + "rf_tx": 0, + } + + +@dataclass(frozen=True) +class RxCaptureStartTask: + task_id: str + agent_id: str + created_at: str + expected_head: str + session_id: str + + def as_mapping(self) -> dict[str, Any]: + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": self.task_id, + "kind": RX_CAPTURE_START_TASK, + "agent_id": self.agent_id, + "created_at": self.created_at, + "expected_head": self.expected_head, + "session_id": self.session_id, + "rx_serial": FIXED_RX_SERIAL, + "rx_authorized": True, + "automatic_retry": False, + "rf_tx": 0, + } + + +@dataclass(frozen=True) +class RxCaptureStatusTask: + task_id: str + agent_id: str + created_at: str + session_id: str + + def as_mapping(self) -> dict[str, Any]: + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": self.task_id, + "kind": RX_CAPTURE_STATUS_TASK, + "agent_id": self.agent_id, + "created_at": self.created_at, + "session_id": self.session_id, + "rf_tx": 0, + } + + +@dataclass(frozen=True) +class CaptureDecodeTask: + task_id: str + agent_id: str + created_at: str + session_id: str + tx_started_at: str + tx_finished_at: str + + def as_mapping(self) -> dict[str, Any]: + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": self.task_id, + "kind": CAPTURE_DECODE_TASK, + "agent_id": self.agent_id, + "created_at": self.created_at, + "session_id": self.session_id, + "tx_started_at": self.tx_started_at, + "tx_finished_at": self.tx_finished_at, + "rf_tx": 0, + } + + +@dataclass(frozen=True) +class CaptureDiagnosticsTask: + task_id: str + agent_id: str + created_at: str + session_id: str + tx_started_at: str + tx_finished_at: str + + def as_mapping(self) -> dict[str, Any]: + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": self.task_id, + "kind": CAPTURE_DIAGNOSTICS_TASK, + "agent_id": self.agent_id, + "created_at": self.created_at, + "session_id": self.session_id, + "tx_started_at": self.tx_started_at, + "tx_finished_at": self.tx_finished_at, + "rf_tx": 0, + } + + +@dataclass(frozen=True) +class StaircaseSearchTask: + task_id: str + agent_id: str + created_at: str + session_id: str + expected_rx_sha256: str + expected_reference_sha256: str + + def as_mapping(self) -> dict[str, Any]: + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": self.task_id, + "kind": STAIRCASE_SEARCH_TASK, + "agent_id": self.agent_id, + "created_at": self.created_at, + "session_id": self.session_id, + "expected_rx_sha256": self.expected_rx_sha256, + "expected_reference_sha256": self.expected_reference_sha256, + "hardware_accessed": False, + "automatic_retry": False, + "rf_tx": 0, + } + + +@dataclass(frozen=True) +class UpdateAgentTask: + task_id: str + agent_id: str + created_at: str + release_id: str + agent_version: str + package_sha256: str + package_bytes: int + content_manifest_sha256: str + compatible_protocol_version: int + approval_expires_at: str + + def as_mapping(self) -> dict[str, Any]: + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": self.task_id, + "kind": UPDATE_AGENT_TASK, + "agent_id": self.agent_id, + "created_at": self.created_at, + "release_id": self.release_id, + "agent_version": self.agent_version, + "package_sha256": self.package_sha256, + "package_bytes": self.package_bytes, + "content_manifest_sha256": self.content_manifest_sha256, + "compatible_protocol_version": self.compatible_protocol_version, + "approval_expires_at": self.approval_expires_at, + "hardware_accessed": False, + "automatic_retry": False, + "rf_tx": 0, + } + + +class ControlState: + """Thread-safe single-agent task state persisted as atomic JSON.""" + + def __init__(self, path: Path) -> None: + self.path = path + self._lock = threading.Lock() + self._data = self._load() + + def _load(self) -> dict[str, Any]: + if not self.path.exists(): + return { + "protocol_version": PROTOCOL_VERSION, + "agents": {}, + "tasks": [], + "results": {}, + "releases": {}, + "bootstrap_approvals": {}, + } + raw = json.loads(self.path.read_text(encoding="utf-8-sig")) + data = _require_mapping(raw, "state") + if data.get("protocol_version") != PROTOCOL_VERSION: + raise ProtocolError("unsupported saved state version") + if not isinstance(data.get("agents"), dict): + raise ProtocolError("saved agents state is invalid") + if not isinstance(data.get("tasks"), list): + raise ProtocolError("saved tasks state is invalid") + if not isinstance(data.get("results"), dict): + raise ProtocolError("saved results state is invalid") + data.setdefault("releases", {}) + data.setdefault("bootstrap_approvals", {}) + if not isinstance(data["releases"], dict): + raise ProtocolError("saved releases state is invalid") + if not isinstance(data["bootstrap_approvals"], dict): + raise ProtocolError("saved bootstrap approvals state is invalid") + return data + + def _save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.path.with_suffix(self.path.suffix + ".tmp") + temporary.write_bytes(canonical_json(self._data) + b"\n") + os.replace(temporary, self.path) + + def enqueue_inventory( + self, + agent_id: str, + *, + include_hackrf_info: bool, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + created_at = utc_now() + compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + task_id = f"INVENTORY_{compact_time}_{secrets.token_hex(4).upper()}" + task = InventoryTask( + task_id=task_id, + agent_id=agent, + created_at=created_at, + include_hackrf_info=include_hackrf_info, + ).as_mapping() + record = { + **task, + "state": "QUEUED", + "leased_at_epoch": None, + "completed_at": None, + } + with self._lock: + self._data["tasks"].append(record) + self._save() + return task + + def enqueue_offline_verify( + self, + agent_id: str, + *, + expected_head: str, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + if GIT_HEAD_PATTERN.fullmatch(expected_head) is None: + raise ProtocolError("expected_head must be a lowercase 40-character hash") + created_at = utc_now() + compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + task_id = f"OFFLINE_{compact_time}_{secrets.token_hex(4).upper()}" + task = OfflineVerifyTask( + task_id=task_id, + agent_id=agent, + created_at=created_at, + expected_head=expected_head, + ).as_mapping() + record = { + **task, + "state": "QUEUED", + "leased_at_epoch": None, + "completed_at": None, + } + with self._lock: + self._data["tasks"].append(record) + self._save() + return task + + def _append_task(self, task: dict[str, Any]) -> dict[str, Any]: + record = { + **task, + "state": "QUEUED", + "leased_at_epoch": None, + "completed_at": None, + } + with self._lock: + self._data["tasks"].append(record) + self._save() + return task + + def enqueue_rx_capture( + self, + agent_id: str, + *, + expected_head: str, + authorization: str, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + if GIT_HEAD_PATTERN.fullmatch(expected_head) is None: + raise ProtocolError("expected_head must be a lowercase 40-character hash") + if authorization != RX_AUTHORIZATION_PHRASE: + raise ProtocolError("exact RX authorization phrase is required") + compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + suffix = secrets.token_hex(4).upper() + task = RxCaptureStartTask( + task_id=f"RXSTART_{compact_time}_{suffix}", + agent_id=agent, + created_at=utc_now(), + expected_head=expected_head, + session_id=f"RF001A_{compact_time}_{suffix}", + ).as_mapping() + return self._append_task(task) + + def enqueue_rx_status( + self, + agent_id: str, + *, + session_id: str, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + session = _validate_session_id(session_id) + compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + task = RxCaptureStatusTask( + task_id=f"RXSTATUS_{compact_time}_{secrets.token_hex(4).upper()}", + agent_id=agent, + created_at=utc_now(), + session_id=session, + ).as_mapping() + return self._append_task(task) + + def enqueue_capture_decode( + self, + agent_id: str, + *, + session_id: str, + tx_started_at: str, + tx_finished_at: str, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + session = _validate_session_id(session_id) + started = _validate_timestamp(tx_started_at, "tx_started_at") + finished = _validate_timestamp(tx_finished_at, "tx_finished_at") + if datetime.fromisoformat(finished) < datetime.fromisoformat(started): + raise ProtocolError("tx_finished_at precedes tx_started_at") + compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + task = CaptureDecodeTask( + task_id=f"DECODE_{compact_time}_{secrets.token_hex(4).upper()}", + agent_id=agent, + created_at=utc_now(), + session_id=session, + tx_started_at=started, + tx_finished_at=finished, + ).as_mapping() + return self._append_task(task) + + def enqueue_capture_diagnostics( + self, + agent_id: str, + *, + session_id: str, + tx_started_at: str, + tx_finished_at: str, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + session = _validate_session_id(session_id) + started = _validate_timestamp(tx_started_at, "tx_started_at") + finished = _validate_timestamp(tx_finished_at, "tx_finished_at") + if datetime.fromisoformat(finished) < datetime.fromisoformat(started): + raise ProtocolError("tx_finished_at precedes tx_started_at") + compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + task = CaptureDiagnosticsTask( + task_id=f"DIAG_{compact_time}_{secrets.token_hex(4).upper()}", + agent_id=agent, + created_at=utc_now(), + session_id=session, + tx_started_at=started, + tx_finished_at=finished, + ).as_mapping() + return self._append_task(task) + + def enqueue_staircase_search( + self, + agent_id: str, + *, + session_id: str, + expected_rx_sha256: str, + expected_reference_sha256: str, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + session = _validate_session_id(session_id) + if SHA256_PATTERN.fullmatch(expected_rx_sha256) is None: + raise ProtocolError("expected_rx_sha256 is invalid") + if SHA256_PATTERN.fullmatch(expected_reference_sha256) is None: + raise ProtocolError("expected_reference_sha256 is invalid") + compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + task = StaircaseSearchTask( + task_id=( + f"STAIRCASE_{compact_time}_{secrets.token_hex(4).upper()}" + ), + agent_id=agent, + created_at=utc_now(), + session_id=session, + expected_rx_sha256=expected_rx_sha256, + expected_reference_sha256=expected_reference_sha256, + ).as_mapping() + return self._append_task(task) + + @staticmethod + def _release_path(package_path: str) -> Path: + candidate = Path(package_path).resolve() + releases_root = DEFAULT_RELEASES_ROOT.resolve() + try: + candidate.relative_to(releases_root) + except ValueError as error: + raise ProtocolError("release package must be inside .local/releases") from error + if not candidate.is_file(): + raise ProtocolError("release package is absent") + return candidate + + def register_agent_release( + self, + package_path: str, + *, + token: bytes, + ) -> dict[str, Any]: + candidate = self._release_path(package_path) + try: + inspection = inspect_release_archive(candidate) + except (OSError, ReleaseValidationError) as error: + raise ProtocolError(f"release package is invalid: {error}") from error + manifest = {**inspection.descriptor(), "package_kind": "AGENT_RELEASE"} + signature = sign_update_manifest(token, manifest) + record = { + "manifest": manifest, + "manifest_signature": signature, + "package_path": str(candidate), + "registered_at": utc_now(), + } + with self._lock: + previous = self._data["releases"].get(inspection.release_id) + if previous is not None and canonical_json(previous) != canonical_json(record): + raise ProtocolError("release_id is already registered differently") + self._data["releases"][inspection.release_id] = record + self._save() + return { + "release_id": inspection.release_id, + "agent_version": inspection.agent_version, + "package_sha256": inspection.package_sha256, + "package_bytes": inspection.package_bytes, + "package_kind": "AGENT_RELEASE", + "registered": True, + "rf_tx": 0, + } + + def register_bootstrap_release( + self, + package_path: str, + *, + release_id: str, + agent_version: str, + content_manifest_sha256: str, + token: bytes, + ) -> dict[str, Any]: + candidate = self._release_path(package_path) + if BOOTSTRAP_RELEASE_ID_PATTERN.fullmatch(release_id) is None: + raise ProtocolError("release_id is invalid") + if re.fullmatch(r"[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+", agent_version) is None: + raise ProtocolError("agent_version is invalid") + if SHA256_PATTERN.fullmatch(content_manifest_sha256) is None: + raise ProtocolError("content_manifest_sha256 is invalid") + digest = hashlib.sha256() + with candidate.open("rb") as stream: + while block := stream.read(4 * 1024 * 1024): + digest.update(block) + manifest = { + "schema_version": 1, + "release_id": release_id, + "agent_version": agent_version, + "protocol_version": PROTOCOL_VERSION, + "package_kind": "BOOTSTRAP", + "package_sha256": digest.hexdigest(), + "package_bytes": candidate.stat().st_size, + "content_manifest_sha256": content_manifest_sha256, + } + record = { + "manifest": manifest, + "manifest_signature": sign_update_manifest(token, manifest), + "package_path": str(candidate), + "registered_at": utc_now(), + } + with self._lock: + previous = self._data["releases"].get(release_id) + if previous is not None and canonical_json(previous) != canonical_json(record): + raise ProtocolError("release_id is already registered differently") + self._data["releases"][release_id] = record + self._save() + return {**manifest, "registered": True, "rf_tx": 0} + + def enqueue_agent_update( + self, + agent_id: str, + *, + release_id: str, + validity_seconds: int, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + if not 60 <= validity_seconds <= 86_400: + raise ProtocolError("update approval validity must be 60 to 86400 seconds") + with self._lock: + release = self._data["releases"].get(release_id) + if not isinstance(release, dict): + raise ProtocolError("release_id is not registered") + manifest = _require_mapping(release.get("manifest"), "release manifest") + if manifest.get("package_kind") != "AGENT_RELEASE": + raise ProtocolError("bootstrap package cannot be used for remote update") + if manifest.get("protocol_version") != PROTOCOL_VERSION: + raise ProtocolError("release protocol version is incompatible") + existing = [ + task + for task in self._data["tasks"] + if task.get("kind") == UPDATE_AGENT_TASK + and task.get("agent_id") == agent + and task.get("release_id") == release_id + and task.get("state") not in {"CANCELLED", "LEASE_EXPIRED"} + ] + if existing: + raise ProtocolError("this release already has an update task") + compact_time = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + expires = datetime.fromtimestamp( + time.time() + validity_seconds, + timezone.utc, + ).isoformat() + task = UpdateAgentTask( + task_id=f"UPDATE_{compact_time}_{secrets.token_hex(4).upper()}", + agent_id=agent, + created_at=utc_now(), + release_id=release_id, + agent_version=str(manifest["agent_version"]), + package_sha256=str(manifest["package_sha256"]), + package_bytes=int(manifest["package_bytes"]), + content_manifest_sha256=str( + manifest["content_manifest_sha256"] + ), + compatible_protocol_version=int(manifest["protocol_version"]), + approval_expires_at=expires, + ).as_mapping() + record = { + **task, + "state": "QUEUED", + "leased_at_epoch": None, + "completed_at": None, + } + self._data["tasks"].append(record) + self._save() + return task + + def approve_bootstrap_download( + self, + release_id: str, + *, + validity_seconds: int, + ) -> dict[str, Any]: + if not 60 <= validity_seconds <= 3_600: + raise ProtocolError("bootstrap validity must be 60 to 3600 seconds") + ticket = secrets.token_urlsafe(32) + with self._lock: + release = self._data["releases"].get(release_id) + if not isinstance(release, dict): + raise ProtocolError("release_id is not registered") + manifest = _require_mapping(release.get("manifest"), "release manifest") + if manifest.get("package_kind") != "BOOTSTRAP": + raise ProtocolError("release is not a bootstrap package") + self._data["bootstrap_approvals"][release_id] = { + "ticket_sha256": hashlib.sha256(ticket.encode("ascii")).hexdigest(), + "expires_at_epoch": int(time.time()) + validity_seconds, + "attempts": 0, + "maximum_attempts": 2, + "created_at": utc_now(), + } + self._save() + return { + "release_id": release_id, + "ticket": ticket, + "expires_in_seconds": validity_seconds, + "maximum_download_attempts": 2, + "rf_tx": 0, + } + + def acquire_bootstrap_download( + self, + release_id: str, + ticket: str, + ) -> dict[str, Any]: + with self._lock: + approval = self._data["bootstrap_approvals"].get(release_id) + release = self._data["releases"].get(release_id) + if not isinstance(approval, dict) or not isinstance(release, dict): + raise AuthenticationError("bootstrap download is not approved") + expected = str(approval.get("ticket_sha256", "")) + actual = hashlib.sha256(ticket.encode("ascii", errors="ignore")).hexdigest() + if not hmac.compare_digest(expected, actual): + raise AuthenticationError("bootstrap download ticket is invalid") + if int(approval.get("expires_at_epoch", 0)) < int(time.time()): + raise AuthenticationError("bootstrap download ticket expired") + attempts = int(approval.get("attempts", 0)) + maximum = int(approval.get("maximum_attempts", 0)) + if attempts >= maximum: + raise AuthenticationError("bootstrap download attempt limit reached") + approval["attempts"] = attempts + 1 + approval["last_attempt_at"] = utc_now() + self._save() + return json.loads(canonical_json(release)) + + def approved_agent_release( + self, + agent_id: str, + release_id: str, + ) -> dict[str, Any]: + agent = _validate_agent_id(agent_id) + with self._lock: + approved = [ + task + for task in self._data["tasks"] + if task.get("kind") == UPDATE_AGENT_TASK + and task.get("agent_id") == agent + and task.get("release_id") == release_id + and task.get("state") in {"QUEUED", "LEASED"} + and datetime.fromisoformat(str(task.get("approval_expires_at"))) + >= datetime.now(timezone.utc) + ] + release = self._data["releases"].get(release_id) + if len(approved) != 1 or not isinstance(release, dict): + raise AuthenticationError("agent release download is not approved") + return json.loads(canonical_json(release)) + + def check_in(self, payload: Mapping[str, Any]) -> dict[str, Any]: + if payload.get("protocol_version") != PROTOCOL_VERSION: + raise ProtocolError("unsupported protocol version") + agent_id = _validate_agent_id(payload.get("agent_id")) + if payload.get("rf_tx") != 0: + raise ProtocolError("agent check-in must report rf_tx = 0") + inventory = _require_mapping(payload.get("inventory"), "inventory") + active_payload = payload.get("active_task") + active_task: dict[str, Any] | None = None + if active_payload is not None: + active_task = _require_mapping(active_payload, "active_task") + _validate_task_id(active_task.get("task_id")) + if active_task.get("state") not in { + "STARTING", + "RUNNING", + "RESULT_PENDING", + }: + raise ProtocolError("active task state is invalid") + processed = active_task.get("processed_samples") + total = active_task.get("total_samples") + if type(processed) is not int or type(total) is not int: + raise ProtocolError("active task progress must use integers") + if processed < 0 or total <= 0 or processed > total: + raise ProtocolError("active task progress is invalid") + if active_task.get("rf_tx") != 0: + raise ProtocolError("active task must report rf_tx = 0") + now_epoch = int(time.time()) + with self._lock: + self._data["agents"][agent_id] = { + "last_seen_at": utc_now(), + "remote_inventory": inventory, + "active_task": active_task, + "rf_tx": 0, + } + selected: dict[str, Any] | None = None + active_matched = False + if active_task is not None: + matches = [ + task + for task in self._data["tasks"] + if task.get("task_id") == active_task.get("task_id") + and task.get("agent_id") == agent_id + ] + if len(matches) != 1 or matches[0].get("state") != "LEASED": + raise ProtocolError("active task does not match a leased task") + matches[0]["leased_at_epoch"] = now_epoch + matches[0]["progress"] = active_task + active_matched = True + for pending in self._data["tasks"]: + if ( + pending.get("kind") == UPDATE_AGENT_TASK + and pending.get("agent_id") == agent_id + and pending.get("state") == "QUEUED" + ): + pending["update_status"] = "WAITING_FOR_IDLE" + pending["blocked_by_task_id"] = active_task["task_id"] + pending["status_updated_at"] = utc_now() + for task in ([] if active_matched else self._data["tasks"]): + if task.get("agent_id") != agent_id: + continue + state = task.get("state") + leased_at = task.get("leased_at_epoch") + expired = ( + state == "LEASED" + and isinstance(leased_at, int) + and now_epoch - leased_at > TASK_LEASE_SECONDS + ) + if expired and task.get("automatic_retry") is False: + task["state"] = "LEASE_EXPIRED" + task["lease_expired_at"] = utc_now() + task["completed_at"] = task["lease_expired_at"] + continue + if state == "QUEUED" or expired: + task["state"] = "LEASED" + task["leased_at_epoch"] = now_epoch + selected = { + key: value + for key, value in task.items() + if key not in {"state", "leased_at_epoch", "completed_at"} + } + break + self._save() + return { + "protocol_version": PROTOCOL_VERSION, + "server_time": utc_now(), + "task": selected, + "rf_tx": 0, + } + + def record_result(self, payload: Mapping[str, Any]) -> dict[str, Any]: + if payload.get("protocol_version") != PROTOCOL_VERSION: + raise ProtocolError("unsupported protocol version") + task_id = _validate_task_id(payload.get("task_id")) + agent_id = _validate_agent_id(payload.get("agent_id")) + if payload.get("rf_tx") != 0: + raise ProtocolError("result must report rf_tx = 0") + if payload.get("status") not in {"COMPLETE", "FAILED"}: + raise ProtocolError("invalid result status") + _require_mapping(payload.get("result"), "result") + errors = payload.get("errors") + if not isinstance(errors, list) or not all( + isinstance(item, str) for item in errors + ): + raise ProtocolError("errors must be a list of strings") + serialized = canonical_json(payload) + if len(serialized) > MAX_JSON_BYTES: + raise ProtocolError("result is too large") + + with self._lock: + matching = [ + task + for task in self._data["tasks"] + if task.get("task_id") == task_id + ] + if len(matching) != 1 or matching[0].get("agent_id") != agent_id: + raise ProtocolError("result does not match a known task") + if matching[0].get("state") == "CANCELLED": + raise ProtocolError("result belongs to a cancelled task") + previous = self._data["results"].get(task_id) + if previous is not None: + if canonical_json(previous) != serialized: + raise ProtocolError("conflicting duplicate result") + return {"accepted": True, "duplicate": True, "rf_tx": 0} + self._data["results"][task_id] = dict(payload) + matching[0]["state"] = "COMPLETE" + matching[0]["completed_at"] = utc_now() + self._save() + return {"accepted": True, "duplicate": False, "rf_tx": 0} + + def cancel_task(self, task_id: str, *, reason: str) -> dict[str, Any]: + task_identifier = _validate_task_id(task_id) + if not isinstance(reason, str) or not reason.strip() or len(reason) > 500: + raise ProtocolError("cancellation reason must contain 1 to 500 characters") + with self._lock: + matching = [ + task + for task in self._data["tasks"] + if task.get("task_id") == task_identifier + ] + if len(matching) != 1: + raise ProtocolError("task to cancel was not found") + task = matching[0] + if task.get("state") == "COMPLETE": + raise ProtocolError("completed task cannot be cancelled") + if task.get("state") != "CANCELLED": + task["state"] = "CANCELLED" + task["cancelled_at"] = utc_now() + task["cancellation_reason"] = reason.strip() + task["completed_at"] = task["cancelled_at"] + self._save() + return { + "task_id": task_identifier, + "state": "CANCELLED", + "cancellation_reason": task.get("cancellation_reason"), + "rf_tx": 0, + } + + def snapshot(self) -> dict[str, Any]: + with self._lock: + return json.loads(canonical_json(self._data)) + + +class ControlHTTPServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__( + self, + server_address: tuple[str, int], + *, + state: ControlState, + token: bytes, + allowed_agent_ip: str, + ) -> None: + super().__init__(server_address, ControlRequestHandler) + self.state = state + self.token = token + self.authenticator = RequestAuthenticator(token) + self.allowed_agent_ip = allowed_agent_ip + + +class ControlRequestHandler(BaseHTTPRequestHandler): + server: ControlHTTPServer + + def log_message(self, format: str, *args: object) -> None: + message = format % args + print(f"HTTP {self.client_address[0]} {message}") + + def _send_json(self, status: HTTPStatus, payload: Mapping[str, Any]) -> None: + body = canonical_json(payload) + b"\n" + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def _send_package(self, release: Mapping[str, Any]) -> None: + package_path = Path(str(release.get("package_path", ""))) + manifest = _require_mapping(release.get("manifest"), "release manifest") + expected_size = int(manifest.get("package_bytes", -1)) + if not package_path.is_file() or package_path.stat().st_size != expected_size: + raise ProtocolError("registered release package is unavailable") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Length", str(expected_size)) + self.send_header("Cache-Control", "no-store") + self.send_header( + "Content-Disposition", + f'attachment; filename="{manifest["release_id"]}.zip"', + ) + self.end_headers() + with package_path.open("rb") as stream: + while block := stream.read(1024 * 1024): + self.wfile.write(block) + + def _read_body(self) -> bytes: + length_text = self.headers.get("Content-Length", "") + if not length_text.isdecimal(): + raise ProtocolError("valid Content-Length is required") + length = int(length_text) + if length > MAX_JSON_BYTES: + raise ProtocolError("request body is too large") + return self.rfile.read(length) + + def _read_authenticated_json(self) -> dict[str, Any]: + body = self._read_body() + self.server.authenticator.verify( + self.command, + self.path, + body, + self.headers, + ) + try: + decoded = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ProtocolError("request body is not valid UTF-8 JSON") from error + return _require_mapping(decoded, "request") + + def _require_agent_source(self) -> None: + if self.client_address[0] != self.server.allowed_agent_ip: + raise AuthenticationError("agent source IP is not allowed") + + def _require_local_source(self) -> None: + bound_address = self.server.server_address[0] + allowed = {"127.0.0.1", "::1"} + if bound_address not in {"0.0.0.0", "::"}: + allowed.add(bound_address) + if self.client_address[0] not in allowed: + raise AuthenticationError("local endpoint requires the server host") + + def do_GET(self) -> None: # noqa: N802 + try: + split = urlsplit(self.path) + parts = split.path.strip("/").split("/") + if self.path == "/api/v1/health": + self._send_json( + HTTPStatus.OK, + { + "status": "OK", + "protocol_version": PROTOCOL_VERSION, + "server_time": utc_now(), + "rf_tx": 0, + }, + ) + return + if self.path == "/api/v1/local/status": + self._require_local_source() + self.server.authenticator.verify( + self.command, + self.path, + b"", + self.headers, + ) + self._send_json(HTTPStatus.OK, self.server.state.snapshot()) + return + if ( + len(parts) == 6 + and parts[:4] == ["api", "v1", "agent", "releases"] + and parts[5] in {"manifest", "package"} + ): + self._require_agent_source() + self.server.authenticator.verify( + self.command, + self.path, + b"", + self.headers, + ) + release_id = parts[4] + query = parse_qs(split.query, strict_parsing=True) + agent_values = query.get("agent_id", []) + if len(agent_values) != 1: + raise ProtocolError("agent_id query parameter is required") + release = self.server.state.approved_agent_release( + agent_values[0], + release_id, + ) + if parts[5] == "manifest": + self._send_json( + HTTPStatus.OK, + { + "manifest": release["manifest"], + "manifest_signature": release["manifest_signature"], + "rf_tx": 0, + }, + ) + else: + self._send_package(release) + return + if ( + len(parts) == 6 + and parts[:4] == ["api", "v1", "bootstrap", "releases"] + and parts[5] == "package" + ): + self._require_agent_source() + query = parse_qs(split.query, strict_parsing=True) + tickets = query.get("ticket", []) + if len(tickets) != 1: + raise AuthenticationError("bootstrap ticket is required") + release = self.server.state.acquire_bootstrap_download( + parts[4], + tickets[0], + ) + self._send_package(release) + return + self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + except AuthenticationError as error: + self._send_json(HTTPStatus.UNAUTHORIZED, {"error": str(error)}) + except ProtocolError as error: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": str(error)}) + + def do_POST(self) -> None: # noqa: N802 + try: + if self.path == "/api/v1/agent/check-in": + self._require_agent_source() + payload = self._read_authenticated_json() + response = self.server.state.check_in(payload) + self._send_json(HTTPStatus.OK, response) + return + if self.path == "/api/v1/agent/result": + self._require_agent_source() + payload = self._read_authenticated_json() + response = self.server.state.record_result(payload) + self._send_json(HTTPStatus.OK, response) + return + if self.path == "/api/v1/local/tasks/inventory": + self._require_local_source() + payload = self._read_authenticated_json() + include = payload.get("include_hackrf_info", True) + if type(include) is not bool: + raise ProtocolError("include_hackrf_info must be boolean") + task = self.server.state.enqueue_inventory( + _validate_agent_id(payload.get("agent_id")), + include_hackrf_info=include, + ) + self._send_json(HTTPStatus.CREATED, task) + return + if self.path == "/api/v1/local/tasks/offline-verify": + self._require_local_source() + payload = self._read_authenticated_json() + task = self.server.state.enqueue_offline_verify( + _validate_agent_id(payload.get("agent_id")), + expected_head=str(payload.get("expected_head", "")), + ) + self._send_json(HTTPStatus.CREATED, task) + return + if self.path == "/api/v1/local/tasks/rx-capture": + self._require_local_source() + payload = self._read_authenticated_json() + task = self.server.state.enqueue_rx_capture( + _validate_agent_id(payload.get("agent_id")), + expected_head=str(payload.get("expected_head", "")), + authorization=str(payload.get("authorization", "")), + ) + self._send_json(HTTPStatus.CREATED, task) + return + if self.path == "/api/v1/local/tasks/rx-status": + self._require_local_source() + payload = self._read_authenticated_json() + task = self.server.state.enqueue_rx_status( + _validate_agent_id(payload.get("agent_id")), + session_id=str(payload.get("session_id", "")), + ) + self._send_json(HTTPStatus.CREATED, task) + return + if self.path == "/api/v1/local/tasks/capture-decode": + self._require_local_source() + payload = self._read_authenticated_json() + task = self.server.state.enqueue_capture_decode( + _validate_agent_id(payload.get("agent_id")), + session_id=str(payload.get("session_id", "")), + tx_started_at=str(payload.get("tx_started_at", "")), + tx_finished_at=str(payload.get("tx_finished_at", "")), + ) + self._send_json(HTTPStatus.CREATED, task) + return + if self.path == "/api/v1/local/tasks/capture-diagnostics": + self._require_local_source() + payload = self._read_authenticated_json() + task = self.server.state.enqueue_capture_diagnostics( + _validate_agent_id(payload.get("agent_id")), + session_id=str(payload.get("session_id", "")), + tx_started_at=str(payload.get("tx_started_at", "")), + tx_finished_at=str(payload.get("tx_finished_at", "")), + ) + self._send_json(HTTPStatus.CREATED, task) + return + if self.path == "/api/v1/local/tasks/staircase-search": + self._require_local_source() + payload = self._read_authenticated_json() + task = self.server.state.enqueue_staircase_search( + _validate_agent_id(payload.get("agent_id")), + session_id=str(payload.get("session_id", "")), + expected_rx_sha256=str( + payload.get("expected_rx_sha256", "") + ), + expected_reference_sha256=str( + payload.get("expected_reference_sha256", "") + ), + ) + self._send_json(HTTPStatus.CREATED, task) + return + if self.path == "/api/v1/local/releases/register-agent": + self._require_local_source() + payload = self._read_authenticated_json() + release = self.server.state.register_agent_release( + str(payload.get("package_path", "")), + token=self.server.token, + ) + self._send_json(HTTPStatus.CREATED, release) + return + if self.path == "/api/v1/local/releases/register-bootstrap": + self._require_local_source() + payload = self._read_authenticated_json() + release = self.server.state.register_bootstrap_release( + str(payload.get("package_path", "")), + release_id=str(payload.get("release_id", "")), + agent_version=str(payload.get("agent_version", "")), + content_manifest_sha256=str( + payload.get("content_manifest_sha256", "") + ), + token=self.server.token, + ) + self._send_json(HTTPStatus.CREATED, release) + return + if self.path == "/api/v1/local/tasks/update-agent": + self._require_local_source() + payload = self._read_authenticated_json() + validity = payload.get("validity_seconds") + if type(validity) is not int: + raise ProtocolError("validity_seconds must be an integer") + task = self.server.state.enqueue_agent_update( + _validate_agent_id(payload.get("agent_id")), + release_id=str(payload.get("release_id", "")), + validity_seconds=validity, + ) + self._send_json(HTTPStatus.CREATED, task) + return + if self.path == "/api/v1/local/releases/approve-bootstrap": + self._require_local_source() + payload = self._read_authenticated_json() + validity = payload.get("validity_seconds") + if type(validity) is not int: + raise ProtocolError("validity_seconds must be an integer") + approval = self.server.state.approve_bootstrap_download( + str(payload.get("release_id", "")), + validity_seconds=validity, + ) + host, port = self.server.server_address[:2] + approval["download_url"] = ( + f"http://{host}:{port}/api/v1/bootstrap/releases/" + f"{approval['release_id']}/package?ticket=" + f"{quote(str(approval['ticket']), safe='')}" + ) + self._send_json(HTTPStatus.CREATED, approval) + return + if self.path == "/api/v1/local/tasks/cancel": + self._require_local_source() + payload = self._read_authenticated_json() + response = self.server.state.cancel_task( + str(payload.get("task_id", "")), + reason=str(payload.get("reason", "")), + ) + self._send_json(HTTPStatus.OK, response) + return + self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) + except AuthenticationError as error: + self._send_json(HTTPStatus.UNAUTHORIZED, {"error": str(error)}) + except ProtocolError as error: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": str(error)}) + except Exception as error: # pragma: no cover - defensive server boundary + self._send_json( + HTTPStatus.INTERNAL_SERVER_ERROR, + {"error": f"internal server error: {type(error).__name__}"}, + ) + + +def request_json( + server_url: str, + token: bytes, + method: str, + target: str, + payload: Mapping[str, Any] | None = None, + *, + timeout: float = 10.0, +) -> dict[str, Any]: + body = b"" if payload is None else canonical_json(payload) + headers = signed_headers(token, method, target, body) + request = Request( + server_url.rstrip("/") + target, + data=body if method.upper() != "GET" else None, + headers=headers, + method=method.upper(), + ) + opener = build_opener(ProxyHandler({})) + try: + with opener.open(request, timeout=timeout) as response: + response_body = response.read(MAX_JSON_BYTES + 1) + except HTTPError as error: + detail = error.read(MAX_JSON_BYTES).decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {error.code}: {detail}") from error + if len(response_body) > MAX_JSON_BYTES: + raise ProtocolError("server response is too large") + decoded = json.loads(response_body.decode("utf-8")) + return _require_mapping(decoded, "response") + + +def request_file( + server_url: str, + token: bytes, + target: str, + destination: Path, + *, + expected_bytes: int, + timeout: float = 30.0, +) -> None: + if expected_bytes <= 0: + raise ProtocolError("expected download size must be positive") + headers = signed_headers(token, "GET", target, b"") + request = Request( + server_url.rstrip("/") + target, + headers=headers, + method="GET", + ) + opener = build_opener(ProxyHandler({})) + destination.parent.mkdir(parents=True, exist_ok=True) + try: + with opener.open(request, timeout=timeout) as response: + length = response.headers.get("Content-Length", "") + if not length.isdecimal() or int(length) != expected_bytes: + raise ProtocolError("download Content-Length mismatch") + written = 0 + with destination.open("xb") as stream: + while block := response.read(min(1024 * 1024, expected_bytes - written + 1)): + written += len(block) + if written > expected_bytes: + raise ProtocolError("download exceeded approved size") + stream.write(block) + if written != expected_bytes: + raise ProtocolError("download ended before the approved size") + except HTTPError as error: + detail = error.read(MAX_JSON_BYTES).decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {error.code}: {detail}") from error + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + token_parser = subparsers.add_parser("init-token") + token_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + + serve_parser = subparsers.add_parser("serve") + serve_parser.add_argument("--bind", default="192.168.255.185") + serve_parser.add_argument("--port", type=int, default=8765) + serve_parser.add_argument("--allowed-agent-ip", default="192.168.255.56") + serve_parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH) + serve_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + serve_parser.add_argument("--log-file", type=Path) + serve_parser.add_argument("--bind-retry-seconds", type=float, default=0.0) + + enqueue_parser = subparsers.add_parser("enqueue-inventory") + enqueue_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + enqueue_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + enqueue_parser.add_argument("--agent-id", required=True) + enqueue_parser.add_argument( + "--without-hackrf-info", + action="store_true", + ) + + offline_parser = subparsers.add_parser("enqueue-offline-verify") + offline_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + offline_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + offline_parser.add_argument("--agent-id", required=True) + offline_parser.add_argument("--expected-head", required=True) + + rx_parser = subparsers.add_parser("enqueue-rx-capture") + rx_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + rx_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + rx_parser.add_argument("--agent-id", required=True) + rx_parser.add_argument("--expected-head", required=True) + rx_parser.add_argument("--authorize-rx", required=True) + + rx_status_parser = subparsers.add_parser("enqueue-rx-status") + rx_status_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + rx_status_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + rx_status_parser.add_argument("--agent-id", required=True) + rx_status_parser.add_argument("--session-id", required=True) + + decode_parser = subparsers.add_parser("enqueue-capture-decode") + decode_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + decode_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + decode_parser.add_argument("--agent-id", required=True) + decode_parser.add_argument("--session-id", required=True) + decode_parser.add_argument("--tx-started-at", required=True) + decode_parser.add_argument("--tx-finished-at", required=True) + + diagnostics_parser = subparsers.add_parser("enqueue-capture-diagnostics") + diagnostics_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + diagnostics_parser.add_argument( + "--token-file", type=Path, default=DEFAULT_TOKEN_PATH + ) + diagnostics_parser.add_argument("--agent-id", required=True) + diagnostics_parser.add_argument("--session-id", required=True) + diagnostics_parser.add_argument("--tx-started-at", required=True) + diagnostics_parser.add_argument("--tx-finished-at", required=True) + + staircase_parser = subparsers.add_parser("enqueue-staircase-search") + staircase_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + staircase_parser.add_argument( + "--token-file", type=Path, default=DEFAULT_TOKEN_PATH + ) + staircase_parser.add_argument("--agent-id", required=True) + staircase_parser.add_argument("--session-id", required=True) + staircase_parser.add_argument("--expected-rx-sha256", required=True) + staircase_parser.add_argument("--expected-reference-sha256", required=True) + + register_release_parser = subparsers.add_parser("register-agent-release") + register_release_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + register_release_parser.add_argument( + "--token-file", type=Path, default=DEFAULT_TOKEN_PATH + ) + register_release_parser.add_argument("--package", required=True, type=Path) + + register_bootstrap_parser = subparsers.add_parser("register-bootstrap-release") + register_bootstrap_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + register_bootstrap_parser.add_argument( + "--token-file", type=Path, default=DEFAULT_TOKEN_PATH + ) + register_bootstrap_parser.add_argument("--package", required=True, type=Path) + register_bootstrap_parser.add_argument("--release-id", required=True) + register_bootstrap_parser.add_argument("--agent-version", required=True) + register_bootstrap_parser.add_argument( + "--content-manifest-sha256", required=True + ) + + update_parser = subparsers.add_parser("enqueue-agent-update") + update_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + update_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + update_parser.add_argument("--agent-id", required=True) + update_parser.add_argument("--release-id", required=True) + update_parser.add_argument("--validity-seconds", type=int, default=3600) + + bootstrap_parser = subparsers.add_parser("approve-bootstrap-download") + bootstrap_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + bootstrap_parser.add_argument( + "--token-file", type=Path, default=DEFAULT_TOKEN_PATH + ) + bootstrap_parser.add_argument("--release-id", required=True) + bootstrap_parser.add_argument("--validity-seconds", type=int, default=1800) + + cancel_parser = subparsers.add_parser("cancel-task") + cancel_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + cancel_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + cancel_parser.add_argument("--task-id", required=True) + cancel_parser.add_argument("--reason", required=True) + + status_parser = subparsers.add_parser("status") + status_parser.add_argument("--server", default=DEFAULT_SERVER_URL) + status_parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + return parser + + +def main() -> None: + arguments = build_parser().parse_args() + if arguments.command == "serve" and arguments.log_file is not None: + redirect_output(arguments.log_file) + if arguments.command == "init-token": + initialize_token(arguments.token_file) + print(f"TOKEN_FILE = {arguments.token_file}") + print("TOKEN_CREATED = YES") + print("RF TX = 0") + return + + token = read_token(arguments.token_file) + if arguments.command == "serve": + if arguments.bind_retry_seconds < 0: + raise ValueError("bind retry interval cannot be negative") + state = ControlState(arguments.state_file) + deadline = time.monotonic() + arguments.bind_retry_seconds + waiting_for_address = False + while True: + try: + server = ControlHTTPServer( + (arguments.bind, arguments.port), + state=state, + token=token, + allowed_agent_ip=arguments.allowed_agent_ip, + ) + break + except OSError as error: + address_unavailable = getattr(error, "winerror", None) == 10049 + retry_open = time.monotonic() < deadline + if not address_unavailable or not retry_open: + raise + if not waiting_for_address: + print( + f"WAITING FOR LOCAL ADDRESS = {arguments.bind} " + f"({arguments.bind_retry_seconds:g} seconds maximum)" + ) + print("RF TX = 0") + waiting_for_address = True + time.sleep(min(5.0, max(0.1, deadline - time.monotonic()))) + print(f"SERVER = http://{arguments.bind}:{arguments.port}") + print(f"ALLOWED AGENT IP = {arguments.allowed_agent_ip}") + print( + "TASK KINDS = INVENTORY, OFFLINE_VERIFY, RX_CAPTURE_START, " + "RX_CAPTURE_STATUS, CAPTURE_DECODE, CAPTURE_DIAGNOSTICS, " + "STAIRCASE_SEARCH, UPDATE_AGENT" + ) + print("RF TX = 0") + try: + server.serve_forever(poll_interval=0.2) + except KeyboardInterrupt: + pass + finally: + server.server_close() + return + + if arguments.command == "enqueue-inventory": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/inventory", + { + "agent_id": arguments.agent_id, + "include_hackrf_info": not arguments.without_hackrf_info, + }, + ) + elif arguments.command == "enqueue-offline-verify": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/offline-verify", + { + "agent_id": arguments.agent_id, + "expected_head": arguments.expected_head, + }, + ) + elif arguments.command == "enqueue-rx-capture": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/rx-capture", + { + "agent_id": arguments.agent_id, + "expected_head": arguments.expected_head, + "authorization": arguments.authorize_rx, + }, + ) + elif arguments.command == "enqueue-rx-status": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/rx-status", + { + "agent_id": arguments.agent_id, + "session_id": arguments.session_id, + }, + ) + elif arguments.command == "enqueue-capture-decode": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/capture-decode", + { + "agent_id": arguments.agent_id, + "session_id": arguments.session_id, + "tx_started_at": arguments.tx_started_at, + "tx_finished_at": arguments.tx_finished_at, + }, + ) + elif arguments.command == "enqueue-capture-diagnostics": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/capture-diagnostics", + { + "agent_id": arguments.agent_id, + "session_id": arguments.session_id, + "tx_started_at": arguments.tx_started_at, + "tx_finished_at": arguments.tx_finished_at, + }, + ) + elif arguments.command == "enqueue-staircase-search": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/staircase-search", + { + "agent_id": arguments.agent_id, + "session_id": arguments.session_id, + "expected_rx_sha256": arguments.expected_rx_sha256, + "expected_reference_sha256": ( + arguments.expected_reference_sha256 + ), + }, + ) + elif arguments.command == "register-agent-release": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/releases/register-agent", + {"package_path": str(arguments.package.resolve())}, + ) + elif arguments.command == "register-bootstrap-release": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/releases/register-bootstrap", + { + "package_path": str(arguments.package.resolve()), + "release_id": arguments.release_id, + "agent_version": arguments.agent_version, + "content_manifest_sha256": ( + arguments.content_manifest_sha256 + ), + }, + ) + elif arguments.command == "enqueue-agent-update": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/update-agent", + { + "agent_id": arguments.agent_id, + "release_id": arguments.release_id, + "validity_seconds": arguments.validity_seconds, + }, + ) + elif arguments.command == "approve-bootstrap-download": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/releases/approve-bootstrap", + { + "release_id": arguments.release_id, + "validity_seconds": arguments.validity_seconds, + }, + ) + elif arguments.command == "cancel-task": + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/local/tasks/cancel", + { + "task_id": arguments.task_id, + "reason": arguments.reason, + }, + ) + else: + response = request_json( + arguments.server, + token, + "GET", + "/api/v1/local/status", + ) + print(json.dumps(response, ensure_ascii=False, indent=2, sort_keys=True)) + print("RF TX = 0") + + +if __name__ == "__main__": + main() diff --git a/tools/rf001_launcher_health_diagnostic.py b/tools/rf001_launcher_health_diagnostic.py new file mode 100644 index 0000000..582a939 --- /dev/null +++ b/tools/rf001_launcher_health_diagnostic.py @@ -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() diff --git a/tools/rf001_offline_decode.py b/tools/rf001_offline_decode.py new file mode 100644 index 0000000..ccd81fd --- /dev/null +++ b/tools/rf001_offline_decode.py @@ -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() diff --git a/tools/rf001_rx_capture.py b/tools/rf001_rx_capture.py new file mode 100644 index 0000000..09fb991 --- /dev/null +++ b/tools/rf001_rx_capture.py @@ -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 diff --git a/tools/rf001_rx_http_agent.py b/tools/rf001_rx_http_agent.py new file mode 100644 index 0000000..284e15d --- /dev/null +++ b/tools/rf001_rx_http_agent.py @@ -0,0 +1,1529 @@ +"""Polling RX-laptop agent for the RF-001 authenticated HTTP channel. + +Only fixed inventory, software verification, receive-only capture, capture +status, and offline decode tasks are implemented. The agent has no generic +command endpoint and contains no RF-transmit operation. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import datetime, timezone +import getpass +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import shutil +import socket +import subprocess +import sys +import time +from typing import Callable, Sequence +from urllib.parse import quote + + +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 ( # noqa: E402 + AGENT_ID_PATTERN, + CAPTURE_DECODE_TASK, + CAPTURE_DIAGNOSTICS_TASK, + DEFAULT_TOKEN_PATH, + INVENTORY_TASK, + OFFLINE_VERIFY_TASK, + PROTOCOL_VERSION, + ProtocolError, + RX_CAPTURE_START_TASK, + RX_CAPTURE_STATUS_TASK, + SESSION_ID_PATTERN, + SHA256_PATTERN, + STAIRCASE_SEARCH_TASK, + UPDATE_AGENT_TASK, + read_token, + request_file, + request_json, + utc_now, +) +from tools.rf001_agent_update import ( # noqa: E402 + ReleaseValidationError, + atomic_write_json, + inspect_release_archive, + sha256_file as update_sha256_file, + verify_update_manifest_signature, +) +from tools.rf001_rx_capture import ( # noqa: E402 + decode_completed_capture, + read_capture_diagnostics, + read_capture_status, + start_rx_capture, +) + + +DEFAULT_SERVER = "http://192.168.255.185:8765" +DEFAULT_POLL_INTERVAL_SECONDS = 2.0 +AGENT_VERSION = "2026.09.29.2" +SUPPORTED_TASKS = ( + "INVENTORY", + "OFFLINE_VERIFY", + "RX_CAPTURE_START", + "RX_CAPTURE_STATUS", + "CAPTURE_DECODE", + "CAPTURE_DIAGNOSTICS", + "STAIRCASE_SEARCH", + "UPDATE_AGENT", +) +DEFAULT_WORK_ROOT = ( + Path(os.environ.get("USERPROFILE", str(Path.home()))) + / "Documents" + / "SDR_Rover_Runtime" +) +CommandRunner = Callable[[Sequence[str], float], "CommandResult"] + + +def redirect_output(path: Path) -> None: + """Send service-mode stdout and stderr to one line-buffered UTF-8 log.""" + + path.parent.mkdir(parents=True, exist_ok=True) + stream = path.open("a", encoding="utf-8", buffering=1) + sys.stdout = stream + sys.stderr = stream + + +@dataclass(frozen=True) +class CommandResult: + arguments: list[str] + returncode: int + stdout: str + stderr: str + + def as_mapping(self) -> dict[str, object]: + return { + "arguments": self.arguments, + "returncode": self.returncode, + "stdout": self.stdout, + "stderr": self.stderr, + } + + +def run_command(arguments: Sequence[str], timeout: float) -> CommandResult: + completed = subprocess.run( + list(arguments), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + check=False, + ) + return CommandResult( + arguments=list(arguments), + returncode=completed.returncode, + stdout=completed.stdout.strip(), + stderr=completed.stderr.strip(), + ) + + +def normalized_agent_id(value: str) -> str: + candidate = re.sub(r"[^a-z0-9._-]+", "-", value.strip().lower()).strip("-._") + if len(candidate) < 3: + candidate = f"rx-{candidate or 'node'}" + candidate = candidate[:64] + if AGENT_ID_PATTERN.fullmatch(candidate) is None: + raise ValueError("unable to derive a valid agent ID") + return candidate + + +def read_git_metadata(repository: Path) -> dict[str, object]: + """Read branch and HEAD without requiring git.exe or changing the checkout.""" + + marker = repository / ".git" + if not marker.exists(): + raise ValueError(f"Git metadata is absent: {marker}") + if marker.is_dir(): + git_dir = marker.resolve() + else: + line = marker.read_text(encoding="utf-8-sig").strip() + match = re.fullmatch(r"gitdir:\s*(.+)", line) + if match is None: + raise ValueError("invalid .git indirection file") + candidate = Path(match.group(1).strip()) + git_dir = ( + candidate.resolve() + if candidate.is_absolute() + else (repository / candidate).resolve() + ) + + common_dir_file = git_dir / "commondir" + if common_dir_file.is_file(): + common_value = Path(common_dir_file.read_text(encoding="utf-8-sig").strip()) + common_dir = ( + common_value.resolve() + if common_value.is_absolute() + else (git_dir / common_value).resolve() + ) + else: + common_dir = git_dir + + head_entry = (git_dir / "HEAD").read_text(encoding="ascii").strip() + if head_entry.startswith("ref: "): + ref_name = head_entry[5:] + if not ref_name.startswith("refs/heads/"): + raise ValueError(f"unsupported HEAD reference: {ref_name}") + branch = ref_name.removeprefix("refs/heads/") + loose_refs = ( + git_dir.joinpath(*ref_name.split("/")), + common_dir.joinpath(*ref_name.split("/")), + ) + loose_ref = next((path for path in loose_refs if path.is_file()), None) + if loose_ref is not None: + head = loose_ref.read_text(encoding="ascii").strip() + else: + head = "" + for packed_refs in (git_dir / "packed-refs", common_dir / "packed-refs"): + if packed_refs.is_file(): + for packed_line in packed_refs.read_text( + encoding="ascii" + ).splitlines(): + fields = packed_line.split() + if len(fields) == 2 and fields[1] == ref_name: + head = fields[0] + break + if head: + break + if not head: + raise ValueError(f"Git reference is unresolved: {ref_name}") + else: + branch = "DETACHED" + head = head_entry + if re.fullmatch(r"[0-9a-f]{40}", head) is None: + raise ValueError("Git HEAD is not a lowercase 40-character hash") + return { + "git_dir": str(git_dir), + "common_dir": str(common_dir), + "head_entry": head_entry, + "branch": branch, + "head": head, + "status": "NOT_RUN_NO_GIT_EXECUTABLE", + } + + +def _git_inventory( + repository: Path, + command_runner: CommandRunner, +) -> dict[str, object]: + result: dict[str, object] = { + "path": str(repository), + "exists": repository.is_dir(), + } + if not repository.is_dir(): + return result + try: + result["metadata"] = read_git_metadata(repository) + except (OSError, ValueError) as error: + result["metadata_error"] = f"{type(error).__name__}: {error}" + commands = { + "top_level": ["git", "-C", str(repository), "rev-parse", "--show-toplevel"], + "branch": ["git", "-C", str(repository), "branch", "--show-current"], + "head": ["git", "-C", str(repository), "rev-parse", "HEAD"], + "status": ["git", "-C", str(repository), "status", "--short", "--branch"], + } + for name, arguments in commands.items(): + result[name] = command_runner(arguments, 15.0).as_mapping() + return result + + +def _resolve_hackrf_info(configured: Path | None) -> Path | None: + if configured is not None: + return configured + discovered = shutil.which("hackrf_info") or shutil.which("hackrf_info.exe") + if discovered: + return Path(discovered) + user_profile = Path(os.environ.get("USERPROFILE", str(Path.home()))) + candidates = ( + user_profile / "Tools" / "HackRF" / "Library" / "bin" / "hackrf_info.exe", + user_profile / "radioconda" / "Library" / "bin" / "hackrf_info.exe", + ) + return next((path for path in candidates if path.is_file()), None) + + +def collect_inventory( + repository: Path, + *, + include_hackrf_info: bool, + hackrf_info_path: Path | None, + command_runner: CommandRunner = run_command, +) -> tuple[dict[str, object], list[str]]: + """Collect fixed read-only host, Git, and optional HackRF information.""" + + errors: list[str] = [] + inventory: dict[str, object] = { + "hostname": socket.gethostname(), + "account": getpass.getuser(), + "platform": platform.platform(), + "python_executable": sys.executable, + "python_version": platform.python_version(), + "collected_at": utc_now(), + "rf_tx": 0, + } + try: + inventory["hostname_command"] = command_runner(["hostname"], 10.0).as_mapping() + except (OSError, subprocess.SubprocessError) as error: + errors.append(f"hostname failed: {type(error).__name__}: {error}") + try: + inventory["whoami_command"] = command_runner(["whoami"], 10.0).as_mapping() + except (OSError, subprocess.SubprocessError) as error: + errors.append(f"whoami failed: {type(error).__name__}: {error}") + try: + inventory["git"] = _git_inventory(repository, command_runner) + except (OSError, subprocess.SubprocessError) as error: + errors.append(f"git inventory failed: {type(error).__name__}: {error}") + + if include_hackrf_info: + resolved = _resolve_hackrf_info(hackrf_info_path) + if resolved is None: + inventory["hackrf_info"] = { + "found": False, + "executed": False, + "rf_tx": 0, + } + errors.append("hackrf_info executable was not found") + elif not resolved.is_file(): + inventory["hackrf_info"] = { + "found": False, + "configured_path": str(resolved), + "executed": False, + "rf_tx": 0, + } + errors.append(f"hackrf_info path does not exist: {resolved}") + else: + try: + outcome = command_runner([str(resolved)], 20.0) + inventory["hackrf_info"] = { + "found": True, + "path": str(resolved), + "executed": True, + "result": outcome.as_mapping(), + "rf_tx": 0, + } + if outcome.returncode != 0: + errors.append( + f"hackrf_info returned exit code {outcome.returncode}" + ) + except (OSError, subprocess.SubprocessError) as error: + inventory["hackrf_info"] = { + "found": True, + "path": str(resolved), + "executed": False, + "rf_tx": 0, + } + errors.append(f"hackrf_info failed: {type(error).__name__}: {error}") + else: + inventory["hackrf_info"] = { + "requested": False, + "executed": False, + "rf_tx": 0, + } + return inventory, errors + + +def _bounded_command(result: CommandResult) -> dict[str, object]: + return { + "arguments": result.arguments, + "returncode": result.returncode, + "stdout": result.stdout[-8_000:], + "stderr": result.stderr[-8_000:], + } + + +def run_offline_verify( + repository: Path, + work_root: Path, + task_id: str, + expected_head: str, + *, + command_runner: CommandRunner = run_command, +) -> tuple[dict[str, object], list[str]]: + """Generate and decode the deterministic RF-001A waveform without hardware.""" + + errors: list[str] = [] + result: dict[str, object] = { + "mode": "SOFTWARE_ONLY", + "hardware_accessed": False, + "rf_tx": 0, + } + try: + metadata = read_git_metadata(repository) + result["git"] = metadata + if metadata["branch"] != "video-development": + raise ValueError("repository branch is not video-development") + if metadata["head"] != expected_head: + raise ValueError( + f"repository HEAD {metadata['head']} does not match {expected_head}" + ) + + prepare_script = repository / "tools" / "rf001a_prepare.py" + decoder_script = ROOT / "tools" / "rf001_offline_decode.py" + truth_path = repository / "configs" / "rf" / "rf001a_truth_metadata.json" + for required in (prepare_script, decoder_script, truth_path): + if not required.is_file(): + raise FileNotFoundError(f"required project file is absent: {required}") + + output_directory = work_root / "offline_verify" / task_id + output_directory.parent.mkdir(parents=True, exist_ok=True) + if output_directory.exists(): + raise FileExistsError( + f"offline verification directory already exists: {output_directory}" + ) + report_path = output_directory / "offline_decode_report.json" + + prepare = command_runner( + [ + sys.executable, + "-B", + "-X", + "utf8", + str(prepare_script), + "--output-dir", + str(output_directory), + ], + 300.0, + ) + result["prepare"] = _bounded_command(prepare) + if prepare.returncode != 0: + raise RuntimeError(f"rf001a_prepare exited with {prepare.returncode}") + + iq_path = output_directory / "tx_waveform.cs8" + generated_truth = output_directory / "tx_metadata.json" + decode = command_runner( + [ + sys.executable, + "-B", + "-X", + "utf8", + str(decoder_script), + "--repository", + str(repository), + "--iq-file", + str(iq_path), + "--truth-metadata", + str(generated_truth), + "--report", + str(report_path), + ], + 900.0, + ) + result["decode"] = _bounded_command(decode) + if decode.returncode != 0: + raise RuntimeError(f"qpsk_rf_rx exited with {decode.returncode}") + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + exact_packets = report.get("exact_packets") + crc_valid_packets = report.get("crc_valid_packets") + result.update( + { + "output_directory": str(output_directory), + "report_path": str(report_path), + "crc_valid_packets": crc_valid_packets, + "exact_packets": exact_packets, + "expected_packets": 100, + } + ) + if crc_valid_packets != 100 or exact_packets != 100: + raise RuntimeError( + "offline decode did not produce 100 CRC-valid and exact packets" + ) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + errors.append(f"{type(error).__name__}: {error}") + return result, errors + + +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 _staircase_result_from_report( + report: dict[str, object], + *, + report_path: Path, + command: CommandResult | None, + reused_existing_report: bool, +) -> dict[str, object]: + search = report.get("search") + if not isinstance(search, dict): + raise ValueError("staircase report search section is invalid") + candidates = search.get("candidates") + if not isinstance(candidates, list): + raise ValueError("staircase report candidates are invalid") + result: dict[str, object] = { + "mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH", + "session_id": report_path.parent.name, + "reference_detection": report.get("verdict"), + "verdict_reasons": report.get("verdict_reasons"), + "coverage_start_sample": search.get("coverage_start_sample"), + "coverage_stop_sample": search.get("coverage_stop_sample"), + "coverage_seconds": search.get("coverage_seconds"), + "coverage_complete": search.get("coverage_complete"), + "source_samples_processed": search.get("source_samples_processed"), + "frequency_grid_hz": search.get("frequency_grid_hz"), + "background_score_p99": search.get("background_score_p99"), + "candidates": candidates, + "verifications": report.get("verifications"), + "capture_sha256": report.get("capture_sha256"), + "reference_sha256": report.get("reference_sha256"), + "analysis_seconds": report.get("analysis_seconds"), + "peak_process_memory_bytes": report.get("peak_process_memory_bytes"), + "peak_additional_memory_bytes": report.get("peak_additional_memory_bytes"), + "analyzer_version": report.get("analyzer_version"), + "report_path": str(report_path), + "report_bytes": report_path.stat().st_size, + "report_sha256": _sha256_file(report_path), + "reused_existing_report": reused_existing_report, + "automatic_retry": False, + "hardware_accessed": False, + "rf_tx": 0, + } + if command is not None: + result["analysis_process"] = _bounded_command(command) + return result + + +def run_staircase_search( + work_root: Path, + session_id: str, + expected_rx_sha256: str, + expected_reference_sha256: str, + *, + command_runner: CommandRunner = run_command, + progress_path: Path | None = None, +) -> tuple[dict[str, object], list[str]]: + """Run the one fixed, software-only staircase analysis.""" + + session_directory = work_root / "rx_capture" / session_id + capture_path = session_directory / "rf001a_ota_capture.cs8" + reference_path = work_root / "references" / "lab065_reference_staircase.cs8" + analyzer_path = Path(__file__).resolve().with_name("rf001_staircase_search.py") + report_path = session_directory / "staircase_search_report.json" + result: dict[str, object] = { + "mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH", + "session_id": session_id, + "reference_detection": "EXECUTION_FAILED", + "report_path": str(report_path), + "automatic_retry": False, + "hardware_accessed": False, + "rf_tx": 0, + } + errors: list[str] = [] + try: + for required in (capture_path, reference_path, analyzer_path): + if not required.is_file(): + raise FileNotFoundError(f"required file is absent: {required}") + capture_sha256 = _sha256_file(capture_path) + if capture_sha256 != expected_rx_sha256: + raise ValueError("RX SHA256 does not match the task") + reference_sha256 = _sha256_file(reference_path) + if reference_sha256 != expected_reference_sha256: + raise ValueError("reference SHA256 does not match the task") + + if report_path.exists(): + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + if not isinstance(report, dict): + raise ValueError("existing staircase report is not a JSON object") + if report.get("capture_sha256") != expected_rx_sha256: + raise ValueError("existing report has another RX SHA256") + if report.get("reference_sha256") != expected_reference_sha256: + raise ValueError("existing report has another reference SHA256") + if report.get("hardware_accessed") is not False: + raise ValueError("existing report does not prove software-only analysis") + if report.get("rf_tx") != 0: + raise ValueError("existing report does not preserve rf_tx = 0") + return ( + _staircase_result_from_report( + report, + report_path=report_path, + command=None, + reused_existing_report=True, + ), + [], + ) + + command_arguments = [ + sys.executable, + "-B", + "-X", + "utf8", + str(analyzer_path), + "--reference", + str(reference_path), + "--capture", + str(capture_path), + "--capture-sha256", + expected_rx_sha256, + "--report", + str(report_path), + ] + if progress_path is not None: + command_arguments.extend(("--progress", str(progress_path))) + command = command_runner( + command_arguments, + 43_200.0, + ) + result["analysis_process"] = _bounded_command(command) + if command.returncode != 0: + raise RuntimeError( + f"staircase analyzer exited with {command.returncode}" + ) + if not report_path.is_file(): + raise RuntimeError("staircase analyzer did not create its report") + report = json.loads(report_path.read_text(encoding="utf-8-sig")) + if not isinstance(report, dict): + raise ValueError("staircase report is not a JSON object") + if report.get("capture_sha256") != expected_rx_sha256: + raise ValueError("staircase report RX SHA256 is invalid") + if report.get("reference_sha256") != expected_reference_sha256: + raise ValueError("staircase report reference SHA256 is invalid") + result = _staircase_result_from_report( + report, + report_path=report_path, + command=command, + reused_existing_report=False, + ) + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.SubprocessError, + ) as error: + errors.append(f"{type(error).__name__}: {error}") + return result, errors + + +def _read_optional_json(path: Path | None) -> dict[str, object] | None: + if path is None or not path.is_file(): + return None + value = json.loads(path.read_text(encoding="utf-8-sig")) + return value if isinstance(value, dict) else None + + +def lightweight_inventory( + repository: Path, + *, + release_id: str | None = None, + launcher_status_path: Path | None = None, +) -> dict[str, object]: + inventory: dict[str, object] = { + "agent_version": AGENT_VERSION, + "release_id": release_id, + "supported_tasks": list(SUPPORTED_TASKS), + "hostname": socket.gethostname(), + "account": getpass.getuser(), + "python_version": platform.python_version(), + "repository": str(repository), + "repository_exists": repository.is_dir(), + "checked_at": utc_now(), + "rf_tx": 0, + } + try: + launcher_status = _read_optional_json(launcher_status_path) + except (OSError, ValueError, json.JSONDecodeError) as error: + inventory["launcher_status_error"] = f"{type(error).__name__}: {error}" + else: + inventory["launcher_status"] = launcher_status + return inventory + + +def _process_exists(process_id: int) -> bool: + if process_id <= 0: + return False + try: + os.kill(process_id, 0) + except OSError: + return False + return True + + +def _job_pointer_path(work_root: Path) -> Path: + return work_root / "agent_jobs" / "active_job.json" + + +def _job_directory(work_root: Path, task_id: str) -> Path: + return work_root / "agent_jobs" / task_id + + +def _load_active_job(work_root: Path) -> dict[str, object] | None: + pointer = _job_pointer_path(work_root) + if not pointer.is_file(): + return None + value = json.loads(pointer.read_text(encoding="utf-8-sig")) + if not isinstance(value, dict): + raise ValueError("active job pointer is invalid") + return value + + +def _staircase_total_samples(work_root: Path, task: dict[str, object]) -> int: + session_id = str(task.get("session_id", "")) + capture = work_root / "rx_capture" / session_id / "rf001a_ota_capture.cs8" + if not capture.is_file() or capture.stat().st_size % 2: + return 1 + return max(1, capture.stat().st_size // 2) + + +def _failed_job_result( + pointer: dict[str, object], + *, + error: str, +) -> dict[str, object]: + task = pointer.get("task") + if not isinstance(task, dict): + raise ValueError("active job task is invalid") + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": str(task["task_id"]), + "agent_id": str(task["agent_id"]), + "status": "FAILED", + "started_at": str(pointer.get("started_at", utc_now())), + "finished_at": utc_now(), + "result": { + "mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH", + "session_id": task.get("session_id"), + "reference_detection": "EXECUTION_FAILED", + "automatic_retry": False, + "hardware_accessed": False, + "rf_tx": 0, + }, + "errors": [error], + "rf_tx": 0, + } + + +def _ensure_job_result(work_root: Path, pointer: dict[str, object]) -> Path | None: + task = pointer.get("task") + if not isinstance(task, dict): + raise ValueError("active job task is invalid") + directory = _job_directory(work_root, str(task["task_id"])) + result_path = directory / "result.json" + if result_path.is_file(): + return result_path + process_id = pointer.get("pid") + if type(process_id) is int and _process_exists(process_id): + return None + atomic_write_json( + result_path, + _failed_job_result( + pointer, + error="background staircase process ended without a result; not retried", + ), + ) + return result_path + + +def _active_task_payload( + work_root: Path, + pointer: dict[str, object], +) -> dict[str, object]: + task = pointer.get("task") + if not isinstance(task, dict): + raise ValueError("active job task is invalid") + directory = _job_directory(work_root, str(task["task_id"])) + progress_path = directory / "progress.json" + total = _staircase_total_samples(work_root, task) + processed = 0 + phase = "STARTING" + if progress_path.is_file(): + progress = json.loads(progress_path.read_text(encoding="utf-8-sig")) + if isinstance(progress, dict): + candidate_total = progress.get("total_samples") + candidate_processed = progress.get("processed_samples") + if type(candidate_total) is int and candidate_total > 0: + total = candidate_total + if type(candidate_processed) is int: + processed = min(total, max(0, candidate_processed)) + phase = str(progress.get("phase", "RUNNING")) + result_path = directory / "result.json" + state = "RESULT_PENDING" if result_path.is_file() else "RUNNING" + return { + "task_id": str(task["task_id"]), + "kind": STAIRCASE_SEARCH_TASK, + "state": state, + "phase": phase, + "processed_samples": processed, + "total_samples": total, + "last_error": None, + "hardware_accessed": False, + "rf_tx": 0, + } + + +def start_staircase_background_job( + task: dict[str, object], + *, + agent_id: str, + work_root: Path, +) -> dict[str, object]: + if _load_active_job(work_root) is not None: + raise RuntimeError("another background job is already active") + task_id = str(task.get("task_id", "")) + directory = _job_directory(work_root, task_id) + directory.mkdir(parents=True, exist_ok=False) + spec_path = directory / "job_spec.json" + result_path = directory / "result.json" + progress_path = directory / "progress.json" + spec = { + "schema_version": 1, + "task": task, + "agent_id": agent_id, + "work_root": str(work_root), + "progress_path": str(progress_path), + "hardware_accessed": False, + "rf_tx": 0, + } + atomic_write_json(spec_path, spec) + worker = Path(__file__).resolve().with_name("rf001_staircase_job.py") + if not worker.is_file(): + raise FileNotFoundError(f"staircase worker is absent: {worker}") + log_stream = (directory / "worker.log").open( + "a", encoding="utf-8", buffering=1 + ) + creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + pointer: dict[str, object] = { + "schema_version": 1, + "task": task, + "pid": None, + "started_at": utc_now(), + "automatic_retry": False, + "hardware_accessed": False, + "rf_tx": 0, + } + # Persist ownership before creating the child. If the agent stops at any + # point after this write, its replacement sees the pointer and must not + # start a second analyzer. + atomic_write_json(_job_pointer_path(work_root), pointer) + process = subprocess.Popen( + [ + sys.executable, + "-u", + "-B", + "-X", + "utf8", + str(worker), + "--job-spec", + str(spec_path), + "--result", + str(result_path), + ], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=log_stream, + stderr=subprocess.STDOUT, + text=True, + creationflags=creation_flags, + ) + log_stream.close() + pointer["pid"] = process.pid + atomic_write_json(_job_pointer_path(work_root), pointer) + return pointer + + +def deliver_background_result( + arguments: argparse.Namespace, + token: bytes, + pointer: dict[str, object], +) -> bool: + result_path = _ensure_job_result(arguments.work_root, pointer) + if result_path is None: + return False + result = json.loads(result_path.read_text(encoding="utf-8-sig")) + if not isinstance(result, dict): + raise ValueError("background result is invalid") + acknowledgement = request_json( + arguments.server, + token, + "POST", + "/api/v1/agent/result", + result, + timeout=arguments.timeout, + ) + if acknowledgement.get("accepted") is not True: + raise RuntimeError("server did not accept background result") + task = pointer.get("task") + if not isinstance(task, dict): + raise ValueError("active job task is invalid") + directory = _job_directory(arguments.work_root, str(task["task_id"])) + atomic_write_json( + directory / "delivery_receipt.json", + { + "delivered_at": utc_now(), + "acknowledgement": acknowledgement, + "rf_tx": 0, + }, + ) + os.replace(_job_pointer_path(arguments.work_root), directory / "delivered_job.json") + return True + + +def _validate_update_task(task: dict[str, object], agent_id: str) -> None: + if task.get("kind") != UPDATE_AGENT_TASK: + raise ProtocolError("task is not UPDATE_AGENT") + if task.get("agent_id") != agent_id: + raise ProtocolError("update task is addressed to another agent") + if task.get("protocol_version") != PROTOCOL_VERSION: + raise ProtocolError("update task protocol version is unsupported") + if task.get("compatible_protocol_version") != PROTOCOL_VERSION: + raise ProtocolError("release protocol version is incompatible") + if task.get("automatic_retry") is not False: + raise ProtocolError("agent update must prohibit automatic retry") + if task.get("hardware_accessed") is not False or task.get("rf_tx") != 0: + raise ProtocolError("agent update must be software-only") + if SHA256_PATTERN.fullmatch(str(task.get("package_sha256", ""))) is None: + raise ProtocolError("update package SHA256 is invalid") + if SHA256_PATTERN.fullmatch( + str(task.get("content_manifest_sha256", "")) + ) is None: + raise ProtocolError("update content manifest SHA256 is invalid") + package_bytes = task.get("package_bytes") + if type(package_bytes) is not int or package_bytes <= 0: + raise ProtocolError("update package size is invalid") + expires = task.get("approval_expires_at") + if not isinstance(expires, str): + raise ProtocolError("update approval expiry is invalid") + if datetime.fromisoformat(expires) < datetime.now(timezone.utc): + raise ProtocolError("update approval expired") + + +def stage_agent_update( + task: dict[str, object], + *, + arguments: argparse.Namespace, + token: bytes, + command_runner: CommandRunner = run_command, +) -> tuple[dict[str, object], list[str], dict[str, object] | None]: + result: dict[str, object] = { + "mode": "FIXED_AGENT_UPDATE", + "update_status": "EXECUTION_FAILED", + "automatic_retry": False, + "hardware_accessed": False, + "rf_tx": 0, + } + errors: list[str] = [] + launcher_request: dict[str, object] | None = None + try: + _validate_update_task(task, arguments.agent_id) + if arguments.install_root is None or arguments.launcher_request is None: + raise RuntimeError("versioned launcher paths are not configured") + release_id = str(task["release_id"]) + agent_version = str(task["agent_version"]) + install_root = arguments.install_root.resolve() + releases_root = install_root / "versions" + final_release = releases_root / release_id + ready_path = final_release / "READY.json" + if ready_path.is_file(): + ready = json.loads(ready_path.read_text(encoding="utf-8-sig")) + if not isinstance(ready, dict): + raise ValueError("existing release READY marker is invalid") + if ( + ready.get("release_id") != release_id + or ready.get("agent_version") != agent_version + or ready.get("package_sha256") != task["package_sha256"] + ): + raise ValueError("existing release differs from the approved release") + update_status = "ALREADY_STAGED" + else: + quoted_release = quote(release_id, safe="") + quoted_agent = quote(arguments.agent_id, safe="") + manifest_target = ( + f"/api/v1/agent/releases/{quoted_release}/manifest" + f"?agent_id={quoted_agent}" + ) + manifest_response = request_json( + arguments.server, + token, + "GET", + manifest_target, + timeout=arguments.timeout, + ) + manifest = manifest_response.get("manifest") + signature = manifest_response.get("manifest_signature") + if not isinstance(manifest, dict) or not isinstance(signature, str): + raise ReleaseValidationError("signed release manifest is invalid") + verify_update_manifest_signature(token, manifest, signature) + expected = { + "release_id": release_id, + "agent_version": agent_version, + "protocol_version": task["compatible_protocol_version"], + "package_sha256": task["package_sha256"], + "package_bytes": task["package_bytes"], + "content_manifest_sha256": task["content_manifest_sha256"], + "package_kind": "AGENT_RELEASE", + } + for field, expected_value in expected.items(): + if manifest.get(field) != expected_value: + raise ReleaseValidationError( + f"signed release manifest field mismatch: {field}" + ) + required_free = ( + int(task["package_bytes"]) + + int(manifest.get("uncompressed_bytes", 0)) + + 32 * 1024 * 1024 + ) + if shutil.disk_usage(install_root).free < required_free: + raise RuntimeError("insufficient free space for staged update") + + staging = install_root / "staging" / str(task["task_id"]) + staging.mkdir(parents=True, exist_ok=True) + package_target = ( + f"/api/v1/agent/releases/{quoted_release}/package" + f"?agent_id={quoted_agent}" + ) + inspection = None + extracted = None + last_error: Exception | None = None + download_state_path = staging / "download_state.json" + download_state = _read_optional_json(download_state_path) or { + "schema_version": 1, + "network_attempts": 0, + "maximum_network_attempts": 2, + "hardware_accessed": False, + "rf_tx": 0, + } + network_attempts = download_state.get("network_attempts") + if type(network_attempts) is not int or not 0 <= network_attempts <= 2: + raise ValueError("staged download attempt counter is invalid") + while network_attempts < 2: + attempt = network_attempts + 1 + download = staging / f"package_attempt_{attempt}.zip" + if download.exists(): + last_error = RuntimeError( + f"download attempt {attempt} was interrupted" + ) + network_attempts = attempt + download_state["network_attempts"] = network_attempts + atomic_write_json(download_state_path, download_state) + continue + network_attempts = attempt + download_state["network_attempts"] = network_attempts + download_state["last_attempt_at"] = utc_now() + atomic_write_json(download_state_path, download_state) + try: + request_file( + arguments.server, + token, + package_target, + download, + expected_bytes=int(task["package_bytes"]), + timeout=max(30.0, arguments.timeout), + ) + if update_sha256_file(download) != task["package_sha256"]: + raise ReleaseValidationError("downloaded package SHA256 mismatch") + extracted = staging / f"extracted_attempt_{attempt}" + inspection = inspect_release_archive( + download, + expected_package_sha256=str(task["package_sha256"]), + expected_package_bytes=int(task["package_bytes"]), + expected_content_manifest_sha256=str( + task["content_manifest_sha256"] + ), + extraction_root=extracted, + ) + break + except (OSError, RuntimeError, ReleaseValidationError) as error: + last_error = error + if inspection is None or extracted is None: + raise RuntimeError(f"release download failed twice: {last_error}") + if ( + inspection.release_id != release_id + or inspection.agent_version != agent_version + or inspection.protocol_version != PROTOCOL_VERSION + ): + raise ReleaseValidationError("extracted release identity mismatch") + self_test = command_runner( + [ + sys.executable, + "-B", + "-X", + "utf8", + str(extracted / "app" / "tools" / "rf001_rx_http_agent.py"), + "--self-test", + ], + 120.0, + ) + result["self_test"] = _bounded_command(self_test) + if self_test.returncode != 0: + raise RuntimeError( + f"new agent self-test exited with {self_test.returncode}" + ) + ready = { + "schema_version": 1, + "status": "READY", + "release_id": release_id, + "agent_version": agent_version, + "protocol_version": PROTOCOL_VERSION, + "package_sha256": inspection.package_sha256, + "content_manifest_sha256": inspection.content_manifest_sha256, + "verified_at": utc_now(), + "hardware_accessed": False, + "rf_tx": 0, + } + atomic_write_json(extracted / "READY.json", ready) + releases_root.mkdir(parents=True, exist_ok=True) + os.replace(extracted, final_release) + atomic_write_json( + install_root / "update_receipts" / f"{task['task_id']}.json", + {**ready, "task_id": task["task_id"]}, + ) + update_status = "STAGED" + launcher_status = _read_optional_json(arguments.launcher_status) + active_release = ( + launcher_status.get("active_release_id") + if isinstance(launcher_status, dict) + else arguments.release_id + ) + if active_release == release_id: + update_status = "ALREADY_INSTALLED" + else: + launcher_request = { + "schema_version": 1, + "task_id": task["task_id"], + "from_release_id": active_release, + "release_id": release_id, + "agent_version": agent_version, + "package_sha256": task["package_sha256"], + "safe_to_switch": True, + "requested_at": utc_now(), + "hardware_accessed": False, + "rf_tx": 0, + } + result.update( + { + "update_status": update_status, + "release_id": release_id, + "agent_version": agent_version, + "package_sha256": task["package_sha256"], + "launcher_switch_requested": launcher_request is not None, + } + ) + except ( + OSError, + ValueError, + RuntimeError, + ProtocolError, + ReleaseValidationError, + subprocess.SubprocessError, + json.JSONDecodeError, + ) as error: + errors.append(f"{type(error).__name__}: {error}") + return result, errors, launcher_request + + +def execute_task( + task: dict[str, object], + *, + agent_id: str, + repository: Path, + hackrf_info_path: Path | None, + hackrf_clock_path: Path | None = None, + hackrf_transfer_path: Path | None = None, + work_root: Path = DEFAULT_WORK_ROOT, + command_runner: CommandRunner = run_command, +) -> dict[str, object]: + if task.get("protocol_version") != PROTOCOL_VERSION: + raise ProtocolError("task protocol version is not supported") + if task.get("agent_id") != agent_id: + raise ProtocolError("task is addressed to another agent") + if task.get("rf_tx") != 0: + raise ProtocolError("task does not preserve rf_tx = 0") + task_id = task.get("task_id") + if not isinstance(task_id, str): + raise ProtocolError("task_id is missing") + started_at = utc_now() + kind = task.get("kind") + if kind == INVENTORY_TASK: + include = task.get("include_hackrf_info") + if type(include) is not bool: + raise ProtocolError("include_hackrf_info must be boolean") + task_result, errors = collect_inventory( + repository, + include_hackrf_info=include, + hackrf_info_path=hackrf_info_path, + command_runner=command_runner, + ) + elif kind == OFFLINE_VERIFY_TASK: + expected_head = task.get("expected_head") + if not isinstance(expected_head, str) or re.fullmatch( + r"[0-9a-f]{40}", expected_head + ) is None: + raise ProtocolError("expected_head is invalid") + task_result, errors = run_offline_verify( + repository, + work_root, + task_id, + expected_head, + command_runner=command_runner, + ) + elif kind == RX_CAPTURE_START_TASK: + expected_head = task.get("expected_head") + session_id = task.get("session_id") + rx_serial = task.get("rx_serial") + if task.get("rx_authorized") is not True: + raise ProtocolError("RX capture task is not explicitly authorized") + if task.get("automatic_retry") is not False: + raise ProtocolError("RX capture task must prohibit automatic retry") + if not isinstance(expected_head, str) or re.fullmatch( + r"[0-9a-f]{40}", expected_head + ) is None: + raise ProtocolError("expected_head is invalid") + if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch( + session_id + ) is None: + raise ProtocolError("session_id is invalid") + if not isinstance(rx_serial, str): + raise ProtocolError("rx_serial is invalid") + metadata = read_git_metadata(repository) + if metadata["branch"] != "video-development": + raise ProtocolError("repository branch is not video-development") + if metadata["head"] != expected_head: + raise ProtocolError("repository HEAD does not match the capture task") + task_result, errors = start_rx_capture( + repository, + work_root, + session_id, + rx_serial, + hackrf_info_path=hackrf_info_path, + hackrf_clock_path=hackrf_clock_path, + hackrf_transfer_path=hackrf_transfer_path, + command_runner=command_runner, + ) + task_result["git"] = metadata + elif kind == RX_CAPTURE_STATUS_TASK: + session_id = task.get("session_id") + if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch( + session_id + ) is None: + raise ProtocolError("session_id is invalid") + task_result, errors = read_capture_status(work_root, session_id) + elif kind == CAPTURE_DECODE_TASK: + session_id = task.get("session_id") + tx_started_at = task.get("tx_started_at") + tx_finished_at = task.get("tx_finished_at") + if not all( + isinstance(value, str) + for value in (session_id, tx_started_at, tx_finished_at) + ): + raise ProtocolError("capture decode task fields are invalid") + if SESSION_ID_PATTERN.fullmatch(str(session_id)) is None: + raise ProtocolError("session_id is invalid") + task_result, errors = decode_completed_capture( + repository, + work_root, + str(session_id), + str(tx_started_at), + str(tx_finished_at), + command_runner=command_runner, + ) + elif kind == CAPTURE_DIAGNOSTICS_TASK: + session_id = task.get("session_id") + tx_started_at = task.get("tx_started_at") + tx_finished_at = task.get("tx_finished_at") + if not all( + isinstance(value, str) + for value in (session_id, tx_started_at, tx_finished_at) + ): + raise ProtocolError("capture diagnostics task fields are invalid") + if SESSION_ID_PATTERN.fullmatch(str(session_id)) is None: + raise ProtocolError("session_id is invalid") + task_result, errors = read_capture_diagnostics( + work_root, + str(session_id), + str(tx_started_at), + str(tx_finished_at), + ) + elif kind == STAIRCASE_SEARCH_TASK: + session_id = task.get("session_id") + expected_rx_sha256 = task.get("expected_rx_sha256") + expected_reference_sha256 = task.get("expected_reference_sha256") + if task.get("automatic_retry") is not False: + raise ProtocolError("staircase search must prohibit automatic retry") + if task.get("hardware_accessed") is not False: + raise ProtocolError("staircase search must be software-only") + if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch( + session_id + ) is None: + raise ProtocolError("session_id is invalid") + if not isinstance(expected_rx_sha256, str) or SHA256_PATTERN.fullmatch( + expected_rx_sha256 + ) is None: + raise ProtocolError("expected_rx_sha256 is invalid") + if not isinstance( + expected_reference_sha256, str + ) or SHA256_PATTERN.fullmatch(expected_reference_sha256) is None: + raise ProtocolError("expected_reference_sha256 is invalid") + task_result, errors = run_staircase_search( + work_root, + session_id, + expected_rx_sha256, + expected_reference_sha256, + command_runner=command_runner, + ) + else: + raise ProtocolError("unsupported task kind") + return { + "protocol_version": PROTOCOL_VERSION, + "task_id": task_id, + "agent_id": 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, + } + + +def run_once(arguments: argparse.Namespace, token: bytes) -> str: + pointer = _load_active_job(arguments.work_root) + if pointer is not None and deliver_background_result(arguments, token, pointer): + pointer = None + elif pointer is not None: + _ensure_job_result(arguments.work_root, pointer) + active_task = ( + _active_task_payload(arguments.work_root, pointer) + if pointer is not None + else None + ) + write_health(arguments, active_task) + check_in = { + "protocol_version": PROTOCOL_VERSION, + "agent_id": arguments.agent_id, + "inventory": lightweight_inventory( + arguments.repository, + release_id=getattr(arguments, "release_id", None), + launcher_status_path=getattr(arguments, "launcher_status", None), + ), + "active_task": active_task, + "rf_tx": 0, + } + response = request_json( + arguments.server, + token, + "POST", + "/api/v1/agent/check-in", + check_in, + timeout=arguments.timeout, + ) + if response.get("protocol_version") != PROTOCOL_VERSION: + raise ProtocolError("server protocol version is not supported") + if response.get("rf_tx") != 0: + raise ProtocolError("server response does not preserve rf_tx = 0") + task = response.get("task") + if active_task is not None: + if task is not None: + raise ProtocolError("server issued a task while another task is active") + return "TASK_RUNNING" + if task is None: + if not arguments.quiet_idle: + print("TASK = NONE") + print("RF TX = 0") + return "NO_TASK" + if not isinstance(task, dict): + raise ProtocolError("server task is not a JSON object") + + if task.get("kind") == STAIRCASE_SEARCH_TASK: + if task.get("automatic_retry") is not False: + raise ProtocolError("staircase search must prohibit automatic retry") + session_id = task.get("session_id") + if not isinstance(session_id, str) or SESSION_ID_PATTERN.fullmatch( + session_id + ) is None: + raise ProtocolError("session_id is invalid") + for field in ("expected_rx_sha256", "expected_reference_sha256"): + value = task.get(field) + if not isinstance(value, str) or SHA256_PATTERN.fullmatch(value) is None: + raise ProtocolError(f"{field} is invalid") + start_staircase_background_job( + task, + agent_id=arguments.agent_id, + work_root=arguments.work_root, + ) + print(f"TASK ID = {task['task_id']}") + print("TASK STATUS = STARTED_BACKGROUND") + print("HARDWARE ACCESSED = false") + print("RF TX = 0") + return "STARTED_BACKGROUND" + + launcher_request = None + if task.get("kind") == UPDATE_AGENT_TASK: + task_result, errors, launcher_request = stage_agent_update( + task, + arguments=arguments, + token=token, + ) + result = { + "protocol_version": PROTOCOL_VERSION, + "task_id": task["task_id"], + "agent_id": arguments.agent_id, + "status": "COMPLETE" if not errors else "FAILED", + "started_at": utc_now(), + "finished_at": utc_now(), + "result": task_result, + "errors": errors, + "rf_tx": 0, + } + else: + result = execute_task( + task, + agent_id=arguments.agent_id, + repository=arguments.repository, + hackrf_info_path=arguments.hackrf_info, + hackrf_clock_path=arguments.hackrf_clock, + hackrf_transfer_path=arguments.hackrf_transfer, + work_root=arguments.work_root, + ) + + acknowledgement = request_json( + arguments.server, + token, + "POST", + "/api/v1/agent/result", + result, + timeout=arguments.timeout, + ) + if acknowledgement.get("accepted") is not True: + raise RuntimeError("server did not accept task result") + if launcher_request is not None: + launcher_request_path = arguments.launcher_request + if launcher_request_path is None: + raise RuntimeError("launcher request path is not configured") + if launcher_request_path.exists(): + existing = json.loads( + launcher_request_path.read_text(encoding="utf-8-sig") + ) + if existing != launcher_request: + raise RuntimeError("another launcher request already exists") + else: + atomic_write_json(launcher_request_path, launcher_request) + print(f"TASK ID = {result['task_id']}") + print(f"TASK STATUS = {result['status']}") + print(f"RESULT ACCEPTED = {acknowledgement.get('accepted')}") + print("RF TX = 0") + return str(result["status"]) + + +def write_health( + arguments: argparse.Namespace, + active_task: dict[str, object] | None, +) -> None: + health_file = getattr(arguments, "health_file", None) + if health_file is None: + return + atomic_write_json( + health_file, + { + "schema_version": 1, + "state": "RUNNING", + "pid": os.getpid(), + "agent_version": AGENT_VERSION, + "release_id": getattr(arguments, "release_id", None), + "launch_nonce": getattr(arguments, "launch_nonce", None), + "busy": active_task is not None, + "active_task_id": ( + active_task.get("task_id") if active_task is not None else None + ), + "updated_at": utc_now(), + "hardware_accessed": False, + "rf_tx": 0, + }, + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server", default=DEFAULT_SERVER) + parser.add_argument("--token-file", type=Path, default=DEFAULT_TOKEN_PATH) + parser.add_argument( + "--agent-id", + default=normalized_agent_id(socket.gethostname()), + type=normalized_agent_id, + ) + parser.add_argument("--repository", type=Path, default=ROOT) + parser.add_argument("--hackrf-info", type=Path) + parser.add_argument("--hackrf-clock", type=Path) + parser.add_argument("--hackrf-transfer", type=Path) + parser.add_argument("--work-root", type=Path, default=DEFAULT_WORK_ROOT) + parser.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL_SECONDS) + parser.add_argument("--timeout", type=float, default=10.0) + parser.add_argument("--once", action="store_true") + parser.add_argument("--quiet-idle", action="store_true") + parser.add_argument("--log-file", type=Path) + parser.add_argument("--release-id") + parser.add_argument("--launch-nonce") + parser.add_argument("--health-file", type=Path) + parser.add_argument("--launcher-status", type=Path) + parser.add_argument("--launcher-request", type=Path) + parser.add_argument("--install-root", type=Path) + parser.add_argument("--self-test", action="store_true") + return parser + + +def run_self_test() -> None: + required = ( + Path(__file__).resolve().with_name("rf001_agent_update.py"), + Path(__file__).resolve().with_name("rf001_http_control.py"), + Path(__file__).resolve().with_name("rf001_staircase_job.py"), + Path(__file__).resolve().with_name("rf001_staircase_search.py"), + ) + missing = [str(path) for path in required if not path.is_file()] + if missing: + raise FileNotFoundError(f"self-test files are absent: {missing}") + if UPDATE_AGENT_TASK not in SUPPORTED_TASKS: + raise RuntimeError("UPDATE_AGENT is not enabled") + if STAIRCASE_SEARCH_TASK not in SUPPORTED_TASKS: + raise RuntimeError("STAIRCASE_SEARCH is not enabled") + print(f"AGENT VERSION = {AGENT_VERSION}") + print("SELF TEST = PASS") + print("HARDWARE ACCESSED = false") + print("RF TX = 0") + + +def main() -> None: + arguments = build_parser().parse_args() + if arguments.self_test: + run_self_test() + return + if arguments.log_file is not None: + redirect_output(arguments.log_file) + if arguments.poll_interval <= 0: + raise ValueError("poll interval must be positive") + if arguments.timeout <= 0: + raise ValueError("timeout must be positive") + token = read_token(arguments.token_file) + print(f"AGENT ID = {arguments.agent_id}") + print(f"SERVER = {arguments.server}") + print( + f"AGENT VERSION = {AGENT_VERSION}\n" + f"SUPPORTED TASKS = {', '.join(SUPPORTED_TASKS)}" + ) + print("RF TX = 0") + while True: + try: + run_once(arguments, token) + except (OSError, RuntimeError, ProtocolError, json.JSONDecodeError) as error: + timestamp = datetime.now(timezone.utc).isoformat() + print(f"{timestamp} AGENT ERROR = {type(error).__name__}: {error}") + print("RF TX = 0") + if arguments.once: + raise SystemExit(1) from error + if arguments.once: + return + time.sleep(arguments.poll_interval) + + +if __name__ == "__main__": + main() diff --git a/tools/rf001_staircase_job.py b/tools/rf001_staircase_job.py new file mode 100644 index 0000000..18c0fc5 --- /dev/null +++ b/tools/rf001_staircase_job.py @@ -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() diff --git a/tools/rf001_staircase_search.py b/tools/rf001_staircase_search.py new file mode 100644 index 0000000..81a0fa3 --- /dev/null +++ b/tools/rf001_staircase_search.py @@ -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() diff --git a/tools/uninstall_rf001_main_autostart.ps1 b/tools/uninstall_rf001_main_autostart.ps1 new file mode 100644 index 0000000..6e3b9d7 --- /dev/null +++ b/tools/uninstall_rf001_main_autostart.ps1 @@ -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" diff --git a/tools/uninstall_rf001_rx_autostart.ps1 b/tools/uninstall_rf001_rx_autostart.ps1 new file mode 100644 index 0000000..ca7320d --- /dev/null +++ b/tools/uninstall_rf001_rx_autostart.ps1 @@ -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"